/** * OIDC discovery + Keycloak device authorization grant helpers. * * Frontend TV client (fp-tv-app, usesExtendedSecurity): * - PKCE S256 on device authorization + code_verifier on token poll * - DPoP (ES256) proof on token poll (and later on REST calls) */ import { createHash, randomBytes } from 'node:crypto'; import { OIDC, USER_AGENT } from './constants.mjs'; import { attachDpopHeaders, generateDpopKeyPair, readDpopNonce } from './dpop.mjs'; /** * Generate a PKCE code_verifier (43–128 chars, unreserved). * @param {number} [bytes] */ export function generateCodeVerifier(bytes = 32) { return base64Url(randomBytes(bytes)); } /** * S256 code_challenge for a verifier. * @param {string} verifier */ export function generateCodeChallenge(verifier) { return base64Url(createHash('sha256').update(verifier, 'ascii').digest()); } /** @param {Buffer} buf */ function base64Url(buf) { return buf .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); } /** * @param {{ fetchImpl?: typeof fetch, wellKnownUrl?: string }} [opts] */ export async function discoverOidc(opts = {}) { const fetchImpl = opts.fetchImpl || fetch; const url = opts.wellKnownUrl || OIDC.wellKnownUrl; const res = await fetchImpl(url, { method: 'GET', headers: { accept: 'application/json', 'user-agent': USER_AGENT }, }); if (!res.ok) { throw new Error(`OIDC discovery failed: HTTP ${res.status} for ${url}`); } const doc = await res.json(); if (!doc.device_authorization_endpoint) { throw new Error('OIDC discovery missing device_authorization_endpoint'); } if (!doc.token_endpoint) { throw new Error('OIDC discovery missing token_endpoint'); } const grants = doc.grant_types_supported || []; if (!grants.includes(OIDC.grantTypeDeviceCode)) { throw new Error(`OIDC discovery missing grant ${OIDC.grantTypeDeviceCode}`); } return { issuer: doc.issuer, deviceAuthorizationEndpoint: doc.device_authorization_endpoint, tokenEndpoint: doc.token_endpoint, scopesSupported: doc.scopes_supported || [], grantTypesSupported: grants, raw: doc, }; } /** * Start device authorization (public client — no client_secret; PKCE required). * @param {{ * discovery: Awaited>, * clientId?: string, * scope?: string, * fetchImpl?: typeof fetch, * codeVerifier?: string, * }} opts */ export async function requestDeviceAuthorization(opts) { const fetchImpl = opts.fetchImpl || fetch; const clientId = opts.clientId || OIDC.clientId; const scope = opts.scope || OIDC.defaultScope; const codeVerifier = opts.codeVerifier || generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const body = new URLSearchParams(); body.set('client_id', clientId); if (scope) body.set('scope', scope); // Required by fp-tv-app (frontend USE_CODE_CHALLENGE / Keycloak public client) body.set('code_challenge_method', 'S256'); body.set('code_challenge', codeChallenge); const res = await fetchImpl(opts.discovery.deviceAuthorizationEndpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json', 'user-agent': USER_AGENT, }, body, }); const json = await res.json().catch(() => ({})); if (!res.ok) { const err = json.error || `HTTP ${res.status}`; const desc = json.error_description ? `: ${json.error_description}` : ''; throw new Error(`device authorization failed: ${err}${desc}`); } if (!json.device_code || !json.user_code) { throw new Error('device authorization response missing device_code/user_code'); } return { deviceCode: json.device_code, userCode: json.user_code, verificationUri: json.verification_uri || json.verification_uri_complete, verificationUriComplete: json.verification_uri_complete || null, expiresIn: Number(json.expires_in) || 600, interval: Number(json.interval) || 5, codeVerifier, }; } /** * Poll token endpoint until authorized, expired, or aborted. * Always sends DPoP (fp-tv-app usesExtendedSecurity / USE_DPOP). * @param {{ * discovery: Awaited>, * deviceCode: string, * clientId?: string, * codeVerifier?: string, * dpopKeyPair?: Awaited>, * intervalSec?: number, * expiresInSec?: number, * fetchImpl?: typeof fetch, * sleep?: (ms: number) => Promise, * now?: () => number, * onPoll?: (info: { status: string, attempt: number }) => void, * signal?: AbortSignal, * }} opts */ export async function pollDeviceToken(opts) { const fetchImpl = opts.fetchImpl || fetch; const sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))); const now = opts.now || (() => Date.now()); const clientId = opts.clientId || OIDC.clientId; const dpopKeyPair = opts.dpopKeyPair || (await generateDpopKeyPair()); let intervalMs = Math.max(1, (opts.intervalSec ?? 5) * 1000); const deadline = now() + Math.max(1, opts.expiresInSec ?? 600) * 1000; let attempt = 0; /** @type {string|null} */ let dpopNonce = null; while (now() < deadline) { if (opts.signal?.aborted) { throw new Error('device token poll aborted'); } attempt += 1; const body = new URLSearchParams(); body.set('grant_type', OIDC.grantTypeDeviceCode); body.set('device_code', opts.deviceCode); body.set('client_id', clientId); if (opts.codeVerifier) body.set('code_verifier', opts.codeVerifier); /** @type {Record} */ const headers = { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json', 'user-agent': USER_AGENT, }; await attachDpopHeaders({ headers, keyPair: dpopKeyPair, method: 'POST', url: opts.discovery.tokenEndpoint, nonce: dpopNonce, }); const res = await fetchImpl(opts.discovery.tokenEndpoint, { method: 'POST', headers, body, }); const nonceHeader = readDpopNonce(res); if (nonceHeader) dpopNonce = nonceHeader; const json = await res.json().catch(() => ({})); if (res.ok && json.access_token) { opts.onPoll?.({ status: 'authorized', attempt }); const obtainedAtMs = now(); const expiresIn = typeof json.expires_in === 'number' ? json.expires_in : Number(json.expires_in); const refreshExpiresIn = typeof json.refresh_expires_in === 'number' ? json.refresh_expires_in : json.refresh_expires_in != null ? Number(json.refresh_expires_in) : null; return { accessToken: json.access_token, refreshToken: json.refresh_token || null, expiresIn: Number.isFinite(expiresIn) ? expiresIn : null, refreshExpiresIn: Number.isFinite(refreshExpiresIn) ? refreshExpiresIn : null, accessExpiresAtMs: Number.isFinite(expiresIn) ? obtainedAtMs + expiresIn * 1000 : null, refreshExpiresAtMs: Number.isFinite(refreshExpiresIn) && refreshExpiresIn > 0 ? obtainedAtMs + refreshExpiresIn * 1000 : null, sessionState: json.session_state || null, tokenType: json.token_type || 'DPoP', scope: json.scope || null, obtainedAtMs, responseKeys: Object.keys(json).sort(), dpopKeyPair, }; } const err = json.error || `HTTP ${res.status}`; opts.onPoll?.({ status: err, attempt }); if (err === 'authorization_pending') { await sleep(intervalMs); continue; } if (err === 'slow_down') { intervalMs += 5000; await sleep(intervalMs); continue; } // Keycloak may require a nonce retry without waiting the full interval if (err === 'use_dpop_nonce' || (err === 'invalid_dpop_proof' && nonceHeader)) { continue; } if (err === 'expired_token' || err === 'access_denied') { throw new Error( `device token poll ended: ${err}${json.error_description ? `: ${json.error_description}` : ''}`, ); } throw new Error( `device token poll failed: ${err}${json.error_description ? `: ${json.error_description}` : ''}`, ); } throw new Error('device token poll timed out'); }