Twitch-VOD-Manager/src/main/infra/loopback-server.ts
xRangerDE 5a5d6f9c47 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>
2026-05-11 23:46:22 +02:00

121 lines
5.3 KiB
TypeScript

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 */ }
},
});
});
});
}