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>
163 lines
5.2 KiB
TypeScript
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;
|
|
}
|