mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
Projektstart
- Phase 1 "Twitch Login" abgeschlossen
This commit is contained in:
@@ -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 : []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user