From f41b00db87787f748ec4f3632abd157f86197828 Mon Sep 17 00:00:00 2001 From: Alastor Date: Fri, 27 Dec 2024 03:05:27 +0100 Subject: [PATCH 01/12] Refactoring the auth facilities in IntegrationManager and AuthManager Fixing a bug in the createToken method of oauth2-client that made it so token was always seen as non expired by having our own createToken method. Adding the ability to pass options to the oauth2 client to be added with the requests. This allows for Integrations to work with APIs that require certain additional fields (For example Id and Secret in body as required by Tiltify) Adding a refreshToken() method to IntegrationManager to allow integrations to manually request a refresh token id autoRefresh is disabled. Adding a getAuth() method to IntegrationManager. Integrations should use this method to get the auth with a valid token to allow the IntegrationManager to autoRefresh the token. Emitting a "token-refreshed" event when the token is refreshed by either method in case the integration needs to do something with it. Renaming the oauth property to auth so it is consistent, since both appeared, and consistent with the type definition currently exposed to integrations. --- src/backend/auth/auth-manager.ts | 31 ++++++- src/backend/auth/auth.d.ts | 11 +++ .../integrations/integration-manager.js | 87 ++++++++++++++++++- 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/backend/auth/auth-manager.ts b/src/backend/auth/auth-manager.ts index f9d9adb29..fed9ef667 100644 --- a/src/backend/auth/auth-manager.ts +++ b/src/backend/auth/auth-manager.ts @@ -85,13 +85,40 @@ class AuthManager extends EventEmitter { authorizationUri: authUri, redirectUri: redirectUri, scopes: scopes, - state: provider.id + state: provider.id, + body: provider?.options?.body, + query: provider?.options?.query, + headers: provider?.options?.headers }); } + createToken(providerId: string, tokenData: ClientOAuth2.Data): ClientOAuth2.Token { + const provider = this.getAuthProvider(providerId); + const accessToken = provider.oauthClient.createToken(tokenData); + + // Hack because of a bug in oauth2-client + // createToken() adds an expires date property that is claculated with Date() + expires_in instead of created_at + expires_in + // As a result, expired() always saw the token valid + let tokenExpires: Date; + if (typeof tokenData.expires_in === 'number') { + tokenExpires = new Date(tokenData.created_at); + tokenExpires.setSeconds(tokenExpires.getSeconds() + Number(tokenData.expires_in)); + } else { + logger.warn(`Unknown duration: ${tokenData.expires_in}`); + return null; + } + // @ts-expect-error 2551 + accessToken.expires = tokenExpires; + return accessToken; + } + + tokenExpired(providerId: string, tokenData: ClientOAuth2.Data): boolean { + return this.createToken(providerId, tokenData).expired(); + } + async refreshTokenIfExpired(providerId: string, tokenData: ClientOAuth2.Data): Promise { const provider = this.getAuthProvider(providerId); - let accessToken = provider.oauthClient.createToken(tokenData); + let accessToken = this.createToken(providerId, tokenData); if (accessToken.expired()) { try { diff --git a/src/backend/auth/auth.d.ts b/src/backend/auth/auth.d.ts index a89a5762d..130e23393 100644 --- a/src/backend/auth/auth.d.ts +++ b/src/backend/auth/auth.d.ts @@ -14,6 +14,17 @@ export interface AuthProviderDefinition { authorizePath: string; tokenPath?: string; }; + options?: { + body?: { + [key: string]: string | string[]; + }; + query?: { + [key: string]: string | string[]; + }; + headers?: { + [key: string]: string | string[]; + }; + }; redirectUriHost?: string; scopes?: string[] | string | undefined; } diff --git a/src/backend/integrations/integration-manager.js b/src/backend/integrations/integration-manager.js index 3d6918c29..58ff2c0cd 100644 --- a/src/backend/integrations/integration-manager.js +++ b/src/backend/integrations/integration-manager.js @@ -46,7 +46,7 @@ class IntegrationManager extends EventEmitter { integration.integration.init( integration.definition.linked, { - oauth: integration.definition.auth, + auth: integration.definition.auth, accountId: integration.definition.accountId, settings: integration.definition.settings, userSettings: integration.definition.userSettings @@ -125,7 +125,7 @@ class IntegrationManager extends EventEmitter { const integrationData = { settings: int.definition.settings, userSettings: int.definition.userSettings, - oauth: int.definition.auth, + auth: int.definition.auth, accountId: int.definition.accountId }; int.integration.onUserSettingsUpdate(integrationData); @@ -323,6 +323,89 @@ class IntegrationManager extends EventEmitter { int.integration.disconnect(); } + async getAuth(integrationId) { + const int = this.getIntegrationById(integrationId); + if (int == null || !int.definition.linked) { + this.emit("integration-disconnected", integrationId); + return null; + } + + let authData = null; + if (int.definition.linkType === "auth") { + const providerId = int.definition?.authProviderDetails.id; + authData = int.definition.auth; + + if (int.definition.autoRefreshToken && authManager.tokenExpired(providerId, authData)) { + const updatedToken = await authManager.refreshTokenIfExpired(providerId, authData); + + if (updatedToken != null) { + this.saveIntegrationAuth(int, updatedToken); + this.emit("token-refreshed", { "integrationId": integrationId, "auth": updatedToken }); + } + authData = updatedToken; + } else if (authManager.tokenExpired(providerId, authData)) { + authData = null; + } + } else if (int.definition.linkType === "id") { + authData = int.definition.accountId; + } + + if (authData == null) { + logger.warn("Could not refresh integration access token!"); + + renderWindow.webContents.send("integrationConnectionUpdate", { + id: integrationId, + connected: false + }); + + logger.info(`Disconnected from ${int.definition.name}`); + this.emit("integration-disconnected", integrationId); + } + return authData; + } + + async refreshToken(integrationId) { + const int = this.getIntegrationById(integrationId); + if (int == null || !int.definition.linked) { + this.emit("integration-disconnected", integrationId); + return; + } + + const integrationData = { + settings: int.definition.settings, + userSettings: int.definition.userSettings + }; + + if (int.definition.linkType === "auth") { + + let authData = int.definition.auth; + if (int.definition.authProviderDetails) { + const updatedToken = await authManager.refreshTokenIfExpired(int.definition.authProviderDetails.id, + int.definition.auth); + + if (updatedToken == null) { + logger.warn("Could not refresh integration access token!"); + + renderWindow.webContents.send("integrationConnectionUpdate", { + id: integrationId, + connected: false + }); + + logger.info(`Disconnected from ${int.definition.name}`); + this.emit("integration-disconnected", integrationId); + return; + } + + this.saveIntegrationAuth(int, updatedToken); + + authData = updatedToken; + this.emit("token-refreshed", { "integrationId": integrationId, "auth": authData }); + } + integrationData.auth = authData; + } + return integrationData; + } + /** * @param {string} integrationId * @returns {boolean} From c987429896d0ec0eb31845592d0e01049d4635c2 Mon Sep 17 00:00:00 2001 From: Alastor Date: Fri, 27 Dec 2024 03:06:34 +0100 Subject: [PATCH 02/12] Changing the syntax of "token-updated" event so it's simpler. --- src/backend/integrations/integration-manager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/integrations/integration-manager.js b/src/backend/integrations/integration-manager.js index 58ff2c0cd..eac3f8b05 100644 --- a/src/backend/integrations/integration-manager.js +++ b/src/backend/integrations/integration-manager.js @@ -340,7 +340,7 @@ class IntegrationManager extends EventEmitter { if (updatedToken != null) { this.saveIntegrationAuth(int, updatedToken); - this.emit("token-refreshed", { "integrationId": integrationId, "auth": updatedToken }); + this.emit("token-refreshed", integrationId, updatedToken); } authData = updatedToken; } else if (authManager.tokenExpired(providerId, authData)) { @@ -399,7 +399,7 @@ class IntegrationManager extends EventEmitter { this.saveIntegrationAuth(int, updatedToken); authData = updatedToken; - this.emit("token-refreshed", { "integrationId": integrationId, "auth": authData }); + this.emit("token-refreshed", integrationId, updatedToken); } integrationData.auth = authData; } From 282b41c3aa936d0a82f791ba015fcd17a3c7f288 Mon Sep 17 00:00:00 2001 From: Alastor Date: Fri, 27 Dec 2024 03:06:53 +0100 Subject: [PATCH 03/12] Fixing autoRefresh being taken from the wrong location --- src/backend/integrations/integration-manager.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/backend/integrations/integration-manager.js b/src/backend/integrations/integration-manager.js index eac3f8b05..591222881 100644 --- a/src/backend/integrations/integration-manager.js +++ b/src/backend/integrations/integration-manager.js @@ -335,9 +335,11 @@ class IntegrationManager extends EventEmitter { const providerId = int.definition?.authProviderDetails.id; authData = int.definition.auth; - if (int.definition.autoRefreshToken && authManager.tokenExpired(providerId, authData)) { - const updatedToken = await authManager.refreshTokenIfExpired(providerId, authData); + if (int.definition.authProviderDetails && + int.definition.authProviderDetails.autoRefreshToken && + authManager.tokenExpired(providerId, authData)) { + const updatedToken = await authManager.refreshTokenIfExpired(providerId, authData); if (updatedToken != null) { this.saveIntegrationAuth(int, updatedToken); this.emit("token-refreshed", integrationId, updatedToken); From 13912f2c9d69b88813818f8052dc9e14f13123c3 Mon Sep 17 00:00:00 2001 From: Alastor Date: Fri, 27 Dec 2024 02:27:16 +0100 Subject: [PATCH 04/12] TS-ifying all integration and auth stuff - TS for IntegrationManager - `IntegrationData` now must contain property `linked` - `refreshToken()` now returns `AuthData` instead of `IntegrationData` - TS for AuthAPIController - Changed `AuthManager` to rely as little as possilbe on `ClientOAuth2` types and more on our own `AuthDetails` type which is better type defined. - Changed `obtainmentTimestamp` custom `AuthDetails` property which was used by twitch auth to `created_at`, more consistent with the `expires_in` standard one. - Added `client_id` and `client_secret` by default in the request body to all OAuth requests instead of just initial token request. - Fixed a few typing and linting mistakes in extralife integration. - created integrations type file, which exposes IntegrationManager and a generic abstract class for IntegrationController which integrations can inherit to emit their custom events and ensure interfacing with the integrationManager. --- src/backend/auth/auth-manager.ts | 117 ++++++--- src/backend/auth/auth.d.ts | 27 +- .../auth/firebot-device-auth-provider.ts | 8 +- src/backend/auth/twitch-auth.ts | 7 +- .../variables/extralife-donations.ts | 2 +- .../variables/extralife-incentives.ts | 4 +- .../extralife/variables/extralife-info.ts | 2 +- .../variables/extralife-milestones.ts | 4 +- ...tion-manager.js => integration-manager.ts} | 239 +++++++++--------- ...hApiController.js => authApiController.ts} | 41 ++- src/types/integrations.d.ts | 110 ++++++++ 11 files changed, 363 insertions(+), 198 deletions(-) rename src/backend/integrations/{integration-manager.js => integration-manager.ts} (65%) rename src/server/api/v1/controllers/{authApiController.js => authApiController.ts} (53%) create mode 100644 src/types/integrations.d.ts diff --git a/src/backend/auth/auth-manager.ts b/src/backend/auth/auth-manager.ts index fed9ef667..9171969c1 100644 --- a/src/backend/auth/auth-manager.ts +++ b/src/backend/auth/auth-manager.ts @@ -1,13 +1,13 @@ -import { EventEmitter } from "events"; +import { TypedEmitter } from "tiny-typed-emitter"; import ClientOAuth2 from "client-oauth2"; import logger from "../logwrapper"; -import { AuthProvider, AuthProviderDefinition } from "./auth"; +import { AuthProvider, AuthProviderDefinition, AuthDetails, AuthManagerEvents } from "./auth"; import { SettingsManager } from "../common/settings-manager"; import frontendCommunicator from "../common/frontend-communicator"; import { Notification, app } from "electron"; import windowManagement from "../app-management/electron/window-management"; -class AuthManager extends EventEmitter { +class AuthManager extends TypedEmitter { private readonly _httpPort: number; private _authProviders: AuthProvider[]; @@ -66,18 +66,25 @@ class AuthManager extends EventEmitter { } buildOAuthClientForProvider(provider: AuthProviderDefinition, redirectUri: string): ClientOAuth2 { - let scopes; + let scopes: string[] = []; if (provider.scopes) { scopes = Array.isArray(provider.scopes) ? (scopes = provider.scopes) : (scopes = provider.scopes.split(" ")); - } else { - scopes = []; } const authUri = `${provider.auth.authorizeHost ?? provider.auth.tokenHost}${provider.auth.authorizePath}`; const tokenUri = `${provider.auth.tokenHost}${provider.auth.tokenPath ?? ""}`; + // Provide client_id and client_secret in body by default. Override if any options are user-specified. + const tokenOptions = { body: {} }; + if (provider.auth.type === "code") { + tokenOptions.body["client_id"] = provider.client.id; + tokenOptions.body["client_secret"] = provider.client.secret; + } + provider.options ??= { body: {} }; + provider.options.body = Object.assign(tokenOptions.body, provider.options.body ?? {}); + return new ClientOAuth2({ clientId: provider.client.id, clientSecret: provider.auth.type === "code" ? provider.client.secret : null, @@ -86,37 +93,80 @@ class AuthManager extends EventEmitter { redirectUri: redirectUri, scopes: scopes, state: provider.id, - body: provider?.options?.body, - query: provider?.options?.query, - headers: provider?.options?.headers + body: provider.options.body, + query: provider.options?.query, + headers: provider.options?.headers }); } - createToken(providerId: string, tokenData: ClientOAuth2.Data): ClientOAuth2.Token { + getAuthDetails(accessToken: ClientOAuth2.Token): AuthDetails { + const tokenData: ClientOAuth2.Data = accessToken.data; + const accessTokenData: AuthDetails = { + access_token: tokenData.access_token, // eslint-disable-line camelcase + refresh_token: tokenData.refresh_token, // eslint-disable-line camelcase + token_type: tokenData.token_type, // eslint-disable-line camelcase + scope: Array.isArray(tokenData.scope) ? (tokenData.scope) : (tokenData.scope.split(" ")) + }; + + if (tokenData.expires_at && tokenData.expires_in) { + // induce created_at if not given + accessTokenData.expires_in = Number(tokenData.expires_in); // eslint-disable-line camelcase + accessTokenData.expires_at = new Date(tokenData.expires_at); // eslint-disable-line camelcase + accessTokenData.created_at = tokenData.created_at ? // eslint-disable-line camelcase + new Date(tokenData.created_at) : + new Date(accessTokenData.expires_at.getTime() - accessTokenData.expires_in * 1000); + } else if (tokenData.expires_at && tokenData.created_at) { + // induce expires_in + accessTokenData.created_at = new Date(tokenData.created_at); // eslint-disable-line camelcase + accessTokenData.expires_at = new Date(tokenData.expires_at); // eslint-disable-line camelcase + accessTokenData.expires_in = (accessTokenData.expires_at.getTime() - accessTokenData.created_at.getTime()) / 1000; // eslint-disable-line camelcase + } else if (tokenData.expires_in) { + // induce expires_at + // created_at = now if absent + accessTokenData.expires_in = Number(tokenData.expires_in); // eslint-disable-line camelcase + accessTokenData.created_at = new Date(tokenData?.created_at); // eslint-disable-line camelcase + accessTokenData.expires_at = new Date(accessTokenData.created_at.getTime() + accessTokenData.expires_in * 1000); // eslint-disable-line camelcase + } else if (tokenData.expires_at) { + // induce expires_in + // induce created_at = now + accessTokenData.expires_at = new Date(tokenData.expires_at); // eslint-disable-line camelcase + accessTokenData.created_at = new Date(); // eslint-disable-line camelcase + accessTokenData.expires_in = (accessTokenData.expires_at.getTime() - accessTokenData.created_at.getTime()) / 1000; // eslint-disable-line camelcase + } else { + // Not enough info on expiry time + // induce creation time + accessTokenData.created_at = new Date(); // eslint-disable-line camelcase + } + return accessTokenData; + } + + createToken(providerId: string, tokenData: AuthDetails): ClientOAuth2.Token { const provider = this.getAuthProvider(providerId); - const accessToken = provider.oauthClient.createToken(tokenData); - - // Hack because of a bug in oauth2-client - // createToken() adds an expires date property that is claculated with Date() + expires_in instead of created_at + expires_in - // As a result, expired() always saw the token valid - let tokenExpires: Date; - if (typeof tokenData.expires_in === 'number') { - tokenExpires = new Date(tokenData.created_at); - tokenExpires.setSeconds(tokenExpires.getSeconds() + Number(tokenData.expires_in)); + const accessToken = provider.oauthClient.createToken(tokenData as ClientOAuth2.Data); + + // Attempt to re-infer expiry date if data is malformed + if (!tokenData.expires_at) { + tokenData = this.getAuthDetails(accessToken); + } + + // Hack to properly recreate the ClientOAuth2 object from the Firebot stored data + if (!tokenData.expires_at) { + logger.warn(`Token has no expiry data. Assuming it is still valid. `); + // @ts-expect-error 2551 + accessToken.expires = Infinity; } else { - logger.warn(`Unknown duration: ${tokenData.expires_in}`); - return null; + // @ts-expect-error 2551 + accessToken.expires = new Date(tokenData.expires_at); } - // @ts-expect-error 2551 - accessToken.expires = tokenExpires; + return accessToken; } - tokenExpired(providerId: string, tokenData: ClientOAuth2.Data): boolean { + tokenExpired(providerId: string, tokenData: AuthDetails): boolean { return this.createToken(providerId, tokenData).expired(); } - async refreshTokenIfExpired(providerId: string, tokenData: ClientOAuth2.Data): Promise { + async refreshTokenIfExpired(providerId: string, tokenData: AuthDetails): Promise { const provider = this.getAuthProvider(providerId); let accessToken = this.createToken(providerId, tokenData); @@ -134,10 +184,11 @@ class AuthManager extends EventEmitter { return null; } } - return accessToken.data; + + return this.getAuthDetails(accessToken); } - async revokeTokens(providerId: string, tokenData: ClientOAuth2.Data): Promise { + async revokeTokens(providerId: string, tokenData: AuthDetails): Promise { const provider = this.getAuthProvider(providerId); if (provider == null) { return; @@ -151,15 +202,15 @@ class AuthManager extends EventEmitter { } } - successfulAuth(providerId: string, tokenData: unknown): void { - this.emit("auth-success", { providerId: providerId, tokenData: tokenData }); + successfulAuth(providerId: string, tokenData: AuthDetails): void { + this.emit("auth-success", providerId, tokenData); } } -const manager = new AuthManager(); +const authManager = new AuthManager(); frontendCommunicator.onAsync("begin-device-auth", async (providerId: string): Promise => { - const provider = manager.getAuthProvider(providerId); + const provider = authManager.getAuthProvider(providerId); if (provider?.details?.auth?.type !== "device") { return; } @@ -204,7 +255,7 @@ frontendCommunicator.onAsync("begin-device-auth", async (providerId: string): Pr clearInterval(tokenCheckInterval); const tokenData = await tokenResponse.json(); - manager.successfulAuth(providerId, tokenData); + authManager.successfulAuth(providerId, tokenData); if ( Notification.isSupported() && @@ -233,4 +284,4 @@ frontendCommunicator.onAsync("begin-device-auth", async (providerId: string): Pr } }); -export = manager; \ No newline at end of file +export = authManager; \ No newline at end of file diff --git a/src/backend/auth/auth.d.ts b/src/backend/auth/auth.d.ts index 130e23393..f749d87a0 100644 --- a/src/backend/auth/auth.d.ts +++ b/src/backend/auth/auth.d.ts @@ -27,6 +27,7 @@ export interface AuthProviderDefinition { }; redirectUriHost?: string; scopes?: string[] | string | undefined; + autoRefreshToken: boolean; } export interface AuthProvider { @@ -38,25 +39,39 @@ export interface AuthProvider { details: AuthProviderDefinition; } +// RFC6749 defines the following fields: +// - access_token : REQUIRED +// - refresh_token: OPTIONNAL +// - token_type : REQUIRED +// - expires_in: RECOMENDED +// - scope: OPTIONNAL. REQUIRED if different from client's request +// - state: REQUIRED export interface AuthDetails { /** The access token */ access_token: string; + /** The refresh token */ + refresh_token?: string; + /** The type of access token */ token_type: string; /** OAuth scopes of the access token */ - scope: string[]; + scope?: string[]; - /** When the token was obtained, in epoch timestamp format */ - obtainment_timestamp?: number; + /** When the token has been created */ + created_at?: Date; /** How many seconds before the token expires */ expires_in?: number; - /** JSON representation of when access token expires */ + /** When access token expires */ expires_at?: Date; - /** The refresh token */ - refresh_token?: string; + /** Extra fields to be compatible with Type ClientOAuth2.Data */ + [key: string]: unknown; +} + +export interface AuthManagerEvents { + "auth-success": (providerId: string, tokenData: AuthDetails) => void } \ No newline at end of file diff --git a/src/backend/auth/firebot-device-auth-provider.ts b/src/backend/auth/firebot-device-auth-provider.ts index f8d0fc455..c694f9241 100644 --- a/src/backend/auth/firebot-device-auth-provider.ts +++ b/src/backend/auth/firebot-device-auth-provider.ts @@ -23,11 +23,11 @@ class FirebotDeviceAuthProvider { logger.debug(`Persisting ${accountType} access token`); - const auth: AuthDetails = account.auth ?? { } as AuthDetails; + const auth: AuthDetails = account.auth ?? {} as AuthDetails; auth.access_token = token.accessToken; // eslint-disable-line camelcase auth.refresh_token = token.refreshToken; // eslint-disable-line camelcase auth.expires_in = token.expiresIn; // eslint-disable-line camelcase - auth.obtainment_timestamp = token.obtainmentTimestamp; // eslint-disable-line camelcase + auth.created_at = new Date(token.obtainmentTimestamp); // eslint-disable-line camelcase auth.expires_at = getExpiryDateOfAccessToken({ // eslint-disable-line camelcase expiresIn: token.expiresIn, obtainmentTimestamp: token.obtainmentTimestamp @@ -52,7 +52,7 @@ class FirebotDeviceAuthProvider { accessToken: streamerAcccount.auth.access_token, refreshToken: streamerAcccount.auth.refresh_token, expiresIn: streamerAcccount.auth.expires_in, - obtainmentTimestamp: streamerAcccount.auth.obtainment_timestamp ?? Date.now(), + obtainmentTimestamp: new Date(streamerAcccount.auth?.created_at).getTime(), scope: scopes } }); @@ -82,7 +82,7 @@ class FirebotDeviceAuthProvider { accessToken: botAcccount.auth.access_token, refreshToken: botAcccount.auth.refresh_token, expiresIn: botAcccount.auth.expires_in, - obtainmentTimestamp: botAcccount.auth.obtainment_timestamp ?? Date.now(), + obtainmentTimestamp: new Date(botAcccount.auth?.created_at).getTime(), scope: scopes } }); diff --git a/src/backend/auth/twitch-auth.ts b/src/backend/auth/twitch-auth.ts index 584d2cb18..a4ba637af 100644 --- a/src/backend/auth/twitch-auth.ts +++ b/src/backend/auth/twitch-auth.ts @@ -28,6 +28,7 @@ class TwitchAuthProviders { tokenPath: this._tokenPath, type: "device" }, + autoRefreshToken: false, scopes: [ 'bits:read', 'channel:edit:commercial', @@ -104,6 +105,7 @@ class TwitchAuthProviders { tokenPath: this._tokenPath, type: "device" }, + autoRefreshToken: false, scopes: [ 'channel:moderate', 'chat:edit', @@ -149,8 +151,7 @@ async function getUserCurrent(accessToken: string) { return null; } -authManager.on("auth-success", async (authData) => { - const { providerId, tokenData } = authData; +authManager.on("auth-success", async (providerId, tokenData) => { if (providerId === twitchAuthProviders.streamerAccountProviderId || providerId === twitchAuthProviders.botAccountProviderId) { @@ -172,7 +173,7 @@ authManager.on("auth-success", async (authData) => { broadcasterType: userData.broadcaster_type, auth: { ...tokenData, - obtainment_timestamp: obtainmentTimestamp, // eslint-disable-line camelcase + created_at: new Date(obtainmentTimestamp), // eslint-disable-line camelcase expires_at: getExpiryDateOfAccessToken({ // eslint-disable-line camelcase expiresIn: tokenData.expires_in, obtainmentTimestamp: obtainmentTimestamp diff --git a/src/backend/integrations/builtin/extralife/variables/extralife-donations.ts b/src/backend/integrations/builtin/extralife/variables/extralife-donations.ts index c4c663329..a59fe1e76 100644 --- a/src/backend/integrations/builtin/extralife/variables/extralife-donations.ts +++ b/src/backend/integrations/builtin/extralife/variables/extralife-donations.ts @@ -42,7 +42,7 @@ const ExtraLifeDonations: ReplaceVariable = { } if (participantID == null) { - participantID = integrationManager.getIntegrationAccountId("extralife"); + participantID = Number(integrationManager.getIntegrationAccountId("extralife")); } if (sortName == null || sortName.trim() === '') { diff --git a/src/backend/integrations/builtin/extralife/variables/extralife-incentives.ts b/src/backend/integrations/builtin/extralife/variables/extralife-incentives.ts index 785e72fb4..4d1204275 100644 --- a/src/backend/integrations/builtin/extralife/variables/extralife-incentives.ts +++ b/src/backend/integrations/builtin/extralife/variables/extralife-incentives.ts @@ -38,14 +38,14 @@ const ExtraLifeIncentives: ReplaceVariable = { } if (participantID == null) { - participantID = integrationManager.getIntegrationAccountId("extralife"); + participantID = Number(integrationManager.getIntegrationAccountId("extralife")); } if (rewardDesc == null || rewardDesc.trim() === '') { rewardDesc = null; } - let extraLifeCall = await getParticipantIncentives(participantID, {orderBy: 'amount ASC'}).then((result) => { + let extraLifeCall = await getParticipantIncentives(participantID, { orderBy: 'amount ASC' }).then((result) => { result = result.data; if (rewardDesc != null) { diff --git a/src/backend/integrations/builtin/extralife/variables/extralife-info.ts b/src/backend/integrations/builtin/extralife/variables/extralife-info.ts index 4db6b6108..6433f6243 100644 --- a/src/backend/integrations/builtin/extralife/variables/extralife-info.ts +++ b/src/backend/integrations/builtin/extralife/variables/extralife-info.ts @@ -62,7 +62,7 @@ const ExtraLifeInfo: ReplaceVariable = { }, evaluator: (_, infoPath: string, participantID: number, returnJson: boolean) => { if (participantID == null) { - participantID = integrationManager.getIntegrationAccountId("extralife"); + participantID = Number(integrationManager.getIntegrationAccountId("extralife")); } if (infoPath == null || infoPath.trim() === '') { diff --git a/src/backend/integrations/builtin/extralife/variables/extralife-milestones.ts b/src/backend/integrations/builtin/extralife/variables/extralife-milestones.ts index 02315434c..2f6eb2845 100644 --- a/src/backend/integrations/builtin/extralife/variables/extralife-milestones.ts +++ b/src/backend/integrations/builtin/extralife/variables/extralife-milestones.ts @@ -38,7 +38,7 @@ const ExtraLifeMilestones: ReplaceVariable = { } if (participantID == null) { - participantID = integrationManager.getIntegrationAccountId("extralife"); + participantID = Number(integrationManager.getIntegrationAccountId("extralife")); } if (milestoneGoal.trim() === '') { @@ -54,7 +54,7 @@ const ExtraLifeMilestones: ReplaceVariable = { return 0; }); - let extraLifeCall = await getParticipantMilestones(participantID, {orderBy: 'fundraisingGoal ASC'}).then((result) => { + let extraLifeCall = await getParticipantMilestones(participantID, { orderBy: 'fundraisingGoal ASC' }).then((result) => { result = result.data; if (milestoneGoal != null) { diff --git a/src/backend/integrations/integration-manager.js b/src/backend/integrations/integration-manager.ts similarity index 65% rename from src/backend/integrations/integration-manager.js rename to src/backend/integrations/integration-manager.ts index 591222881..8464decb4 100644 --- a/src/backend/integrations/integration-manager.js +++ b/src/backend/integrations/integration-manager.ts @@ -1,23 +1,40 @@ -"use strict"; -const { ipcMain } = require("electron"); -const logger = require("../logwrapper"); -const profileManager = require("../common/profile-manager"); -const authManager = require("../auth/auth-manager"); -const EventEmitter = require("events"); -const { shell } = require('electron'); -const { SettingsManager } = require('../common/settings-manager'); -const frontEndCommunicator = require('../common/frontend-communicator'); -const { setValuesForFrontEnd, buildSaveDataFromSettingValues } = require("../common/firebot-setting-helpers"); - -/**@extends {NodeJS.EventEmitter} */ -class IntegrationManager extends EventEmitter { +import { ipcMain, shell } from "electron"; +import { TypedEmitter } from "tiny-typed-emitter"; +import { SettingsManager } from "../common/settings-manager"; +import { setValuesForFrontEnd, buildSaveDataFromSettingValues } from "../common/firebot-setting-helpers"; +import { AuthDetails } from "../auth/auth"; +import { + AccountIdDetails, + Integration, + IntegrationData, + IntegrationDefinition, + LinkData, + IntegrationManagerEvents +} from "../../types/integrations"; +import { FirebotParams } from "@crowbartools/firebot-custom-scripts-types/types/modules/firebot-parameters"; +import logger from "../logwrapper"; +import profileManager from "../common/profile-manager"; +import authManager from "../auth/auth-manager"; +import frontEndCommunicator from "../common/frontend-communicator"; + +class IntegrationManager extends TypedEmitter { + private _integrations: Array = []; + constructor() { super(); + authManager.on("auth-success", (providerId, tokenData) => { + const int = this._integrations.find(i => i.definition.linkType === "auth" && + i.definition.authProviderDetails.id === providerId); + if (int != null) { - this._integrations = []; + this.saveIntegrationAuth(int, tokenData); + + this.linkIntegration(int, { auth: tokenData }); + } + }); } - registerIntegration(integration) { + registerIntegration(integration: Integration): void { integration.definition.linked = false; if (integration.definition.linkType === "auth") { @@ -26,7 +43,7 @@ class IntegrationManager extends EventEmitter { const integrationDb = profileManager.getJsonDbInProfile("/integrations"); try { - const integrationSettings = integrationDb.getData(`/${integration.definition.id}`); + const integrationSettings = integrationDb.getData(`/${integration.definition.id}`) as IntegrationData; if (integrationSettings != null) { integration.definition.settings = integrationSettings.settings; integration.definition.userSettings = integrationSettings.userSettings; @@ -50,7 +67,7 @@ class IntegrationManager extends EventEmitter { accountId: integration.definition.accountId, settings: integration.definition.settings, userSettings: integration.definition.userSettings - } + } as IntegrationData ); this._integrations.push(integration); @@ -63,35 +80,35 @@ class IntegrationManager extends EventEmitter { global.renderWindow.webContents.send("integrationsUpdated"); } - integration.integration.on("connected", (id) => { - renderWindow.webContents.send("integrationConnectionUpdate", { - id: id, + integration.integration.on("connected", (integrationId: string) => { + global.renderWindow.webContents.send("integrationConnectionUpdate", { + id: integrationId, connected: true }); - this.emit("integration-connected", id); - logger.info(`Successfully connected to ${id}`); + this.emit("integration-connected", integrationId); + logger.info(`Successfully connected to ${integrationId}`); }); - integration.integration.on("disconnected", (id) => { - renderWindow.webContents.send("integrationConnectionUpdate", { - id: id, + integration.integration.on("disconnected", (integrationId: string) => { + global.renderWindow.webContents.send("integrationConnectionUpdate", { + id: integrationId, connected: false }); - this.emit("integration-disconnected", id); - logger.info(`Disconnected from ${id}`); + this.emit("integration-disconnected", integrationId); + logger.info(`Disconnected from ${integrationId}`); }); - integration.integration.on("reconnect", (id) => { - logger.debug(`Reconnecting to ${id}...`); - this.connectIntegration(id); + integration.integration.on("reconnect", (integrationId: string) => { + logger.debug(`Reconnecting to ${integrationId}...`); + this.connectIntegration(integrationId); }); - integration.integration.on("settings-update", (id, settings) => { + integration.integration.on("settings-update", (integrationId: string, settings: FirebotParams) => { try { const integrationDb = profileManager.getJsonDbInProfile("/integrations"); - integrationDb.push(`/${id}/settings`, settings); + integrationDb.push(`/${integrationId}/settings`, settings); - const int = this.getIntegrationById(id); + const int = this.getIntegrationById(integrationId); if (int != null) { int.definition.linked = true; int.definition.settings = settings; @@ -103,15 +120,15 @@ class IntegrationManager extends EventEmitter { }); } - getIntegrationUserSettings(integrationId) { - const int = this.getIntegrationById(integrationId); + getIntegrationUserSettings(integrationId: string): Params { + const int = this.getIntegrationById(integrationId); if (int == null) { return null; } return int.definition.userSettings; } - saveIntegrationUserSettings(id, settings, notifyInt = true) { + saveIntegrationUserSettings(id: string, settings: FirebotParams, notifyInt = true): void { try { const integrationDb = profileManager.getJsonDbInProfile("/integrations"); integrationDb.push(`/${id}/userSettings`, settings); @@ -127,7 +144,7 @@ class IntegrationManager extends EventEmitter { userSettings: int.definition.userSettings, auth: int.definition.auth, accountId: int.definition.accountId - }; + } as IntegrationData; int.integration.onUserSettingsUpdate(integrationData); } } catch (error) { @@ -135,16 +152,16 @@ class IntegrationManager extends EventEmitter { } } - getIntegrationById(integrationId) { - return this._integrations.find(i => i.definition.id === integrationId); + getIntegrationById(integrationId: string): Integration { + return this._integrations.find(i => i.definition.id === integrationId) as Integration; } - getIntegrationDefinitionById(integrationId) { - const integration = this.getIntegrationById(integrationId); + getIntegrationDefinitionById(integrationId: string): IntegrationDefinition { + const integration = this.getIntegrationById(integrationId); return integration ? integration.definition : null; } - integrationIsConnectable(integrationId) { + integrationIsConnectable(integrationId: string): boolean { const integration = this.getIntegrationDefinitionById(integrationId); if (integration == null) { return false; @@ -155,7 +172,7 @@ class IntegrationManager extends EventEmitter { return true; } - getAllIntegrationDefinitions() { + getAllIntegrationDefinitions(): Array { return this._integrations .map(i => i.definition) .map((i) => { @@ -165,16 +182,17 @@ class IntegrationManager extends EventEmitter { description: i.description, linked: i.linked, linkType: i.linkType, + idDetails: i.linkType === "id" ? i.idDetails : undefined, + authProviderDetails: i.linkType === "auth" ? i.authProviderDetails : undefined, connectionToggle: i.connectionToggle, - idDetails: i.idDetails, configurable: i.configurable, settings: i.settings, settingCategories: i.settingCategories ? setValuesForFrontEnd(i.settingCategories, i.userSettings) : undefined - }; + } as IntegrationDefinition; }); } - saveIntegrationAuth(integration, authData) { + saveIntegrationAuth(integration: Integration, authData: AuthDetails): void { integration.definition.auth = authData; try { @@ -186,12 +204,12 @@ class IntegrationManager extends EventEmitter { } } - getIntegrationAccountId(integrationId) { + getIntegrationAccountId(integrationId: string): AccountIdDetails { const int = this.getIntegrationById(integrationId); return int?.definition?.accountId; } - saveIntegrationAccountId(integration, accountId) { + saveIntegrationAccountId(integration: Integration, accountId: AccountIdDetails): void { integration.definition.accountId = accountId; try { @@ -203,7 +221,7 @@ class IntegrationManager extends EventEmitter { } } - startIntegrationLink(integrationId) { + startIntegrationLink(integrationId: string): void { const int = this.getIntegrationById(integrationId); if (int == null || int.definition.linked) { return; @@ -223,7 +241,7 @@ class IntegrationManager extends EventEmitter { } } - async linkIntegration(int, linkData) { + async linkIntegration(int: Integration, linkData: LinkData): Promise { try { await int.integration.link(linkData); } catch (error) { @@ -237,23 +255,25 @@ class IntegrationManager extends EventEmitter { integrationDb.push(`/${int.definition.id}/linked`, true); int.definition.linked = true; - renderWindow.webContents.send("integrationsUpdated"); + global.renderWindow.webContents.send("integrationsUpdated"); frontEndCommunicator.send("integrationLinked", { id: int.definition.id, connectionToggle: int.definition.connectionToggle }); } - unlinkIntegration(integrationId) { + unlinkIntegration(integrationId: string): Promise { const int = this.getIntegrationById(integrationId); if (int == null || !int.definition.linked) { return; } - this.disconnectIntegration(int); + this.disconnectIntegration(integrationId); try { - int.integration.unlink(); + if (int.integration.unlink) { + int.integration.unlink(); + } const integrationDb = profileManager.getJsonDbInProfile("/integrations"); integrationDb.delete(`/${integrationId}`); int.definition.settings = null; @@ -264,21 +284,22 @@ class IntegrationManager extends EventEmitter { logger.warn(error); } - renderWindow.webContents.send("integrationsUpdated"); + global.renderWindow.webContents.send("integrationsUpdated"); frontEndCommunicator.send("integrationUnlinked", integrationId); } - async connectIntegration(integrationId) { + async connectIntegration(integrationId: string): Promise { const int = this.getIntegrationById(integrationId); if (int == null || !int.definition.linked) { this.emit("integration-disconnected", integrationId); return; } - const integrationData = { + const integrationData: IntegrationData = { settings: int.definition.settings, - userSettings: int.definition.userSettings + userSettings: int.definition.userSettings, + linked: int.definition.linked }; if (int.definition.linkType === "auth") { @@ -291,7 +312,7 @@ class IntegrationManager extends EventEmitter { if (updatedToken == null) { logger.warn("Could not refresh integration access token!"); - renderWindow.webContents.send("integrationConnectionUpdate", { + global.renderWindow.webContents.send("integrationConnectionUpdate", { id: integrationId, connected: false }); @@ -315,7 +336,7 @@ class IntegrationManager extends EventEmitter { int.integration.connect(integrationData); } - disconnectIntegration(integrationId) { + disconnectIntegration(integrationId: string): Promise { const int = this.getIntegrationById(integrationId); if (int == null || !int.definition.linked || !int.integration.connected) { return; @@ -323,39 +344,39 @@ class IntegrationManager extends EventEmitter { int.integration.disconnect(); } - async getAuth(integrationId) { + async getAuth(integrationId: string): Promise { const int = this.getIntegrationById(integrationId); if (int == null || !int.definition.linked) { this.emit("integration-disconnected", integrationId); return null; } - let authData = null; + let authData: LinkData = null; if (int.definition.linkType === "auth") { const providerId = int.definition?.authProviderDetails.id; - authData = int.definition.auth; + authData = { auth: int.definition.auth }; if (int.definition.authProviderDetails && int.definition.authProviderDetails.autoRefreshToken && - authManager.tokenExpired(providerId, authData)) { + authManager.tokenExpired(providerId, authData.auth)) { - const updatedToken = await authManager.refreshTokenIfExpired(providerId, authData); + const updatedToken = await authManager.refreshTokenIfExpired(providerId, authData.auth); if (updatedToken != null) { this.saveIntegrationAuth(int, updatedToken); this.emit("token-refreshed", integrationId, updatedToken); } - authData = updatedToken; - } else if (authManager.tokenExpired(providerId, authData)) { + authData.auth = updatedToken; + } else if (authManager.tokenExpired(providerId, authData.auth)) { authData = null; } } else if (int.definition.linkType === "id") { - authData = int.definition.accountId; + authData = { accountId: int.definition.accountId }; } if (authData == null) { logger.warn("Could not refresh integration access token!"); - renderWindow.webContents.send("integrationConnectionUpdate", { + global.renderWindow.webContents.send("integrationConnectionUpdate", { id: integrationId, connected: false }); @@ -366,21 +387,22 @@ class IntegrationManager extends EventEmitter { return authData; } - async refreshToken(integrationId) { + async refreshToken(integrationId: string): Promise { const int = this.getIntegrationById(integrationId); if (int == null || !int.definition.linked) { this.emit("integration-disconnected", integrationId); return; } - const integrationData = { + const integrationData: IntegrationData = { settings: int.definition.settings, - userSettings: int.definition.userSettings + userSettings: int.definition.userSettings, + linked: int.definition.linked }; + let authData = null; if (int.definition.linkType === "auth") { - - let authData = int.definition.auth; + authData = int.definition.auth; if (int.definition.authProviderDetails) { const updatedToken = await authManager.refreshTokenIfExpired(int.definition.authProviderDetails.id, int.definition.auth); @@ -388,7 +410,7 @@ class IntegrationManager extends EventEmitter { if (updatedToken == null) { logger.warn("Could not refresh integration access token!"); - renderWindow.webContents.send("integrationConnectionUpdate", { + global.renderWindow.webContents.send("integrationConnectionUpdate", { id: integrationId, connected: false }); @@ -405,26 +427,18 @@ class IntegrationManager extends EventEmitter { } integrationData.auth = authData; } - return integrationData; + return authData; } - /** - * @param {string} integrationId - * @returns {boolean} - */ - integrationCanConnect(integrationId) { + integrationCanConnect(integrationId: string): boolean { const int = this.getIntegrationById(integrationId); if (int == null) { return false; } - return !!int.integration.connectionToggle; + return !!int.definition.connectionToggle; } - /** - * @param {string} integrationId - * @returns {boolean} - */ - integrationIsConnected(integrationId) { + integrationIsConnected(integrationId: string): boolean { const int = this.getIntegrationById(integrationId); if (int == null) { return false; @@ -432,81 +446,66 @@ class IntegrationManager extends EventEmitter { return int.integration.connected; } - /** - * @param {string} integrationId - * @returns {boolean} - */ - integrationIsLinked(integrationId) { + integrationIsLinked(integrationId: string): boolean { const int = this.getIntegrationById(integrationId); if (int == null) { return false; } - return int.integration.linked; + return int.definition.linked; } } -const manager = new IntegrationManager(); +const integrationManager = new IntegrationManager(); -frontEndCommunicator.on("integrationUserSettingsUpdate", (integrationData) => { +frontEndCommunicator.on("integrationUserSettingsUpdate", (integrationData: IntegrationDefinition) => { if (integrationData == null) { return; } - const int = manager.getIntegrationById(integrationData.id); + const int = integrationManager.getIntegrationById(integrationData.id); if (int != null) { - manager.saveIntegrationUserSettings(int.definition.id, + integrationManager.saveIntegrationUserSettings(int.definition.id, buildSaveDataFromSettingValues(integrationData.settingCategories, int.definition.userSettings)); } }); - -frontEndCommunicator.onAsync("enteredIntegrationAccountId", async (idData) => { +// TODO: The day the frontend is moved to typescript, this arguments list needs to be flattened +// Having a dict here allows to tell the frontend what goes where for now +frontEndCommunicator.onAsync<[{ integrationId: string, accountId: string }]>("enteredIntegrationAccountId", async (idData) => { const { integrationId, accountId } = idData; - const int = manager.getIntegrationById(integrationId); + const int = integrationManager.getIntegrationById(integrationId); if (int == null) { return; } - manager.saveIntegrationAccountId(int, accountId); + integrationManager.saveIntegrationAccountId(int, accountId); - manager.linkIntegration(int, { accountId: accountId }); -}); - -authManager.on("auth-success", (authData) => { - const { providerId, tokenData } = authData; - const int = manager._integrations.find(i => i.definition.linkType === "auth" && - i.definition.authProviderDetails.id === providerId); - if (int != null) { - - manager.saveIntegrationAuth(int, tokenData); - - manager.linkIntegration(int, { auth: tokenData }); - } + integrationManager.linkIntegration(int, { accountId: accountId }); }); ipcMain.on("linkIntegration", (event, integrationId) => { logger.info("got 'linkIntegration' request"); - manager.startIntegrationLink(integrationId); + integrationManager.startIntegrationLink(integrationId); }); ipcMain.on("unlinkIntegration", (event, integrationId) => { logger.info("got 'unlinkIntegration' request"); - manager.unlinkIntegration(integrationId); + integrationManager.unlinkIntegration(integrationId); }); ipcMain.on("connectIntegration", (event, integrationId) => { logger.info("got 'connectIntegration' request"); - manager.connectIntegration(integrationId); + integrationManager.connectIntegration(integrationId); }); ipcMain.on("disconnectIntegration", (event, integrationId) => { logger.info("got 'disconnectIntegration' request"); - manager.disconnectIntegration(integrationId); + integrationManager.disconnectIntegration(integrationId); }); ipcMain.on("getAllIntegrationDefinitions", (event) => { logger.info("got 'get all integrations' request"); - event.returnValue = manager.getAllIntegrationDefinitions(); + event.returnValue = integrationManager.getAllIntegrationDefinitions(); }); -module.exports = manager; +export = integrationManager; diff --git a/src/server/api/v1/controllers/authApiController.js b/src/server/api/v1/controllers/authApiController.ts similarity index 53% rename from src/server/api/v1/controllers/authApiController.js rename to src/server/api/v1/controllers/authApiController.ts index dc0b9d07f..625b979be 100644 --- a/src/server/api/v1/controllers/authApiController.js +++ b/src/server/api/v1/controllers/authApiController.ts @@ -1,11 +1,12 @@ -"use strict"; -const logger = require('../../../../backend/logwrapper'); -const authManager = require('../../../../backend/auth/auth-manager'); +import logger from '../../../../backend/logwrapper'; +import authManager from '../../../../backend/auth/auth-manager'; +import { Request, Response } from "express"; +import { AuthProvider } from "../../../../backend/auth/auth"; +import ClientOAuth2 from "client-oauth2"; -exports.getAuth = (req, res) => { +export function getAuth(req: Request, res: Response) { const providerId = req.query.providerId; - - const provider = authManager.getAuthProvider(providerId); + const provider: AuthProvider = typeof providerId === "string" ? authManager.getAuthProvider(providerId) : null; if (provider == null) { return res.status(400).json('Invalid providerId query param'); @@ -14,40 +15,29 @@ exports.getAuth = (req, res) => { logger.info(`Redirecting to provider auth uri: ${provider.authorizationUri}`); res.redirect(provider.authorizationUri); -}; +} -exports.getAuthCallback = async ( - /** @type {import("express").Request} */ req, - /** @type {import("express").Response} */ res) => { +export async function getAuthCallback(req: Request, res: Response) { const state = req.query.state; - /** @type {import("../../../../backend/auth/auth").AuthProvider} */ - const provider = authManager.getAuthProvider(state); + const provider: AuthProvider = typeof state === "string" ? authManager.getAuthProvider(state) : null; if (provider == null) { return res.status(400).json('Invalid provider id in state'); } try { const fullUrl = req.originalUrl.replace("callback2", "callback"); - /** @type {import("client-oauth2").Token} */ - let token; + let token: ClientOAuth2.Token; const authType = provider.details.auth.type ?? "code"; - /** @type {import("client-oauth2").Options} */ - const tokenOptions = { body: {} }; - switch (authType) { case "token": - token = await provider.oauthClient.token.getToken(fullUrl, tokenOptions); + token = await provider.oauthClient.token.getToken(fullUrl); break; case "code": - // Force these because the library adds them as an auth header, not in the body - tokenOptions.body["client_id"] = provider.details.client.id; - tokenOptions.body["client_secret"] = provider.details.client.secret; - - token = await provider.oauthClient.code.getToken(fullUrl, tokenOptions); + token = await provider.oauthClient.code.getToken(fullUrl); break; default: @@ -55,8 +45,7 @@ exports.getAuthCallback = async ( } logger.info(`Received token from provider id '${provider.id}'`); - const tokenData = token.data; - tokenData.scope = tokenData.scope?.split(" "); + const tokenData = authManager.getAuthDetails(token); authManager.successfulAuth(provider.id, tokenData); @@ -65,4 +54,4 @@ exports.getAuthCallback = async ( logger.error('Access Token Error', error.message); return res.status(500).json('Authentication failed'); } -}; +} diff --git a/src/types/integrations.d.ts b/src/types/integrations.d.ts new file mode 100644 index 000000000..7756aa999 --- /dev/null +++ b/src/types/integrations.d.ts @@ -0,0 +1,110 @@ +import { + TypedEmitter, + ListenerSignature +} from "tiny-typed-emitter"; +import { + FirebotParameterCategories, + FirebotParams +} from "@crowbartools/firebot-custom-scripts-types/types/modules/firebot-parameters"; +import { + AuthProviderDefinition, + AuthDetails +} from "../backend/auth/auth"; + +export type AccountIdDefinition = { + label: string; + steps: string; +} +export type AccountIdDetails = string + +export type IntegrationData = { + settings?: Params; + userSettings?: Params; + auth?: AuthDetails; + accountId?: AccountIdDetails; + linked?: boolean; +}; + +type LinkIdDefinition = { linkType: "id", idDetails: AccountIdDefinition }; +type LinkAuthDefinition = { linkType: "auth", authProviderDetails: AuthProviderDefinition }; +type LinkOtherDefinition = { linkType: "other" | "none", [key: string]: unknown }; + +export type IntegrationDefinition = { + id: string; + name: string; + description: string; + connectionToggle?: boolean; + configurable?: boolean; + settingCategories: FirebotParameterCategories; +} & IntegrationData & (LinkIdDefinition | LinkAuthDefinition | LinkOtherDefinition); + +type LinkIdData = { accountId: AccountIdDetails }; +type LinkAuthData = { auth: AuthDetails }; +export type LinkData = LinkIdData | LinkAuthData | null; + +export interface IntegrationEvents { + "connected": (integrationId: string) => void; + "disconnected": (integrationId: string) => void; + "reconnect": (integrationId: string) => void; + "settings-update": (integrationId: string, settings: FirebotParams) => void; +} + +export abstract class IntegrationController< + Params extends FirebotParams = FirebotParams, + Events extends IntegrationEvents = IntegrationEvents +> extends TypedEmitter> { + connected: boolean; + abstract init( + linked: boolean, + integrationData: IntegrationData + ): void | PromiseLike; + abstract link?(linkData: LinkData): void | PromiseLike; + abstract unlink?(): void | PromiseLike; + abstract connect?( + integrationData: IntegrationData + ): void | PromiseLike; + abstract disconnect?(): void | PromiseLike; + abstract onUserSettingsUpdate?( + integrationData: IntegrationData + ): void | PromiseLike; +} + +export type Integration< + Params extends FirebotParams = FirebotParams, + Events extends IntegrationEvents = IntegrationEvents +> = { + definition: IntegrationDefinition; + integration: IntegrationController; +}; + +export interface IntegrationManagerEvents { + "integrationRegistered": (integration: Integration) => void; + "integration-connected": (integrationId: string) => void; + "integration-disconnected": (integrationId: string) => void; + "token-refreshed": (integrationId: string, updatedToken: AuthDetails) => void; +} + +export declare class IntegrationManager extends TypedEmitter { + private _integrations; + constructor(); + registerIntegration(integration: Integration): void; + getIntegrationUserSettings(integrationId: string): Params; + saveIntegrationUserSettings(id: string, settings: FirebotParams, notifyInt?: boolean): void; + getIntegrationById(integrationId: string): Integration; + getIntegrationDefinitionById(integrationId: string): IntegrationDefinition; + integrationIsConnectable(integrationId: string): boolean; + getAllIntegrationDefinitions(): Array; + saveIntegrationAuth(integration: Integration, authData: AuthDetails): void; + getIntegrationAccountId(integrationId: string): AccountIdDetails; + saveIntegrationAccountId(integration: Integration, accountId: AccountIdDetails): void; + startIntegrationLink(integrationId: string): void; + linkIntegration(int: Integration, linkData: LinkData): Promise; + unlinkIntegration(integrationId: string): Promise; + connectIntegration(integrationId: string): Promise; + disconnectIntegration(integrationId: string): Promise; + getAuth(integrationId: string): Promise; + refreshToken(integrationId: string): Promise; + integrationCanConnect(integrationId: string): boolean; + integrationIsConnected(integrationId: string): boolean; + integrationIsLinked(integrationId: string): boolean; +} \ No newline at end of file From ad94a7b00ffd827cb68ccef60a24e2414fb6c690 Mon Sep 17 00:00:00 2001 From: Alastor Date: Fri, 27 Dec 2024 17:56:23 +0100 Subject: [PATCH 05/12] Changing token dates to timestamp for storage purposes - JSonDb properly restores numbers, not Dates, so a timestamp is better stored - Solving a bug where token created_at and expires_at wouldn't properly refresh when refreshing tokens, leading to chain refreshing. --- src/backend/auth/auth-manager.ts | 29 ++++++++++--------- src/backend/auth/auth.d.ts | 8 ++--- .../auth/firebot-device-auth-provider.ts | 4 +-- src/backend/auth/twitch-auth.ts | 4 +-- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/backend/auth/auth-manager.ts b/src/backend/auth/auth-manager.ts index 9171969c1..49f5d4032 100644 --- a/src/backend/auth/auth-manager.ts +++ b/src/backend/auth/auth-manager.ts @@ -111,31 +111,31 @@ class AuthManager extends TypedEmitter { if (tokenData.expires_at && tokenData.expires_in) { // induce created_at if not given accessTokenData.expires_in = Number(tokenData.expires_in); // eslint-disable-line camelcase - accessTokenData.expires_at = new Date(tokenData.expires_at); // eslint-disable-line camelcase + accessTokenData.expires_at = new Date(tokenData.expires_at).getTime(); // eslint-disable-line camelcase accessTokenData.created_at = tokenData.created_at ? // eslint-disable-line camelcase - new Date(tokenData.created_at) : - new Date(accessTokenData.expires_at.getTime() - accessTokenData.expires_in * 1000); + new Date(tokenData.created_at).getTime() : + new Date(accessTokenData.expires_at - accessTokenData.expires_in * 1000).getTime(); } else if (tokenData.expires_at && tokenData.created_at) { // induce expires_in - accessTokenData.created_at = new Date(tokenData.created_at); // eslint-disable-line camelcase - accessTokenData.expires_at = new Date(tokenData.expires_at); // eslint-disable-line camelcase - accessTokenData.expires_in = (accessTokenData.expires_at.getTime() - accessTokenData.created_at.getTime()) / 1000; // eslint-disable-line camelcase + accessTokenData.created_at = new Date(tokenData.created_at).getTime(); // eslint-disable-line camelcase + accessTokenData.expires_at = new Date(tokenData.expires_at).getTime(); // eslint-disable-line camelcase + accessTokenData.expires_in = (accessTokenData.expires_at - accessTokenData.created_at) / 1000; // eslint-disable-line camelcase } else if (tokenData.expires_in) { // induce expires_at // created_at = now if absent accessTokenData.expires_in = Number(tokenData.expires_in); // eslint-disable-line camelcase - accessTokenData.created_at = new Date(tokenData?.created_at); // eslint-disable-line camelcase - accessTokenData.expires_at = new Date(accessTokenData.created_at.getTime() + accessTokenData.expires_in * 1000); // eslint-disable-line camelcase + accessTokenData.created_at = new Date(tokenData?.created_at).getTime(); // eslint-disable-line camelcase + accessTokenData.expires_at = new Date(accessTokenData.created_at + accessTokenData.expires_in * 1000).getTime(); // eslint-disable-line camelcase } else if (tokenData.expires_at) { // induce expires_in // induce created_at = now - accessTokenData.expires_at = new Date(tokenData.expires_at); // eslint-disable-line camelcase - accessTokenData.created_at = new Date(); // eslint-disable-line camelcase - accessTokenData.expires_in = (accessTokenData.expires_at.getTime() - accessTokenData.created_at.getTime()) / 1000; // eslint-disable-line camelcase + accessTokenData.expires_at = new Date(tokenData.expires_at).getTime(); // eslint-disable-line camelcase + accessTokenData.created_at = new Date().getTime(); // eslint-disable-line camelcase + accessTokenData.expires_in = (accessTokenData.expires_at - accessTokenData.created_at) / 1000; // eslint-disable-line camelcase } else { // Not enough info on expiry time // induce creation time - accessTokenData.created_at = new Date(); // eslint-disable-line camelcase + accessTokenData.created_at = new Date().getTime(); // eslint-disable-line camelcase } return accessTokenData; } @@ -168,7 +168,7 @@ class AuthManager extends TypedEmitter { async refreshTokenIfExpired(providerId: string, tokenData: AuthDetails): Promise { const provider = this.getAuthProvider(providerId); - let accessToken = this.createToken(providerId, tokenData); + let accessToken: ClientOAuth2.Token = this.createToken(providerId, tokenData); if (accessToken.expired()) { try { @@ -178,6 +178,9 @@ class AuthManager extends TypedEmitter { : provider.details.scopes.split(" ") }; + // Remove the useless extra data that would be left untouched by the token provider + accessToken.data.created_at = null; // eslint-disable-line camelcase + accessToken.data.expires_at = null; // eslint-disable-line camelcase accessToken = await accessToken.refresh(params); } catch (error) { logger.warn("Error refreshing access token: ", error); diff --git a/src/backend/auth/auth.d.ts b/src/backend/auth/auth.d.ts index f749d87a0..5af2ec59c 100644 --- a/src/backend/auth/auth.d.ts +++ b/src/backend/auth/auth.d.ts @@ -59,14 +59,14 @@ export interface AuthDetails { /** OAuth scopes of the access token */ scope?: string[]; - /** When the token has been created */ - created_at?: Date; + /** Timestamp of when the token has been created */ + created_at?: number; /** How many seconds before the token expires */ expires_in?: number; - /** When access token expires */ - expires_at?: Date; + /** Timestamp of when access token expires */ + expires_at?: number; /** Extra fields to be compatible with Type ClientOAuth2.Data */ [key: string]: unknown; diff --git a/src/backend/auth/firebot-device-auth-provider.ts b/src/backend/auth/firebot-device-auth-provider.ts index c694f9241..00c45e6c8 100644 --- a/src/backend/auth/firebot-device-auth-provider.ts +++ b/src/backend/auth/firebot-device-auth-provider.ts @@ -27,11 +27,11 @@ class FirebotDeviceAuthProvider { auth.access_token = token.accessToken; // eslint-disable-line camelcase auth.refresh_token = token.refreshToken; // eslint-disable-line camelcase auth.expires_in = token.expiresIn; // eslint-disable-line camelcase - auth.created_at = new Date(token.obtainmentTimestamp); // eslint-disable-line camelcase + auth.created_at = token.obtainmentTimestamp; // eslint-disable-line camelcase auth.expires_at = getExpiryDateOfAccessToken({ // eslint-disable-line camelcase expiresIn: token.expiresIn, obtainmentTimestamp: token.obtainmentTimestamp - }); + }).getTime(); account.auth = auth; accountAccess.updateAccount(accountType, account, false); diff --git a/src/backend/auth/twitch-auth.ts b/src/backend/auth/twitch-auth.ts index a4ba637af..0ed4d0e6d 100644 --- a/src/backend/auth/twitch-auth.ts +++ b/src/backend/auth/twitch-auth.ts @@ -173,11 +173,11 @@ authManager.on("auth-success", async (providerId, tokenData) => { broadcasterType: userData.broadcaster_type, auth: { ...tokenData, - created_at: new Date(obtainmentTimestamp), // eslint-disable-line camelcase + created_at: obtainmentTimestamp, // eslint-disable-line camelcase expires_at: getExpiryDateOfAccessToken({ // eslint-disable-line camelcase expiresIn: tokenData.expires_in, obtainmentTimestamp: obtainmentTimestamp - }) + }).getTime() } }; From b44952a2794f18d5bbf2db2e077cb950d95d6318 Mon Sep 17 00:00:00 2001 From: Alastor Date: Fri, 27 Dec 2024 18:08:41 +0100 Subject: [PATCH 06/12] Removing unncessary Date objects uses when doing math on timestamps --- src/backend/auth/auth-manager.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/backend/auth/auth-manager.ts b/src/backend/auth/auth-manager.ts index 49f5d4032..cd1d57b56 100644 --- a/src/backend/auth/auth-manager.ts +++ b/src/backend/auth/auth-manager.ts @@ -108,13 +108,14 @@ class AuthManager extends TypedEmitter { scope: Array.isArray(tokenData.scope) ? (tokenData.scope) : (tokenData.scope.split(" ")) }; + // Attempting to infer missing timestamps and duration from whatever data we have available. if (tokenData.expires_at && tokenData.expires_in) { // induce created_at if not given accessTokenData.expires_in = Number(tokenData.expires_in); // eslint-disable-line camelcase accessTokenData.expires_at = new Date(tokenData.expires_at).getTime(); // eslint-disable-line camelcase accessTokenData.created_at = tokenData.created_at ? // eslint-disable-line camelcase new Date(tokenData.created_at).getTime() : - new Date(accessTokenData.expires_at - accessTokenData.expires_in * 1000).getTime(); + accessTokenData.expires_at - accessTokenData.expires_in * 1000; } else if (tokenData.expires_at && tokenData.created_at) { // induce expires_in accessTokenData.created_at = new Date(tokenData.created_at).getTime(); // eslint-disable-line camelcase @@ -125,7 +126,7 @@ class AuthManager extends TypedEmitter { // created_at = now if absent accessTokenData.expires_in = Number(tokenData.expires_in); // eslint-disable-line camelcase accessTokenData.created_at = new Date(tokenData?.created_at).getTime(); // eslint-disable-line camelcase - accessTokenData.expires_at = new Date(accessTokenData.created_at + accessTokenData.expires_in * 1000).getTime(); // eslint-disable-line camelcase + accessTokenData.expires_at = accessTokenData.created_at + accessTokenData.expires_in * 1000; // eslint-disable-line camelcase } else if (tokenData.expires_at) { // induce expires_in // induce created_at = now From 738c601f4e4f0759dc00d113d9802f2cbfc33c85 Mon Sep 17 00:00:00 2001 From: Alastor Date: Sat, 28 Dec 2024 01:40:00 +0100 Subject: [PATCH 07/12] Adding declaration of AuthManager class to it's type file --- src/backend/auth/auth.d.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/backend/auth/auth.d.ts b/src/backend/auth/auth.d.ts index 5af2ec59c..4bf677e6d 100644 --- a/src/backend/auth/auth.d.ts +++ b/src/backend/auth/auth.d.ts @@ -74,4 +74,19 @@ export interface AuthDetails { export interface AuthManagerEvents { "auth-success": (providerId: string, tokenData: AuthDetails) => void +} + +export declare class AuthManager extends TypedEmitter { + private readonly _httpPort; + private _authProviders; + constructor(); + registerAuthProvider(provider: AuthProviderDefinition): void; + getAuthProvider(providerId: string): AuthProvider; + buildOAuthClientForProvider(provider: AuthProviderDefinition, redirectUri: string): ClientOAuth2; + getAuthDetails(accessToken: ClientOAuth2.Token): AuthDetails; + createToken(providerId: string, tokenData: AuthDetails): ClientOAuth2.Token; + tokenExpired(providerId: string, tokenData: AuthDetails): boolean; + refreshTokenIfExpired(providerId: string, tokenData: AuthDetails): Promise; + revokeTokens(providerId: string, tokenData: AuthDetails): Promise; + successfulAuth(providerId: string, tokenData: AuthDetails): void; } \ No newline at end of file From e6ca91435dbfbce5f4e5abac328b3269cd804aec Mon Sep 17 00:00:00 2001 From: Alastor Date: Sat, 28 Dec 2024 03:54:14 +0100 Subject: [PATCH 08/12] Adding default for `Integrationcontroller.connected` so existing integrations don't break. --- src/types/integrations.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/integrations.d.ts b/src/types/integrations.d.ts index 7756aa999..416ea0661 100644 --- a/src/types/integrations.d.ts +++ b/src/types/integrations.d.ts @@ -53,7 +53,7 @@ export abstract class IntegrationController< Params extends FirebotParams = FirebotParams, Events extends IntegrationEvents = IntegrationEvents > extends TypedEmitter> { - connected: boolean; + connected = false; abstract init( linked: boolean, integrationData: IntegrationData From ffd063c89e0d7a5f75f4766abc7d8290925759c7 Mon Sep 17 00:00:00 2001 From: Alastor Date: Sat, 28 Dec 2024 17:08:28 +0100 Subject: [PATCH 09/12] Added typedEmitter to the auth type definition, to support the AuthManager definition --- src/backend/auth/auth.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/auth/auth.d.ts b/src/backend/auth/auth.d.ts index 4bf677e6d..8e2976b76 100644 --- a/src/backend/auth/auth.d.ts +++ b/src/backend/auth/auth.d.ts @@ -1,3 +1,4 @@ +import { TypedEmitter } from "tiny-typed-emitter"; import type ClientOAuth2 from "client-oauth2"; export interface AuthProviderDefinition { From f0ad8166b114db943081a03ade426346f79fc362 Mon Sep 17 00:00:00 2001 From: Alastor Date: Sun, 29 Dec 2024 02:13:46 +0100 Subject: [PATCH 10/12] Integrations types is a TS file, not a D.TS file, because of the abstract class. --- src/types/{integrations.d.ts => integrations.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/types/{integrations.d.ts => integrations.ts} (100%) diff --git a/src/types/integrations.d.ts b/src/types/integrations.ts similarity index 100% rename from src/types/integrations.d.ts rename to src/types/integrations.ts From deff1c1afec1302014f906e0fe3d5f3dd051163f Mon Sep 17 00:00:00 2001 From: Alastor Date: Thu, 2 Jan 2025 16:50:24 +0100 Subject: [PATCH 11/12] Roll back AuthManager change so "auth-success" event gets a dictionary passed --- src/backend/auth/auth-manager.ts | 2 +- src/backend/auth/auth.d.ts | 2 +- src/backend/auth/twitch-auth.ts | 2 +- src/backend/integrations/integration-manager.ts | 4 +--- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/backend/auth/auth-manager.ts b/src/backend/auth/auth-manager.ts index cd1d57b56..88d02c46a 100644 --- a/src/backend/auth/auth-manager.ts +++ b/src/backend/auth/auth-manager.ts @@ -207,7 +207,7 @@ class AuthManager extends TypedEmitter { } successfulAuth(providerId: string, tokenData: AuthDetails): void { - this.emit("auth-success", providerId, tokenData); + this.emit("auth-success", { "providerId": providerId, "tokenData": tokenData}); } } diff --git a/src/backend/auth/auth.d.ts b/src/backend/auth/auth.d.ts index 8e2976b76..7f9587c4b 100644 --- a/src/backend/auth/auth.d.ts +++ b/src/backend/auth/auth.d.ts @@ -74,7 +74,7 @@ export interface AuthDetails { } export interface AuthManagerEvents { - "auth-success": (providerId: string, tokenData: AuthDetails) => void + "auth-success": (data: { providerId: string, tokenData: AuthDetails }) => void } export declare class AuthManager extends TypedEmitter { diff --git a/src/backend/auth/twitch-auth.ts b/src/backend/auth/twitch-auth.ts index 0ed4d0e6d..b857fdf1b 100644 --- a/src/backend/auth/twitch-auth.ts +++ b/src/backend/auth/twitch-auth.ts @@ -151,7 +151,7 @@ async function getUserCurrent(accessToken: string) { return null; } -authManager.on("auth-success", async (providerId, tokenData) => { +authManager.on("auth-success", async ({providerId, tokenData}) => { if (providerId === twitchAuthProviders.streamerAccountProviderId || providerId === twitchAuthProviders.botAccountProviderId) { diff --git a/src/backend/integrations/integration-manager.ts b/src/backend/integrations/integration-manager.ts index 8464decb4..f3452b384 100644 --- a/src/backend/integrations/integration-manager.ts +++ b/src/backend/integrations/integration-manager.ts @@ -22,7 +22,7 @@ class IntegrationManager extends TypedEmitter { constructor() { super(); - authManager.on("auth-success", (providerId, tokenData) => { + authManager.on("auth-success", ({providerId, tokenData}) => { const int = this._integrations.find(i => i.definition.linkType === "auth" && i.definition.authProviderDetails.id === providerId); if (int != null) { @@ -469,8 +469,6 @@ frontEndCommunicator.on("integrationUserSettingsUpdate", (integrationData: Integ } }); -// TODO: The day the frontend is moved to typescript, this arguments list needs to be flattened -// Having a dict here allows to tell the frontend what goes where for now frontEndCommunicator.onAsync<[{ integrationId: string, accountId: string }]>("enteredIntegrationAccountId", async (idData) => { const { integrationId, accountId } = idData; const int = integrationManager.getIntegrationById(integrationId); From a9d573b06a11258934cbb61e65d64933b62d53db Mon Sep 17 00:00:00 2001 From: Alastor Date: Thu, 2 Jan 2025 16:55:17 +0100 Subject: [PATCH 12/12] Updating IntegrationManager so "token-refreshed" events are passed with a dictionary for consistency. --- src/backend/integrations/integration-manager.ts | 4 ++-- src/types/integrations.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/integrations/integration-manager.ts b/src/backend/integrations/integration-manager.ts index f3452b384..f915a0a50 100644 --- a/src/backend/integrations/integration-manager.ts +++ b/src/backend/integrations/integration-manager.ts @@ -363,7 +363,7 @@ class IntegrationManager extends TypedEmitter { const updatedToken = await authManager.refreshTokenIfExpired(providerId, authData.auth); if (updatedToken != null) { this.saveIntegrationAuth(int, updatedToken); - this.emit("token-refreshed", integrationId, updatedToken); + this.emit("token-refreshed", {"integrationId": integrationId, "updatedToken": updatedToken}); } authData.auth = updatedToken; } else if (authManager.tokenExpired(providerId, authData.auth)) { @@ -423,7 +423,7 @@ class IntegrationManager extends TypedEmitter { this.saveIntegrationAuth(int, updatedToken); authData = updatedToken; - this.emit("token-refreshed", integrationId, updatedToken); + this.emit("token-refreshed", {"integrationId": integrationId, "updatedToken": updatedToken}); } integrationData.auth = authData; } diff --git a/src/types/integrations.ts b/src/types/integrations.ts index 416ea0661..c659493c0 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -81,7 +81,7 @@ export interface IntegrationManagerEvents { "integrationRegistered": (integration: Integration) => void; "integration-connected": (integrationId: string) => void; "integration-disconnected": (integrationId: string) => void; - "token-refreshed": (integrationId: string, updatedToken: AuthDetails) => void; + "token-refreshed": (data: {integrationId: string, updatedToken: AuthDetails}) => void; } export declare class IntegrationManager extends TypedEmitter {