Projektstart

- Phase 1 "Twitch Login" abgeschlossen
This commit is contained in:
Andre
2026-04-07 20:11:00 +02:00
commit 7d19ccf652
23 changed files with 8606 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
// Orchestriert den kompletten Twitch-Login-Lifecycle und hält den aktuellen Token-Status.
import { EventEmitter } from 'node:events'
import {
pollForToken,
refreshAccessToken,
requestDeviceCode,
type DeviceCodeResponse,
type TokenResponse
} from './deviceCodeFlow'
import { clearTokens, loadTokens, saveTokens, type StoredTokens } from './tokenStore'
const SCOPES = ['user:read:follows'] as const
const REFRESH_SAFETY_MS = 60_000
export type AuthStatus = 'logged-out' | 'logged-in'
export interface DeviceFlowStartInfo {
userCode: string
verificationUri: string
expiresInSec: number
}
export type AuthEvent =
| { kind: 'authorized' }
| { kind: 'denied' }
| { kind: 'expired' }
| { kind: 'cancelled' }
| { kind: 'error'; message: string }
export class AuthService extends EventEmitter {
private tokens: StoredTokens | null = null
private currentFlow: AbortController | null = null
constructor(private readonly clientId: string) {
super()
}
async init(): Promise<void> {
this.tokens = await loadTokens()
}
getStatus(): AuthStatus {
return this.tokens ? 'logged-in' : 'logged-out'
}
isConfigured(): boolean {
return this.clientId.length > 0
}
/**
* Startet den Device Code Flow. Liefert die Anzeigedaten sofort zurück
* und pollt im Hintergrund. Statuswechsel werden als 'auth-event' emittiert.
*/
async startDeviceFlow(): Promise<DeviceFlowStartInfo> {
if (!this.isConfigured()) {
throw new Error(
'TWITCH_CLIENT_ID nicht gesetzt. Trage MAIN_VITE_TWITCH_CLIENT_ID in .env ein.'
)
}
this.cancelFlow()
const device: DeviceCodeResponse = await requestDeviceCode(this.clientId, SCOPES)
const ctrl = new AbortController()
this.currentFlow = ctrl
void this.runPolling(device, ctrl.signal)
return {
userCode: device.user_code,
verificationUri: device.verification_uri,
expiresInSec: device.expires_in
}
}
cancelFlow(): void {
this.currentFlow?.abort()
this.currentFlow = null
}
async logout(): Promise<void> {
this.cancelFlow()
this.tokens = null
await clearTokens()
this.emit('auth-event', { kind: 'cancelled' } satisfies AuthEvent)
}
/**
* Liefert ein gültiges Access-Token, refresht falls nötig.
* Wird vom Helix-Client benutzt.
*/
async getValidAccessToken(): Promise<string | null> {
if (!this.tokens) return null
if (this.tokens.expiresAt - REFRESH_SAFETY_MS > Date.now()) {
return this.tokens.accessToken
}
try {
const refreshed = await refreshAccessToken(this.clientId, this.tokens.refreshToken)
this.tokens = tokenResponseToStored(refreshed)
await saveTokens(this.tokens)
return this.tokens.accessToken
} catch {
// Refresh fehlgeschlagen → User muss sich neu einloggen.
this.tokens = null
await clearTokens()
return null
}
}
private async runPolling(device: DeviceCodeResponse, signal: AbortSignal): Promise<void> {
const outcome = await pollForToken({
clientId: this.clientId,
scopes: SCOPES,
deviceCode: device.device_code,
intervalSec: device.interval,
expiresInSec: device.expires_in,
signal
})
if (outcome.kind === 'authorized') {
this.tokens = tokenResponseToStored(outcome.token)
await saveTokens(this.tokens)
this.emit('auth-event', { kind: 'authorized' } satisfies AuthEvent)
} else {
this.emit('auth-event', outcome satisfies AuthEvent)
}
}
}
function tokenResponseToStored(t: TokenResponse): StoredTokens {
return {
accessToken: t.access_token,
refreshToken: t.refresh_token,
expiresAt: Date.now() + t.expires_in * 1000,
scopes: Array.isArray(t.scope) ? t.scope : []
}
}