flybrain/services/bridge/src/auth.ts
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

272 lines
12 KiB
TypeScript

/**
* Loads `tokens.json` (written by `tools/authorize.mts`) and builds the `RefreshingAuthProvider`
* with both identities registered (`docs/design/stage-bridge.md` B1): the bot account (chat) and
* the broadcaster account (redemptions, predictions, follows). Persists refreshed tokens back to
* the same file, atomically, 0600.
*/
import { readFile } from 'node:fs/promises';
import type { AccessTokenMaybeWithUserId, AccessTokenWithUserId, AuthProvider } from '@twurple/auth';
import { RefreshingAuthProvider } from '@twurple/auth';
import { getTokenInfo } from '@twurple/auth';
import { atomicWriteJson } from './atomic-file';
import type { BridgeConfig } from './config';
import { assertRequiredScopes, type GrantedScopes } from './scopes';
/** One stored OAuth token, shaped like `@twurple/auth`'s `AccessToken` plus the user id. */
export interface StoredToken {
userId: string;
accessToken: string;
refreshToken: string | null;
scope: string[];
expiresIn: number | null;
obtainmentTimestamp: number;
}
export interface TokensFile {
bot: StoredToken;
broadcaster: StoredToken;
}
export async function loadTokensFile(path: string): Promise<TokensFile> {
let raw: string;
try {
raw = await readFile(path, 'utf8');
} catch (cause) {
throw new Error(
`could not read TOKENS_FILE at ${path}: ${(cause as Error).message}. Run tools/authorize.mts first.`,
);
}
const parsed = JSON.parse(raw) as Partial<TokensFile>;
if (!parsed.bot || !parsed.broadcaster) {
throw new Error(`${path} is missing "bot" and/or "broadcaster" — re-run tools/authorize.mts`);
}
return parsed as TokensFile;
}
export interface AuthSetup {
/** Carries the bot token only: chat sends and `channel.chat.message`. */
botAuthProvider: RefreshingAuthProvider;
/** Carries the broadcaster token only: follows, raids, Channel Points. */
broadcasterAuthProvider: RefreshingAuthProvider;
botUserId: string;
broadcasterUserId: string;
/** True when one Twitch account holds both roles — see the comment on `buildAuthProvider`. */
sameAccount: boolean;
/**
* Non-null ONLY when both roles are the same Twitch account: a provider that fronts the two
* per-role providers and picks between them by the scopes each call asks for, so a single
* EventSub listener can carry both roles' subscriptions on ONE websocket transport
* (`src/eventsub.ts`, `createRoleRoutingAuthProvider` below). `null` when the accounts differ,
* because then twurple's own per-user socket keying already gives one socket per account and
* there is nothing to merge.
*/
sharedEventSubAuthProvider: AuthProvider | null;
}
/**
* Build ONE `RefreshingAuthProvider` PER ROLE, each holding exactly one token.
*
* This used to be a single provider with both tokens added to it, which is correct only while the
* bot and the broadcaster are different Twitch accounts. They are not required to be: the first
* live channel (`<twitch-channel>`, 2026-09-16) runs both roles on ONE account, so the two
* `addUser` calls carried the SAME user id — and twurple keys its token map by user id, so the
* second call silently REPLACED the first. The surviving token was the broadcaster's, which holds
* `channel:bot`/`moderator:read:followers`/`channel:*:redemptions` and none of
* `user:read:chat`/`user:write:chat`/`user:bot`. The startup scope assertion still passed, because
* `assertStartupScopes` calls `getTokenInfo` on each stored token separately and never asks the
* provider what it would actually hand out; the failure landed later, on the first chat send and
* on the `channel.chat.message` subscription, as a missing-scope error.
*
* One provider per role cannot collide: each holds a single user id and a single token, whether or
* not the two roles are the same account. The cost is one `ApiClient` and one EventSub WebSocket
* per role (`src/eventsub.ts`), which is what Twitch's own model expects anyway — a subscription
* is authorized by the token of the user it names.
*/
export function buildAuthProvider(config: Pick<BridgeConfig, 'twitchClientId' | 'twitchClientSecret' | 'tokensFile'>, tokens: TokensFile): AuthSetup {
const newProvider = (): RefreshingAuthProvider => {
const provider = new RefreshingAuthProvider({
clientId: config.twitchClientId,
clientSecret: config.twitchClientSecret,
});
provider.onRefresh((userId, newTokenData) => {
void persistRefreshedToken(config.tokensFile, tokens, userId, newTokenData).catch((cause: unknown) => {
console.error(`flybridge: failed to persist refreshed token for ${userId}: ${String(cause)}`);
});
});
return provider;
};
const botAuthProvider = newProvider();
botAuthProvider.addUser(tokens.bot.userId, toAccessToken(tokens.bot), ['chat']);
const broadcasterAuthProvider = newProvider();
broadcasterAuthProvider.addUser(tokens.broadcaster.userId, toAccessToken(tokens.broadcaster), ['broadcaster']);
const sameAccount = tokens.bot.userId === tokens.broadcaster.userId;
return {
botAuthProvider,
broadcasterAuthProvider,
botUserId: tokens.bot.userId,
broadcasterUserId: tokens.broadcaster.userId,
sameAccount,
sharedEventSubAuthProvider: sameAccount
? createRoleRoutingAuthProvider({
clientId: config.twitchClientId,
bot: { provider: botAuthProvider, scopes: tokens.bot.scope },
broadcaster: { provider: broadcasterAuthProvider, scopes: tokens.broadcaster.scope },
})
: null,
};
}
export interface RoleRoutingRole {
provider: AuthProvider;
/** The scopes this role's stored token actually carries, from `tokens.json`. */
scopes: readonly string[];
}
export interface RoleRoutingAuthProviderOptions {
clientId: string;
bot: RoleRoutingRole;
broadcaster: RoleRoutingRole;
}
/**
* An `AuthProvider` that fronts the two per-role providers and routes each request to whichever
* role's token carries the SCOPES the request asked for.
*
* WHY THIS EXISTS. `EventSubWsListener` opens one websocket per distinct auth user id, so two
* listeners for one Twitch account meant two transports against Twitch's per-user limit of three
* — the doubling that turned a single 1006 disconnect into the hour of dead chat on 2026-09-16
* (see `src/subscription-health.ts`). One listener means one transport, and one listener takes
* exactly one `ApiClient`, hence one `AuthProvider`. It cannot be a `RefreshingAuthProvider`
* holding both tokens: that provider keys its token map by user id, so the second `addUser` for
* the same account silently replaces the first — the bug the long comment on `buildAuthProvider`
* above is about, and which must not be re-introduced from the other end.
*
* WHY ROUTING BY SCOPE WORKS. Every Helix call twurple makes to create an EventSub subscription
* goes through `BaseApiClient` with `forceType: 'user'` and the endpoint's required scope set, and
* `BaseApiClient` hands that scope set straight to `AuthProvider.getAccessTokenForUser`
* (@twurple/api 8.1.4). So the request says what it needs: `channel.chat.message` asks for
* `user:read:chat` (bot), `channel.follow` v2 for `moderator:read:followers` (broadcaster), the
* Channel Points redemption for `channel:{read,manage}:redemptions` (broadcaster). `channel.raid`
* asks for no scope at all — it needs only *a* user token — and gets the default role.
*
* WHAT IS DELIBERATELY NOT IMPLEMENTED. `refreshAccessTokenForUser` is optional on the interface
* and is omitted: its only argument is a user id, which says nothing about the role when both
* roles are one account, so any implementation would be a coin flip that could hand back a token
* missing the scope the caller needed. Leaving it out means `BaseApiClient` skips the pre-emptive
* refresh and surfaces a 401 instead of guessing — and it costs nothing, because
* `RefreshingAuthProvider.getAccessTokenForUser` already refreshes an expired or
* scope-insufficient token before returning it, and this provider delegates every call to it.
*/
export function createRoleRoutingAuthProvider(options: RoleRoutingAuthProviderOptions): AuthProvider {
const { clientId, bot, broadcaster } = options;
const covers = (granted: readonly string[], requested: readonly string[]): boolean =>
requested.every((scope) => granted.includes(scope));
const pick = (scopeSets: Array<string[] | undefined>): AuthProvider => {
const requested = scopeSets.flatMap((set) => set ?? []);
// No scopes requested: any user token for this account will do (`channel.raid`). The
// broadcaster is the default because it owns the channel-level subscriptions.
if (requested.length === 0) return broadcaster.provider;
if (covers(bot.scopes, requested)) return bot.provider;
if (covers(broadcaster.scopes, requested)) return broadcaster.provider;
// Neither token covers it, which is a missing-scope misconfiguration `assertRequiredScopes`
// should already have refused at startup. Delegate anyway, so the error that reaches the log
// is twurple's own named missing-scope error rather than a null token from here.
console.error(
`flybridge: no stored token carries all of [${requested.join(', ')}]; routing to the broadcaster token`,
);
return broadcaster.provider;
};
return {
clientId,
// The union, because that is what this provider can actually supply for the account. Callers
// use it as a capability check, and answering with one role's scopes would under-report.
getCurrentScopesForUser: (): string[] => [...new Set([...bot.scopes, ...broadcaster.scopes])],
getAccessTokenForUser: async (
user,
...scopeSets: Array<string[] | undefined>
): Promise<AccessTokenWithUserId | null> => await pick(scopeSets).getAccessTokenForUser(user, ...scopeSets),
getAnyAccessToken: async (user?): Promise<AccessTokenMaybeWithUserId> =>
await broadcaster.provider.getAnyAccessToken(user),
};
}
function toAccessToken(token: StoredToken): {
accessToken: string;
refreshToken: string | null;
scope: string[];
expiresIn: number | null;
obtainmentTimestamp: number;
} {
return {
accessToken: token.accessToken,
refreshToken: token.refreshToken,
scope: token.scope,
expiresIn: token.expiresIn,
obtainmentTimestamp: token.obtainmentTimestamp,
};
}
/**
* Persist a refreshed token back into `tokens.json`.
*
* The role is identified by the token's SCOPE SET, not by its user id: when both roles are the
* same account (the `<twitch-channel>` case) the id says nothing, and writing the refreshed
* broadcaster token over the `bot` entry would destroy the chat scopes on disk — a corruption that
* survives a restart, unlike the in-memory collision `buildAuthProvider` describes. Scopes are
* preserved exactly across a refresh, so they are a reliable discriminator.
*/
async function persistRefreshedToken(
tokensFilePath: string,
tokens: TokensFile,
userId: string,
newToken: { accessToken: string; refreshToken: string | null; scope: string[]; expiresIn: number | null; obtainmentTimestamp: number },
): Promise<void> {
const key = roleForRefreshedToken(tokens, userId, newToken.scope);
if (!key) {
console.error(
`flybridge: refreshed token for ${userId} matches neither stored role by scope set; not persisting`,
);
return;
}
tokens[key] = { userId, ...newToken };
await atomicWriteJson(tokensFilePath, tokens);
}
function roleForRefreshedToken(
tokens: TokensFile,
userId: string,
scope: readonly string[],
): keyof TokensFile | null {
const sameSet = (a: readonly string[], b: readonly string[]): boolean =>
a.length === b.length && [...a].sort().join(' ') === [...b].sort().join(' ');
if (sameSet(tokens.bot.scope, scope)) return 'bot';
if (sameSet(tokens.broadcaster.scope, scope)) return 'broadcaster';
// Different accounts: the id is unambiguous, so fall back to it.
if (tokens.bot.userId !== tokens.broadcaster.userId) {
return tokens.bot.userId === userId ? 'bot' : 'broadcaster';
}
return null;
}
/**
* Startup scope assertion via `getTokenInfo` (`docs/design/stage-bridge.md` B1): refuses to
* start with a named missing scope rather than failing at the first subscription.
*/
export async function assertStartupScopes(
config: Pick<BridgeConfig, 'twitchClientId' | 'featureRedemptions' | 'featurePredictions'>,
tokens: TokensFile,
): Promise<void> {
const [botInfo, broadcasterInfo] = await Promise.all([
getTokenInfo(tokens.bot.accessToken, config.twitchClientId),
getTokenInfo(tokens.broadcaster.accessToken, config.twitchClientId),
]);
const granted: GrantedScopes = { bot: botInfo.scopes, broadcaster: broadcasterInfo.scopes };
assertRequiredScopes(config, granted);
}