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 : []
}
}
+150
View File
@@ -0,0 +1,150 @@
// Twitch OAuth 2.0 Device Code Flow.
// Doku: https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#device-code-grant-flow
const DEVICE_ENDPOINT = 'https://id.twitch.tv/oauth2/device'
const TOKEN_ENDPOINT = 'https://id.twitch.tv/oauth2/token'
export interface DeviceCodeResponse {
device_code: string
user_code: string
verification_uri: string
expires_in: number
interval: number
}
export interface TokenResponse {
access_token: string
refresh_token: string
expires_in: number
scope: string[]
token_type: string
}
export async function requestDeviceCode(
clientId: string,
scopes: readonly string[]
): Promise<DeviceCodeResponse> {
const body = new URLSearchParams({
client_id: clientId,
scopes: scopes.join(' ')
})
const res = await fetch(DEVICE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body
})
if (!res.ok) {
throw new Error(`Device-Code-Request fehlgeschlagen (${res.status}): ${await res.text()}`)
}
return (await res.json()) as DeviceCodeResponse
}
export type PollOutcome =
| { kind: 'authorized'; token: TokenResponse }
| { kind: 'denied' }
| { kind: 'expired' }
| { kind: 'cancelled' }
| { kind: 'error'; message: string }
interface PollOptions {
clientId: string
scopes: readonly string[]
deviceCode: string
intervalSec: number
expiresInSec: number
signal?: AbortSignal
}
export async function pollForToken(opts: PollOptions): Promise<PollOutcome> {
const deadline = Date.now() + opts.expiresInSec * 1000
let intervalMs = Math.max(1, opts.intervalSec) * 1000
while (Date.now() < deadline) {
if (opts.signal?.aborted) return { kind: 'cancelled' }
await sleep(intervalMs, opts.signal)
if (opts.signal?.aborted) return { kind: 'cancelled' }
const body = new URLSearchParams({
client_id: opts.clientId,
scopes: opts.scopes.join(' '),
device_code: opts.deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
})
let res: Response
try {
res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body
})
} catch (err) {
return { kind: 'error', message: (err as Error).message }
}
if (res.ok) {
const token = (await res.json()) as TokenResponse
return { kind: 'authorized', token }
}
// Twitch liefert bei Pending einen 400 mit message-Feld.
let payload: { message?: string; status?: number } = {}
try {
payload = await res.json()
} catch {
/* nicht-JSON Antwort, ignorieren */
}
const msg = (payload.message ?? '').toLowerCase()
if (msg.includes('authorization_pending') || msg.includes('pending')) {
continue
}
if (msg.includes('slow_down') || msg.includes('slow down')) {
intervalMs += 5000
continue
}
if (msg.includes('expired')) return { kind: 'expired' }
if (msg.includes('denied') || msg.includes('access_denied')) return { kind: 'denied' }
return { kind: 'error', message: payload.message ?? `HTTP ${res.status}` }
}
return { kind: 'expired' }
}
export async function refreshAccessToken(
clientId: string,
refreshToken: string
): Promise<TokenResponse> {
const body = new URLSearchParams({
client_id: clientId,
grant_type: 'refresh_token',
refresh_token: refreshToken
})
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body
})
if (!res.ok) {
throw new Error(`Token-Refresh fehlgeschlagen (${res.status}): ${await res.text()}`)
}
return (await res.json()) as TokenResponse
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal?.aborted) {
resolve()
return
}
const t = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, ms)
const onAbort = (): void => {
clearTimeout(t)
resolve()
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
+71
View File
@@ -0,0 +1,71 @@
// Verschlüsselte Persistenz für Twitch-Tokens.
// Nutzt Electron safeStorage (OS-Keystore unter der Haube) — keine native Module nötig.
import { app, safeStorage } from 'electron'
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
export interface StoredTokens {
accessToken: string
refreshToken: string
/** Unix ms */
expiresAt: number
scopes: string[]
}
const FILE_NAME = 'twitch-tokens.bin'
function filePath(): string {
return join(app.getPath('userData'), FILE_NAME)
}
export async function saveTokens(tokens: StoredTokens): Promise<void> {
const json = JSON.stringify(tokens)
const path = filePath()
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(json)
await fs.writeFile(path, encrypted)
} else {
// Fallback: unverschlüsselt (z. B. headless Linux ohne Keyring).
// Datei nur für aktuellen User lesbar machen.
await fs.writeFile(path, json, { mode: 0o600 })
}
}
export async function loadTokens(): Promise<StoredTokens | null> {
const path = filePath()
let raw: Buffer
try {
raw = await fs.readFile(path)
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
throw err
}
let json: string
if (safeStorage.isEncryptionAvailable()) {
try {
json = safeStorage.decryptString(raw)
} catch {
// Datei evtl. mit anderem Key geschrieben — als ungültig behandeln.
return null
}
} else {
json = raw.toString('utf8')
}
try {
return JSON.parse(json) as StoredTokens
} catch {
return null
}
}
export async function clearTokens(): Promise<void> {
try {
await fs.unlink(filePath())
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
}
}
+62
View File
@@ -0,0 +1,62 @@
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'node:path'
import { AuthService } from './auth/authService'
import { registerIpcHandlers } from './ipc/handlers'
const isDev = !app.isPackaged
function createWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 1024,
minHeight: 640,
show: false,
autoHideMenuBar: true,
backgroundColor: '#0e0e10',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
win.once('ready-to-show', () => win.show())
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
if (isDev && process.env.ELECTRON_RENDERER_URL) {
win.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
win.loadFile(join(__dirname, '../renderer/index.html'))
}
return win
}
app.whenReady().then(async () => {
const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? ''
const auth = new AuthService(clientId)
await auth.init()
registerIpcHandlers(auth)
if (!clientId) {
console.warn(
'[twitch4steamdeck] MAIN_VITE_TWITCH_CLIENT_ID nicht gesetzt — Login wird im UI als nicht konfiguriert angezeigt.'
)
}
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+28
View File
@@ -0,0 +1,28 @@
import { BrowserWindow, ipcMain } from 'electron'
import type { AuthEvent, AuthService } from '../auth/authService'
export const IPC = {
authStatus: 'auth:get-status',
authStart: 'auth:start-device-flow',
authCancel: 'auth:cancel',
authLogout: 'auth:logout',
authConfigured: 'auth:is-configured',
/** main → renderer */
authEvent: 'auth:event'
} as const
export function registerIpcHandlers(auth: AuthService): void {
ipcMain.handle(IPC.authStatus, () => auth.getStatus())
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
ipcMain.handle(IPC.authCancel, () => {
auth.cancelFlow()
})
ipcMain.handle(IPC.authLogout, () => auth.logout())
auth.on('auth-event', (event: AuthEvent) => {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IPC.authEvent, event)
}
})
}