diff --git a/src/backend/auth/auth-manager.ts b/src/backend/auth/auth-manager.ts index f9d9adb29..88d02c46a 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, @@ -85,13 +92,84 @@ 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 }); } - async refreshTokenIfExpired(providerId: string, tokenData: ClientOAuth2.Data): Promise { + 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(" ")) + }; + + // 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() : + 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 + 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).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 + 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().getTime(); // eslint-disable-line camelcase + } + return accessTokenData; + } + + createToken(providerId: string, tokenData: AuthDetails): ClientOAuth2.Token { const provider = this.getAuthProvider(providerId); - let accessToken = provider.oauthClient.createToken(tokenData); + 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 { + // @ts-expect-error 2551 + accessToken.expires = new Date(tokenData.expires_at); + } + + return accessToken; + } + + tokenExpired(providerId: string, tokenData: AuthDetails): boolean { + return this.createToken(providerId, tokenData).expired(); + } + + async refreshTokenIfExpired(providerId: string, tokenData: AuthDetails): Promise { + const provider = this.getAuthProvider(providerId); + let accessToken: ClientOAuth2.Token = this.createToken(providerId, tokenData); if (accessToken.expired()) { try { @@ -101,16 +179,20 @@ class AuthManager extends EventEmitter { : 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); 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; @@ -124,15 +206,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": providerId, "tokenData": 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; } @@ -177,7 +259,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() && @@ -206,4 +288,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 a89a5762d..f5446bb44 100644 --- a/src/backend/auth/auth.d.ts +++ b/src/backend/auth/auth.d.ts @@ -14,8 +14,20 @@ 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; + autoRefreshToken: boolean; } export interface AuthProvider { @@ -27,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; + /** Timestamp of when the token has been created */ + created_at?: number; /** How many seconds before the token expires */ expires_in?: number; - /** JSON representation of when access token expires */ - expires_at?: Date; + /** Timestamp of when access token expires */ + expires_at?: number; - /** The refresh token */ - refresh_token?: string; + /** Extra fields to be compatible with Type ClientOAuth2.Data */ + [key: string]: unknown; +} + +export interface AuthManagerEvents { + "auth-success": (data: { 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..00c45e6c8 100644 --- a/src/backend/auth/firebot-device-auth-provider.ts +++ b/src/backend/auth/firebot-device-auth-provider.ts @@ -23,15 +23,15 @@ 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 = 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); @@ -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 6d704cd11..dea19566e 100644 --- a/src/backend/auth/twitch-auth.ts +++ b/src/backend/auth/twitch-auth.ts @@ -27,6 +27,7 @@ class TwitchAuthProviders { tokenPath: this._tokenPath, type: "device" }, + autoRefreshToken: false, scopes: [ "bits:read", "channel:edit:commercial", @@ -107,6 +108,7 @@ class TwitchAuthProviders { tokenPath: this._tokenPath, type: "device" }, + autoRefreshToken: false, scopes: [ "channel:moderate", "chat:edit", @@ -152,8 +154,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) { @@ -175,11 +176,11 @@ authManager.on("auth-success", async (authData) => { broadcasterType: userData.broadcaster_type, auth: { ...tokenData, - obtainment_timestamp: 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() } }; 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 52% rename from src/backend/integrations/integration-manager.js rename to src/backend/integrations/integration-manager.ts index 03b841e3c..91184b28e 100644 --- a/src/backend/integrations/integration-manager.js +++ b/src/backend/integrations/integration-manager.ts @@ -1,22 +1,40 @@ -"use strict"; -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 { 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.saveIntegrationAuth(int, tokenData); - this._integrations = []; + this.linkIntegration(int, { auth: tokenData }); + } + }); } - registerIntegration(integration) { + registerIntegration(integration: Integration): void { integration.definition.linked = false; if (integration.definition.linkType === "auth") { @@ -25,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; @@ -45,11 +63,11 @@ 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 - } + } as IntegrationData ); this._integrations.push(integration); @@ -60,35 +78,36 @@ class IntegrationManager extends EventEmitter { frontendCommunicator.send("integrationsUpdated"); - integration.integration.on("connected", (id) => { + integration.integration.on("connected", (integrationId: string) => { frontendCommunicator.send("integrationConnectionUpdate", { - id: id, + 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) => { + integration.integration.on("disconnected", (integrationId: string) => { frontendCommunicator.send("integrationConnectionUpdate", { - id: id, + 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; @@ -100,15 +119,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); @@ -122,9 +141,9 @@ 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 - }; + } as IntegrationData; int.integration.onUserSettingsUpdate(integrationData); } } catch (error) { @@ -132,16 +151,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; @@ -152,7 +171,7 @@ class IntegrationManager extends EventEmitter { return true; } - getAllIntegrationDefinitions() { + getAllIntegrationDefinitions(): Array { return this._integrations .map(i => i.definition) .map((i) => { @@ -162,16 +181,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 { @@ -183,12 +203,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 { @@ -200,7 +220,7 @@ class IntegrationManager extends EventEmitter { } } - startIntegrationLink(integrationId) { + startIntegrationLink(integrationId: string): void { const int = this.getIntegrationById(integrationId); if (int == null || int.definition.linked) { return; @@ -220,7 +240,7 @@ class IntegrationManager extends EventEmitter { } } - async linkIntegration(int, linkData) { + async linkIntegration(int: Integration, linkData: LinkData): Promise { try { await int.integration.link(linkData); } catch (error) { @@ -241,16 +261,18 @@ class IntegrationManager extends EventEmitter { }); } - 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; @@ -265,16 +287,17 @@ class IntegrationManager extends EventEmitter { 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") { @@ -311,7 +334,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; @@ -319,23 +342,101 @@ class IntegrationManager extends EventEmitter { int.integration.disconnect(); } - /** - * @param {string} integrationId - * @returns {boolean} - */ - integrationCanConnect(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: LinkData = null; + if (int.definition.linkType === "auth") { + const providerId = int.definition?.authProviderDetails.id; + authData = { auth: int.definition.auth }; + + if (int.definition.authProviderDetails && + int.definition.authProviderDetails.autoRefreshToken && + authManager.tokenExpired(providerId, authData.auth)) { + + const updatedToken = await authManager.refreshTokenIfExpired(providerId, authData.auth); + if (updatedToken != null) { + this.saveIntegrationAuth(int, updatedToken); + this.emit("token-refreshed", {"integrationId": integrationId, "updatedToken": updatedToken}); + } + authData.auth = updatedToken; + } else if (authManager.tokenExpired(providerId, authData.auth)) { + authData = null; + } + } else if (int.definition.linkType === "id") { + authData = { accountId: int.definition.accountId }; + } + + if (authData == null) { + logger.warn("Could not refresh integration access token!"); + + global.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: string): Promise { + const int = this.getIntegrationById(integrationId); + if (int == null || !int.definition.linked) { + this.emit("integration-disconnected", integrationId); + return; + } + + const integrationData: IntegrationData = { + settings: int.definition.settings, + userSettings: int.definition.userSettings, + linked: int.definition.linked + }; + + let authData = null; + if (int.definition.linkType === "auth") { + 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!"); + + global.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, "updatedToken": updatedToken}); + } + integrationData.auth = authData; + } + return authData; + } + + 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; @@ -343,81 +444,65 @@ 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) => { +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); - - manager.linkIntegration(int, { accountId: accountId }); -}); + integrationManager.saveIntegrationAccountId(int, 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 }); }); -frontendCommunicator.on("linkIntegration", (integrationId) => { +frontendCommunicator.on("linkIntegration", (integrationId: string) => { logger.info("got 'linkIntegration' request"); - manager.startIntegrationLink(integrationId); + integrationManager.startIntegrationLink(integrationId); }); -frontendCommunicator.on("unlinkIntegration", (integrationId) => { +frontendCommunicator.on("unlinkIntegration", (integrationId: string) => { logger.info("got 'unlinkIntegration' request"); - manager.unlinkIntegration(integrationId); + integrationManager.unlinkIntegration(integrationId); }); -frontendCommunicator.on("connectIntegration", (integrationId) => { +frontendCommunicator.on("connectIntegration", (integrationId: string) => { logger.info("got 'connectIntegration' request"); - manager.connectIntegration(integrationId); + integrationManager.connectIntegration(integrationId); }); -frontendCommunicator.on("disconnectIntegration", (integrationId) => { +frontendCommunicator.on("disconnectIntegration", (integrationId: string) => { logger.info("got 'disconnectIntegration' request"); - manager.disconnectIntegration(integrationId); + integrationManager.disconnectIntegration(integrationId); }); frontendCommunicator.on("getAllIntegrationDefinitions", () => { logger.info("got 'get all integrations' request"); - return manager.getAllIntegrationDefinitions(); + return 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..476418d20 --- /dev/null +++ b/src/types/integrations.d.ts @@ -0,0 +1,108 @@ +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 interface IntegrationController< + Params extends FirebotParams = FirebotParams, + Events extends IntegrationEvents = IntegrationEvents +> extends TypedEmitter> { + connected: boolean; + init: ( + linked: boolean, + integrationData: IntegrationData + ) => void | PromiseLike; + link?: (linkData: LinkData) => void | PromiseLike; + unlink?: () => void | PromiseLike; + connect? :( + integrationData: IntegrationData + ) => void | PromiseLike; + disconnect?: () => void | PromiseLike; + 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": (data: {integrationId: string, updatedToken: AuthDetails}) => void; +} + +export declare class IntegrationManager extends TypedEmitter { + 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