142 lines
4.7 KiB
TypeScript
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)
|
|
}
|
|
}
|
|
}
|