Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integration manager refactoring & Integration tokens auto refreshing #2949

Open
wants to merge 15 commits into
base: v5
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 99 additions & 17 deletions src/backend/auth/auth-manager.ts
Original file line number Diff line number Diff line change
@@ -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<AuthManagerEvents> {
private readonly _httpPort: number;
private _authProviders: AuthProvider[];

Expand Down Expand Up @@ -66,32 +66,110 @@ 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,
accessTokenUri: provider.auth.type === "token" ? null : tokenUri,
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<unknown> {
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<AuthDetails> {
const provider = this.getAuthProvider(providerId);
let accessToken: ClientOAuth2.Token = this.createToken(providerId, tokenData);

if (accessToken.expired()) {
try {
Expand All @@ -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<void> {
async revokeTokens(providerId: string, tokenData: AuthDetails): Promise<void> {
const provider = this.getAuthProvider(providerId);
if (provider == null) {
return;
Expand All @@ -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<void> => {
const provider = manager.getAuthProvider(providerId);
const provider = authManager.getAuthProvider(providerId);
if (provider?.details?.auth?.type !== "device") {
return;
}
Expand Down Expand Up @@ -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() &&
Expand Down Expand Up @@ -206,4 +288,4 @@ frontendCommunicator.onAsync("begin-device-auth", async (providerId: string): Pr
}
});

export = manager;
export = authManager;
56 changes: 49 additions & 7 deletions src/backend/auth/auth.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TypedEmitter } from "tiny-typed-emitter";
import type ClientOAuth2 from "client-oauth2";

export interface AuthProviderDefinition {
Expand All @@ -14,8 +15,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 {
Expand All @@ -27,25 +40,54 @@ 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
}

export declare class AuthManager extends TypedEmitter<AuthManagerEvents> {
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<AuthDetails>;
revokeTokens(providerId: string, tokenData: AuthDetails): Promise<void>;
successfulAuth(providerId: string, tokenData: AuthDetails): void;
}
10 changes: 5 additions & 5 deletions src/backend/auth/firebot-device-auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
}
});
Expand Down Expand Up @@ -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
}
});
Expand Down
9 changes: 5 additions & 4 deletions src/backend/auth/twitch-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class TwitchAuthProviders {
tokenPath: this._tokenPath,
type: "device"
},
autoRefreshToken: false,
scopes: [
'bits:read',
'channel:edit:commercial',
Expand Down Expand Up @@ -104,6 +105,7 @@ class TwitchAuthProviders {
tokenPath: this._tokenPath,
type: "device"
},
autoRefreshToken: false,
scopes: [
'channel:moderate',
'chat:edit',
Expand Down Expand Up @@ -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) {
Expand All @@ -172,11 +173,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()
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const ExtraLifeDonations: ReplaceVariable = {
}

if (participantID == null) {
participantID = integrationManager.getIntegrationAccountId("extralife");
participantID = Number(integrationManager.getIntegrationAccountId("extralife"));
}

if (sortName == null || sortName.trim() === '') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() === '') {
Expand Down
Loading
Loading