Automatically reconfigure GitHub notifications when a token is updated. (#388)

* Refactor methods

* changelog

* Tweaks
This commit is contained in:
Will Hunt 2022-07-11 18:08:09 +01:00 committed by GitHub
parent 74a3611134
commit c68bcafeb6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 95 additions and 19 deletions

2
Cargo.lock generated
View File

@ -147,7 +147,7 @@ checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
[[package]]
name = "matrix-hookshot"
version = "1.7.2"
version = "1.7.3"
dependencies = [
"contrast",
"hex",

1
changelog.d/388.bugfix Normal file
View File

@ -0,0 +1 @@
GitHub admin room notifications will now continue to work if you reauthenticate with GitHub.

View File

@ -58,6 +58,7 @@
"rss-parser": "^3.12.0",
"source-map-support": "^0.5.21",
"string-argv": "^0.3.1",
"tiny-typed-emitter": "^2.1.0",
"uuid": "^8.3.2",
"vm2": "^3.9.6",
"winston": "^3.3.3",

View File

@ -60,7 +60,7 @@ export class AdminRoom extends AdminRoomCommandHandler {
return this.widgetAccessToken === token;
}
public notificationsEnabled(type: "github"|"gitlab", instanceName?: string) {
public notificationsEnabled(type: string, instanceName?: string) {
if (type === "github") {
return this.data.github?.notifications?.enabled;
}

View File

@ -76,6 +76,7 @@ export class Bridge {
this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url);
this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient);
this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config);
this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this));
this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true}));
this.as.expressAppInstance.get("/ready", (_, res) => res.status(this.ready ? 200 : 500).send({ready: this.ready}));
}
@ -1188,4 +1189,34 @@ export class Bridge {
log.debug(`Set up ${roomId} as an admin room for ${adminRoom.userId}`);
return adminRoom;
}
private onTokenUpdated(type: string, userId: string, token: string, instanceUrl?: string) {
let instanceName: string|undefined;
if (type === "gitlab") {
// TODO: Refactor our API to depend on either instanceUrl or instanceName.
instanceName = Object.entries(this.config.gitlab?.instances || {}).find(i => i[1].url === instanceUrl)?.[0];
} else if (type === "github") {
// GitHub tokens are special
token = UserTokenStore.parseGitHubToken(token).access_token;
} else {
return;
}
for (const adminRoom of [...this.adminRooms.values()].filter(r => r.userId === userId)) {
if (adminRoom?.notificationsEnabled(type, instanceName)) {
log.debug(`Token was updated for ${userId} (${type}), notifying notification watcher`);
this.queue.push<NotificationsEnableEvent>({
eventName: "notifications.user.enable",
sender: "Bridge",
data: {
userId: adminRoom.userId,
roomId: adminRoom.roomId,
token,
filterParticipating: adminRoom.notificationsParticipating("github"),
type,
instanceUrl,
},
}).catch(ex => log.error(`Failed to push notifications.user.enable:`, ex));
}
}
}
}

View File

