From f3281d13419522cc034b5700006836f4725ffac8 Mon Sep 17 00:00:00 2001 From: Zack Williamson Date: Thu, 14 Dec 2023 12:48:07 -0500 Subject: [PATCH 001/184] feat: Twitch Device Code Flow for login --- secrets.gpg | Bin 374 -> 371 bytes .../electron/events/when-ready.js | 4 +- src/backend/auth/auth-manager.js | 82 +++- src/backend/auth/auth.d.ts | 2 +- .../auth/firebot-device-auth-provider.ts | 141 +++++++ .../auth/firebot-static-auth-provider.ts | 45 --- src/backend/auth/twitch-auth.js | 217 ----------- src/backend/auth/twitch-auth.ts | 177 +++++++++ .../auth/twitch-device-auth-provider.ts | 361 ++++++++++++++++++ src/backend/chat/twitch-chat.ts | 10 +- src/backend/common/account-access.js | 2 +- src/backend/effects/builtin/http-request.js | 2 +- src/backend/twitch-api/api.ts | 9 +- .../twitch-api/pubsub/pubsub-client.js | 4 +- src/backend/twitch-api/resource/auth.ts | 40 ++ .../directives/modals/misc/botLoginModal.js | 50 --- .../modals/misc/twitch-login-modal.js | 85 +++++ src/gui/app/index.html | 4 +- src/gui/app/services/connection.service.js | 19 +- 19 files changed, 919 insertions(+), 335 deletions(-) create mode 100644 src/backend/auth/firebot-device-auth-provider.ts delete mode 100644 src/backend/auth/firebot-static-auth-provider.ts delete mode 100644 src/backend/auth/twitch-auth.js create mode 100644 src/backend/auth/twitch-auth.ts create mode 100644 src/backend/auth/twitch-device-auth-provider.ts create mode 100644 src/backend/twitch-api/resource/auth.ts delete mode 100644 src/gui/app/directives/modals/misc/botLoginModal.js create mode 100644 src/gui/app/directives/modals/misc/twitch-login-modal.js diff --git a/secrets.gpg b/secrets.gpg index ceb3316e745d515cf1bcb075b17f47c0f4a30de6..9225a6f8b2df2bb29753b7b366850510d5221e90 100644 GIT binary patch literal 371 zcmV-(0gV2P4Fm}T0;3WeQ*#l8_xjSnp#hlq7!bct=u?%y3~N`0a!h($AQHqG1ni#g zE{h88bBWKR^UpocHw#AIK(i=#@oXt8Kv!vT{~In&sCW9<3z9u}jGm<<2*f%!j9JYx z34>>~@QOSQ@VWrhZH6E?oQUV6q8jV-$I$QnCo?ILtF8H4k&q04@iTA%twMz#q3GPn z1P+B=Nrm5NvpFTzz(~}wJc#*JMgu&jM%O1by)~=4kV7(zYy(uTD~-0^Cjf1UViM*n z?|Ym>7@{L-L3C|XJiH8}Hwj#HE$i=a`1)rjs~L!yoabKb`!i>8|3~gWoiM zb5%DrQO1RqDX}%`JDkdbv>wkii51n`<5aG=dN)yAxaP)&e~bFkph3zo5ZSw`qNv`X z6Pbh3T=+75lSI&x;SQZRDerR#m9lf7!VJPEsl`f>B~8gvNFD&1$1`;crlbMIx=%vV zSgZ=Yg9ef0s9vW~(#?h7s0WF7UXatr!PUv3XuR{vYSfQERJ>|Cjwz!bcdY8AC~9zI`TlrD)K0R UM7FF;e(RhW;@9;ITHxf=eHGok-2eap diff --git a/src/backend/app-management/electron/events/when-ready.js b/src/backend/app-management/electron/events/when-ready.js index 908358d54..f1ece4bdb 100644 --- a/src/backend/app-management/electron/events/when-ready.js +++ b/src/backend/app-management/electron/events/when-ready.js @@ -38,8 +38,8 @@ exports.whenReady = async () => { const accountAccess = require("../../../common/account-access"); await accountAccess.updateAccountCache(false); - const firebotStaticAuthProvider = require("../../../auth/firebot-static-auth-provider"); - firebotStaticAuthProvider.setupStaticAuthProvider(); + const firebotDeviceAuthProvider = require("../../../auth/firebot-device-auth-provider"); + firebotDeviceAuthProvider.setupDeviceAuthProvider(); const connectionManager = require("../../../common/connection-manager"); diff --git a/src/backend/auth/auth-manager.js b/src/backend/auth/auth-manager.js index 9a8631233..60a715901 100644 --- a/src/backend/auth/auth-manager.js +++ b/src/backend/auth/auth-manager.js @@ -4,6 +4,7 @@ const EventEmitter = require("events"); const logger = require("../logwrapper"); const { settings } = require("../common/settings-access"); const OAuthClient = require("client-oauth2"); +const frontendCommunicator = require("../common/frontend-communicator"); const HTTP_PORT = settings.getWebServerPort(); @@ -27,14 +28,31 @@ class AuthManager extends EventEmitter { const oauthClient = this.buildOAuthClientForProvider(provider, redirectUri); - const authorizationUri = provider.auth.type === "token" - ? oauthClient.token.getUri() - : oauthClient.code.getUri(); + let authorizationUri = ""; + + switch (provider.auth.type) { + case "token": + authorizationUri = oauthClient.token.getUri(); + break; + + case "code": + authorizationUri = oauthClient.code.getUri(); + break; + + case "device": + authorizationUri = `${provider.auth.tokenHost}${provider.auth.authorizePath}`; + break; + } + + const tokenUri = provider.auth.type === "device" + ? `${provider.auth.tokenHost}${provider.auth.tokenPath ?? ""}` + : null; const authProvider = { id: provider.id, oauthClient: oauthClient, authorizationUri: authorizationUri, + tokenUri: tokenUri, redirectUri: redirectUri, details: provider }; @@ -64,7 +82,7 @@ class AuthManager extends EventEmitter { return new OAuthClient({ clientId: provider.client.id, - clientSecret: provider.auth.type === "token" ? null : provider.client.secret, + clientSecret: provider.auth.type === "code" ? provider.client.secret : null, accessTokenUri: provider.auth.type === "token" ? null : tokenUri, authorizationUri: authUri, redirectUri: redirectUri, @@ -114,4 +132,60 @@ class AuthManager extends EventEmitter { const manager = new AuthManager(); +frontendCommunicator.onAsync("begin-device-auth", async (providerId) => { + const provider = manager.getAuthProvider(providerId); + if (provider?.details?.auth?.type !== "device") { + return; + } + + const formData = new FormData(); + formData.append("client_id", provider.details.client.id); + formData.append("scopes", Array.isArray(provider.details.scopes) + ? provider.details.scopes.join(" ") + : provider.details.scopes); + + // Get the device auth request + const response = await fetch(provider.authorizationUri, { + method: "POST", + body: formData + }); + + if (response.ok) { + const deviceAuthData = await response.json(); + + frontendCommunicator.send("device-code-received", { + loginUrl: deviceAuthData.verification_uri, + code: deviceAuthData.user_code + }); + + const tokenRequestData = new FormData(); + tokenRequestData.append("client_id", provider.details.client.id); + tokenRequestData.append("scopes", Array.isArray(provider.details.scopes) + ? provider.details.scopes.join(" ") + : provider.details.scopes); + tokenRequestData.append("device_code", deviceAuthData.device_code); + tokenRequestData.append("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); + + const tokenCheckInterval = setInterval(async () => { + const tokenResponse = await fetch(provider.tokenUri, { + method: "POST", + body: tokenRequestData + }); + + if (tokenResponse.ok) { + clearInterval(tokenCheckInterval); + const tokenData = await tokenResponse.json(); + + manager.successfulAuth(providerId, tokenData); + } + }, deviceAuthData.interval * 1000); + + frontendCommunicator.on("cancel-device-token-check", () => { + if (tokenCheckInterval) { + clearInterval(tokenCheckInterval); + } + }); + } +}); + module.exports = manager; \ No newline at end of file diff --git a/src/backend/auth/auth.d.ts b/src/backend/auth/auth.d.ts index a1a42fe00..1c940e1cb 100644 --- a/src/backend/auth/auth.d.ts +++ b/src/backend/auth/auth.d.ts @@ -8,7 +8,7 @@ export interface AuthProviderDefinition { secret?: string; }; auth: { - type: "code" | "token"; + type: "code" | "token" | "device"; tokenHost: string; authorizePath: string; tokenPath?: string; diff --git a/src/backend/auth/firebot-device-auth-provider.ts b/src/backend/auth/firebot-device-auth-provider.ts new file mode 100644 index 000000000..ea239123c --- /dev/null +++ b/src/backend/auth/firebot-device-auth-provider.ts @@ -0,0 +1,141 @@ +import logger from "../logwrapper"; +import accountAccess, { FirebotAccount } from "../common/account-access"; +import twitchAuth from "./twitch-auth"; +import TwitchApi from "../twitch-api/api"; +import { AuthDetails, AuthProviderDefinition } from "./auth"; +import { AccessToken, getExpiryDateOfAccessToken } from "@twurple/auth"; +import { DeviceAuthProvider } from "./twitch-device-auth-provider"; +import frontendCommunicator from "../common/frontend-communicator"; + +class FirebotDeviceAuthProvider { + streamerProvider: DeviceAuthProvider; + botProvider: DeviceAuthProvider; + + private onRefresh(accountType: "streamer" | "bot", userId: string, token: AccessToken): void { + const account: FirebotAccount = accountType === "streamer" + ? accountAccess.getAccounts().streamer + : accountAccess.getAccounts().bot; + + logger.debug(`Persisting ${accountType} access token`); + + const auth: AuthDetails = account.auth ?? { } as AuthDetails; + auth.access_token = token.accessToken; + auth.refresh_token = token.refreshToken; + auth.expires_in = token.expiresIn; + auth.obtainment_timestamp = token.obtainmentTimestamp; + auth.expires_at = getExpiryDateOfAccessToken({ + expiresIn: token.expiresIn, + obtainmentTimestamp: token.obtainmentTimestamp + }); + + account.auth = auth; + accountAccess.updateAccount(accountType, account, false); + } + + setupDeviceAuthProvider(): void { + if (accountAccess.getAccounts().streamer.loggedIn) { + const streamerAcccount = accountAccess.getAccounts().streamer; + + const scopes: string[] = Array.isArray(twitchAuth.streamerAccountProvider.scopes) + ? twitchAuth.streamerAccountProvider.scopes + : twitchAuth.streamerAccountProvider.scopes.split(" "); + + this.streamerProvider = new DeviceAuthProvider({ + userId: streamerAcccount.userId, + clientId: twitchAuth.twitchClientId, + accessToken: { + accessToken: streamerAcccount.auth.access_token, + refreshToken: streamerAcccount.auth.refresh_token, + expiresIn: streamerAcccount.auth.expires_in, + obtainmentTimestamp: streamerAcccount.auth.obtainment_timestamp ?? Date.now(), + scope: scopes + }, + scopes: scopes + }); + + this.streamerProvider.onRefresh((userId, token) => this.onRefresh("streamer", userId, token)); + } else { + this.streamerProvider = null; + } + + if (accountAccess.getAccounts().bot.loggedIn) { + const botAcccount = accountAccess.getAccounts().bot; + + const scopes: string[] = Array.isArray(twitchAuth.botAccountProvider.scopes) + ? twitchAuth.botAccountProvider.scopes + : twitchAuth.botAccountProvider.scopes.split(" "); + + this.botProvider = new DeviceAuthProvider({ + userId: botAcccount.userId, + clientId: twitchAuth.twitchClientId, + accessToken: { + accessToken: botAcccount.auth.access_token, + refreshToken: botAcccount.auth.refresh_token, + expiresIn: botAcccount.auth.expires_in, + obtainmentTimestamp: botAcccount.auth.obtainment_timestamp ?? Date.now(), + scope: scopes + }, + scopes: scopes + }); + + this.botProvider.onRefresh((userId, token) => this.onRefresh("bot", userId, token)); + } else { + this.botProvider = null; + } + + TwitchApi.setupApiClients(this.streamerProvider, this.botProvider); + } +} + +const isTwitchTokenDataValid = function(definition: AuthProviderDefinition, authDetails: AuthDetails): boolean { + const scopes = Array.isArray(definition.scopes) + ? definition.scopes + : definition.scopes.split(" "); + + return ( + // Ensure authDetails exist + authDetails && + + // Make sure we have a refresh token + authDetails.refresh_token && + + // Make sure there's at least some scopes + authDetails.scope && + authDetails.scope.length > 0 && + + // check all required scopes are present + scopes.every(scope => authDetails.scope.includes(scope)) + ); +}; + +const firebotDeviceAuthProvider = new FirebotDeviceAuthProvider(); + +accountAccess.events.on("account-update", () => { + firebotDeviceAuthProvider.setupDeviceAuthProvider(); +}); + +frontendCommunicator.onAsync("validate-twitch-account", async ({ accountType, authDetails }) => { + let definition: AuthProviderDefinition; + + switch (accountType) { + case "streamer": + definition = twitchAuth.streamerAccountProvider; + break; + + case "bot": + definition = twitchAuth.botAccountProvider; + break; + + default: + break; + } + + if (definition) { + return isTwitchTokenDataValid(definition, authDetails) + && await TwitchApi.auth.isTokenValid(accountType); + } + + return true; +}); + +export = firebotDeviceAuthProvider; \ No newline at end of file diff --git a/src/backend/auth/firebot-static-auth-provider.ts b/src/backend/auth/firebot-static-auth-provider.ts deleted file mode 100644 index 8fec5d001..000000000 --- a/src/backend/auth/firebot-static-auth-provider.ts +++ /dev/null @@ -1,45 +0,0 @@ -import accountAccess from "../common/account-access"; -import twitchAuth from "./twitch-auth"; -import TwitchApi from "../twitch-api/api"; -import { StaticAuthProvider } from "@twurple/auth"; - -class FirebotStaticAuthProvider { - streamerProvider: StaticAuthProvider; - botProvider: StaticAuthProvider; - - setupStaticAuthProvider(): void { - if (accountAccess.getAccounts().streamer.loggedIn) { - const streamerAcccount = accountAccess.getAccounts().streamer; - - this.streamerProvider = new StaticAuthProvider( - twitchAuth.TWITCH_CLIENT_ID, - streamerAcccount.auth.access_token, - twitchAuth.STREAMER_ACCOUNT_PROVIDER.scopes as string[] - ); - } else { - this.streamerProvider = null; - } - - if (accountAccess.getAccounts().bot.loggedIn) { - const botAcccount = accountAccess.getAccounts().bot; - - this.botProvider = new StaticAuthProvider( - twitchAuth.TWITCH_CLIENT_ID, - botAcccount.auth.access_token, - twitchAuth.BOT_ACCOUNT_PROVIDER.scopes as string[] - ); - } else { - this.botProvider = null; - } - - TwitchApi.setupApiClients(this.streamerProvider, this.botProvider); - } -} - -const firebotStaticAuthProvider = new FirebotStaticAuthProvider(); - -accountAccess.events.on("account-update", () => { - firebotStaticAuthProvider.setupStaticAuthProvider(); -}); - -export = firebotStaticAuthProvider; \ No newline at end of file diff --git a/src/backend/auth/twitch-auth.js b/src/backend/auth/twitch-auth.js deleted file mode 100644 index a35096c51..000000000 --- a/src/backend/auth/twitch-auth.js +++ /dev/null @@ -1,217 +0,0 @@ -"use strict"; - -const authManager = require("./auth-manager"); -const accountAccess = require("../common/account-access"); -const frontendCommunicator = require("../common/frontend-communicator"); -const logger = require("../logwrapper"); -const axios = require("axios").default; - -const { secrets } = require("../secrets-manager"); - -const TWITCH_CLIENT_ID = secrets.twitchClientId; - -exports.TWITCH_CLIENT_ID = TWITCH_CLIENT_ID; - -const HOST = "https://id.twitch.tv"; -const AUTHORIZE_PATH = "/oauth2/authorize"; - -const STREAMER_ACCOUNT_PROVIDER_ID = "twitch:streamer-account"; -const BOT_ACCOUNT_PROVIDER_ID = "twitch:bot-account"; - -/** - * @param {import("./auth").AuthProviderDefinition} definition - * @param {import("./auth").AuthDetails} authDetails - * @returns boolean - */ -const validator = function(definition, authDetails) { - return ( - // Ensure authDetails exist - authDetails && - - // Check for old auth code flow token - !authDetails.refresh_token && - - // Make sure there's at least some scopes - authDetails.scope && - authDetails.scope.length > 0 && - - // check all required scopes are present - definition.scopes.every(scope => authDetails.scope.includes(scope)) - ); -}; - -/** @type {import("./auth").AuthProviderDefinition} */ -const STREAMER_ACCOUNT_PROVIDER = { - id: STREAMER_ACCOUNT_PROVIDER_ID, - name: "Streamer Account", - client: { - id: TWITCH_CLIENT_ID - }, - auth: { - tokenHost: HOST, - authorizePath: AUTHORIZE_PATH, - type: "token" - }, - scopes: [ - 'bits:read', - 'channel:edit:commercial', - 'channel:manage:broadcast', - 'channel:manage:moderators', - 'channel:manage:polls', - 'channel:manage:predictions', - 'channel:manage:raids', - 'channel:manage:redemptions', - 'channel:manage:schedule', - 'channel:manage:videos', - 'channel:manage:vips', - 'channel:moderate', - 'channel:read:charity', - 'channel:read:editors', - 'channel:read:goals', - 'channel:read:hype_train', - 'channel:read:polls', - 'channel:read:predictions', - 'channel:read:redemptions', - 'channel:read:stream_key', - 'channel:read:subscriptions', - 'channel:read:vips', - 'channel_commercial', - 'channel_editor', - 'channel_read', - 'channel_subscriptions', - 'chat:edit', - 'chat:read', - 'clips:edit', - 'moderation:read', - 'moderator:manage:announcements', - 'moderator:manage:automod', - 'moderator:manage:automod_settings', - 'moderator:manage:banned_users', - 'moderator:manage:blocked_terms', - 'moderator:manage:chat_messages', - 'moderator:manage:chat_settings', - 'moderator:manage:shield_mode', - 'moderator:manage:shoutouts', - 'moderator:read:automod_settings', - 'moderator:read:blocked_terms', - 'moderator:read:chat_settings', - 'moderator:read:chatters', - 'moderator:read:followers', - 'moderator:read:shield_mode', - 'moderator:read:shoutouts', - 'user:edit:broadcast', - 'user:manage:blocked_users', - 'user:manage:whispers', - 'user:read:blocked_users', - 'user:read:broadcast', - 'user:read:follows', - 'user:read:subscriptions', - 'user_subscriptions', - 'user_follows_edit', - 'user_read', - 'whispers:edit', - 'whispers:read' - ] -}; - -/** @type {import("./auth").AuthProviderDefinition} */ -const BOT_ACCOUNT_PROVIDER = { - id: BOT_ACCOUNT_PROVIDER_ID, - name: "Bot Account", - client: { - id: TWITCH_CLIENT_ID - }, - auth: { - tokenHost: HOST, - authorizePath: AUTHORIZE_PATH, - type: "token" - }, - scopes: [ - 'channel:moderate', - 'chat:edit', - 'chat:read', - 'moderator:manage:announcements', - 'user:manage:whispers', - 'whispers:edit', - 'whispers:read', - 'channel_read' - ] -}; - -exports.STREAMER_ACCOUNT_PROVIDER = STREAMER_ACCOUNT_PROVIDER; -exports.BOT_ACCOUNT_PROVIDER = BOT_ACCOUNT_PROVIDER; - -exports.registerTwitchAuthProviders = () => { - authManager.registerAuthProvider(STREAMER_ACCOUNT_PROVIDER); - authManager.registerAuthProvider(BOT_ACCOUNT_PROVIDER); -}; - -async function getUserCurrent(accessToken) { - try { - const response = await axios.get('https://api.twitch.tv/helix/users', { - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'User-Agent': 'Firebot v5', - 'Client-ID': TWITCH_CLIENT_ID - }, - responseType: "json" - }); - - if (response.status >= 200 && response.status <= 204) { - const userData = response.data; - if (userData.data && userData.data.length > 0) { - return userData.data[0]; - } - } - } catch (error) { - logger.error("Error getting current twitch user", error); - } - return null; -} - -authManager.on("auth-success", async authData => { - const { providerId, tokenData } = authData; - - if (providerId === STREAMER_ACCOUNT_PROVIDER_ID || providerId === BOT_ACCOUNT_PROVIDER_ID) { - const userData = await getUserCurrent(tokenData.access_token); - if (userData == null) { - return; - } - - const accountType = providerId === STREAMER_ACCOUNT_PROVIDER_ID ? "streamer" : "bot"; - const accountObject = { - username: userData.login, - displayName: userData.display_name, - channelId: userData.id, - userId: userData.id, - avatar: userData.profile_image_url, - broadcasterType: userData.broadcaster_type, - auth: tokenData - }; - - accountAccess.updateAccount(accountType, accountObject); - } -}); - -frontendCommunicator.on("validate-twitch-account", ({ accountType, authDetails }) => { - let definition; - - switch (accountType) { - case "streamer": - definition = STREAMER_ACCOUNT_PROVIDER; - break; - - case "bot": - definition = BOT_ACCOUNT_PROVIDER; - break; - - default: - break; - } - - if (definition) { - return validator(definition, authDetails); - } - - return true; -}); diff --git a/src/backend/auth/twitch-auth.ts b/src/backend/auth/twitch-auth.ts new file mode 100644 index 000000000..c165cfbfc --- /dev/null +++ b/src/backend/auth/twitch-auth.ts @@ -0,0 +1,177 @@ +import authManager from "./auth-manager"; +import accountAccess, { FirebotAccount } from "../common/account-access"; +import logger from "../logwrapper"; +import { secrets } from "../secrets-manager"; +import { AuthProviderDefinition } from "./auth"; + +const axios = require("axios").default; + +class TwitchAuthProviders { + private readonly _host = "https://id.twitch.tv"; + private readonly _authorizePath = "/oauth2/device"; + private readonly _tokenPath = "/oauth2/token"; + + readonly streamerAccountProviderId = "twitch:streamer-account"; + readonly botAccountProviderId = "twitch:bot-account"; + + readonly twitchClientId = secrets.twitchClientId; + + readonly streamerAccountProvider: AuthProviderDefinition = { + id: this.streamerAccountProviderId, + name: "Streamer Account", + client: { + id: this.twitchClientId + }, + auth: { + tokenHost: this._host, + authorizePath: this._authorizePath, + tokenPath: this._tokenPath, + type: "device" + }, + scopes: [ + 'bits:read', + 'channel:edit:commercial', + 'channel:manage:ads', + 'channel:manage:broadcast', + 'channel:manage:moderators', + 'channel:manage:polls', + 'channel:manage:predictions', + 'channel:manage:raids', + 'channel:manage:redemptions', + 'channel:manage:schedule', + 'channel:manage:videos', + 'channel:manage:vips', + 'channel:moderate', + 'channel:read:ads', + 'channel:read:charity', + 'channel:read:editors', + 'channel:read:goals', + 'channel:read:hype_train', + 'channel:read:polls', + 'channel:read:predictions', + 'channel:read:redemptions', + 'channel:read:stream_key', + 'channel:read:subscriptions', + 'channel:read:vips', + 'channel_commercial', + 'channel_editor', + 'channel_read', + 'channel_subscriptions', + 'chat:edit', + 'chat:read', + 'clips:edit', + 'moderation:read', + 'moderator:manage:announcements', + 'moderator:manage:automod', + 'moderator:manage:automod_settings', + 'moderator:manage:banned_users', + 'moderator:manage:blocked_terms', + 'moderator:manage:chat_messages', + 'moderator:manage:chat_settings', + 'moderator:manage:shield_mode', + 'moderator:manage:shoutouts', + 'moderator:read:automod_settings', + 'moderator:read:blocked_terms', + 'moderator:read:chat_settings', + 'moderator:read:chatters', + 'moderator:read:followers', + 'moderator:read:shield_mode', + 'moderator:read:shoutouts', + 'user:edit:broadcast', + 'user:manage:blocked_users', + 'user:manage:whispers', + 'user:read:blocked_users', + 'user:read:broadcast', + 'user:read:chat', + 'user:read:follows', + 'user:read:subscriptions', + 'user_subscriptions', + 'user_follows_edit', + 'user_read', + 'whispers:edit', + 'whispers:read' + ] + }; + + botAccountProvider: AuthProviderDefinition = { + id: this.botAccountProviderId, + name: "Bot Account", + client: { + id: this.twitchClientId + }, + auth: { + tokenHost: this._host, + authorizePath: this._authorizePath, + tokenPath: this._tokenPath, + type: "device" + }, + scopes: [ + 'channel:moderate', + 'chat:edit', + 'chat:read', + 'moderator:manage:announcements', + 'user:manage:whispers', + 'user:read:chat', + 'whispers:edit', + 'whispers:read', + 'channel_read' + ] + }; + + registerTwitchAuthProviders() { + authManager.registerAuthProvider(this.streamerAccountProvider); + authManager.registerAuthProvider(this.botAccountProvider); + }; +} + +async function getUserCurrent(accessToken: string) { + try { + const response = await axios.get('https://api.twitch.tv/helix/users', { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'User-Agent': 'Firebot v5', + 'Client-ID': this.twitchClientId + }, + responseType: "json" + }); + + if (response.status >= 200 && response.status <= 204) { + const userData = response.data; + if (userData.data && userData.data.length > 0) { + return userData.data[0]; + } + } + } catch (error) { + logger.error("Error getting current twitch user", error); + } + return null; +} + +const twitchAuthProviders = new TwitchAuthProviders(); + +authManager.on("auth-success", async authData => { + const { providerId, tokenData } = authData; + + if (providerId === twitchAuthProviders.streamerAccountProviderId + || providerId === twitchAuthProviders.botAccountProviderId) { + const userData = await getUserCurrent(tokenData.access_token); + if (userData == null) { + return; + } + + const accountType = providerId === twitchAuthProviders.streamerAccountProviderId ? "streamer" : "bot"; + const accountObject: FirebotAccount = { + username: userData.login, + displayName: userData.display_name, + channelId: userData.id, + userId: userData.id, + avatar: userData.profile_image_url, + broadcasterType: userData.broadcaster_type, + auth: tokenData + }; + + accountAccess.updateAccount(accountType, accountObject); + } +}); + +export = twitchAuthProviders; \ No newline at end of file diff --git a/src/backend/auth/twitch-device-auth-provider.ts b/src/backend/auth/twitch-device-auth-provider.ts new file mode 100644 index 000000000..5fca54fe1 --- /dev/null +++ b/src/backend/auth/twitch-device-auth-provider.ts @@ -0,0 +1,361 @@ +/** + * This file exists because there's currently no Twurple AuthProvider that supports Device Code Flow. + * Once one exists, this can go away. In the meantime, it stays in order to refresh tokens automagically. + */ + +import { Enumerable } from '@d-fischer/shared-utils'; +import { EventEmitter } from '@d-fischer/typed-event-emitter'; +import { CustomError, extractUserId, type UserIdResolvable } from '@twurple/common'; +import type { AccessToken, AccessTokenMaybeWithUserId, AccessTokenWithUserId, TokenInfoData } from '@twurple/auth'; +import { InvalidTokenError, TokenInfo } from '@twurple/auth'; +import type { AuthProvider } from '@twurple/auth'; +import { callTwitchApi, HttpStatusCodeError } from '@twurple/api-call'; + +const scopeEquivalencies = new Map([ + ['channel_commercial', ['channel:edit:commercial']], + ['channel_editor', ['channel:manage:broadcast']], + ['channel_read', ['channel:read:stream_key']], + ['channel_subscriptions', ['channel:read:subscriptions']], + ['user_blocks_read', ['user:read:blocked_users']], + ['user_blocks_edit', ['user:manage:blocked_users']], + ['user_follows_edit', ['user:edit:follows']], + ['user_read', ['user:read:email']], + ['user_subscriptions', ['user:read:subscriptions']], + ['user:edit:broadcast', ['channel:manage:broadcast', 'channel:manage:extensions']], +]); + +interface AccessTokenData { + access_token: string; + refresh_token: string; + expires_in?: number; + scope?: string[]; +} + +/** + * Thrown whenever you try to execute an action in the context of a user + * who is already known to have an invalid token saved in its {@link AuthProvider}. + */ +export class CachedRefreshFailureError extends CustomError { + constructor(public readonly userId: string) { + super(`The user context for the user ${userId} has been disabled. +This happened because the access token became invalid (e.g. by expiry) and refreshing it failed (e.g. because the account's password was changed). +The user will need to reauthenticate to continue.`); + } +} + +/** + * Gets information about an access token. + * + * @param accessToken The access token to get the information of. + * @param clientId The client ID of your application. + * + * You need to obtain one using one of the [Twitch OAuth flows](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/). + */ +async function getTokenInfo(accessToken: string, clientId?: string): Promise { + try { + const data = await callTwitchApi({ type: 'auth', url: 'validate' }, clientId, accessToken); + return new TokenInfo(data); + } catch (e) { + if (e instanceof HttpStatusCodeError && e.statusCode === 401) { + throw new InvalidTokenError({ cause: e }); + } + throw e; + } +} + +/** + * Compares scopes for a non-upgradable {@link AuthProvider} instance. + * + * @param scopesToCompare The scopes to compare against. + * @param requestedScopes The scopes you requested. + */ +function compareScopes(scopesToCompare: string[], requestedScopes?: string[]): void { + if (requestedScopes?.length) { + const scopes = new Set( + scopesToCompare.flatMap(scope => [scope, ...(scopeEquivalencies.get(scope) ?? [])]), + ); + + if (requestedScopes.every(scope => !scopes.has(scope))) { + const scopesStr = requestedScopes.join(', '); + throw new Error( + `This token does not have any of the requested scopes (${scopesStr}) and can not be upgraded. +If you need dynamically upgrading scopes, please implement the AuthProvider interface accordingly: + +\thttps://twurple.js.org/reference/auth/interfaces/AuthProvider.html`, + ); + } + } +} + +/** + * Compares scope sets for a non-upgradable {@link AuthProvider} instance. + * + * @param scopesToCompare The scopes to compare against. + * @param requestedScopeSets The scope sets you requested. + */ +function compareScopeSets(scopesToCompare: string[], requestedScopeSets: string[][]): void { + for (const requestedScopes of requestedScopeSets) { + compareScopes(scopesToCompare, requestedScopes); + } +} + +/** + * Compares scopes for a non-upgradable `AuthProvider` instance, loading them from the token if necessary, + * and returns them together with the user ID. + * + * @param clientId The client ID of your application. + * @param token The access token. + * @param userId The user ID that was already loaded. + * @param loadedScopes The scopes that were already loaded. + * @param requestedScopeSets The scope sets you requested. + */ +async function loadAndCompareTokenInfo( + clientId: string, + token: string, + userId?: string, + loadedScopes?: string[], + requestedScopeSets?: Array, +): Promise<[string[] | undefined, string]> { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (requestedScopeSets?.length || !userId) { + const userInfo = await getTokenInfo(token, clientId); + if (!userInfo.userId) { + throw new Error('Trying to use an app access token as a user access token'); + } + + const scopesToCompare = loadedScopes ?? userInfo.scopes; + if (requestedScopeSets) { + compareScopeSets( + scopesToCompare, + requestedScopeSets.filter((val): val is string[] => Boolean(val)), + ); + } + + return [scopesToCompare, userInfo.userId]; + } + + return [loadedScopes, userId]; +} + +function createRefreshTokenQuery(clientId: string, refreshToken: string) { + return { + grant_type: 'refresh_token', + client_id: clientId, + refresh_token: refreshToken, + }; +} + +function createAccessTokenFromData(data: AccessTokenData): AccessToken { + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || null, + scope: data.scope ?? [], + expiresIn: data.expires_in ?? null, + obtainmentTimestamp: Date.now(), + }; +} + +/** + * Refreshes an expired access token with your client credentials and the refresh token that was given by the initial authentication. + * + * @param clientId The client ID of your application. + * @param clientSecret The client secret of your application. + * @param refreshToken The refresh token. + */ +async function refreshUserToken( + clientId: string, + refreshToken: string, +): Promise { + return createAccessTokenFromData( + await callTwitchApi({ + type: 'auth', + url: 'token', + method: 'POST', + query: createRefreshTokenQuery(clientId, refreshToken), + }), + ); +} + +interface DeviceAuthProviderConfig { + userId: UserIdResolvable, + clientId: string, + accessToken: string | AccessToken, + scopes?: string[] +} + +/** + * An auth provider that always returns the same initially given credentials. + * + * This has the added benefit of refreshing tokens for Device Code Flow. + */ +export class DeviceAuthProvider extends EventEmitter implements AuthProvider { + /** @internal */ @Enumerable(false) private readonly _clientId: string; + /** @internal */ @Enumerable(false) private _accessToken: AccessToken; + private _userId?: string; + private _scopes?: string[]; + private readonly _cachedRefreshFailures = new Set(); + + /** + * Fires when a user token is refreshed. + * + * @param userId The ID of the user whose token was successfully refreshed. + * @param token The refreshed token data. + */ + readonly onRefresh = this.registerEvent<[userId: string, token: AccessToken]>(); + + /** + * Fires when a user token fails to refresh. + * + * @param userId The ID of the user whose token wasn't successfully refreshed. + */ + readonly onRefreshFailure = this.registerEvent<[userId: string]>(); + + /** + * Creates a new auth provider with static credentials. + * + * @param clientId The client ID of your application. + * @param accessToken The access token to provide. + * + * You need to obtain one using one of the [Twitch OAuth flows](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/). + * @param scopes The scopes the supplied token has. + * + * If this argument is given, the scopes need to be correct, or weird things might happen. If it's not (i.e. it's `undefined`), we fetch the correct scopes for you. + * + * If you can't exactly say which scopes your token has, don't use this parameter/set it to `undefined`. + */ + constructor(deviceAuthConfig: DeviceAuthProviderConfig) { + super(); + + this._userId = extractUserId(deviceAuthConfig.userId); + this._clientId = deviceAuthConfig.clientId; + this._accessToken = + typeof deviceAuthConfig.accessToken === 'string' + ? { + accessToken: deviceAuthConfig.accessToken, + refreshToken: null, + scope: deviceAuthConfig.scopes ?? [], + expiresIn: null, + obtainmentTimestamp: Date.now(), + } + : deviceAuthConfig.accessToken; + this._scopes = deviceAuthConfig.scopes; + } + + /** + * The client ID. + */ + get clientId(): string { + return this._clientId; + } + + /** + * Gets the static access token. + * + * If the current access token does not have the requested scopes, this method throws. + * This makes supplying an access token with the correct scopes from the beginning necessary. + * + * @param user Ignored. + * @param scopeSets The requested scopes. + */ + async getAccessTokenForUser( + user: UserIdResolvable, + ...scopeSets: Array + ): Promise { + return await this._getAccessToken(scopeSets); + } + + /** + * Gets the static access token. + * + * If the current access token does not have the requested scopes, this method throws. + * This makes supplying an access token with the correct scopes from the beginning necessary. + * + * @param intent Ignored. + * @param scopeSets The requested scopes. + */ + async getAccessTokenForIntent( + intent: string, + ...scopeSets: Array + ): Promise { + return await this._getAccessToken(scopeSets); + } + + /** + * Gets the static access token. + */ + async getAnyAccessToken(): Promise { + return await this._getAccessToken(); + } + + /** + * The scopes that are currently available using the access token. + */ + getCurrentScopesForUser(): string[] { + return this._scopes ?? []; + } + + /** + * Requests that the provider fetches a new token from Twitch for the given user. + * + * @param user The user to refresh the token for. + */ + async refreshAccessTokenForUser(user: UserIdResolvable): Promise { + const userId = extractUserId(user); + + if (this._cachedRefreshFailures.has(userId)) { + throw new CachedRefreshFailureError(userId); + } + + const previousTokenData = this._accessToken; + + if (!previousTokenData) { + throw new Error('Trying to refresh non-existent token'); + } + + const tokenData = await this._refreshUserTokenWithCallback(userId, previousTokenData.refreshToken!); + + this._accessToken = tokenData; + this.emit(this.onRefresh, userId, tokenData); + + return { + ...tokenData, + userId, + }; + } + + /** + * Requests that the provider fetches a new token from Twitch for the given intent. + * + * @param intent The intent to refresh the token for. + */ + async refreshAccessTokenForIntent(intent: string): Promise { + const userId = this._userId!; + + return await this.refreshAccessTokenForUser(userId); + } + + private async _getAccessToken(requestedScopeSets?: Array): Promise { + const [scopes, userId] = await loadAndCompareTokenInfo( + this._clientId, + this._accessToken.accessToken, + this._userId, + this._scopes, + requestedScopeSets, + ); + + this._scopes = scopes; + this._userId = userId; + + return { ...this._accessToken, userId }; + } + + private async _refreshUserTokenWithCallback(userId: string, refreshToken: string): Promise { + try { + return await refreshUserToken(this.clientId, refreshToken); + } catch (e) { + this._cachedRefreshFailures.add(userId); + this.emit(this.onRefreshFailure, userId); + throw e; + } + } +} \ No newline at end of file diff --git a/src/backend/chat/twitch-chat.ts b/src/backend/chat/twitch-chat.ts index d33a9bbb8..4c96aae6e 100644 --- a/src/backend/chat/twitch-chat.ts +++ b/src/backend/chat/twitch-chat.ts @@ -8,7 +8,7 @@ import commandHandler from "./commands/commandHandler"; import * as twitchSlashCommandHandler from "./twitch-slash-command-handler"; import logger from "../logwrapper"; -import firebotStaticAuthProvider from "../auth/firebot-static-auth-provider"; +import firebotDeviceAuthProvider from "../auth/firebot-device-auth-provider"; import accountAccess from "../common/account-access"; import frontendCommunicator from "../common/frontend-communicator"; import twitchEventsHandler from "../events/twitch-events"; @@ -80,8 +80,8 @@ class TwitchChat extends EventEmitter { return; } - const streamerAuthProvider = firebotStaticAuthProvider.streamerProvider; - const botAuthProvider = firebotStaticAuthProvider.botProvider; + const streamerAuthProvider = firebotDeviceAuthProvider.streamerProvider; + const botAuthProvider = firebotDeviceAuthProvider.botProvider; if (streamerAuthProvider == null && botAuthProvider == null) { return; } @@ -131,7 +131,7 @@ class TwitchChat extends EventEmitter { } }); - await this._streamerChatClient.connect(); + this._streamerChatClient.connect(); await chatHelpers.handleChatConnect(); @@ -164,7 +164,7 @@ class TwitchChat extends EventEmitter { twitchChatListeners.setupBotChatListeners(this._botChatClient); - await this._botChatClient.connect(); + this._botChatClient.connect(); } catch (error) { logger.error("Error joining streamers chat channel with Bot account", error); } diff --git a/src/backend/common/account-access.js b/src/backend/common/account-access.js index e6c8b5fbf..ec3d80830 100644 --- a/src/backend/common/account-access.js +++ b/src/backend/common/account-access.js @@ -18,7 +18,7 @@ const accountEvents = new EventEmitter(); * @property {string} avatar - The avatar url for the account * @property {string} broadcasterType - "partner", "affiliate" or "" * @property {import("../auth/auth").AuthDetails} auth - Auth token details for the account - * @property {boolean} loggedIn - If the account is linked/logged in + * @property {boolean=} loggedIn - If the account is linked/logged in */ diff --git a/src/backend/effects/builtin/http-request.js b/src/backend/effects/builtin/http-request.js index c13664ad9..e3d6ce2cc 100644 --- a/src/backend/effects/builtin/http-request.js +++ b/src/backend/effects/builtin/http-request.js @@ -235,7 +235,7 @@ const effect = { headers = { ...headers, 'Authorization': `Bearer ${accessToken}`, - 'Client-ID': twitchAuth.TWITCH_CLIENT_ID + 'Client-ID': twitchAuth.twitchClientId }; } diff --git a/src/backend/twitch-api/api.ts b/src/backend/twitch-api/api.ts index 48b5d86e9..20981410a 100644 --- a/src/backend/twitch-api/api.ts +++ b/src/backend/twitch-api/api.ts @@ -1,9 +1,10 @@ import { ApiClient } from "@twurple/api"; -import { StaticAuthProvider } from "@twurple/auth"; +import { AuthProvider } from "@twurple/auth"; import logger from "../logwrapper"; import accountAccess from "../common/account-access"; +import { TwitchAuthApi } from "./resource/auth"; import { TwitchBitsApi } from "./resource/bits"; import { TwitchCategoriesApi } from "./resource/categories"; import { TwitchChannelRewardsApi } from "./resource/channel-rewards"; @@ -19,7 +20,7 @@ class TwitchApi { private _streamerClient: ApiClient; private _botClient: ApiClient; - setupApiClients(streamerProvider: StaticAuthProvider, botProvider: StaticAuthProvider): void { + setupApiClients(streamerProvider: AuthProvider, botProvider: AuthProvider): void { if (!streamerProvider && !botProvider) { return; } @@ -50,6 +51,10 @@ class TwitchApi { return this._botClient; } + get auth() { + return new TwitchAuthApi(this._streamerClient, this._botClient); + } + get bits() { return new TwitchBitsApi(this._streamerClient, this._botClient); } diff --git a/src/backend/twitch-api/pubsub/pubsub-client.js b/src/backend/twitch-api/pubsub/pubsub-client.js index d2e56e7d3..b7a078e84 100644 --- a/src/backend/twitch-api/pubsub/pubsub-client.js +++ b/src/backend/twitch-api/pubsub/pubsub-client.js @@ -2,7 +2,7 @@ const logger = require("../../logwrapper"); const accountAccess = require("../../common/account-access"); const frontendCommunicator = require("../../common/frontend-communicator"); -const firebotStaticAuthProvider = require("../../auth/firebot-static-auth-provider"); +const firebotDeviceAuthProvider = require("../../auth/firebot-device-auth-provider"); const chatRolesManager = require("../../roles/chat-roles-manager"); const { PubSubClient } = require("@twurple/pubsub"); @@ -58,7 +58,7 @@ async function createClient() { logger.info("Connecting to Twitch PubSub..."); - const authProvider = firebotStaticAuthProvider.streamerProvider; + const authProvider = firebotDeviceAuthProvider.streamerProvider; pubSubClient = new PubSubClient({ authProvider }); diff --git a/src/backend/twitch-api/resource/auth.ts b/src/backend/twitch-api/resource/auth.ts new file mode 100644 index 000000000..91eec3007 --- /dev/null +++ b/src/backend/twitch-api/resource/auth.ts @@ -0,0 +1,40 @@ +import logger from '../../logwrapper'; +import accountAccess from "../../common/account-access"; +import { ApiClient } from "@twurple/api"; + +export class TwitchAuthApi { + streamerClient: ApiClient; + botClient: ApiClient; + + constructor(streamerClient: ApiClient, botClient: ApiClient) { + this.streamerClient = streamerClient; + this.botClient = botClient; + } + + async isTokenValid(type: "streamer" | "bot"): Promise { + try { + let userId: string, apiClient: ApiClient; + + switch (type) { + case "streamer": + userId = accountAccess.getAccounts().streamer.userId; + apiClient = this.streamerClient; + break; + + case "bot": + userId = accountAccess.getAccounts().bot.userId; + apiClient = this.botClient; + break; + } + + // This shouldn only happen if an account is not logged in + if (!userId || !apiClient) return false; + + const token = await apiClient.getTokenInfo(); + return token?.userId === userId; + } catch (error) { + logger.error(`Failed to validate token`, error.message); + return false; + } + } +}; \ No newline at end of file diff --git a/src/gui/app/directives/modals/misc/botLoginModal.js b/src/gui/app/directives/modals/misc/botLoginModal.js deleted file mode 100644 index 2e09dab07..000000000 --- a/src/gui/app/directives/modals/misc/botLoginModal.js +++ /dev/null @@ -1,50 +0,0 @@ -"use strict"; - -(function() { - angular.module("firebotApp") - .component("botLoginModal", { - template: ` - - - - `, - bindings: { - resolve: "<", - close: "&", - dismiss: "&" - }, - controller: function($rootScope, settingsService, ngToast, backendCommunicator) { - const $ctrl = this; - - backendCommunicator.on("accountUpdate", accounts => { - if (accounts.bot.loggedIn) { - $ctrl.dismiss(); - } - }); - - $ctrl.botLoginUrl = `http://localhost:${settingsService.getWebServerPort()}/api/v1/auth?providerId=${encodeURIComponent("twitch:bot-account")}`; - - $ctrl.copy = function() { - $rootScope.copyTextToClipboard($ctrl.botLoginUrl); - - ngToast.create({ - className: 'success', - content: 'Bot login url copied!' - }); - }; - } - }); -}()); diff --git a/src/gui/app/directives/modals/misc/twitch-login-modal.js b/src/gui/app/directives/modals/misc/twitch-login-modal.js new file mode 100644 index 000000000..375de5b7a --- /dev/null +++ b/src/gui/app/directives/modals/misc/twitch-login-modal.js @@ -0,0 +1,85 @@ +"use strict"; + +(function() { + angular.module("firebotApp") + .component("twitchLoginModal", { + template: ` + + + + `, + bindings: { + resolve: "<", + close: "&", + dismiss: "&" + }, + controller: function($rootScope, ngToast, backendCommunicator) { + const $ctrl = this; + $ctrl.loaded = false; + + backendCommunicator.on("device-code-received", (details) => { + $ctrl.loaded = true; + $ctrl.loginUrl = details.loginUrl; + $ctrl.code = details.code; + }); + + backendCommunicator.on("accountUpdate", accounts => { + switch ($ctrl.accountType) { + case "streamer": + if (accounts.streamer.loggedIn) { + $ctrl.dismiss(); + } + break; + case "bot": + if (accounts.bot.loggedIn) { + $ctrl.dismiss(); + } + break; + default: + break; + } + }); + + $ctrl.loginUrl = "https://www.twitch.tv/activate"; + $ctrl.code = ""; + + $ctrl.copy = function() { + $rootScope.copyTextToClipboard($ctrl.loginUrl); + + ngToast.create({ + className: 'success', + content: 'Twitch login URL copied!' + }); + }; + + $ctrl.$onInit = function() { + $ctrl.accountType = $ctrl.resolve.accountType; + + backendCommunicator.fireEventAsync("begin-device-auth", `twitch:${$ctrl.accountType}-account`); + }; + } + }); +}()); diff --git a/src/gui/app/index.html b/src/gui/app/index.html index 859f72502..4e8766454 100644 --- a/src/gui/app/index.html +++ b/src/gui/app/index.html @@ -278,7 +278,6 @@ - @@ -296,6 +295,7 @@ + @@ -474,7 +474,7 @@