import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import type { WorksSquareTokenPayload } from './works-square-session'; const DEFAULT_CLIENT_ID = 'makelore'; const DEFAULT_SCOPE = 'openid profile phone offline_access'; const REDIRECT_URI = 'niancode://auth/callback'; const STATE_TTL_MS = 10 * 60_000; type PlatformAuthMetadata = { authorization_endpoint: string; token_endpoint: string; }; type PendingAuthorization = { state: string; codeVerifier: string; expiresAt: number; }; const pendingAuthorizations = new Map(); let metadataPromise: Promise | null = null; function configuredIssuer(): string { const raw = (process.env.PLATFORM_AUTH_ISSUER || WORKS_SQUARE_CONFIG.apiBaseUrl).trim().replace(/\/+$/, ''); const parsed = new URL(raw); if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) { throw new Error('PLATFORM_AUTH_ISSUER must be an absolute HTTP(S) URL without credentials or query parameters'); } return parsed.toString().replace(/\/$/, ''); } function configuredClientId(): string { return process.env.PLATFORM_AUTH_CLIENT_ID?.trim() || DEFAULT_CLIENT_ID; } function configuredScope(): string { return process.env.PLATFORM_AUTH_SCOPE?.trim() || DEFAULT_SCOPE; } function absoluteHttpUrl(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) throw new Error(`OIDC discovery missing ${field}`); const parsed = new URL(value); if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) { throw new Error(`OIDC discovery returned an invalid ${field}`); } return parsed.toString(); } async function loadMetadata(): Promise { const issuer = configuredIssuer(); const response = await proxyAwareFetch(`${issuer}/.well-known/openid-configuration`, { method: 'GET', headers: { Accept: 'application/json' }, }); if (!response.ok) throw new Error(`OIDC discovery failed (${response.status})`); const payload = await response.json() as Record; return { authorization_endpoint: absoluteHttpUrl(payload.authorization_endpoint, 'authorization_endpoint'), token_endpoint: absoluteHttpUrl(payload.token_endpoint, 'token_endpoint'), }; } async function getMetadata(): Promise { if (!metadataPromise) { metadataPromise = loadMetadata().catch((error) => { metadataPromise = null; throw error; }); } return metadataPromise; } function takePendingAuthorization( requestId: string | null, returnedState: string, ): PendingAuthorization | null { if (requestId) { const pending = pendingAuthorizations.get(requestId); pendingAuthorizations.delete(requestId); return pending ?? null; } // The platform redirect is a fixed niancode:// URI and does not append our // local request id. The state value is the OIDC correlation handle instead. for (const [candidateId, pending] of pendingAuthorizations) { if (pending.state === returnedState) { pendingAuthorizations.delete(candidateId); return pending; } } return null; } function cleanupPendingAuthorizations(now = Date.now()): void { for (const [requestId, pending] of pendingAuthorizations) { if (pending.expiresAt <= now) pendingAuthorizations.delete(requestId); } } function codeChallengeS256(verifier: string): string { return createHash('sha256').update(verifier, 'ascii').digest('base64url'); } function readTokenPayload(value: unknown): WorksSquareTokenPayload { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('OIDC token endpoint returned an invalid response'); } const payload = value as WorksSquareTokenPayload; if (typeof payload.access_token !== 'string' || !payload.access_token.trim()) { throw new Error('OIDC token endpoint did not return access_token'); } return payload; } export async function startPlatformAuthorization(): Promise<{ requestId: string; authorizationUrl: string; }> { cleanupPendingAuthorizations(); const metadata = await getMetadata(); const requestId = randomUUID(); const state = randomBytes(32).toString('base64url'); const codeVerifier = randomBytes(64).toString('base64url'); pendingAuthorizations.set(requestId, { state, codeVerifier, expiresAt: Date.now() + STATE_TTL_MS, }); const url = new URL(metadata.authorization_endpoint); url.searchParams.set('client_id', configuredClientId()); url.searchParams.set('response_type', 'code'); url.searchParams.set('redirect_uri', REDIRECT_URI); url.searchParams.set('scope', configuredScope()); url.searchParams.set('state', state); url.searchParams.set('nonce', randomBytes(32).toString('base64url')); url.searchParams.set('code_challenge', codeChallengeS256(codeVerifier)); url.searchParams.set('code_challenge_method', 'S256'); return { requestId, authorizationUrl: url.toString() }; } export async function completePlatformAuthorization( requestId: string | null, code: string, returnedState: string, ): Promise { cleanupPendingAuthorizations(); const pending = takePendingAuthorization(requestId, returnedState); if (!pending || pending.expiresAt <= Date.now()) throw new Error('OIDC authorization request expired'); if (pending.state !== returnedState) throw new Error('OIDC authorization state mismatch'); const metadata = await getMetadata(); const response = await proxyAwareFetch(metadata.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: configuredClientId(), code_verifier: pending.codeVerifier, }).toString(), }); if (!response.ok) { const detail = await response.text(); throw new Error(`OIDC token exchange failed (${response.status}): ${detail.slice(0, 180)}`); } return readTokenPayload(await response.json()); } export function resetPlatformAuthorizationForTests(): void { pendingAuthorizations.clear(); metadataPromise = null; } export const PLATFORM_AUTH_REDIRECT_URI = REDIRECT_URI;