|
| 1 | +/** |
| 2 | + * RFC 8414 authorization server metadata discovery for the QA server's Managed OAuth. The authorize and |
| 3 | + * token endpoints are fetched from the well-known document Cloudflare serves on the QA origin rather than |
| 4 | + * assumed, and validated against the configured team domain before anything is sent to them. |
| 5 | + */ |
| 6 | +import {isRecord} from '@libs/ObjectUtils'; |
| 7 | + |
| 8 | +import CONFIG from '@src/CONFIG'; |
| 9 | + |
| 10 | +import {getQAOrigin} from './Config'; |
| 11 | + |
| 12 | +/** RFC 8414 §3 fixes the path; Cloudflare serves the document at the edge on the protected origin */ |
| 13 | +const WELL_KNOWN_PATH = '/.well-known/oauth-authorization-server'; |
| 14 | + |
| 15 | +/** A hung metadata fetch would stall the sign-in flow (and, via refresh, the cross-tab lock) */ |
| 16 | +const METADATA_TIMEOUT_MS = 10_000; |
| 17 | + |
| 18 | +type AuthServerEndpoints = { |
| 19 | + /** Where the browser navigates to authorize (RFC 8414 `authorization_endpoint`) */ |
| 20 | + authorizationEndpoint: string; |
| 21 | + |
| 22 | + /** Where codes and refresh tokens are exchanged (RFC 8414 `token_endpoint`) */ |
| 23 | + tokenEndpoint: string; |
| 24 | +}; |
| 25 | + |
| 26 | +let metadataPromise: Promise<AuthServerEndpoints> | null = null; |
| 27 | + |
| 28 | +/** The single issuer this client trusts, pinned by configuration before any fetched endpoint is believed */ |
| 29 | +function getExpectedIssuer(): string { |
| 30 | + return `https://${CONFIG.QA_AUTH.TEAM_DOMAIN}`; |
| 31 | +} |
| 32 | + |
| 33 | +function validateEndpoint(value: unknown, issuerOrigin: string, name: string): string { |
| 34 | + let parsed: URL; |
| 35 | + try { |
| 36 | + parsed = new URL(typeof value === 'string' ? value : ''); |
| 37 | + } catch { |
| 38 | + throw new Error(`Authorization server metadata has a missing or malformed ${name}`); |
| 39 | + } |
| 40 | + // Endpoints receive the authorization code and refresh tokens, so they must live on the pinned issuer |
| 41 | + if (parsed.origin !== issuerOrigin) { |
| 42 | + throw new Error(`Authorization server metadata ${name} does not belong to the expected issuer`); |
| 43 | + } |
| 44 | + return parsed.href; |
| 45 | +} |
| 46 | + |
| 47 | +async function fetchAndValidateMetadata(): Promise<AuthServerEndpoints> { |
| 48 | + const response = await fetch(new URL(WELL_KNOWN_PATH, getQAOrigin()).href, { |
| 49 | + credentials: 'omit', |
| 50 | + signal: AbortSignal.timeout(METADATA_TIMEOUT_MS), |
| 51 | + }); |
| 52 | + if (!response.ok) { |
| 53 | + throw new Error(`Authorization server metadata request failed with HTTP ${response.status}`); |
| 54 | + } |
| 55 | + const json: unknown = await response.json().catch(() => null); |
| 56 | + if (!isRecord(json)) { |
| 57 | + throw new Error('Authorization server metadata is not a JSON object'); |
| 58 | + } |
| 59 | + // RFC 8414 §3.3: the issuer in the document must exactly match the issuer the client expects |
| 60 | + const expectedIssuer = getExpectedIssuer(); |
| 61 | + if (json.issuer !== expectedIssuer) { |
| 62 | + throw new Error('Authorization server metadata issuer does not match the configured team domain'); |
| 63 | + } |
| 64 | + // This client only implements S256 (RFC 7636), so an issuer without it could never complete a flow |
| 65 | + if (!Array.isArray(json.code_challenge_methods_supported) || !json.code_challenge_methods_supported.includes('S256')) { |
| 66 | + throw new Error('Authorization server does not support the S256 PKCE challenge method'); |
| 67 | + } |
| 68 | + const issuerOrigin = new URL(expectedIssuer).origin; |
| 69 | + return { |
| 70 | + authorizationEndpoint: validateEndpoint(json.authorization_endpoint, issuerOrigin, 'authorization_endpoint'), |
| 71 | + tokenEndpoint: validateEndpoint(json.token_endpoint, issuerOrigin, 'token_endpoint'), |
| 72 | + }; |
| 73 | +} |
| 74 | + |
| 75 | +/** |
| 76 | + * Single-flight and cached for the page's lifetime — the metadata is static per environment. A failure |
| 77 | + * clears the cache so the next attempt retries, and rejects as a plain error: transient for the callers' |
| 78 | + * terminal/transient split, never an OAuthError. |
| 79 | + */ |
| 80 | +function getAuthServerEndpoints(): Promise<AuthServerEndpoints> { |
| 81 | + metadataPromise ??= fetchAndValidateMetadata().catch((error: unknown) => { |
| 82 | + metadataPromise = null; |
| 83 | + throw error; |
| 84 | + }); |
| 85 | + return metadataPromise; |
| 86 | +} |
| 87 | + |
| 88 | +export {getAuthServerEndpoints}; |
| 89 | +export type {AuthServerEndpoints}; |
0 commit comments