Skip to content

Commit ebb3464

Browse files
committed
Implement endpoint discovery
1 parent fcb129d commit ebb3464

10 files changed

Lines changed: 239 additions & 35 deletions

File tree

src/CONFIG.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ export default {
145145
API_ROOT: qaExpensifyURL ? addTrailingForwardSlash(qaExpensifyURL) : '',
146146
TEAM_DOMAIN: get(Config, 'QA_CF_TEAM_DOMAIN', ''),
147147
CLIENT_ID: get(Config, 'QA_CF_OAUTH_CLIENT_ID', ''),
148+
// Which Access-protected endpoint the test tool calls to verify auth is a property of the
149+
// environment — the dev worker exposes an echo route, another QA host will offer something else
150+
CHECK_PATH: get(Config, 'QA_AUTH_CHECK_PATH', '').replace(/^\/+/, ''),
148151
},
149152
SENTRY_DSN: get(Config, 'SENTRY_DSN', 'https://7b463fb4d4402d342d1166d929a62f4e@o4510228013121536.ingest.us.sentry.io/4510228107427840'),
150153
} as const;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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};

src/libs/CloudflareAccess/Config/index.native.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,8 @@ function getQAOrigin(): string {
1515
return '';
1616
}
1717

18-
function getAuthorizationEndpoint(): string {
19-
return '';
20-
}
21-
22-
function getTokenEndpoint(): string {
23-
return '';
24-
}
25-
2618
function getOAuthRedirectURI(): string {
2719
return '';
2820
}
2921

30-
export {getAuthorizationEndpoint, getOAuthRedirectURI, getQAOrigin, getTokenEndpoint, isQAAuthConfigured, isQAServerRequest};
22+
export {getOAuthRedirectURI, getQAOrigin, isQAAuthConfigured, isQAServerRequest};

src/libs/CloudflareAccess/Config/index.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ const TEAM_DOMAIN_SHAPE = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$/;
99

1010
/** Anything short of a complete, well-formed config and every consumer behaves as if the feature is absent */
1111
function isQAAuthConfigured(): boolean {
12-
const {API_ROOT, TEAM_DOMAIN, CLIENT_ID} = CONFIG.QA_AUTH;
12+
const {API_ROOT, TEAM_DOMAIN, CLIENT_ID, CHECK_PATH} = CONFIG.QA_AUTH;
1313

14-
if (!API_ROOT || !TEAM_DOMAIN || !CLIENT_ID) {
14+
if (!API_ROOT || !TEAM_DOMAIN || !CLIENT_ID || !CHECK_PATH) {
1515
return false;
1616
}
1717

@@ -47,17 +47,9 @@ function isQAServerRequest(url: string): boolean {
4747
}
4848
}
4949

50-
function getAuthorizationEndpoint(): string {
51-
return `https://${CONFIG.QA_AUTH.TEAM_DOMAIN}/cdn-cgi/access/oauth/authorization`;
52-
}
53-
54-
function getTokenEndpoint(): string {
55-
return `https://${CONFIG.QA_AUTH.TEAM_DOMAIN}/cdn-cgi/access/oauth/token`;
56-
}
57-
5850
/** Must be registered as an allowed redirect URI on the Access application. Read lazily: no `window` on native. */
5951
function getOAuthRedirectURI(): string {
6052
return `${window.location.origin}/oauth/callback`;
6153
}
6254

63-
export {getAuthorizationEndpoint, getOAuthRedirectURI, getQAOrigin, getTokenEndpoint, isQAAuthConfigured, isQAServerRequest};
55+
export {getOAuthRedirectURI, getQAOrigin, isQAAuthConfigured, isQAServerRequest};

src/libs/CloudflareAccess/OAuthClient.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {isRecord} from '@libs/ObjectUtils';
77
import CONFIG from '@src/CONFIG';
88
import type CloudflareSession from '@src/types/onyx/CloudflareSession';
99

10-
import {getAuthorizationEndpoint, getOAuthRedirectURI, getQAOrigin, getTokenEndpoint} from './Config';
10+
import {getAuthServerEndpoints} from './AuthServerMetadata';
11+
import {getOAuthRedirectURI, getQAOrigin} from './Config';
1112

1213
/** A hung token endpoint would otherwise hold the cross-tab refresh lock indefinitely */
1314
const TOKEN_ENDPOINT_TIMEOUT_MS = 10_000;
@@ -24,7 +25,8 @@ class OAuthError extends Error {
2425

2526
/** POSTs form-encoded params to the token endpoint and validates the response into a CloudflareSession */
2627
async function postTokenEndpoint(body: URLSearchParams): Promise<CloudflareSession> {
27-
const response = await fetch(getTokenEndpoint(), {
28+
const {tokenEndpoint} = await getAuthServerEndpoints();
29+
const response = await fetch(tokenEndpoint, {
2830
method: 'POST',
2931
headers: [['Content-Type', 'application/x-www-form-urlencoded']],
3032
body: body.toString(),
@@ -67,8 +69,9 @@ async function postTokenEndpoint(body: URLSearchParams): Promise<CloudflareSessi
6769
}
6870

6971
/** Builds the authorization URL the browser navigates to */
70-
function buildAuthorizeURL({state, codeChallenge}: {state: string; codeChallenge: string}): string {
71-
const url = new URL(getAuthorizationEndpoint());
72+
async function buildAuthorizeURL({state, codeChallenge}: {state: string; codeChallenge: string}): Promise<string> {
73+
const {authorizationEndpoint} = await getAuthServerEndpoints();
74+
const url = new URL(authorizationEndpoint);
7275
url.searchParams.set('response_type', 'code');
7376
url.searchParams.set('client_id', CONFIG.QA_AUTH.CLIENT_ID);
7477
url.searchParams.set('redirect_uri', getOAuthRedirectURI());

src/libs/actions/CloudflareProbe.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ async function runCloudflareAuthProbe({shouldRedirectOnReauthRequired = false}:
6363
}
6464
}
6565

66-
const response = await fetchWithQAAuth(`${CONFIG.QA_AUTH.API_ROOT}api/CloudflareAuthProbe`, {method: CONST.NETWORK.METHOD.POST});
66+
const response = await fetchWithQAAuth(`${CONFIG.QA_AUTH.API_ROOT}${CONFIG.QA_AUTH.CHECK_PATH}`, {method: CONST.NETWORK.METHOD.POST});
6767
if (!response.ok) {
6868
return {status: 'error', detail: `HTTP ${response.status}`};
6969
}

src/libs/actions/CloudflareSession.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,15 @@ async function beginCloudflareAuthRedirect(returnURL: string = window.location.h
8383
try {
8484
const pkce = await generatePKCEPair();
8585
const state = generateState();
86+
// Resolved before the flow record is stored, so a failed discovery leaves nothing behind
87+
const authorizeURL = await buildAuthorizeURL({state, codeChallenge: pkce.codeChallenge});
8688
if (generation !== sessionGeneration) {
87-
// Signed out while the key material was generated — do not navigate a signed-out tab
89+
// Signed out while this flow was being prepared — do not navigate a signed-out tab
8890
throw new Error('Cloudflare auth flow was cancelled by sign-out');
8991
}
9092
// Must be stored before the navigation — module memory does not survive the unload
9193
savePendingAuthFlow({state, codeVerifier: pkce.codeVerifier, returnURL, createdAt: Date.now()});
92-
window.location.assign(buildAuthorizeURL({state, codeChallenge: pkce.codeChallenge}));
94+
window.location.assign(authorizeURL);
9395
} catch (error) {
9496
isRedirectInFlight = false;
9597
throw error;

0 commit comments

Comments
 (0)