@ -34,14 +34,16 @@ export class GitHubWatcher extends EventEmitter implements NotificationWatcherTa
private octoKit: Octokit;
public failureCount = 0;
private interval?: NodeJS.Timeout;
private lastReadTs = 0;
public readonly type = "github";
public readonly instanceUrl = undefined;
constructor(token: string, baseUrl: URL, public userId: string, public roomId: string, since: number, private participating = false) {
constructor(token: string, baseUrl: URL, public userId: string, public roomId: string, private lastReadTs: number, private participating = false) {
super();
this.octoKit = GithubInstance.createUserOctokit(token, baseUrl);
this.lastReadTs = since;
}
public get since() {
return this.lastReadTs;
}
public start(intervalMs: number) {

View File

@ -8,6 +8,7 @@ export interface NotificationWatcherTask extends EventEmitter {
instanceUrl?: string;
roomId: string;
failureCount: number;
since: number;
start(intervalMs: number): void;
stop(): void;
}

View File

@ -79,13 +79,18 @@ Check your token is still valid, and then turn notifications back on.`, "m.notic
}
let task: NotificationWatcherTask;
const key = UserNotificationWatcher.constructMapKey(data.userId, data.type, data.instanceUrl);
const existing = this.userIntervals.get(key);
const since = data.since || existing?.since;
if (since === undefined) {
throw Error('`since` value missing from data payload, and no previous since value exists');
}
if (data.type === "github") {
if (!this.config.github) {
throw Error('GitHub is not configured');
}
task = new GitHubWatcher(data.token, this.config.github.baseUrl, data.userId, data.roomId, data.since, data.filterParticipating);
task = new GitHubWatcher(data.token, this.config.github.baseUrl, data.userId, data.roomId, since, data.filterParticipating);
} else if (data.type === "gitlab" && data.instanceUrl) {
task = new GitLabWatcher(data.token, data.instanceUrl, data.userId, data.roomId, data.since);
task = new GitLabWatcher(data.token, data.instanceUrl, data.userId, data.roomId, since);
} else {
throw Error('Notification type not known');
}

View File

@ -16,6 +16,7 @@ import { JiraOnPremOAuth } from "./Jira/oauth/OnPremOAuth";
import { JiraOnPremClient } from "./Jira/client/OnPremClient";
import { JiraCloudClient } from "./Jira/client/CloudClient";
import { TokenError, TokenErrorCode } from "./errors";
import { TypedEmitter } from "tiny-typed-emitter";
const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-hookshot.github.password-store:";
const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-hookshot.gitlab.password-store:";
@ -52,12 +53,17 @@ function tokenKey(type: TokenType, userId: string, legacy = false, instanceUrl?:
const MAX_TOKEN_PART_SIZE = 128;
const OAUTH_TIMEOUT_MS = 1000 * 60 * 30;
export class UserTokenStore {
interface Emitter {
onNewToken: (type: TokenType, userId: string, token: string, instanceUrl?: string) => void,
}
export class UserTokenStore extends TypedEmitter<Emitter> {
private key!: Buffer;
private oauthSessionStore: Map<string, {userId: string, timeout: NodeJS.Timeout}> = new Map();
private userTokens: Map<string, string>;
public readonly jiraOAuth?: JiraOAuth;
constructor(private keyPath: string, private intent: Intent, private config: BridgeConfig) {
super();
this.userTokens = new Map();
if (config.jira?.oauth) {
if ("client_id" in config.jira.oauth) {
@ -81,9 +87,10 @@ export class UserTokenStore {
}
const key = tokenKey(type, userId, false, instanceUrl);
const tokenParts: string[] = [];
while (token && token.length > 0) {
const part = token.slice(0, MAX_TOKEN_PART_SIZE);
token = token.substring(MAX_TOKEN_PART_SIZE);
let tokenSource = token;
while (tokenSource && tokenSource.length > 0) {
const part = tokenSource.slice(0, MAX_TOKEN_PART_SIZE);
tokenSource = tokenSource.substring(MAX_TOKEN_PART_SIZE);
tokenParts.push(publicEncrypt(this.key, Buffer.from(part)).toString("base64"));
}
const data: StoredTokenData = {
@ -94,6 +101,7 @@ export class UserTokenStore {
this.userTokens.set(key, token);
log.info(`Stored new ${type} token for ${userId}`);
log.debug(`Stored`, data);
this.emit("onNewToken", type, userId, token, instanceUrl);
}
public async clearUserToken(type: TokenType, userId: string, instanceUrl?: string): Promise<boolean> {
@ -139,19 +147,22 @@ export class UserTokenStore {
return null;
}
public static parseGitHubToken(token: string): GitHubOAuthToken {
if (!token.startsWith('{')) {
// Old style token
return { access_token: token, token_type: 'pat' };
} else {
return JSON.parse(token);
}
}
public async getGitHubToken(userId: string) {
const storeTokenResponse = await this.getUserToken("github", userId);
if (!storeTokenResponse) {
return null;
}
let senderToken: GitHubOAuthToken;
if (!storeTokenResponse.startsWith('{')) {
// Old style token
senderToken = { access_token: storeTokenResponse, token_type: 'pat' };
} else {
senderToken = JSON.parse(storeTokenResponse);
}
let senderToken = UserTokenStore.parseGitHubToken(storeTokenResponse);
const date = Date.now();
if (senderToken.expires_in && senderToken.expires_in < date) {
log.info(`GitHub access token for ${userId} has expired ${senderToken.expires_in} < ${date}, attempting refresh`);

View File

@ -21,7 +21,7 @@ const log = new LogWrapper("Webhooks");
export interface NotificationsEnableEvent {
userId: string;
roomId: string;
since: number;
since?: number;
token: string;
filterParticipating: boolean;
type: "github"|"gitlab";

View File

@ -4816,6 +4816,13 @@ run-parallel@^1.1.9:
dependencies:
queue-microtask "^1.2.2"
rxjs@^7.5.2:
version "7.5.5"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.5.tgz#2ebad89af0f560f460ad5cc4213219e1f7dd4e9f"
integrity sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==
dependencies:
tslib "^2.1.0"
safe-buffer@5.1.2, safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@ -5206,6 +5213,11 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
tiny-typed-emitter@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz#b3b027fdd389ff81a152c8e847ee2f5be9fad7b5"
integrity sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==
to-fast-properties@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
@ -5264,6 +5276,11 @@ tslib@^1.8.1:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.1.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
tsutils@^3.21.0:
version "3.21.0"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
@ -5308,6 +5325,13 @@ type-is@~1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
typed-emitter@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/typed-emitter/-/typed-emitter-2.1.0.tgz#ca78e3d8ef1476f228f548d62e04e3d4d3fd77fb"
integrity sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==
optionalDependencies:
rxjs "^7.5.2"
typescript@^4.5.2:
version "4.5.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8"