diff --git a/src/main/domain/pkce.test.ts b/src/main/domain/pkce.test.ts new file mode 100644 index 0000000..33be3f9 --- /dev/null +++ b/src/main/domain/pkce.test.ts @@ -0,0 +1,45 @@ +import { test, expect, describe } from 'vitest'; +import * as crypto from 'crypto'; +import { createPkcePair, generateState } from './pkce'; + +describe('createPkcePair', () => { + test('returns S256 method', () => { + expect(createPkcePair().codeChallengeMethod).toBe('S256'); + }); + + test('verifier is 43+ chars base64url-safe', () => { + const { codeVerifier } = createPkcePair(); + expect(codeVerifier.length).toBeGreaterThanOrEqual(43); + // RFC 7636 unreserved chars only: [A-Z a-z 0-9 - . _ ~] + // base64url uses [A-Z a-z 0-9 - _], no = padding. + expect(/^[A-Za-z0-9_-]+$/.test(codeVerifier)).toBe(true); + }); + + test('challenge matches sha256(verifier) base64url-encoded', () => { + const pair = createPkcePair(); + const expected = crypto.createHash('sha256').update(pair.codeVerifier).digest('base64') + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(pair.codeChallenge).toBe(expected); + }); + + test('two pairs differ (sufficient entropy)', () => { + const a = createPkcePair(); + const b = createPkcePair(); + expect(a.codeVerifier).not.toBe(b.codeVerifier); + expect(a.codeChallenge).not.toBe(b.codeChallenge); + }); +}); + +describe('generateState', () => { + test('returns >= 16 chars', () => { + expect(generateState().length).toBeGreaterThanOrEqual(16); + }); + + test('base64url-safe charset', () => { + expect(/^[A-Za-z0-9_-]+$/.test(generateState())).toBe(true); + }); + + test('two states differ', () => { + expect(generateState()).not.toBe(generateState()); + }); +}); diff --git a/src/main/domain/pkce.ts b/src/main/domain/pkce.ts new file mode 100644 index 0000000..0f95e7d --- /dev/null +++ b/src/main/domain/pkce.ts @@ -0,0 +1,35 @@ +import * as crypto from 'crypto'; + +/** + * PKCE (Proof Key for Code Exchange) Helper fuer OAuth 2.1 Authorization Code Flow. + * RFC 7636. Twitch unterstuetzt S256. + */ + +export interface PkcePair { + codeVerifier: string; // 43-128 ASCII chars [A-Z a-z 0-9 - . _ ~] + codeChallenge: string; // base64url(sha256(codeVerifier)) + codeChallengeMethod: 'S256'; +} + +function base64url(buf: Buffer): string { + return buf.toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +export function createPkcePair(): PkcePair { + // 32 random bytes → 43-char base64url. Innerhalb der RFC-Range. + const verifier = base64url(crypto.randomBytes(32)); + const challenge = base64url(crypto.createHash('sha256').update(verifier).digest()); + return { + codeVerifier: verifier, + codeChallenge: challenge, + codeChallengeMethod: 'S256', + }; +} + +export function generateState(): string { + // 16 random bytes als base64url-State-Parameter (CSRF-Schutz). + return base64url(crypto.randomBytes(16)); +} diff --git a/src/main/domain/twitch-oauth.test.ts b/src/main/domain/twitch-oauth.test.ts new file mode 100644 index 0000000..6898e2b --- /dev/null +++ b/src/main/domain/twitch-oauth.test.ts @@ -0,0 +1,153 @@ +import { test, expect, describe } from 'vitest'; +import { + startLoginFlow, + awaitAuthorizationCode, + exchangeCodeForToken, + fetchTwitchUserInfo, +} from './twitch-oauth'; +import * as http from 'http'; + +function httpGet(url: string): Promise<{ status: number }> { + return new Promise((resolve, reject) => { + const req = http.get(url, res => { + res.on('data', () => { /* drain */ }); + res.on('end', () => resolve({ status: res.statusCode ?? 0 })); + }); + req.on('error', reject); + }); +} + +describe('startLoginFlow', () => { + test('builds Twitch authorize URL with required params + PKCE + state', async () => { + const flow = await startLoginFlow({ + clientId: 'test-client', + scopes: ['user:read:email', 'channel:read:subscriptions'], + }); + try { + expect(flow.authUrl).toContain('https://id.twitch.tv/oauth2/authorize'); + const url = new URL(flow.authUrl); + expect(url.searchParams.get('client_id')).toBe('test-client'); + expect(url.searchParams.get('response_type')).toBe('code'); + expect(url.searchParams.get('scope')).toBe('user:read:email channel:read:subscriptions'); + expect(url.searchParams.get('state')).toBe(flow.state); + expect(url.searchParams.get('code_challenge')).toBe(flow.pkce.codeChallenge); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('redirect_uri')).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/oauth\/callback$/); + } finally { + flow.server.close(); + } + }); +}); + +describe('awaitAuthorizationCode', () => { + test('returns code on successful redirect with matching state', async () => { + const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] }); + try { + const captureP = awaitAuthorizationCode(flow, 3000); + await httpGet(`${flow.server.url}?code=AUTHCODE&state=${flow.state}`); + const result = await captureP; + expect(result.code).toBe('AUTHCODE'); + expect(result.state).toBe(flow.state); + } finally { + flow.server.close(); + } + }); + + test('rejects on state mismatch (CSRF protection)', async () => { + const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] }); + try { + // .catch fangt unhandled rejection ab — wir pruefen den Error manuell. + const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e); + await httpGet(`${flow.server.url}?code=AUTHCODE&state=WRONG_STATE`); + const err = await captureP; + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/state mismatch/); + } finally { + flow.server.close(); + } + }); + + test('rejects on error parameter', async () => { + const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] }); + try { + const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e); + await httpGet(`${flow.server.url}?error=access_denied&error_description=user+denied`); + const err = await captureP; + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/access_denied/); + } finally { + flow.server.close(); + } + }); + + test('rejects on missing code', async () => { + const flow = await startLoginFlow({ clientId: 't', scopes: ['user:read:email'] }); + try { + const captureP = awaitAuthorizationCode(flow, 3000).catch((e: Error) => e); + await httpGet(`${flow.server.url}?state=${flow.state}`); + const err = await captureP; + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/missing code/); + } finally { + flow.server.close(); + } + }); +}); + +describe('exchangeCodeForToken', () => { + test('POSTs correct body and returns parsed token', async () => { + let capturedBody: string | null = null; + const fakeFetch = async (_url: string | URL | Request, init?: RequestInit): Promise => { + capturedBody = init?.body as string; + return new Response(JSON.stringify({ + access_token: 'ACC', + refresh_token: 'REF', + expires_in: 14400, + scope: ['user:read:email'], + token_type: 'bearer', + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }; + const token = await exchangeCodeForToken({ + clientId: 'cid', code: 'CODE', codeVerifier: 'VERIFIER', + redirectUri: 'http://127.0.0.1:5555/oauth/callback', + fetchImpl: fakeFetch as unknown as typeof fetch, + }); + expect(token.access_token).toBe('ACC'); + expect(token.refresh_token).toBe('REF'); + expect(capturedBody).toContain('client_id=cid'); + expect(capturedBody).toContain('code=CODE'); + expect(capturedBody).toContain('code_verifier=VERIFIER'); + expect(capturedBody).toContain('grant_type=authorization_code'); + }); + + test('throws on non-2xx response', async () => { + const fakeFetch = async (): Promise => new Response('bad request', { status: 400 }); + await expect(exchangeCodeForToken({ + clientId: 'cid', code: 'X', codeVerifier: 'V', redirectUri: 'http://x', + fetchImpl: fakeFetch as unknown as typeof fetch, + })).rejects.toThrow(/400/); + }); +}); + +describe('fetchTwitchUserInfo', () => { + test('returns first user from helix /users response', async () => { + const fakeFetch = async (url: string | URL | Request, init?: RequestInit): Promise => { + const headers = init?.headers as Record; + expect(headers['Authorization']).toBe('Bearer TOKEN'); + expect(headers['Client-Id']).toBe('CID'); + return new Response(JSON.stringify({ + data: [{ id: '12345', login: 'alice', display_name: 'Alice' }], + }), { status: 200 }); + }; + const user = await fetchTwitchUserInfo('TOKEN', 'CID', fakeFetch as unknown as typeof fetch); + expect(user.id).toBe('12345'); + expect(user.login).toBe('alice'); + expect(user.display_name).toBe('Alice'); + }); + + test('throws when no user in response', async () => { + const fakeFetch = async (): Promise => new Response(JSON.stringify({ data: [] }), { status: 200 }); + await expect(fetchTwitchUserInfo('T', 'C', fakeFetch as unknown as typeof fetch)) + .rejects.toThrow(/no user/); + }); +}); diff --git a/src/main/domain/twitch-oauth.ts b/src/main/domain/twitch-oauth.ts new file mode 100644 index 0000000..85979cf --- /dev/null +++ b/src/main/domain/twitch-oauth.ts @@ -0,0 +1,162 @@ +import { createPkcePair, generateState, type PkcePair } from './pkce'; +import { startLoopbackServer, type LoopbackServer } from '../infra/loopback-server'; + +/** + * Twitch OAuth 2.1 Authorization Code Flow + PKCE. + * + * Twitch supports PKCE since ~2022. Endpoints: + * Authorize: https://id.twitch.tv/oauth2/authorize + * Token: https://id.twitch.tv/oauth2/token + * Validate: https://id.twitch.tv/oauth2/validate + * Helix /users (whoami): https://api.twitch.tv/helix/users + * + * Flow: + * 1. startLoginFlow({clientId, scopes}) → { authUrl, ... } + * 2. shell.openExternal(authUrl) im Caller (main.ts hat shell) + * 3. await completeLoginFlow(state) → wartet auf Loopback-Redirect + * 4. Exchange code+verifier gegen token via fetch + * 5. Helix /users mit Bearer-Token → twitch_user_id + login + display_name + * + * Plan 03b liefert NUR Module + Tests. Eigentlicher login-flow IPC handler + * + Renderer-Button kommt in Folgeplan, weil das Twitch-Account-Setup + * (Client-ID in Twitch Dev Console mit korrektem Redirect-URI) erst + * vorbereitet werden muss. + */ + +const TWITCH_AUTHORIZE_URL = 'https://id.twitch.tv/oauth2/authorize'; +const TWITCH_TOKEN_URL = 'https://id.twitch.tv/oauth2/token'; +const TWITCH_HELIX_USERS_URL = 'https://api.twitch.tv/helix/users'; + +export interface TwitchTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + scope: string[]; + token_type: 'bearer'; +} + +export interface TwitchUserInfo { + id: string; + login: string; + display_name: string; +} + +export interface LoginStart { + authUrl: string; + state: string; + pkce: PkcePair; + server: LoopbackServer; + redirectUri: string; +} + +export interface LoginStartOptions { + clientId: string; + scopes: string[]; + pathPrefix?: string; // default '/oauth/callback' + port?: number; // 0 = OS-chooses +} + +export async function startLoginFlow(opts: LoginStartOptions): Promise { + const server = await startLoopbackServer({ + pathPrefix: opts.pathPrefix ?? '/oauth/callback', + port: opts.port, + }); + + const pkce = createPkcePair(); + const state = generateState(); + const redirectUri = server.url; + + const params = new URLSearchParams({ + client_id: opts.clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: opts.scopes.join(' '), + state, + code_challenge: pkce.codeChallenge, + code_challenge_method: pkce.codeChallengeMethod, + force_verify: 'true', + }); + const authUrl = `${TWITCH_AUTHORIZE_URL}?${params.toString()}`; + + return { authUrl, state, pkce, server, redirectUri }; +} + +export interface CompleteLoginResult { + code: string; + state: string; +} + +/** + * Wartet auf Redirect-Capture und prueft state. + * Throws bei mismatch state, bei `?error=` Parameter, oder bei Timeout. + */ +export async function awaitAuthorizationCode(login: LoginStart, timeoutMs?: number): Promise { + const params = await login.server.awaitParams({ timeoutMs }); + if (params.has('error')) { + const err = params.get('error') ?? 'unknown_error'; + const desc = params.get('error_description') ?? ''; + throw new Error(`twitch-oauth: provider error: ${err}${desc ? ` — ${desc}` : ''}`); + } + const returnedState = params.get('state') ?? ''; + if (returnedState !== login.state) { + throw new Error('twitch-oauth: state mismatch (possible CSRF or stale flow)'); + } + const code = params.get('code'); + if (!code) { + throw new Error('twitch-oauth: missing code parameter'); + } + return { code, state: returnedState }; +} + +export interface TokenExchangeOptions { + clientId: string; + code: string; + codeVerifier: string; + redirectUri: string; + fetchImpl?: typeof fetch; +} + +export async function exchangeCodeForToken(opts: TokenExchangeOptions): Promise { + const fetchFn = opts.fetchImpl ?? fetch; + const body = new URLSearchParams({ + client_id: opts.clientId, + code: opts.code, + code_verifier: opts.codeVerifier, + grant_type: 'authorization_code', + redirect_uri: opts.redirectUri, + }); + + const res = await fetchFn(TWITCH_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + + const text = await res.text(); + if (!res.ok) { + throw new Error(`twitch-oauth: token endpoint ${res.status}: ${text}`); + } + return JSON.parse(text) as TwitchTokenResponse; +} + +export async function fetchTwitchUserInfo( + accessToken: string, + clientId: string, + fetchImpl?: typeof fetch +): Promise { + const fetchFn = fetchImpl ?? fetch; + const res = await fetchFn(TWITCH_HELIX_USERS_URL, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Client-Id': clientId, + }, + }); + const text = await res.text(); + if (!res.ok) { + throw new Error(`twitch-oauth: helix /users ${res.status}: ${text}`); + } + const json = JSON.parse(text) as { data?: TwitchUserInfo[] }; + const first = json.data?.[0]; + if (!first) throw new Error('twitch-oauth: helix /users returned no user'); + return first; +} diff --git a/src/main/infra/loopback-server.test.ts b/src/main/infra/loopback-server.test.ts new file mode 100644 index 0000000..f175a6a --- /dev/null +++ b/src/main/infra/loopback-server.test.ts @@ -0,0 +1,58 @@ +import { test, expect, describe } from 'vitest'; +import * as http from 'http'; +import { startLoopbackServer } from './loopback-server'; + +function httpGet(url: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.get(url, res => { + let body = ''; + res.on('data', chunk => { body += chunk.toString(); }); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body })); + }); + req.on('error', reject); + }); +} + +describe('startLoopbackServer', () => { + test('binds to 127.0.0.1 and returns url with pathPrefix', async () => { + const server = await startLoopbackServer({ pathPrefix: '/cb' }); + expect(server.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/cb$/); + server.close(); + }); + + test('captures redirect params (code + state)', async () => { + const server = await startLoopbackServer({ pathPrefix: '/cb' }); + const captureP = server.awaitParams({ timeoutMs: 3000 }); + const response = await httpGet(`${server.url}?code=abc123&state=xyz`); + expect(response.status).toBe(200); + const params = await captureP; + expect(params.get('code')).toBe('abc123'); + expect(params.get('state')).toBe('xyz'); + server.close(); + }); + + test('non-matching path returns 404, capture not triggered', async () => { + const server = await startLoopbackServer({ pathPrefix: '/cb' }); + const captureP = server.awaitParams({ timeoutMs: 500 }); + const response = await httpGet(`${server.url.replace('/cb', '/other')}`); + expect(response.status).toBe(404); + await expect(captureP).rejects.toThrow(/timeout/); + server.close(); + }); + + test('error param renders errorHtml', async () => { + const server = await startLoopbackServer({ pathPrefix: '/cb' }); + const captureP = server.awaitParams({ timeoutMs: 3000 }); + const response = await httpGet(`${server.url}?error=access_denied`); + expect(response.body).toContain('Fehler'); + const params = await captureP; + expect(params.get('error')).toBe('access_denied'); + server.close(); + }); + + test('timeout rejects', async () => { + const server = await startLoopbackServer({ pathPrefix: '/cb' }); + await expect(server.awaitParams({ timeoutMs: 200 })).rejects.toThrow(/timeout/); + server.close(); + }); +}); diff --git a/src/main/infra/loopback-server.ts b/src/main/infra/loopback-server.ts new file mode 100644 index 0000000..67912f1 --- /dev/null +++ b/src/main/infra/loopback-server.ts @@ -0,0 +1,120 @@ +import * as http from 'http'; +import { URL } from 'url'; + +/** + * Ephemerer HTTP-Server auf localhost:PORT fuer OAuth-Redirect-Capture. + * RFC 8252 (OAuth 2.0 for Native Apps) — System-Browser + Loopback-Redirect. + * + * Lifecycle: + * const server = await startLoopbackServer({ pathPrefix: '/oauth/callback' }); + * console.log(server.url); // http://127.0.0.1:54321/oauth/callback + * const params = await server.awaitParams({ timeoutMs: 5 * 60 * 1000 }); + * server.close(); + * + * Bindet immer auf 127.0.0.1 (nicht 0.0.0.0) — der OS-Listener ist nur lokal + * erreichbar, kein Firewall-Prompt unter Windows. + */ + +export interface LoopbackServerOptions { + pathPrefix: string; // z.B. '/oauth/callback' + port?: number; // 0 = OS waehlt freien Port + successHtml?: string; // HTML-Antwort beim Capture + errorHtml?: string; +} + +export interface LoopbackServer { + readonly url: string; + awaitParams(opts?: { timeoutMs?: number }): Promise; + close(): void; +} + +const DEFAULT_SUCCESS = `Login erfolgreich + +

Login erfolgreich

Du kannst dieses Fenster jetzt schliessen.

`; + +const DEFAULT_ERROR = `Fehler + +

Fehler

Login abgebrochen.

`; + +export function startLoopbackServer(opts: LoopbackServerOptions): Promise { + const successHtml = opts.successHtml ?? DEFAULT_SUCCESS; + const errorHtml = opts.errorHtml ?? DEFAULT_ERROR; + const pathPrefix = opts.pathPrefix.startsWith('/') ? opts.pathPrefix : '/' + opts.pathPrefix; + + return new Promise((resolve, reject) => { + let resolveCapture: ((p: URLSearchParams) => void) | null = null; + let rejectCapture: ((e: Error) => void) | null = null; + let captureSettled = false; + + const captureP = new Promise((res, rej) => { + resolveCapture = res; + rejectCapture = rej; + }); + + const server = http.createServer((req, res) => { + try { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + if (!url.pathname.startsWith(pathPrefix)) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('not found'); + return; + } + const params = url.searchParams; + const hasError = params.has('error'); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(hasError ? errorHtml : successHtml); + if (!captureSettled && resolveCapture) { + captureSettled = true; + resolveCapture(params); + } + } catch (e) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('internal error'); + if (!captureSettled && rejectCapture) { + captureSettled = true; + rejectCapture(e instanceof Error ? e : new Error(String(e))); + } + } + }); + + server.on('error', reject); + server.listen(opts.port ?? 0, '127.0.0.1', () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') { + server.close(); + reject(new Error('loopback-server: failed to determine bound port')); + return; + } + const url = `http://127.0.0.1:${addr.port}${pathPrefix}`; + + resolve({ + url, + async awaitParams(awaitOpts) { + const timeoutMs = awaitOpts?.timeoutMs ?? 5 * 60 * 1000; + let timer: NodeJS.Timeout | null = null; + const timeoutP = new Promise((_, rej) => { + timer = setTimeout(() => { + if (!captureSettled && rejectCapture) { + captureSettled = true; + rejectCapture(new Error('loopback-server: timeout waiting for redirect')); + } + rej(new Error('loopback-server: timeout waiting for redirect')); + }, timeoutMs); + }); + try { + return await Promise.race([captureP, timeoutP]); + } finally { + if (timer) clearTimeout(timer); + } + }, + close() { + try { server.close(); } catch { /* already closed */ } + }, + }); + }); + }); +}