/** * RFC 8628 device authorization against the Openference OAuth AS * (same endpoints Deyin uses). */ import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai"; import { OAUTH_CLIENT_ID, OAUTH_SCOPES, OIDC_DISCOVERY_URL, OPENFERENCE_ORIGIN, } from "./constants.ts"; export interface OidcDiscovery { device_authorization_endpoint: string; token_endpoint: string; issuer?: string; } export interface DeviceStartResult { device_code: string; user_code: string; verification_uri: string; verification_uri_complete: string; expires_in: number; interval: number; } export interface DeviceAuthDependencies { fetchImpl?: typeof fetch; sleep?: (ms: number, signal?: AbortSignal) => Promise; discoveryUrl?: string; clientId?: string; scope?: string; now?: () => number; } const DEFAULT_INTERVAL_MS = 5_000; /** Hosts allowed for discovery-derived endpoints and user-facing login links. */ export const OPENFERENCE_ALLOWED_HOSTS = new Set([ "openference.com", "www.openference.com", "api.openference.com", ]); /** * Reject non-HTTPS or non-Openference URLs (defense in depth for discovery / * device responses). */ export function assertOpenferenceHttpsUrl(url: string, label: string): string { let parsed: URL; try { parsed = new URL(url); } catch { throw new Error(`${label} is not a valid URL`); } if (parsed.protocol !== "https:") { throw new Error(`${label} must use https`); } if (parsed.username || parsed.password) { throw new Error(`${label} must not include credentials`); } if (!OPENFERENCE_ALLOWED_HOSTS.has(parsed.hostname)) { throw new Error(`${label} host is not an Openference endpoint`); } return parsed.toString(); } function pinDiscovery(doc: OidcDiscovery): OidcDiscovery { return { issuer: doc.issuer ? assertOpenferenceHttpsUrl(doc.issuer, "OIDC issuer") : undefined, device_authorization_endpoint: assertOpenferenceHttpsUrl( doc.device_authorization_endpoint, "device_authorization_endpoint", ), token_endpoint: assertOpenferenceHttpsUrl(doc.token_endpoint, "token_endpoint"), }; } function pinDeviceStart(start: DeviceStartResult): DeviceStartResult { const verification_uri = assertOpenferenceHttpsUrl( start.verification_uri, "verification_uri", ); let complete = start.verification_uri_complete; if (!complete) { complete = `${verification_uri}?user_code=${encodeURIComponent(start.user_code)}`; } complete = assertOpenferenceHttpsUrl(complete, "verification_uri_complete"); return { ...start, verification_uri, verification_uri_complete: complete }; } function defaultSleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(new Error("Login cancelled")); const onAbort = () => { clearTimeout(timer); reject(new Error("Login cancelled")); }; const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); signal?.addEventListener("abort", onAbort, { once: true }); }); } export async function discoverOidc( deps: DeviceAuthDependencies = {}, ): Promise { const fetchImpl = deps.fetchImpl ?? fetch; const url = deps.discoveryUrl ?? OIDC_DISCOVERY_URL; assertOpenferenceHttpsUrl(url, "OIDC discovery URL"); const res = await fetchImpl(url); if (!res.ok) throw new Error(`OIDC discovery failed: HTTP ${res.status}`); const doc = (await res.json()) as Partial; if (!doc.device_authorization_endpoint || !doc.token_endpoint) { throw new Error("OIDC discovery missing device or token endpoint"); } return pinDiscovery(doc as OidcDiscovery); } export async function startDeviceAuthorization( deps: DeviceAuthDependencies = {}, ): Promise<{ start: DeviceStartResult; discovery: OidcDiscovery }> { const fetchImpl = deps.fetchImpl ?? fetch; const discovery = await discoverOidc(deps); const clientId = deps.clientId ?? OAUTH_CLIENT_ID; const scope = deps.scope ?? OAUTH_SCOPES; const res = await fetchImpl(discovery.device_authorization_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, scope }).toString(), }); if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error(`Device authorization failed: HTTP ${res.status} ${body}`); } const raw = (await res.json()) as DeviceStartResult; if (!raw.device_code || !raw.user_code || !raw.verification_uri) { throw new Error("Device authorization response missing required fields"); } return { start: pinDeviceStart(raw), discovery }; } type TokenPayload = { access_token?: string; refresh_token?: string; expires_in?: number; error?: string; error_description?: string; }; /** * Poll the token endpoint until approved, denied, or expired. * Honors server expires_in (no shorter client-side cap that races the AS). */ export async function pollDeviceToken( discovery: OidcDiscovery, deviceCode: string, deps: DeviceAuthDependencies = {}, options: { intervalSeconds?: number; expiresInSeconds?: number; signal?: AbortSignal; } = {}, ): Promise { const fetchImpl = deps.fetchImpl ?? fetch; const sleep = deps.sleep ?? defaultSleep; const now = deps.now ?? Date.now; const clientId = deps.clientId ?? OAUTH_CLIENT_ID; let intervalMs = Math.max( 1000, (options.intervalSeconds ?? DEFAULT_INTERVAL_MS / 1000) * 1000, ); const expiresInSeconds = options.expiresInSeconds ?? 600; const deadline = now() + expiresInSeconds * 1000; while (now() < deadline) { if (options.signal?.aborted) throw new Error("Login cancelled"); const res = await fetchImpl(discovery.token_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: deviceCode, client_id: clientId, }).toString(), }); const data = (await res.json().catch(() => ({}))) as TokenPayload; if (res.ok && data.access_token) { if (!data.refresh_token) { throw new Error( "Token response missing refresh_token; re-run /login openference with offline_access", ); } const expiresIn = typeof data.expires_in === "number" ? data.expires_in : 3600; return { access: data.access_token, refresh: data.refresh_token, expires: now() + expiresIn * 1000, }; } const err = data.error ?? ""; if (err === "authorization_pending") { await sleep(intervalMs, options.signal); continue; } if (err === "slow_down") { intervalMs += 5000; await sleep(intervalMs, options.signal); continue; } if (err === "access_denied") throw new Error("Login denied in browser"); if (err === "expired_token") { throw new Error("Device code expired; run /login openference again"); } throw new Error( data.error_description ?? `Token poll failed: ${err || `HTTP ${res.status}`}`, ); } throw new Error("Device login timed out; run /login openference again"); } /** Refresh an access token using the offline_access refresh token. */ export async function refreshAccessToken( credentials: OAuthCredentials, deps: DeviceAuthDependencies = {}, ): Promise { if (!credentials.refresh) { throw new Error("No refresh token; run /login openference again"); } const fetchImpl = deps.fetchImpl ?? fetch; const now = deps.now ?? Date.now; const discovery = await discoverOidc(deps); const clientId = deps.clientId ?? OAUTH_CLIENT_ID; const res = await fetchImpl(discovery.token_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: credentials.refresh, client_id: clientId, }).toString(), }); const data = (await res.json().catch(() => ({}))) as TokenPayload; if (!res.ok || !data.access_token) { throw new Error( data.error_description ?? `Token refresh failed: HTTP ${res.status}`, ); } if (!data.refresh_token && !credentials.refresh) { throw new Error("Token refresh returned no refresh_token"); } const expiresIn = typeof data.expires_in === "number" ? data.expires_in : 3600; return { access: data.access_token, refresh: data.refresh_token ?? credentials.refresh, expires: now() + expiresIn * 1000, }; } /** * Full device-flow login: start → show link → poll. * Prints verification_uri_complete so the user can click or copy into a browser. */ export async function loginWithDeviceFlow( callbacks: OAuthLoginCallbacks, deps: DeviceAuthDependencies = {}, ): Promise { callbacks.onProgress?.("Starting Openference sign-in…"); const { start, discovery } = await startDeviceAuthorization(deps); const link = start.verification_uri_complete; callbacks.onDeviceCode({ userCode: start.user_code, verificationUri: start.verification_uri, intervalSeconds: start.interval ?? 5, expiresInSeconds: start.expires_in ?? 600, }); callbacks.onAuth({ url: link }); callbacks.onProgress?.( `Open this link to sign in and approve:\n${link}\nOr visit ${start.verification_uri} and enter code ${start.user_code}`, ); callbacks.onProgress?.("Waiting for browser approval…"); return pollDeviceToken(discovery, start.device_code, deps, { intervalSeconds: start.interval ?? DEFAULT_INTERVAL_MS / 1000, expiresInSeconds: start.expires_in ?? 600, signal: callbacks.signal, }); } /** Default verification page used only when synthesizing complete URIs locally. */ export const DEFAULT_DEVICE_PAGE = `${OPENFERENCE_ORIGIN}/app/oauth/device`;