Twitch-VOD-Manager/src/main/domain/twitch-oauth.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

163 lines
5.2 KiB
TypeScript

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