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:
parent
c08b6fef7d
commit
5a5d6f9c47
45
src/main/domain/pkce.test.ts
Normal file
45
src/main/domain/pkce.test.ts
Normal 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
35
src/main/domain/pkce.ts
Normal 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));
|
||||
}
|
||||
153
src/main/domain/twitch-oauth.test.ts
Normal file
153
src/main/domain/twitch-oauth.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
162
src/main/domain/twitch-oauth.ts
Normal file
162
src/main/domain/twitch-oauth.ts
Normal 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;
|
||||
}
|
||||
58
src/main/infra/loopback-server.test.ts
Normal file
58
src/main/infra/loopback-server.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
120
src/main/infra/loopback-server.ts
Normal file
120
src/main/infra/loopback-server.ts
Normal 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 */ }
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user