feat(auth): Twitch OAuth 2.1 Authorization Code Flow + PKCE + Loopback (21 tests)

3 new modules:
- src/main/domain/pkce.ts: PKCE pair (S256) + state (RFC 7636)
- src/main/infra/loopback-server.ts: ephemeral 127.0.0.1:PORT redirect capture
- src/main/domain/twitch-oauth.ts: startLoginFlow / awaitAuthorizationCode /
  exchangeCodeForToken / fetchTwitchUserInfo

Flow runs entirely scaffold-ready — IPC wiring + Client ID config and
shell.openExternal(authUrl) trigger come in a follow-on plan once the user
registers a Twitch dev app. 164 unit tests gruen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 23:46:22 +02:00
parent c08b6fef7d
commit 5a5d6f9c47
6 changed files with 573 additions and 0 deletions

View File

@ -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());
});
});

35
src/main/domain/pkce.ts Normal file
View File

@ -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));
}

View File

@ -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<Response> => {
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<Response> => 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<Response> => {
const headers = init?.headers as Record<string, string>;
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<Response> => new Response(JSON.stringify({ data: [] }), { status: 200 });
await expect(fetchTwitchUserInfo('T', 'C', fakeFetch as unknown as typeof fetch))
.rejects.toThrow(/no user/);
});
});

View File

@ -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<LoginStart> {
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<CompleteLoginResult> {
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<TwitchTokenResponse> {
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<TwitchUserInfo> {
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;
}

View File

@ -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();
});
});

View File

@ -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<URLSearchParams>;
close(): void;
}
const DEFAULT_SUCCESS = `<!doctype html><html><head><meta charset="utf-8"><title>Login erfolgreich</title>
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
h1{color:#9146FF;margin:0 0 0.5rem}</style></head>
<body><div class="box"><h1>Login erfolgreich</h1><p>Du kannst dieses Fenster jetzt schliessen.</p></div></body></html>`;
const DEFAULT_ERROR = `<!doctype html><html><head><meta charset="utf-8"><title>Fehler</title>
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
h1{color:#ff4444;margin:0 0 0.5rem}</style></head>
<body><div class="box"><h1>Fehler</h1><p>Login abgebrochen.</p></div></body></html>`;
export function startLoopbackServer(opts: LoopbackServerOptions): Promise<LoopbackServer> {
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<URLSearchParams>((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<URLSearchParams>((_, 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 */ }
},
});
});
});
}