Files
Twungeon/apps/backend/src/twitchOAuth.ts
T

142 lines
4.7 KiB
TypeScript

import { randomBytes } from 'node:crypto'
import { z } from 'zod'
import {
REQUIRED_TWITCH_SCOPES,
storedTwitchTokenExists,
writeStoredTwitchToken
} from '../../../packages/twitch-adapter/src/tokenStore.js'
const TokenResponseSchema = z.object({
access_token: z.string().min(1),
refresh_token: z.string().min(1),
expires_in: z.number().int().nonnegative(),
scope: z.array(z.string()),
token_type: z.string()
})
const ValidationResponseSchema = z.object({
client_id: z.string().min(1),
login: z.string().min(1),
scopes: z.array(z.string()),
user_id: z.string().min(1),
expires_in: z.number().int().nonnegative()
})
export interface TwitchOAuthConfig {
clientId: string
clientSecret: string
redirectUri: string
expectedLogin: string
tokenFile: string
}
export interface TwitchOAuthResult {
login: string
userId: string
scopes: string[]
}
export interface TwitchOAuthStatus {
configured: boolean
authorized: boolean
redirectUri: string
}
export class TwitchOAuthService {
private readonly states = new Map<string, number>()
constructor(
private readonly config: TwitchOAuthConfig,
private readonly fetchImplementation: typeof fetch = fetch,
private readonly now: () => number = Date.now
) {}
startAuthorization(): string {
this.removeExpiredStates()
if (this.states.size >= 100) {
throw new Error('Too many OAuth attempts are pending. Wait ten minutes and try again.')
}
const state = randomBytes(32).toString('base64url')
this.states.set(state, this.now() + 10 * 60_000)
const url = new URL('https://id.twitch.tv/oauth2/authorize')
url.searchParams.set('client_id', this.config.clientId)
url.searchParams.set('redirect_uri', this.config.redirectUri)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', REQUIRED_TWITCH_SCOPES.join(' '))
url.searchParams.set('state', state)
return url.toString()
}
async completeAuthorization(code: string, state: string): Promise<TwitchOAuthResult> {
this.removeExpiredStates()
const expiresAt = this.states.get(state)
this.states.delete(state)
if (!expiresAt || expiresAt <= this.now()) {
throw new Error('The OAuth state is missing or expired. Start authorization again.')
}
if (!code) throw new Error('Twitch did not return an authorization code.')
const tokenResponse = await this.fetchImplementation('https://id.twitch.tv/oauth2/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: this.config.redirectUri
})
})
if (!tokenResponse.ok) {
throw new Error(`Twitch token exchange failed with HTTP ${tokenResponse.status}.`)
}
const token = TokenResponseSchema.parse(await tokenResponse.json())
const validationResponse = await this.fetchImplementation('https://id.twitch.tv/oauth2/validate', {
headers: { authorization: `OAuth ${token.access_token}` }
})
if (!validationResponse.ok) {
throw new Error(`Twitch token validation failed with HTTP ${validationResponse.status}.`)
}
const validation = ValidationResponseSchema.parse(await validationResponse.json())
if (validation.client_id !== this.config.clientId) {
throw new Error('Twitch returned a token for a different application.')
}
if (validation.login.toLowerCase() !== this.config.expectedLogin.toLowerCase()) {
throw new Error(`Authorize with the configured broadcaster account: ${this.config.expectedLogin}.`)
}
const missingScopes = REQUIRED_TWITCH_SCOPES.filter(scope => !validation.scopes.includes(scope))
if (missingScopes.length) {
throw new Error(`The Twitch token is missing required scopes: ${missingScopes.join(', ')}.`)
}
await writeStoredTwitchToken(this.config.tokenFile, {
accessToken: token.access_token,
refreshToken: token.refresh_token,
scope: validation.scopes,
expiresIn: token.expires_in,
obtainmentTimestamp: this.now(),
userId: validation.user_id,
login: validation.login,
clientId: validation.client_id
})
return { login: validation.login, userId: validation.user_id, scopes: validation.scopes }
}
async status(): Promise<TwitchOAuthStatus> {
return {
configured: true,
authorized: await storedTwitchTokenExists(this.config.tokenFile),
redirectUri: this.config.redirectUri
}
}
private removeExpiredStates(): void {
const now = this.now()
for (const [state, expiresAt] of this.states) {
if (expiresAt <= now) this.states.delete(state)
}
}
}