diff --git a/src/CONFIG.ts b/src/CONFIG.ts index 16c0110b9ca8..80734bf3cf3f 100644 --- a/src/CONFIG.ts +++ b/src/CONFIG.ts @@ -43,6 +43,7 @@ const secureNgrokURL = addTrailingForwardSlash(get(Config, 'SECURE_NGROK_URL', ' const secureExpensifyUrl = addTrailingForwardSlash(get(Config, 'SECURE_EXPENSIFY_URL', 'https://secure.expensify.com/')); const useNgrok = get(Config, 'USE_NGROK', 'false') === 'true'; const useWebProxy = get(Config, 'USE_WEB_PROXY', 'true') === 'true'; +const qaExpensifyURL = get(Config, 'QA_EXPENSIFY_URL', ''); const expensifyComWithProxy = getPlatform() === 'web' && useWebProxy ? '/' : expensifyURL; // Throw errors on dev if config variables are not set correctly @@ -138,5 +139,12 @@ export default { SKIP_ONBOARDING: get(Config, 'SKIP_ONBOARDING', 'false') === 'true', // eslint-disable-next-line no-restricted-properties IS_HYBRID_APP: HybridAppModule.isHybridApp(), + // Auth for the Cloudflare Access-protected QA server; empty values disable the feature entirely + QA_AUTH: { + // Only normalize a non-empty value: addTrailingForwardSlash('') returns '/' and would look configured + API_ROOT: qaExpensifyURL ? addTrailingForwardSlash(qaExpensifyURL) : '', + TEAM_DOMAIN: get(Config, 'QA_CF_TEAM_DOMAIN', ''), + CLIENT_ID: get(Config, 'QA_CF_OAUTH_CLIENT_ID', ''), + }, SENTRY_DSN: get(Config, 'SENTRY_DSN', 'https://7b463fb4d4402d342d1166d929a62f4e@o4510228013121536.ingest.us.sentry.io/4510228107427840'), } as const; diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 40671c15fc60..e747b8af066d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2497,6 +2497,8 @@ const CONST = { POLICY_DIFF_WARNING: 305, }, HTTP_STATUS: { + // Cloudflare Access rejects an expired bearer token at the HTTP layer, before any jsonCode body + UNAUTHORIZED: 401, // When Cloudflare throttles TOO_MANY_REQUESTS: 429, INTERNAL_SERVER_ERROR: 500, @@ -6713,6 +6715,7 @@ const CONST = { WORKSPACES_TAB: 'LAST_VISITED_PATH_WORKSPACES_TAB', SETTINGS_TAB: 'LAST_VISITED_PATH_SETTINGS_TAB', }, + QA_AUTH_REDIRECT_FLOW: 'QA_AUTH_REDIRECT_FLOW', }, RESERVATION_TYPE: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 727fafedca9c..d243a2efb636 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -606,6 +606,9 @@ const ONYXKEYS = { /** Indicates whether we should use the staging version of the secure API server */ SHOULD_USE_STAGING_SERVER: 'shouldUseStagingServer', + /** OAuth session used to reach the Cloudflare Access-protected QA server */ + CF_SESSION: 'cfSession', + /** Indicates whether the debug mode is currently enabled */ IS_DEBUG_MODE_ENABLED: 'isDebugModeEnabled', @@ -1727,6 +1730,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_PRIVATE_TAX_EXEMPT]: boolean; [ONYXKEYS.SHOULD_MASK_ONYX_STATE]: boolean; [ONYXKEYS.SHOULD_USE_STAGING_SERVER]: boolean; + [ONYXKEYS.CF_SESSION]: OnyxTypes.CloudflareSession; [ONYXKEYS.IS_DEBUG_MODE_ENABLED]: boolean; [ONYXKEYS.SHOULD_SHOW_BRANCH_NAME_IN_TITLE]: boolean; [ONYXKEYS.IS_SENTRY_DEBUG_ENABLED]: boolean; diff --git a/src/components/QAAuthTestToolRows/index.native.tsx b/src/components/QAAuthTestToolRows/index.native.tsx new file mode 100644 index 000000000000..990f4df7d13c --- /dev/null +++ b/src/components/QAAuthTestToolRows/index.native.tsx @@ -0,0 +1,8 @@ +/** Web-only for now: the OAuth callback needs claimed Universal/App Links on native */ +function QAAuthTestToolRows() { + return null; +} + +QAAuthTestToolRows.displayName = 'QAAuthTestToolRows'; + +export default QAAuthTestToolRows; diff --git a/src/components/QAAuthTestToolRows/index.tsx b/src/components/QAAuthTestToolRows/index.tsx new file mode 100644 index 000000000000..1c524f586565 --- /dev/null +++ b/src/components/QAAuthTestToolRows/index.tsx @@ -0,0 +1,110 @@ +import Button from '@components/ButtonComposed'; +import TestToolRow from '@components/TestToolRow'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {isQAAuthConfigured} from '@libs/CloudflareAccess/Config'; +import {getCloudflareAuthRedirectOutcome} from '@libs/CloudflareAccess/handleAuthRedirectCallback'; + +import type {CloudflareAuthProbeResult, CloudflareAuthProbeStatus} from '@userActions/CloudflareProbe'; +import {runCloudflareAuthProbe} from '@userActions/CloudflareProbe'; +import {clearCloudflareSession} from '@userActions/CloudflareSession'; + +import CONST from '@src/CONST'; + +import {useState} from 'react'; + +/** The semantic probe outcomes are translated; the raw `detail` diagnostic stays verbatim */ +const PROBE_STATUS_TRANSLATION_KEYS = { + success: 'qaAuthStatusSuccess', + reauthRequired: 'qaAuthStatusReauthRequired', + error: 'qaAuthStatusError', +} as const satisfies Record; + +/** A failed round trip is otherwise invisible: the handler ran during boot, long before this mounts */ +function getFailedRedirectResult(): CloudflareAuthProbeResult | null { + const {outcome, errorMessage} = getCloudflareAuthRedirectOutcome(); + if (outcome === 'not-a-callback' || outcome === 'exchanging') { + return null; + } + return {status: 'error', detail: errorMessage}; +} + +/** + * Test-tool rows for the QA server auth flow, rendered only when the QA credentials are configured. + * + * With no session, Run navigates the whole tab to Cloudflare, so its spinner stays up until the page + * unloads and the result of a completed round trip only shows on the next press. + */ +function QAAuthTestToolRows() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const [isOperationRunning, setIsOperationRunning] = useState(false); + // Seeded from the boot-time redirect outcome, not an effect: it is fixed for the lifetime of the page + const [probeResult, setProbeResult] = useState(getFailedRedirectResult); + // Consecutive probes produce identical results, so without a changing element the button reads as dead + const [probeCompletedAt, setProbeCompletedAt] = useState(null); + + if (!isQAAuthConfigured()) { + return null; + } + + return ( + <> + + + + + + + {!!probeResult && ( + + {translate(`initialSettingsPage.troubleshoot.${PROBE_STATUS_TRANSLATION_KEYS[probeResult.status]}`)} + {probeResult.detail ? ` (${probeResult.detail})` : ''} + {probeCompletedAt ? ` — ${probeCompletedAt.toLocaleTimeString()}` : ''} + + )} + + ); +} + +QAAuthTestToolRows.displayName = 'QAAuthTestToolRows'; + +export default QAAuthTestToolRows; diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index 0d3b5f31beff..d3fcf54c70ca 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -19,6 +19,7 @@ import {Platform} from 'react-native'; import BiometricsTestToolRow from './BiometricsTestToolRow'; import Button from './Button'; +import QAAuthTestToolRows from './QAAuthTestToolRows'; import SoftKillTestToolRow from './SoftKillTestToolRow'; import Switch from './Switch'; import TestCrash from './TestCrash'; @@ -131,6 +132,9 @@ function TestToolMenu() { )} + {/* QA server auth flow — web only, and only when it is configured. */} + + {/* When toggled the app will be forced offline. */} { + const response = await fetch(getTokenEndpoint(), { + method: 'POST', + headers: [['Content-Type', 'application/x-www-form-urlencoded']], + body: body.toString(), + credentials: 'omit', + }); + + const json: unknown = await response.json().catch(() => null); + + if (!response.ok) { + // OAuth error responses come as {error, error_description} on a 4xx (RFC 6749 §5.2) + if (isRecord(json) && typeof json.error === 'string') { + throw new OAuthError(json.error, typeof json.error_description === 'string' ? json.error_description : undefined); + } + throw new Error(`Token endpoint failed with HTTP ${response.status}`); + } + + if ( + !isRecord(json) || + typeof json.access_token !== 'string' || + json.access_token === '' || + typeof json.refresh_token !== 'string' || + json.refresh_token === '' || + typeof json.expires_in !== 'number' || + json.expires_in <= 0 || + typeof json.token_type !== 'string' || + json.token_type.toLowerCase() !== 'bearer' + ) { + // Terminal: retrying won't fix a protocol mismatch. token_type is checked because callers hardcode + // the Bearer scheme — another type must never be persisted as if it were one. + throw new OAuthError('invalid_response', 'Token endpoint returned an unexpected response shape'); + } + + return { + accessToken: json.access_token, + refreshToken: json.refresh_token, + expiresAt: Date.now() + json.expires_in * 1000, + }; +} + +/** Builds the authorization URL the browser navigates to */ +function buildAuthorizeURL({state, codeChallenge}: {state: string; codeChallenge: string}): string { + const url = new URL(getAuthorizationEndpoint()); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', CONFIG.QA_AUTH.CLIENT_ID); + url.searchParams.set('redirect_uri', getOAuthRedirectURI()); + url.searchParams.set('state', state); + url.searchParams.set('code_challenge', codeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + // RFC 8707 — Cloudflare binds the issued token to this resource; omitting it breaks the exchange + url.searchParams.set('resource', getQAOrigin()); + return url.toString(); +} + +/** Exchanges an authorization code (plus the PKCE verifier) for a session */ +function exchangeCode({code, codeVerifier}: {code: string; codeVerifier: string}): Promise { + const body = new URLSearchParams(); + body.set('grant_type', 'authorization_code'); + body.set('code', code); + body.set('code_verifier', codeVerifier); + // Must byte-match the redirect_uri sent in the authorize request + body.set('redirect_uri', getOAuthRedirectURI()); + body.set('client_id', CONFIG.QA_AUTH.CLIENT_ID); + body.set('resource', getQAOrigin()); + return postTokenEndpoint(body); +} + +/** Cloudflare rotates the refresh token on every call, so the returned one replaces the (now spent) input */ +function refreshTokens(refreshToken: string): Promise { + const body = new URLSearchParams(); + body.set('grant_type', 'refresh_token'); + body.set('refresh_token', refreshToken); + // No `resource` here — Cloudflare's refresh grant takes the client ID and the token only + body.set('client_id', CONFIG.QA_AUTH.CLIENT_ID); + return postTokenEndpoint(body); +} + +export {buildAuthorizeURL, exchangeCode, OAuthError, refreshTokens}; diff --git a/src/libs/CloudflareAccess/PendingAuthFlowStorage.ts b/src/libs/CloudflareAccess/PendingAuthFlowStorage.ts new file mode 100644 index 000000000000..cfeb65d764c4 --- /dev/null +++ b/src/libs/CloudflareAccess/PendingAuthFlowStorage.ts @@ -0,0 +1,99 @@ +/** + * Parks the in-flight authorize round trip across the page unload: navigating to Cloudflare destroys module + * memory, so the verifier, state and return URL have to survive in storage. + * + * sessionStorage because it is synchronous (readable before the first render), scoped to the tab that started + * the flow (making the state check a per-tab provenance check) and dropped when the tab closes. + */ +import {isRecord} from '@libs/ObjectUtils'; + +import CONST from '@src/CONST'; + +/** Cloudflare's authorization codes are short-lived anyway; an older record is treated as absent */ +const PENDING_AUTH_FLOW_TTL_MS = 10 * 60 * 1000; + +type PendingAuthFlow = { + /** CSRF/provenance value echoed back by Cloudflare on the callback */ + state: string; + + /** The PKCE secret, revealed only at the token exchange */ + codeVerifier: string; + + /** Absolute URL (route plus any open RHP) the user should land back on */ + returnURL: string; + + /** Epoch ms — see PENDING_AUTH_FLOW_TTL_MS */ + createdAt: number; +}; + +/** Storage access itself throws in hardened browser configurations, not just the write */ +function getSessionStorage(): Storage | null { + if (typeof window === 'undefined') { + return null; + } + try { + return window.sessionStorage ?? null; + } catch { + return null; + } +} + +/** + * Throws when web storage is unavailable — the caller must refuse to redirect in that case rather than + * navigate away and lose the verifier with no way to finish the exchange. + */ +function savePendingAuthFlow(flow: PendingAuthFlow): void { + const storage = getSessionStorage(); + if (!storage) { + throw new Error('Session storage is unavailable — cannot start the QA auth redirect'); + } + storage.setItem(CONST.SESSION_STORAGE_KEYS.QA_AUTH_REDIRECT_FLOW, JSON.stringify(flow)); +} + +/** + * Single-use: removes the record before returning it, so a replayed callback URL finds nothing. + * Returns null when absent, unreadable, malformed or expired. + */ +function consumePendingAuthFlow(): PendingAuthFlow | null { + const storage = getSessionStorage(); + if (!storage) { + return null; + } + const raw = storage.getItem(CONST.SESSION_STORAGE_KEYS.QA_AUTH_REDIRECT_FLOW); + storage.removeItem(CONST.SESSION_STORAGE_KEYS.QA_AUTH_REDIRECT_FLOW); + if (!raw) { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if ( + !isRecord(parsed) || + typeof parsed.state !== 'string' || + parsed.state === '' || + typeof parsed.codeVerifier !== 'string' || + parsed.codeVerifier === '' || + typeof parsed.returnURL !== 'string' || + typeof parsed.createdAt !== 'number' + ) { + return null; + } + + if (Date.now() - parsed.createdAt > PENDING_AUTH_FLOW_TTL_MS) { + return null; + } + + return {state: parsed.state, codeVerifier: parsed.codeVerifier, returnURL: parsed.returnURL, createdAt: parsed.createdAt}; +} + +function clearPendingAuthFlow(): void { + getSessionStorage()?.removeItem(CONST.SESSION_STORAGE_KEYS.QA_AUTH_REDIRECT_FLOW); +} + +export {clearPendingAuthFlow, consumePendingAuthFlow, savePendingAuthFlow}; +export type {PendingAuthFlow}; diff --git a/src/libs/CloudflareAccess/fetchWithQAAuth.ts b/src/libs/CloudflareAccess/fetchWithQAAuth.ts new file mode 100644 index 000000000000..bcc918bea0d4 --- /dev/null +++ b/src/libs/CloudflareAccess/fetchWithQAAuth.ts @@ -0,0 +1,60 @@ +/** + * `fetch` against the Cloudflare Access-protected QA origin: attaches the bearer token and recovers from an + * expired one. + * + * Standalone on purpose — nothing in the app routes to QA yet, so HttpUtils stays untouched and no app + * request can grow a QA header by accident. This is the logic that moves there once QA routing lands. + */ +import {getCloudflareSession, markCloudflareSessionRejected, refreshCloudflareSession} from '@userActions/CloudflareSession'; + +import CONST from '@src/CONST'; + +import {isQAServerRequest} from './Config'; + +/** Thrown when the session can't be recovered — the caller has to start a fresh authorize round trip */ +const CF_REAUTH_REQUIRED = 'Cloudflare re-authentication required'; + +/** Narrow on purpose: keeps the header merge below a plain object spread */ +type QAAuthRequestOptions = { + method?: string; + headers?: Record; + body?: FormData | string; +}; + +/** + * Attaches the bearer only for an exact match on the configured QA origin. On a 401: one refresh, one retry; + * a second 401 rejects with CF_REAUTH_REQUIRED. Transient refresh failures reject as-is, session intact. + */ +async function fetchWithQAAuth(url: string, options: QAAuthRequestOptions = {}, isRetry = false): Promise { + const accessToken = isQAServerRequest(url) ? (getCloudflareSession()?.accessToken ?? null) : null; + + const response = await fetch(url, { + method: options.method, + body: options.body, + headers: accessToken ? {...options.headers, Authorization: `Bearer ${accessToken}`} : options.headers, + // Same as HttpUtils: no cookies on API requests, the token travels in the header + credentials: 'omit', + }); + + if (response.status !== CONST.HTTP_STATUS.UNAUTHORIZED || !accessToken) { + return response; + } + + if (isRetry) { + // Refresh demonstrably can't fix this session, so drop it and let the next attempt re-authorize + await markCloudflareSessionRejected(accessToken); + throw new Error(CF_REAUTH_REQUIRED); + } + + const refreshResult = await refreshCloudflareSession(accessToken); + if (refreshResult === 'reauth-required') { + // Terminal; refreshCloudflareSession already cleared the dead session + throw new Error(CF_REAUTH_REQUIRED); + } + + // Retry once, picking up the rotated token from the cache + return fetchWithQAAuth(url, options, true); +} + +export default fetchWithQAAuth; +export {CF_REAUTH_REQUIRED}; diff --git a/src/libs/CloudflareAccess/generatePKCE.ts b/src/libs/CloudflareAccess/generatePKCE.ts new file mode 100644 index 000000000000..c6734c1e2de6 --- /dev/null +++ b/src/libs/CloudflareAccess/generatePKCE.ts @@ -0,0 +1,51 @@ +/** + * PKCE (RFC 7636) helpers for the Cloudflare Access OAuth flow. Written once against the WebCrypto + * surface and fed by a per-platform provider, so the two platforms share one implementation. + */ +import Base64URL from '@src/utils/Base64URL'; + +import getWebCrypto from './getWebCrypto'; + +type PKCEPair = { + /** The secret the client keeps and reveals only at token exchange */ + codeVerifier: string; + + /** base64url(SHA-256(codeVerifier)), sent with the authorize request */ + codeChallenge: string; +}; + +/** 32 random bytes → 43-char base64url verifier: the RFC 7636 minimum length, with full entropy */ +const CODE_VERIFIER_BYTE_LENGTH = 32; + +/** The state parameter is CSRF protection only — 16 bytes is plenty */ +const STATE_BYTE_LENGTH = 16; + +/** Cloudflare's parameter parsing chokes on a challenge starting with `-` or `_`, failing with a + * misleading "code_challenge_method must be S256" error, so such pairs are regenerated */ +const CHALLENGE_STARTS_ALPHANUMERIC = /^[a-zA-Z0-9]/; + +async function computeCodeChallenge(codeVerifier: string): Promise { + const digest = await getWebCrypto.sha256(new TextEncoder().encode(codeVerifier)); + return Base64URL.encode(new Uint8Array(digest)); +} + +/** Generates a fresh verifier/challenge pair, regenerating until the challenge starts alphanumeric */ +async function generatePKCEPair(): Promise { + const codeVerifier = Base64URL.encode(getWebCrypto.getRandomValues(new Uint8Array(CODE_VERIFIER_BYTE_LENGTH))); + const codeChallenge = await computeCodeChallenge(codeVerifier); + + // Regenerate as a pair — the verifier and challenge must stay together + if (!CHALLENGE_STARTS_ALPHANUMERIC.test(codeChallenge)) { + return generatePKCEPair(); + } + + return {codeVerifier, codeChallenge}; +} + +/** Random state parameter to bind the authorize round-trip against CSRF */ +function generateState(): string { + return Base64URL.encode(getWebCrypto.getRandomValues(new Uint8Array(STATE_BYTE_LENGTH))); +} + +export {generatePKCEPair, generateState}; +export type {PKCEPair}; diff --git a/src/libs/CloudflareAccess/getWebCrypto/index.native.ts b/src/libs/CloudflareAccess/getWebCrypto/index.native.ts new file mode 100644 index 000000000000..e71ab945871f --- /dev/null +++ b/src/libs/CloudflareAccess/getWebCrypto/index.native.ts @@ -0,0 +1,17 @@ +import type WebCryptoProvider from './types'; + +/** + * Native: unimplemented while the flow is web-only. The eventual version must use react-native-quick-crypto's + * WebCrypto surface (`getRandomValues` + `subtle.digest`), NOT its Node-style `createHash`, so the PKCE + * helper keeps one implementation. Throwing keeps the module import-safe and accidental use loud. + */ +const webCrypto: WebCryptoProvider = { + getRandomValues: () => { + throw new Error('CloudflareAccess getWebCrypto is not implemented on native yet'); + }, + sha256: () => { + throw new Error('CloudflareAccess getWebCrypto is not implemented on native yet'); + }, +}; + +export default webCrypto; diff --git a/src/libs/CloudflareAccess/getWebCrypto/index.ts b/src/libs/CloudflareAccess/getWebCrypto/index.ts new file mode 100644 index 000000000000..606dc4b020b8 --- /dev/null +++ b/src/libs/CloudflareAccess/getWebCrypto/index.ts @@ -0,0 +1,9 @@ +import type WebCryptoProvider from './types'; + +/** Web: the browser's built-in WebCrypto. Requires a secure context — the dev server is https via mkcert. */ +const webCrypto: WebCryptoProvider = { + getRandomValues: (array) => globalThis.crypto.getRandomValues(array), + sha256: (data) => globalThis.crypto.subtle.digest('SHA-256', data), +}; + +export default webCrypto; diff --git a/src/libs/CloudflareAccess/getWebCrypto/types.ts b/src/libs/CloudflareAccess/getWebCrypto/types.ts new file mode 100644 index 000000000000..c903d7878fe5 --- /dev/null +++ b/src/libs/CloudflareAccess/getWebCrypto/types.ts @@ -0,0 +1,13 @@ +/** + * The minimal WebCrypto surface the PKCE helper needs. Both platform implementations must satisfy + * this contract so the PKCE logic itself stays platform-agnostic. + */ +type WebCryptoProvider = { + /** Fills the array with cryptographically strong random values and returns it (synchronous, per spec) */ + getRandomValues: (array: Uint8Array) => Uint8Array; + + /** SHA-256 digest of the given bytes */ + sha256: (data: BufferSource) => Promise; +}; + +export default WebCryptoProvider; diff --git a/src/libs/CloudflareAccess/handleAuthRedirectCallback/index.native.ts b/src/libs/CloudflareAccess/handleAuthRedirectCallback/index.native.ts new file mode 100644 index 000000000000..4f9fdfb048d9 --- /dev/null +++ b/src/libs/CloudflareAccess/handleAuthRedirectCallback/index.native.ts @@ -0,0 +1,12 @@ +import type {CloudflareAuthRedirectOutcome, CloudflareAuthRedirectResult} from './types'; + +/** Native: nothing to handle — receiving the callback needs claimed Universal/App Links, not set up yet */ +function handleCloudflareAuthRedirectCallback(): CloudflareAuthRedirectOutcome { + return 'not-a-callback'; +} + +function getCloudflareAuthRedirectOutcome(): CloudflareAuthRedirectResult { + return {outcome: 'not-a-callback'}; +} + +export {getCloudflareAuthRedirectOutcome, handleCloudflareAuthRedirectCallback}; diff --git a/src/libs/CloudflareAccess/handleAuthRedirectCallback/index.ts b/src/libs/CloudflareAccess/handleAuthRedirectCallback/index.ts new file mode 100644 index 000000000000..05141ebcef9c --- /dev/null +++ b/src/libs/CloudflareAccess/handleAuthRedirectCallback/index.ts @@ -0,0 +1,109 @@ +/** + * Callback-boot half of the same-tab OAuth redirect: Cloudflare delivers the authorization code as this + * document's own location, so it has to be picked up during boot, before any render. + * + * The URL is also rewritten back to where the user came from — no app route lives at the redirect path, so + * otherwise React Navigation boots straight into /not-found. + */ +import {getOAuthRedirectURI, isQAAuthConfigured} from '@libs/CloudflareAccess/Config'; +import {OAuthError} from '@libs/CloudflareAccess/OAuthClient'; +import {consumePendingAuthFlow} from '@libs/CloudflareAccess/PendingAuthFlowStorage'; + +import {completeCloudflareAuthRedirect} from '@userActions/CloudflareSession'; + +import type {CloudflareAuthRedirectOutcome, CloudflareAuthRedirectResult} from './types'; + +let lastOutcome: CloudflareAuthRedirectOutcome = 'not-a-callback'; +let lastErrorMessage: string | undefined; + +/** Same-origin only: this is the one stored field fed back into navigation, so it is treated as tainted */ +function toSafeReturnPath(returnURL: string | undefined): string { + if (!returnURL) { + return '/'; + } + try { + const parsed = new URL(returnURL, window.location.origin); + if (parsed.origin !== window.location.origin) { + return '/'; + } + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return '/'; + } +} + +/** Call once during boot, before any render. No-op on every load that isn't the callback. */ +function handleCloudflareAuthRedirectCallback(): CloudflareAuthRedirectOutcome { + lastErrorMessage = undefined; + + if (!isQAAuthConfigured()) { + lastOutcome = 'not-a-callback'; + return lastOutcome; + } + + let callbackPath: string; + try { + callbackPath = new URL(getOAuthRedirectURI()).pathname; + } catch { + lastOutcome = 'not-a-callback'; + return lastOutcome; + } + + if (window.location.pathname !== callbackPath) { + lastOutcome = 'not-a-callback'; + return lastOutcome; + } + + // Params read before the rewrite, flow consumed before any validation: the record is single-use, so a + // replayed callback finds nothing however this call ends. + const params = new URL(window.location.href).searchParams; + const flow = consumePendingAuthFlow(); + + // Unconditional: even an invalid callback must leave the user on a real route + window.history.replaceState(null, '', toSafeReturnPath(flow?.returnURL)); + + if (!flow) { + lastOutcome = 'no-pending-flow'; + lastErrorMessage = 'No pending QA auth flow in this tab — start the sign-in again'; + return lastOutcome; + } + + // State first: a callback that fails provenance is discarded wholesale, its other params untrusted + if (params.get('state') !== flow.state) { + lastOutcome = 'invalid-callback'; + lastErrorMessage = 'OAuth callback state mismatch'; + return lastOutcome; + } + + const oauthError = params.get('error'); + if (oauthError) { + // e.g. access_denied — the provider refused; never attempt the exchange + lastOutcome = 'provider-error'; + lastErrorMessage = new OAuthError(oauthError, params.get('error_description') ?? undefined).message; + return lastOutcome; + } + + const code = params.get('code'); + if (!code) { + lastOutcome = 'invalid-callback'; + lastErrorMessage = 'OAuth callback is missing the authorization code'; + return lastOutcome; + } + + // Fire and forget; the catch only prevents an unhandled rejection. Callers joining via + // getPendingCloudflareAuthCompletion() still see the failure on their own handler. + completeCloudflareAuthRedirect({code, codeVerifier: flow.codeVerifier}).catch((error: unknown) => { + lastErrorMessage = error instanceof Error ? error.message : String(error); + }); + + lastOutcome = 'exchanging'; + return lastOutcome; +} + +/** What the boot-time callback handling concluded, for UI that wants to surface a failed round trip */ +function getCloudflareAuthRedirectOutcome(): CloudflareAuthRedirectResult { + return {outcome: lastOutcome, errorMessage: lastErrorMessage}; +} + +export {getCloudflareAuthRedirectOutcome, handleCloudflareAuthRedirectCallback}; +export type {CloudflareAuthRedirectOutcome}; diff --git a/src/libs/CloudflareAccess/handleAuthRedirectCallback/types.ts b/src/libs/CloudflareAccess/handleAuthRedirectCallback/types.ts new file mode 100644 index 000000000000..ad1bb6ead3e2 --- /dev/null +++ b/src/libs/CloudflareAccess/handleAuthRedirectCallback/types.ts @@ -0,0 +1,18 @@ +type CloudflareAuthRedirectOutcome = + /** Every normal boot, every native boot, and every boot without QA auth configured */ + | 'not-a-callback' + /** The code exchange started; join it with getPendingCloudflareAuthCompletion() */ + | 'exchanging' + /** State mismatch or no authorization code — nothing was exchanged */ + | 'invalid-callback' + /** Cloudflare reported an OAuth error (e.g. access_denied) */ + | 'provider-error' + /** No stored flow in this tab: a replayed callback URL, or one opened in a different tab */ + | 'no-pending-flow'; + +type CloudflareAuthRedirectResult = { + outcome: CloudflareAuthRedirectOutcome; + errorMessage?: string; +}; + +export type {CloudflareAuthRedirectOutcome, CloudflareAuthRedirectResult}; diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 141063734e14..fe81002bd32d 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -31,6 +31,8 @@ const onyxKeysToRemove = new Set | ValueOf { + try { + await waitForCloudflareSessionHydration(); + // A callback boot may still be exchanging the code — join it instead of starting a second round trip + const pendingCompletion = getPendingCloudflareAuthCompletion(); + if (pendingCompletion) { + await pendingCompletion; + } + + const session = getCloudflareSession(); + if (!session) { + // Never settles — nothing below runs + await beginCloudflareAuthRedirect(); + } else if (isSessionNearExpiry(session)) { + // Transient failures throw and land in the catch below as a plain 'error', session intact + const refreshResult = await refreshCloudflareSession(); + if (refreshResult === 'reauth-required') { + // No redirect from here: a background failure must not navigate the tab away + return {status: 'reauthRequired'}; + } + } + + const response = await fetchWithQAAuth(`${CONFIG.QA_AUTH.API_ROOT}api/CloudflareAuthProbe`, {method: CONST.NETWORK.METHOD.POST}); + if (!response.ok) { + return {status: 'error', detail: `HTTP ${response.status}`}; + } + // Cloudflare resolves the token at the edge and injects the user's JWT, so the origin can echo back + // how the request authenticated. Read loosely — it's a diagnostic, not a contract. + const body: unknown = await response.json().catch(() => null); + const authenticatedVia = isRecord(body) && typeof body.authenticatedVia === 'string' ? body.authenticatedVia : null; + return {status: 'success', detail: `authenticatedVia: ${authenticatedVia ?? 'null'}`}; + } catch (error) { + if (error instanceof Error && error.message === CF_REAUTH_REQUIRED) { + // Whoever threw this already dropped the dead session + return {status: 'reauthRequired'}; + } + return {status: 'error', detail: error instanceof Error ? error.message : undefined}; + } +} + +export {runCloudflareAuthProbe}; +export type {CloudflareAuthProbeResult, CloudflareAuthProbeStatus}; diff --git a/src/libs/actions/CloudflareSession.ts b/src/libs/actions/CloudflareSession.ts new file mode 100644 index 000000000000..a343911bfc7e --- /dev/null +++ b/src/libs/actions/CloudflareSession.ts @@ -0,0 +1,181 @@ +/** + * Owns the Cloudflare Access OAuth session for the QA server: Onyx-backed cache, the same-tab redirect + * flow, and the single-flight refresh. Web-only until native claims Universal/App Links. + */ +import {isQAAuthConfigured} from '@libs/CloudflareAccess/Config'; +import {generatePKCEPair, generateState} from '@libs/CloudflareAccess/generatePKCE'; +import {buildAuthorizeURL, exchangeCode, OAuthError, refreshTokens} from '@libs/CloudflareAccess/OAuthClient'; +import {clearPendingAuthFlow, savePendingAuthFlow} from '@libs/CloudflareAccess/PendingAuthFlowStorage'; +import {registerSessionCleanupCallback} from '@libs/SessionCleanup'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import type CloudflareSession from '@src/types/onyx/CloudflareSession'; + +import Onyx from 'react-native-onyx'; + +/** Refresh proactively when the access token has less lifetime left than this */ +const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60_000; + +/** `undefined` = Onyx not read yet, `null` = read and absent — NetworkStore's hydration convention */ +let sessionCache: CloudflareSession | null | undefined; + +// Definite assignment: the Promise executor runs synchronously, so this is set before anything reads it +let resolveHydration!: () => void; +const hydrationPromise = new Promise((resolve) => { + resolveHydration = resolve; +}); + +// Gated: this module loads on every app start, so an unconfigured build must not pay for a subscription +// and a sign-out callback it can never use +if (isQAAuthConfigured()) { + // We have used `connectWithoutView` here because this module-level cache is not connected to any UI component + Onyx.connectWithoutView({ + key: ONYXKEYS.CF_SESSION, + callback: (value) => { + sessionCache = value ?? null; + resolveHydration(); + }, + }); + + // Onyx.clear wipes the key but its callback is async, so drop the cache synchronously. Cache only: + // clearing the in-flight refs below wouldn't cancel the work, it would just let a second flight overlap. + registerSessionCleanupCallback(() => { + sessionCache = null; + clearPendingAuthFlow(); + }); +} else { + // Nothing will ever hydrate the cache, so a waiter must not block forever + sessionCache = null; + resolveHydration(); +} + +function getCloudflareSession(): CloudflareSession | null | undefined { + return sessionCache; +} + +function waitForCloudflareSessionHydration(): Promise { + return hydrationPromise; +} + +function isSessionNearExpiry(session: CloudflareSession): boolean { + return session.expiresAt - Date.now() < ACCESS_TOKEN_EXPIRY_BUFFER_MS; +} + +let isRedirectInFlight = false; + +/** + * Navigates this tab to Cloudflare to start the authorize round trip. Never settles once the navigation is + * requested — the page is leaving, so callers must run nothing after it. Rejects only if the flow couldn't + * be stored, since navigating away without the verifier would strand the exchange. + */ +async function beginCloudflareAuthRedirect(returnURL: string = window.location.href): Promise { + if (isRedirectInFlight) { + // A second press while the first navigation is settling must not overwrite the stored flow + return new Promise(() => {}); + } + isRedirectInFlight = true; + try { + const pkce = await generatePKCEPair(); + const state = generateState(); + // Must be stored before the navigation — module memory does not survive the unload + savePendingAuthFlow({state, codeVerifier: pkce.codeVerifier, returnURL, createdAt: Date.now()}); + window.location.assign(buildAuthorizeURL({state, codeChallenge: pkce.codeChallenge})); + } catch (error) { + isRedirectInFlight = false; + throw error; + } + return new Promise(() => {}); +} + +/** Single-flight: a caller joining mid-exchange must not burn the single-use authorization code twice */ +let redirectCompletionPromise: Promise | null = null; + +function completeCloudflareAuthRedirect({code, codeVerifier}: {code: string; codeVerifier: string}): Promise { + redirectCompletionPromise ??= exchangeCode({code, codeVerifier}) + .then((session) => { + // Cache first: a request fired during this boot must see the token before disk I/O settles. If + // Onyx.set rejects, the cache keeps the (real, usable) session and a reload self-heals. + sessionCache = session; + return Onyx.set(ONYXKEYS.CF_SESSION, session); + }) + .finally(() => { + redirectCompletionPromise = null; + }); + return redirectCompletionPromise; +} + +/** Non-null only mid-exchange, so callers join it instead of starting a second redirect */ +function getPendingCloudflareAuthCompletion(): Promise | null { + return redirectCompletionPromise; +} + +type CloudflareRefreshResult = 'refreshed' | 'skipped-newer-token' | 'reauth-required'; + +let refreshPromise: Promise | null = null; + +/** + * Single-flight refresh; the rotated pair is persisted before it resolves. Resolves `'reauth-required'` only + * for terminal failures (session already cleared) — transient ones reject and keep the session alive. Pass + * the token a 401 was seen with to get `'skipped-newer-token'` when rotation already happened. + */ +function refreshCloudflareSession(staleAccessToken?: string): Promise { + // Join before the staleness shortcut: resolution guarantees the rotated pair already hit Onyx + if (refreshPromise) { + return refreshPromise; + } + const current = sessionCache; + if (!current?.refreshToken) { + return Promise.resolve('reauth-required'); + } + // Rotation already completed while this caller's request was in flight — retry with the new token + if (staleAccessToken && current.accessToken !== staleAccessToken) { + return Promise.resolve('skipped-newer-token'); + } + refreshPromise = refreshTokens(current.refreshToken) + .then((session) => { + sessionCache = session; + return Onyx.set(ONYXKEYS.CF_SESSION, session).then((): CloudflareRefreshResult => 'refreshed'); + }) + .catch((error): Promise => { + if (error instanceof OAuthError && (error.code === 'invalid_grant' || error.code === 'invalid_response')) { + // Both mean the stored refresh token is spent (invalid_response = a 2xx arrived, so CF rotated + // even though the new pair was unreadable). Keeping it would trap every future attempt in the + // refresh branch instead of reaching the no-session redirect. + return clearCloudflareSession().then(() => 'reauth-required'); + } + throw error; + }) + .finally(() => { + refreshPromise = null; + }); + return refreshPromise; +} + +function clearCloudflareSession(): Promise { + sessionCache = null; // synchronous — a probe pressed right after Clear must not read the dead session + return Onyx.set(ONYXKEYS.CF_SESSION, null); +} + +/** + * Drops a session that still got 401 after a refresh, so the next attempt starts a fresh authorize round + * trip. Guarded on the rejected token so a concurrently established session isn't collateral damage. + */ +function markCloudflareSessionRejected(rejectedAccessToken: string): Promise { + if (sessionCache?.accessToken !== rejectedAccessToken) { + return Promise.resolve(); + } + return clearCloudflareSession(); +} + +export { + beginCloudflareAuthRedirect, + clearCloudflareSession, + completeCloudflareAuthRedirect, + getCloudflareSession, + getPendingCloudflareAuthCompletion, + isSessionNearExpiry, + markCloudflareSessionRejected, + refreshCloudflareSession, + waitForCloudflareSessionHydration, +}; +export type {CloudflareRefreshResult}; diff --git a/src/setup/index.ts b/src/setup/index.ts index bd1533bdcb66..abf738c0ee01 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -1,3 +1,4 @@ +import {handleCloudflareAuthRedirectCallback} from '@libs/CloudflareAccess/handleAuthRedirectCallback'; import intlPolyfill from '@libs/IntlPolyfill'; import {setDeviceID} from '@userActions/Device'; @@ -89,6 +90,11 @@ export default function () { // handlers are registered before any push arrives, including Android headless/background wake-ups. import('@libs/Notification/PushNotification/subscribeToPushNotifications'); + // The QA auth callback arrives as a full page load, so no component is around to receive it: the code is + // picked up and the URL restored here, before React Navigation resolves the initial route. After + // Onyx.init() because a completed exchange persists the session. No-op on every other load. + handleCloudflareAuthRedirectCallback(); + initOnyxDerivedValues(); setDeviceID(); diff --git a/src/types/onyx/CloudflareSession.ts b/src/types/onyx/CloudflareSession.ts new file mode 100644 index 000000000000..d329d0289bef --- /dev/null +++ b/src/types/onyx/CloudflareSession.ts @@ -0,0 +1,13 @@ +/** OAuth session used to reach the Cloudflare Access-protected QA server */ +type CloudflareSession = { + /** Opaque `oauth:…` bearer token, ~15 min lifetime */ + accessToken: string; + + /** Rotates on every refresh — must always be persisted atomically together with accessToken */ + refreshToken: string; + + /** Epoch ms when accessToken expires (computed from the token response's expires_in at issue time) */ + expiresAt: number; +}; + +export default CloudflareSession; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index a26297812c3f..8f1d4a4e3e56 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -43,6 +43,7 @@ import type { } from './CardFeeds'; import type CardOnWaitlist from './CardOnWaitlist'; import type ChronosTimeTracking from './ChronosTimeTracking'; +import type CloudflareSession from './CloudflareSession'; import type CodingRuleMatchingTransaction from './CodingRuleMatchingTransaction'; import type CompanyCardsLoadingState from './CompanyCardsLoadingState'; import type ConciergePendingFollowupList from './ConciergePendingFollowupList'; @@ -431,6 +432,7 @@ export type { DomainPendingActions, DomainSecurityGroup, ChronosTimeTracking, + CloudflareSession, CodingRuleMatchingTransaction, UserSecurityGroupData, DeviceBiometrics, diff --git a/tests/unit/CloudflareAccessTest.ts b/tests/unit/CloudflareAccessTest.ts new file mode 100644 index 000000000000..4a45a821c401 --- /dev/null +++ b/tests/unit/CloudflareAccessTest.ts @@ -0,0 +1,367 @@ +/** + * PKCE encoding pinned to the RFC 7636 Appendix B vector, the config security boundary + * (isQAServerRequest), and the OAuth client's request/response contract. + */ +import type * as ConfigModule from '@libs/CloudflareAccess/Config'; +import type * as PKCEModule from '@libs/CloudflareAccess/generatePKCE'; +import type * as OAuthClientModule from '@libs/CloudflareAccess/OAuthClient'; +import type * as PendingAuthFlowStorageModule from '@libs/CloudflareAccess/PendingAuthFlowStorage'; + +import Base64URL from '@src/utils/Base64URL'; + +import {webcrypto} from 'crypto'; + +// Mutable QA config the '@src/CONFIG' mock closes over — tests tweak fields per case. +// The `mock` prefix is what lets the hoisted jest.mock factory reference it. +const mockQAAuth = { + API_ROOT: 'https://qa.example.com/', + TEAM_DOMAIN: 'team.cloudflareaccess.com', + CLIENT_ID: 'client-123', +}; + +jest.mock('@src/CONFIG', () => ({__esModule: true, default: {QA_AUTH: mockQAAuth}})); + +// Jest resolves getWebCrypto/index.native.ts (the throwing stub) under the jest-expo preset, +// so the provider is mocked; the default implementation is Node's real WebCrypto. +jest.mock('@libs/CloudflareAccess/getWebCrypto', () => ({ + __esModule: true, + default: { + getRandomValues: jest.fn(), + sha256: jest.fn(), + }, +})); + +// Lazy-require so the @src/CONFIG mock factory sees an initialized mockQAAuth — otherwise the +// hoisted import order would resolve CONFIG.default while mockQAAuth was still in the TDZ. +const {getQAOrigin, isQAAuthConfigured, isQAServerRequest} = require('@libs/CloudflareAccess/Config'); +const {clearPendingAuthFlow, consumePendingAuthFlow, savePendingAuthFlow} = require('@libs/CloudflareAccess/PendingAuthFlowStorage'); +const {buildAuthorizeURL, exchangeCode, OAuthError, refreshTokens} = require('@libs/CloudflareAccess/OAuthClient'); +const {generatePKCEPair, generateState} = require('@libs/CloudflareAccess/generatePKCE'); +const getWebCrypto = require<{default: {getRandomValues: jest.Mock; sha256: jest.Mock}}>('@libs/CloudflareAccess/getWebCrypto').default; + +function resetQAAuthConfig() { + mockQAAuth.API_ROOT = 'https://qa.example.com/'; + mockQAAuth.TEAM_DOMAIN = 'team.cloudflareaccess.com'; + mockQAAuth.CLIENT_ID = 'client-123'; +} + +beforeEach(() => { + jest.clearAllMocks(); + resetQAAuthConfig(); + getWebCrypto.getRandomValues.mockImplementation((array: Uint8Array) => webcrypto.getRandomValues(array)); + getWebCrypto.sha256.mockImplementation((data: BufferSource) => webcrypto.subtle.digest('SHA-256', data)); +}); + +describe('pkce', () => { + it('produces the RFC 7636 Appendix B challenge for the Appendix B verifier', async () => { + // The spec's worked example pins the whole encoding chain end to end + const appendixBVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + const appendixBChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + const verifierBytes = Base64URL.decode(appendixBVerifier); + getWebCrypto.getRandomValues.mockImplementation((array: Uint8Array) => { + array.set(verifierBytes); + return array; + }); + + const {codeVerifier, codeChallenge} = await generatePKCEPair(); + + expect(codeVerifier).toBe(appendixBVerifier); + expect(codeChallenge).toBe(appendixBChallenge); + }); + + it('generates a 43-char base64url verifier and a 22-char state', async () => { + const {codeVerifier} = await generatePKCEPair(); + expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(generateState()).toMatch(/^[A-Za-z0-9_-]{22}$/); + }); + + it('regenerates deterministically when the challenge starts with a non-alphanumeric character', async () => { + // Leading digest byte 248 → top 6 bits = 62 → first base64url char '-' (the CF parser quirk); + // leading byte 65 → top 6 bits = 16 → 'Q'. A controlled sequence proves the guard, unlike a + // probabilistic run which would let a deleted guard pass most of the time. + const digestStartingWithDash = new Uint8Array(32); + digestStartingWithDash[0] = 248; + const digestStartingAlphanumeric = new Uint8Array(32); + digestStartingAlphanumeric[0] = 65; + // Sanity-check the premise of the test itself + expect(Base64URL.encode(digestStartingWithDash).startsWith('-')).toBe(true); + + const firstBytes = new Uint8Array(32).fill(1); + const secondBytes = new Uint8Array(32).fill(2); + getWebCrypto.getRandomValues + .mockImplementationOnce((array: Uint8Array) => { + array.set(firstBytes); + return array; + }) + .mockImplementationOnce((array: Uint8Array) => { + array.set(secondBytes); + return array; + }); + getWebCrypto.sha256.mockResolvedValueOnce(digestStartingWithDash.buffer).mockResolvedValueOnce(digestStartingAlphanumeric.buffer); + + const {codeVerifier, codeChallenge} = await generatePKCEPair(); + + expect(getWebCrypto.sha256).toHaveBeenCalledTimes(2); + // The regenerated pair must stay together: second verifier with the second challenge + expect(codeVerifier).toBe(Base64URL.encode(secondBytes)); + expect(codeChallenge).toBe(Base64URL.encode(digestStartingAlphanumeric)); + expect(codeChallenge).toMatch(/^[a-zA-Z0-9]/); + }); +}); + +describe('config', () => { + it.each([ + ['the exact configured origin', 'https://qa.example.com/api/OpenApp', true], + ['a lookalike origin', 'https://evil-qa.example.com/api/OpenApp', false], + ['the http scheme on the right host', 'http://qa.example.com/api/OpenApp', false], + ['a different port on the right host', 'https://qa.example.com:444/api/OpenApp', false], + ['the QA host appearing only in the path', 'https://attacker.com/qa.example.com', false], + ['a garbage string', 'not a url at all', false], + ])('isQAServerRequest with %s → %s', (description, url, expected) => { + expect(isQAServerRequest(url)).toBe(expected); + }); + + it('treats an empty config as not configured', () => { + mockQAAuth.API_ROOT = ''; + mockQAAuth.TEAM_DOMAIN = ''; + mockQAAuth.CLIENT_ID = ''; + expect(isQAAuthConfigured()).toBe(false); + expect(isQAServerRequest('https://qa.example.com/api/OpenApp')).toBe(false); + }); + + it('treats a partial config as not configured — missing API root', () => { + mockQAAuth.API_ROOT = ''; + expect(isQAAuthConfigured()).toBe(false); + expect(isQAServerRequest('https://qa.example.com/api/OpenApp')).toBe(false); + }); + + it('treats a partial config as not configured — missing client ID', () => { + mockQAAuth.CLIENT_ID = ''; + expect(isQAAuthConfigured()).toBe(false); + expect(isQAServerRequest('https://qa.example.com/api/OpenApp')).toBe(false); + }); + + it('rejects an http API root even when every value is present', () => { + mockQAAuth.API_ROOT = 'http://qa.example.com/'; + expect(isQAAuthConfigured()).toBe(false); + expect(isQAServerRequest('http://qa.example.com/api/OpenApp')).toBe(false); + }); + + it.each([ + ['a scheme', 'https://team.cloudflareaccess.com'], + ['a trailing slash', 'team.cloudflareaccess.com/'], + ['a single label', 'localhost'], + ])('rejects a team domain with %s', (description, teamDomain) => { + mockQAAuth.TEAM_DOMAIN = teamDomain; + expect(isQAAuthConfigured()).toBe(false); + expect(isQAServerRequest('https://qa.example.com/api/OpenApp')).toBe(false); + }); + + it('derives the RFC 8707 resource in origin form (no trailing slash)', () => { + expect(getQAOrigin()).toBe('https://qa.example.com'); + }); +}); + +describe('oAuthClient', () => { + // Wire-format bodies are built from entries throughout: the OAuth protocol mandates snake_case + // keys, which the naming-convention lint rule forbids as object-literal property names + const VALID_TOKEN_ENTRIES: Array<[string, unknown]> = [ + ['access_token', 'oauth:access'], + ['refresh_token', 'oauth:refresh'], + ['expires_in', 900], + ['token_type', 'bearer'], + ['scope', ''], + ['resource', 'https://qa.example.com'], + ]; + + function tokenBody(overrides: Array<[string, unknown]> = []): Record { + return Object.fromEntries([...VALID_TOKEN_ENTRIES, ...overrides]); + } + + type CapturedRequest = {url: string; init: RequestInit}; + + /** Parses a captured form-encoded body; the implementation always posts a string, so non-strings parse as empty */ + function bodyParams(init: RequestInit | undefined): Record { + const body = init?.body; + return Object.fromEntries(new URLSearchParams(typeof body === 'string' ? body : '').entries()); + } + + /** Mocks global fetch; the typed implementation captures arguments so assertions never touch `mock.calls` (any-typed) */ + function mockTokenEndpoint(status: number, body: unknown): CapturedRequest[] { + const captured: CapturedRequest[] = []; + global.fetch = jest.fn().mockImplementation((url: string, init: RequestInit) => { + captured.push({url, init}); + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => (body === undefined ? Promise.reject(new SyntaxError('Unexpected end of JSON input')) : Promise.resolve(body)), + }); + }); + return captured; + } + + it('maps an OAuth error response to an OAuthError with the protocol code', async () => { + mockTokenEndpoint( + 400, + Object.fromEntries([ + ['error', 'invalid_grant'], + ['error_description', 'refresh token is invalid'], + ]), + ); + const result = refreshTokens('oauth:spent-refresh-token'); + await expect(result).rejects.toBeInstanceOf(OAuthError); + await expect(result).rejects.toMatchObject({code: 'invalid_grant', message: 'refresh token is invalid'}); + }); + + it('maps a non-OAuth failure to a plain error, not an OAuthError', async () => { + mockTokenEndpoint(502, undefined); + const result = refreshTokens('oauth:refresh'); + await expect(result).rejects.toThrow('Token endpoint failed with HTTP 502'); + await expect(result).rejects.not.toBeInstanceOf(OAuthError); + }); + + it.each([ + ['a missing refresh_token', tokenBody([['refresh_token', undefined]])], + ['an empty access_token', tokenBody([['access_token', '']])], + ['a non-numeric expires_in', tokenBody([['expires_in', '900']])], + ['a missing token_type', tokenBody([['token_type', undefined]])], + ['a non-bearer token_type', tokenBody([['token_type', 'mac']])], + ['a non-object body', 'not-json-object'], + ])('rejects a 2xx with %s as a terminal invalid_response', async (description, body) => { + mockTokenEndpoint(200, body); + await expect(refreshTokens('oauth:refresh')).rejects.toMatchObject({code: 'invalid_response'}); + }); + + it('accepts token_type case-insensitively and maps the response into a session', async () => { + mockTokenEndpoint(200, tokenBody([['token_type', 'Bearer']])); + const session = await refreshTokens('oauth:refresh'); + expect(session.accessToken).toBe('oauth:access'); + expect(session.refreshToken).toBe('oauth:refresh'); + expect(session.expiresAt).toBeGreaterThan(Date.now()); + }); + + it('buildAuthorizeURL carries exactly the verified parameter set', () => { + const url = new URL(buildAuthorizeURL({state: 'state-1', codeChallenge: 'challenge-1'})); + expect(`${url.origin}${url.pathname}`).toBe('https://team.cloudflareaccess.com/cdn-cgi/access/oauth/authorization'); + expect(Object.fromEntries(url.searchParams.entries())).toEqual( + Object.fromEntries([ + ['response_type', 'code'], + ['client_id', 'client-123'], + ['redirect_uri', `${window.location.origin}/oauth/callback`], + ['state', 'state-1'], + ['code_challenge', 'challenge-1'], + ['code_challenge_method', 'S256'], + ['resource', 'https://qa.example.com'], + ]), + ); + }); + + it('exchangeCode posts the verified body with a redirect_uri byte-matching the authorize request', async () => { + const authorizeRedirectURI = new URL(buildAuthorizeURL({state: 's', codeChallenge: 'c'})).searchParams.get('redirect_uri'); + const captured = mockTokenEndpoint(200, tokenBody()); + + await exchangeCode({code: 'code-1', codeVerifier: 'verifier-1'}); + + const request = captured.at(0); + expect(request?.url).toBe('https://team.cloudflareaccess.com/cdn-cgi/access/oauth/token'); + expect(request?.init.method).toBe('POST'); + expect(request?.init.credentials).toBe('omit'); + expect(request?.init.headers).toEqual([['Content-Type', 'application/x-www-form-urlencoded']]); + expect(bodyParams(request?.init)).toEqual( + Object.fromEntries([ + ['grant_type', 'authorization_code'], + ['code', 'code-1'], + ['code_verifier', 'verifier-1'], + ['redirect_uri', authorizeRedirectURI], + ['client_id', 'client-123'], + ['resource', 'https://qa.example.com'], + ]), + ); + }); + + it('refreshTokens posts the verified body and omits resource', async () => { + const captured = mockTokenEndpoint(200, tokenBody()); + + await refreshTokens('oauth:refresh-1'); + + expect(bodyParams(captured.at(0)?.init)).toEqual( + Object.fromEntries([ + ['grant_type', 'refresh_token'], + ['refresh_token', 'oauth:refresh-1'], + ['client_id', 'client-123'], + ]), + ); + }); +}); + +describe('pendingAuthFlowStorage', () => { + const STORAGE_KEY = 'QA_AUTH_REDIRECT_FLOW'; + const FLOW = {state: 'state-1', codeVerifier: 'verifier-1', returnURL: 'http://localhost/settings/troubleshoot', createdAt: 1_700_000_000_000}; + + let nowSpy: jest.SpyInstance; + + beforeEach(() => { + window.sessionStorage.clear(); + nowSpy = jest.spyOn(Date, 'now').mockReturnValue(FLOW.createdAt); + }); + + afterEach(() => { + nowSpy.mockRestore(); + window.sessionStorage.clear(); + }); + + it('round-trips the flow record', () => { + savePendingAuthFlow(FLOW); + expect(consumePendingAuthFlow()).toEqual(FLOW); + }); + + it('is single-use: the record is removed even before it is validated', () => { + savePendingAuthFlow(FLOW); + consumePendingAuthFlow(); + expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + // A replayed callback URL finds nothing — the verifier can never be reused + expect(consumePendingAuthFlow()).toBeNull(); + }); + + it('treats an expired record as absent, so a stale verifier is never exchanged', () => { + savePendingAuthFlow(FLOW); + nowSpy.mockReturnValue(FLOW.createdAt + 11 * 60 * 1000); + expect(consumePendingAuthFlow()).toBeNull(); + }); + + it.each([ + ['unparseable JSON', 'not json'], + ['a missing verifier', JSON.stringify({state: 's', returnURL: '/', createdAt: FLOW.createdAt})], + ['an empty state', JSON.stringify({...FLOW, state: ''})], + ])('returns null for %s, and still clears it', (_label, raw) => { + window.sessionStorage.setItem(STORAGE_KEY, raw); + expect(consumePendingAuthFlow()).toBeNull(); + expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('clearPendingAuthFlow drops a pending record', () => { + savePendingAuthFlow(FLOW); + clearPendingAuthFlow(); + expect(consumePendingAuthFlow()).toBeNull(); + }); + + it('throws when the write fails, so the caller refuses to navigate away without a stored verifier', () => { + // jsdom's Storage methods are not spy-able, so the whole object is swapped out + const realSessionStorage = window.sessionStorage; + Object.defineProperty(window, 'sessionStorage', { + value: { + getItem: () => null, + removeItem: () => {}, + setItem: () => { + throw new Error('QuotaExceededError'); + }, + }, + writable: true, + configurable: true, + }); + + expect(() => savePendingAuthFlow(FLOW)).toThrow('QuotaExceededError'); + + Object.defineProperty(window, 'sessionStorage', {value: realSessionStorage, writable: true, configurable: true}); + }); +}); diff --git a/tests/unit/CloudflareAuthRedirectCallbackTest.ts b/tests/unit/CloudflareAuthRedirectCallbackTest.ts new file mode 100644 index 000000000000..46d6c6c5abdc --- /dev/null +++ b/tests/unit/CloudflareAuthRedirectCallbackTest.ts @@ -0,0 +1,150 @@ +/** + * The boot-time callback handler: the gate table keeping a callback that fails provenance away from the + * token exchange, plus the URL rewrite that keeps the boot off the redirect path. + * + * Requires the web implementation explicitly — jest resolves platform-split modules to their native variant. + */ +import type * as AuthRedirectCallbackModule from '@libs/CloudflareAccess/handleAuthRedirectCallback/index.ts'; +import type * as PendingAuthFlowStorageModule from '@libs/CloudflareAccess/PendingAuthFlowStorage'; + +import type * as SessionActionsModule from '@userActions/CloudflareSession'; + +const mockQAAuth = { + API_ROOT: 'https://qa.example.com/', + TEAM_DOMAIN: 'team.cloudflareaccess.com', + CLIENT_ID: 'client-123', +}; + +jest.mock('@src/CONFIG', () => ({__esModule: true, default: {QA_AUTH: mockQAAuth}})); + +jest.mock('@userActions/CloudflareSession', () => ({ + __esModule: true, + completeCloudflareAuthRedirect: jest.fn(() => Promise.resolve()), +})); + +const RETURN_URL = 'http://localhost/settings/troubleshoot'; +const FLOW = {state: 'state-1', codeVerifier: 'verifier-1', returnURL: RETURN_URL, createdAt: 1_700_000_000_000}; + +let authRedirectCallback: typeof AuthRedirectCallbackModule; +let pendingAuthFlowStorage: typeof PendingAuthFlowStorageModule; +let sessionActions: typeof SessionActionsModule; +let replaceStateSpy: jest.SpyInstance; +let nowSpy: jest.SpyInstance; + +/** Points jsdom at the callback URL without triggering a real navigation */ +function arrangeCallbackURL(search: string) { + Object.defineProperty(window, 'location', { + value: {origin: 'http://localhost', href: `http://localhost/oauth/callback${search}`, pathname: '/oauth/callback'}, + writable: true, + configurable: true, + }); +} + +let realLocation: Location; + +beforeEach(() => { + jest.resetModules(); + window.sessionStorage.clear(); + realLocation = window.location; + mockQAAuth.CLIENT_ID = 'client-123'; + nowSpy = jest.spyOn(Date, 'now').mockReturnValue(FLOW.createdAt); + replaceStateSpy = jest.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + pendingAuthFlowStorage = require('@libs/CloudflareAccess/PendingAuthFlowStorage'); + sessionActions = require('@userActions/CloudflareSession'); + authRedirectCallback = require('@libs/CloudflareAccess/handleAuthRedirectCallback/index.ts'); +}); + +afterEach(() => { + replaceStateSpy.mockRestore(); + nowSpy.mockRestore(); + Object.defineProperty(window, 'location', {value: realLocation, writable: true, configurable: true}); +}); + +describe('handleCloudflareAuthRedirectCallback', () => { + it('is a no-op off the callback path — every normal boot runs this', () => { + pendingAuthFlowStorage.savePendingAuthFlow(FLOW); + Object.defineProperty(window, 'location', { + value: {origin: 'http://localhost', href: RETURN_URL, pathname: '/settings/troubleshoot'}, + writable: true, + configurable: true, + }); + + expect(authRedirectCallback.handleCloudflareAuthRedirectCallback()).toBe('not-a-callback'); + expect(replaceStateSpy).not.toHaveBeenCalled(); + expect(sessionActions.completeCloudflareAuthRedirect).not.toHaveBeenCalled(); + // A pending flow from another tab's round trip must survive an unrelated boot + expect(pendingAuthFlowStorage.consumePendingAuthFlow()).not.toBeNull(); + }); + + it('is a no-op when QA auth is not configured', () => { + mockQAAuth.CLIENT_ID = ''; + arrangeCallbackURL('?code=auth-code-1&state=state-1'); + pendingAuthFlowStorage.savePendingAuthFlow(FLOW); + + expect(authRedirectCallback.handleCloudflareAuthRedirectCallback()).toBe('not-a-callback'); + expect(sessionActions.completeCloudflareAuthRedirect).not.toHaveBeenCalled(); + }); + + it('exchanges the code and restores the URL before the exchange resolves', () => { + arrangeCallbackURL('?code=auth-code-1&state=state-1'); + pendingAuthFlowStorage.savePendingAuthFlow(FLOW); + + expect(authRedirectCallback.handleCloudflareAuthRedirectCallback()).toBe('exchanging'); + // Synchronous, and before React Navigation reads window.location + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/settings/troubleshoot'); + expect(sessionActions.completeCloudflareAuthRedirect).toHaveBeenCalledWith({code: 'auth-code-1', codeVerifier: FLOW.codeVerifier}); + }); + + it('validates state first: a foreign callback is discarded wholesale, even with error and code present', () => { + arrangeCallbackURL('?state=WRONG&error=access_denied&code=evil-code'); + pendingAuthFlowStorage.savePendingAuthFlow(FLOW); + + expect(authRedirectCallback.handleCloudflareAuthRedirectCallback()).toBe('invalid-callback'); + expect(sessionActions.completeCloudflareAuthRedirect).not.toHaveBeenCalled(); + expect(authRedirectCallback.getCloudflareAuthRedirectOutcome().errorMessage).toBe('OAuth callback state mismatch'); + // Still rescued off the redirect path, which has no app route + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/settings/troubleshoot'); + }); + + it('surfaces a provider refusal without exchanging', () => { + arrangeCallbackURL('?state=state-1&error=access_denied&error_description=User+refused'); + pendingAuthFlowStorage.savePendingAuthFlow(FLOW); + + expect(authRedirectCallback.handleCloudflareAuthRedirectCallback()).toBe('provider-error'); + expect(sessionActions.completeCloudflareAuthRedirect).not.toHaveBeenCalled(); + expect(authRedirectCallback.getCloudflareAuthRedirectOutcome().errorMessage).toBe('User refused'); + }); + + it('rejects a callback with no authorization code', () => { + arrangeCallbackURL('?state=state-1'); + pendingAuthFlowStorage.savePendingAuthFlow(FLOW); + + expect(authRedirectCallback.handleCloudflareAuthRedirectCallback()).toBe('invalid-callback'); + expect(sessionActions.completeCloudflareAuthRedirect).not.toHaveBeenCalled(); + }); + + it('refuses a callback with no stored flow, and lands on a safe route', () => { + // A replayed callback URL, or one opened in a tab that never started the flow + arrangeCallbackURL('?code=auth-code-1&state=state-1'); + + expect(authRedirectCallback.handleCloudflareAuthRedirectCallback()).toBe('no-pending-flow'); + expect(sessionActions.completeCloudflareAuthRedirect).not.toHaveBeenCalled(); + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/'); + }); + + it('never navigates to a foreign origin, even though the returnURL is our own storage', () => { + arrangeCallbackURL('?code=auth-code-1&state=state-1'); + pendingAuthFlowStorage.savePendingAuthFlow({...FLOW, returnURL: 'https://evil.example.com/steal'}); + + expect(authRedirectCallback.handleCloudflareAuthRedirectCallback()).toBe('exchanging'); + expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/'); + }); + + it('consumes the flow record even when the callback is rejected, so it can never be replayed', () => { + arrangeCallbackURL('?state=WRONG&code=evil-code'); + pendingAuthFlowStorage.savePendingAuthFlow(FLOW); + + authRedirectCallback.handleCloudflareAuthRedirectCallback(); + expect(pendingAuthFlowStorage.consumePendingAuthFlow()).toBeNull(); + }); +}); diff --git a/tests/unit/CloudflareFetchTest.ts b/tests/unit/CloudflareFetchTest.ts new file mode 100644 index 000000000000..3a4bdb65ec44 --- /dev/null +++ b/tests/unit/CloudflareFetchTest.ts @@ -0,0 +1,135 @@ +/** + * Which requests get the bearer header, and the 401 → refresh → single retry recovery. The session action is + * mocked; its own invariants live in CloudflareSessionTest, so asserting them here would test the mock. + */ +import type * as ConfigModule from '@libs/CloudflareAccess/Config'; +import {isQAServerRequest} from '@libs/CloudflareAccess/Config'; +import fetchWithQAAuth, {CF_REAUTH_REQUIRED} from '@libs/CloudflareAccess/fetchWithQAAuth'; + +import {getCloudflareSession, markCloudflareSessionRejected, refreshCloudflareSession} from '@userActions/CloudflareSession'; + +jest.mock('@userActions/CloudflareSession', () => ({ + __esModule: true, + getCloudflareSession: jest.fn(), + markCloudflareSessionRejected: jest.fn(), + refreshCloudflareSession: jest.fn(), +})); + +jest.mock('@libs/CloudflareAccess/Config', () => ({ + __esModule: true, + ...jest.requireActual('@libs/CloudflareAccess/Config'), + isQAServerRequest: jest.fn(() => false), +})); + +const QA_API_ROOT = 'https://qa.example.com/'; +const QA_URL = `${QA_API_ROOT}api/CloudflareAuthProbe`; +const OTHER_URL = 'https://www.expensify.com/api/OpenApp'; +const SESSION_A = {accessToken: 'oauth:access-a', refreshToken: 'oauth:refresh-a', expiresAt: 1900000000000}; +const SESSION_B = {accessToken: 'oauth:access-b', refreshToken: 'oauth:refresh-b', expiresAt: 1900000900000}; + +function response(status: number) { + return {ok: status >= 200 && status < 300, status, json: () => Promise.resolve({})}; +} + +type CapturedRequest = {url: string; init: RequestInit}; + +/** Scripted responses with typed argument capture, so assertions never touch `mock.calls` (any-typed) */ +function mockFetchSequence(...responses: Array>) { + const captured: CapturedRequest[] = []; + const fetchMock = jest.fn().mockImplementation((url: string, init: RequestInit) => { + captured.push({url, init}); + return Promise.resolve(responses.at(Math.min(captured.length, responses.length) - 1)); + }); + global.fetch = fetchMock; + return {fetchMock, captured}; +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(isQAServerRequest).mockImplementation((url: string) => url.startsWith(QA_API_ROOT)); + jest.mocked(getCloudflareSession).mockReturnValue(SESSION_A); + jest.mocked(refreshCloudflareSession).mockResolvedValue('refreshed'); + jest.mocked(markCloudflareSessionRejected).mockResolvedValue(undefined); +}); + +describe('fetchWithQAAuth', () => { + it('attaches the bearer header on a QA request, keeps credentials omitted', async () => { + const {captured} = mockFetchSequence(response(200)); + + await fetchWithQAAuth(QA_URL, {method: 'post'}); + + expect(captured.at(0)?.init.headers).toEqual({Authorization: `Bearer ${SESSION_A.accessToken}`}); + expect(captured.at(0)?.init.credentials).toBe('omit'); + }); + + it('sends no auth header for any other origin, even with a live session', async () => { + const {captured} = mockFetchSequence(response(200)); + + await fetchWithQAAuth(OTHER_URL, {method: 'post'}); + + expect(captured.at(0)?.init.headers).toBeUndefined(); + expect(captured.at(0)?.init.credentials).toBe('omit'); + }); + + it('on a 401: refreshes once with the used token and retries once with the rotated token', async () => { + jest.mocked(getCloudflareSession).mockReturnValueOnce(SESSION_A).mockReturnValue(SESSION_B); + const {fetchMock, captured} = mockFetchSequence(response(401), response(200)); + + await expect(fetchWithQAAuth(QA_URL, {method: 'post'})).resolves.toMatchObject({status: 200}); + + expect(refreshCloudflareSession).toHaveBeenCalledTimes(1); + expect(refreshCloudflareSession).toHaveBeenCalledWith(SESSION_A.accessToken); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(captured.at(1)?.init.headers).toEqual({Authorization: `Bearer ${SESSION_B.accessToken}`}); + }); + + it('rejects with the re-auth sentinel and does not retry when the refresh outcome is terminal', async () => { + jest.mocked(refreshCloudflareSession).mockResolvedValue('reauth-required'); + const {fetchMock} = mockFetchSequence(response(401)); + + await expect(fetchWithQAAuth(QA_URL, {method: 'post'})).rejects.toThrow(CF_REAUTH_REQUIRED); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(markCloudflareSessionRejected).not.toHaveBeenCalled(); + }); + + it('propagates a transient refresh failure as-is — the session is still alive', async () => { + const transientError = new TypeError('Failed to fetch'); + jest.mocked(refreshCloudflareSession).mockRejectedValue(transientError); + const {fetchMock} = mockFetchSequence(response(401)); + + await expect(fetchWithQAAuth(QA_URL, {method: 'post'})).rejects.toBe(transientError); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(markCloudflareSessionRejected).not.toHaveBeenCalled(); + }); + + it('on a second 401: drops the rejected session and rejects with the re-auth sentinel', async () => { + jest.mocked(getCloudflareSession).mockReturnValueOnce(SESSION_A).mockReturnValue(SESSION_B); + const {fetchMock} = mockFetchSequence(response(401), response(401)); + + await expect(fetchWithQAAuth(QA_URL, {method: 'post'})).rejects.toThrow(CF_REAUTH_REQUIRED); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(refreshCloudflareSession).toHaveBeenCalledTimes(1); + expect(markCloudflareSessionRejected).toHaveBeenCalledTimes(1); + expect(markCloudflareSessionRejected).toHaveBeenCalledWith(SESSION_B.accessToken); + }); + + it('leaves a 401 from any other origin alone — no refresh, no retry', async () => { + const {fetchMock} = mockFetchSequence(response(401)); + + await expect(fetchWithQAAuth(OTHER_URL, {method: 'post'})).resolves.toMatchObject({status: 401}); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(refreshCloudflareSession).not.toHaveBeenCalled(); + }); + + it('returns a non-401 error response untouched, so the caller decides', async () => { + mockFetchSequence(response(500)); + + await expect(fetchWithQAAuth(QA_URL, {method: 'post'})).resolves.toMatchObject({status: 500}); + + expect(refreshCloudflareSession).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/CloudflareProbeTest.ts b/tests/unit/CloudflareProbeTest.ts new file mode 100644 index 000000000000..820740ad8ca3 --- /dev/null +++ b/tests/unit/CloudflareProbeTest.ts @@ -0,0 +1,143 @@ +/** + * The probe's decision tree: which branch runs for which session state, and that every failure comes back + * as a semantic result rather than a rejection. Its dependencies are mocked; they have their own suites. + */ +import {CF_REAUTH_REQUIRED} from '@libs/CloudflareAccess/fetchWithQAAuth'; + +import {runCloudflareAuthProbe} from '@userActions/CloudflareProbe'; +import {beginCloudflareAuthRedirect, getCloudflareSession, getPendingCloudflareAuthCompletion, isSessionNearExpiry, refreshCloudflareSession} from '@userActions/CloudflareSession'; + +import type CloudflareSession from '@src/types/onyx/CloudflareSession'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +/** The slice of the fetch Response the probe touches; a full Response stub would add nothing */ +type ProbeResponse = {ok: boolean; status: number; json: () => Promise}; + +const mockFetchWithQAAuth = jest.fn, [string, {method?: string}]>(); + +function jsonResponse(body: unknown): ProbeResponse { + return {ok: true, status: 200, json: () => Promise.resolve(body)}; +} + +jest.mock('@userActions/CloudflareSession', () => ({ + __esModule: true, + getCloudflareSession: jest.fn(), + getPendingCloudflareAuthCompletion: jest.fn(() => null), + isSessionNearExpiry: jest.fn(() => false), + refreshCloudflareSession: jest.fn(), + waitForCloudflareSessionHydration: jest.fn(() => Promise.resolve()), + beginCloudflareAuthRedirect: jest.fn(), +})); + +jest.mock('@libs/CloudflareAccess/fetchWithQAAuth', () => ({ + __esModule: true, + // Forwarding wrapper instead of the mock itself: the factory runs while the hoisted import chain is still + // executing, before mockFetchWithQAAuth's initializer — a direct reference would capture undefined + default: (...args: Parameters) => mockFetchWithQAAuth(...args), + CF_REAUTH_REQUIRED: 'Cloudflare re-authentication required', +})); + +const SESSION: CloudflareSession = {accessToken: 'oauth:access', refreshToken: 'oauth:refresh', expiresAt: 1900000000000}; + +beforeEach(() => { + jest.clearAllMocks(); + // clearAllMocks keeps implementations, and the redirect stub is deliberately never-settling in one + // case — leaking that into the next test would hang it + jest.mocked(beginCloudflareAuthRedirect).mockReset(); + jest.mocked(isSessionNearExpiry).mockReturnValue(false); + jest.mocked(getPendingCloudflareAuthCompletion).mockReturnValue(null); + mockFetchWithQAAuth.mockResolvedValue(jsonResponse({jsonCode: 200, authenticatedVia: 'oauth-bearer'})); +}); + +describe('runCloudflareAuthProbe', () => { + it('with no session: starts the redirect and never fires the request — the page is leaving', async () => { + jest.mocked(getCloudflareSession).mockReturnValue(null); + // The real one navigates the tab away and never settles + jest.mocked(beginCloudflareAuthRedirect).mockReturnValue(new Promise(() => {})); + + let isSettled = false; + runCloudflareAuthProbe().then(() => { + isSettled = true; + return undefined; + }); + await waitForBatchedUpdates(); + + expect(beginCloudflareAuthRedirect).toHaveBeenCalledTimes(1); + expect(mockFetchWithQAAuth).not.toHaveBeenCalled(); + expect(isSettled).toBe(false); + }); + + it('joins a callback-boot exchange instead of starting a second redirect', async () => { + // The boot after the callback: the exchange is in flight, and populates the cache before the + // probe reads it — so no second round trip is needed + jest.mocked(getCloudflareSession).mockReturnValue(SESSION); + jest.mocked(getPendingCloudflareAuthCompletion).mockReturnValue(Promise.resolve()); + + await expect(runCloudflareAuthProbe()).resolves.toEqual({status: 'success', detail: 'authenticatedVia: oauth-bearer'}); + + expect(beginCloudflareAuthRedirect).not.toHaveBeenCalled(); + }); + + it('surfaces a failed callback-boot exchange as a semantic error', async () => { + jest.mocked(getCloudflareSession).mockReturnValue(null); + jest.mocked(getPendingCloudflareAuthCompletion).mockReturnValue(Promise.reject(new Error('invalid_grant'))); + + await expect(runCloudflareAuthProbe()).resolves.toEqual({status: 'error', detail: 'invalid_grant'}); + + expect(beginCloudflareAuthRedirect).not.toHaveBeenCalled(); + expect(mockFetchWithQAAuth).not.toHaveBeenCalled(); + }); + + it('with a fresh session: goes straight to the request, no auth flow', async () => { + jest.mocked(getCloudflareSession).mockReturnValue(SESSION); + + await expect(runCloudflareAuthProbe()).resolves.toEqual({status: 'success', detail: 'authenticatedVia: oauth-bearer'}); + + expect(beginCloudflareAuthRedirect).not.toHaveBeenCalled(); + expect(refreshCloudflareSession).not.toHaveBeenCalled(); + }); + + it('near expiry with a terminal refresh: reports reauthRequired with no redirect and no request', async () => { + jest.mocked(getCloudflareSession).mockReturnValue(SESSION); + jest.mocked(isSessionNearExpiry).mockReturnValue(true); + jest.mocked(refreshCloudflareSession).mockResolvedValue('reauth-required'); + + await expect(runCloudflareAuthProbe()).resolves.toEqual({status: 'reauthRequired'}); + + // A background failure must never navigate the tab away + expect(beginCloudflareAuthRedirect).not.toHaveBeenCalled(); + expect(mockFetchWithQAAuth).not.toHaveBeenCalled(); + }); + + it('near expiry with a transient refresh failure: reports a plain error, keeps advice honest', async () => { + jest.mocked(getCloudflareSession).mockReturnValue(SESSION); + jest.mocked(isSessionNearExpiry).mockReturnValue(true); + jest.mocked(refreshCloudflareSession).mockRejectedValue(new TypeError('Failed to fetch')); + + await expect(runCloudflareAuthProbe()).resolves.toEqual({status: 'error', detail: 'Failed to fetch'}); + + expect(mockFetchWithQAAuth).not.toHaveBeenCalled(); + }); + + it('maps the request-level re-auth rejection to reauthRequired', async () => { + jest.mocked(getCloudflareSession).mockReturnValue(SESSION); + mockFetchWithQAAuth.mockRejectedValue(new Error(CF_REAUTH_REQUIRED)); + + await expect(runCloudflareAuthProbe()).resolves.toEqual({status: 'reauthRequired'}); + }); + + it('maps a redirect that could not start to a semantic error result', async () => { + jest.mocked(getCloudflareSession).mockReturnValue(null); + jest.mocked(beginCloudflareAuthRedirect).mockRejectedValue(new Error('Session storage is unavailable')); + + await expect(runCloudflareAuthProbe()).resolves.toEqual({status: 'error', detail: 'Session storage is unavailable'}); + }); + + it('reports success with a null echo when the Worker response carries no authenticatedVia', async () => { + jest.mocked(getCloudflareSession).mockReturnValue(SESSION); + mockFetchWithQAAuth.mockResolvedValue(jsonResponse({jsonCode: 200})); + + await expect(runCloudflareAuthProbe()).resolves.toEqual({status: 'success', detail: 'authenticatedVia: null'}); + }); +}); diff --git a/tests/unit/CloudflareSessionTest.ts b/tests/unit/CloudflareSessionTest.ts new file mode 100644 index 000000000000..dd61eca8abe8 --- /dev/null +++ b/tests/unit/CloudflareSessionTest.ts @@ -0,0 +1,345 @@ +/** + * Single-flight refresh with rotated-token persistence, the terminal/transient failure split, and both + * halves of the redirect flow. Modules are re-required per test because the module-level caches are + * exactly what's under test. + */ +import type * as ConfigModule from '@libs/CloudflareAccess/Config'; +import type * as PKCEModule from '@libs/CloudflareAccess/generatePKCE'; +import type WebCryptoProvider from '@libs/CloudflareAccess/getWebCrypto/types'; +import type * as OAuthClientModule from '@libs/CloudflareAccess/OAuthClient'; +import type * as PendingAuthFlowStorageModule from '@libs/CloudflareAccess/PendingAuthFlowStorage'; + +import type * as SessionActionsModule from '@userActions/CloudflareSession'; + +import type * as OnyxKeysModule from '@src/ONYXKEYS'; +import type CloudflareSession from '@src/types/onyx/CloudflareSession'; + +// Default type import only: a namespace import would pull in the restricted `useOnyx` name +import type OnyxDefault from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +type PKCEPair = PKCEModule.PKCEPair; + +const AUTHORIZE_URL = 'https://team.cloudflareaccess.com/cdn-cgi/access/oauth/authorization?mock=1'; + +// The module gates its subscription and cleanup on a complete config; everything under test is behind it +jest.mock('@libs/CloudflareAccess/Config', () => ({ + __esModule: true, + ...jest.requireActual('@libs/CloudflareAccess/Config'), + isQAAuthConfigured: jest.fn(() => true), +})); + +jest.mock('@libs/CloudflareAccess/OAuthClient', () => ({ + __esModule: true, + // Keep the real OAuthError class — the terminal/transient split hangs on instanceof + ...jest.requireActual('@libs/CloudflareAccess/OAuthClient'), + buildAuthorizeURL: jest.fn(() => AUTHORIZE_URL), + exchangeCode: jest.fn(), + refreshTokens: jest.fn(), +})); + +jest.mock('@libs/CloudflareAccess/generatePKCE', () => ({ + __esModule: true, + generatePKCEPair: jest.fn(), + generateState: jest.fn(() => 'test-state'), +})); + +const SESSION_A: CloudflareSession = {accessToken: 'oauth:access-a', refreshToken: 'oauth:refresh-a', expiresAt: 1900000000000}; +const SESSION_B: CloudflareSession = {accessToken: 'oauth:access-b', refreshToken: 'oauth:refresh-b', expiresAt: 1900000900000}; + +const PAIR_1: PKCEPair = {codeVerifier: 'verifier-1', codeChallenge: 'challenge-1'}; + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return {promise, resolve, reject}; +} + +let Onyx: typeof OnyxDefault; +let ONYXKEYS: typeof OnyxKeysModule.default; +let SessionActions: typeof SessionActionsModule; +let oAuthClient: typeof OAuthClientModule; +let pkce: typeof PKCEModule; +let pendingAuthFlowStorage: typeof PendingAuthFlowStorageModule; +let assignSpy: jest.Mock; +let realLocation: Location; + +beforeEach(() => { + jest.resetModules(); + // The redirect flow record lives in jsdom's real sessionStorage — drop leftovers from earlier tests + window.sessionStorage.clear(); + // jsdom throws "Not implemented: navigation" on a real location.assign + realLocation = window.location; + assignSpy = jest.fn(); + Object.defineProperty(window, 'location', { + value: {origin: 'http://localhost', href: 'http://localhost/settings/troubleshoot', pathname: '/settings/troubleshoot', assign: assignSpy}, + writable: true, + configurable: true, + }); + Onyx = require<{default: typeof OnyxDefault}>('react-native-onyx').default; + ONYXKEYS = require('@src/ONYXKEYS').default; + Onyx.init({keys: ONYXKEYS}); + oAuthClient = require('@libs/CloudflareAccess/OAuthClient'); + pkce = require('@libs/CloudflareAccess/generatePKCE'); + pendingAuthFlowStorage = require('@libs/CloudflareAccess/PendingAuthFlowStorage'); + SessionActions = require('@userActions/CloudflareSession'); +}); + +afterEach(() => { + Object.defineProperty(window, 'location', {value: realLocation, writable: true, configurable: true}); +}); + +async function seedSession(session: CloudflareSession | null) { + await Onyx.set(ONYXKEYS.CF_SESSION, session); + await waitForBatchedUpdates(); +} + +describe('refreshCloudflareSession', () => { + it('is single-flight: concurrent callers share one refreshTokens call', async () => { + await seedSession(SESSION_A); + const refreshDeferred = createDeferred(); + jest.mocked(oAuthClient.refreshTokens).mockReturnValue(refreshDeferred.promise); + + const first = SessionActions.refreshCloudflareSession(); + const second = SessionActions.refreshCloudflareSession(); + expect(second).toBe(first); + + refreshDeferred.resolve(SESSION_B); + await expect(first).resolves.toBe('refreshed'); + await expect(second).resolves.toBe('refreshed'); + expect(oAuthClient.refreshTokens).toHaveBeenCalledTimes(1); + expect(SessionActions.getCloudflareSession()).toEqual(SESSION_B); + }); + + it('joins the in-flight refresh before the staleness shortcut, so late callers cannot race ahead of persistence', async () => { + await seedSession(SESSION_A); + jest.mocked(oAuthClient.refreshTokens).mockResolvedValue(SESSION_B); + const persistDeferred = createDeferred(); + const setSpy = jest.spyOn(Onyx, 'set').mockReturnValue(persistDeferred.promise); + + const inFlight = SessionActions.refreshCloudflareSession(); + await waitForBatchedUpdates(); // rotation resolved, cache updated, Onyx.set still pending + + // The cache already holds SESSION_B, so the staleness shortcut WOULD match — but the join must win + const lateCaller = SessionActions.refreshCloudflareSession(SESSION_A.accessToken); + expect(lateCaller).toBe(inFlight); + + let isSettled = false; + inFlight.then(() => { + isSettled = true; + return undefined; + }); + await waitForBatchedUpdates(); + expect(isSettled).toBe(false); // not before the rotated pair is persisted + + persistDeferred.resolve(); + await expect(inFlight).resolves.toBe('refreshed'); + expect(oAuthClient.refreshTokens).toHaveBeenCalledTimes(1); + setSpy.mockRestore(); + }); + + it('skips with no network call when the token was already rotated and no refresh is in flight', async () => { + await seedSession(SESSION_B); + await expect(SessionActions.refreshCloudflareSession(SESSION_A.accessToken)).resolves.toBe('skipped-newer-token'); + expect(oAuthClient.refreshTokens).not.toHaveBeenCalled(); + }); + + it.each(['invalid_grant', 'invalid_response'])('clears the session and resolves reauth-required on the terminal %s', async (code) => { + await seedSession(SESSION_A); + jest.mocked(oAuthClient.refreshTokens).mockRejectedValue(new oAuthClient.OAuthError(code)); + + await expect(SessionActions.refreshCloudflareSession()).resolves.toBe('reauth-required'); + expect(SessionActions.getCloudflareSession()).toBeNull(); + }); + + it('rethrows transient failures and keeps the session', async () => { + await seedSession(SESSION_A); + const transientError = new TypeError('Failed to fetch'); + jest.mocked(oAuthClient.refreshTokens).mockRejectedValue(transientError); + + await expect(SessionActions.refreshCloudflareSession()).rejects.toBe(transientError); + expect(SessionActions.getCloudflareSession()).toEqual(SESSION_A); + }); + + it('resolves reauth-required without a network call when there is no session', async () => { + await seedSession(null); + await expect(SessionActions.refreshCloudflareSession()).resolves.toBe('reauth-required'); + expect(oAuthClient.refreshTokens).not.toHaveBeenCalled(); + }); +}); + +describe('markCloudflareSessionRejected', () => { + it('drops the session when the rejected token matches', async () => { + await seedSession(SESSION_A); + await SessionActions.markCloudflareSessionRejected(SESSION_A.accessToken); + expect(SessionActions.getCloudflareSession()).toBeNull(); + }); + + it('leaves a newer session untouched', async () => { + await seedSession(SESSION_B); + await SessionActions.markCloudflareSessionRejected(SESSION_A.accessToken); + expect(SessionActions.getCloudflareSession()).toEqual(SESSION_B); + }); +}); + +describe('beginCloudflareAuthRedirect', () => { + it('stores the flow record before navigating — module memory does not survive the unload', async () => { + jest.mocked(pkce.generatePKCEPair).mockResolvedValue(PAIR_1); + const savedBeforeAssign: Array = []; + assignSpy.mockImplementation(() => { + savedBeforeAssign.push(window.sessionStorage.getItem('QA_AUTH_REDIRECT_FLOW')); + }); + + SessionActions.beginCloudflareAuthRedirect('http://localhost/settings/troubleshoot'); + await waitForBatchedUpdates(); + + expect(assignSpy).toHaveBeenCalledWith(AUTHORIZE_URL); + // The record must already be readable at the moment the navigation is requested + expect(savedBeforeAssign.at(0)).not.toBeNull(); + expect(pendingAuthFlowStorage.consumePendingAuthFlow()).toMatchObject({ + state: 'test-state', + codeVerifier: PAIR_1.codeVerifier, + returnURL: 'http://localhost/settings/troubleshoot', + }); + expect(jest.mocked(oAuthClient.buildAuthorizeURL)).toHaveBeenCalledWith({state: 'test-state', codeChallenge: PAIR_1.codeChallenge}); + }); + + it('never settles once the navigation is requested, so callers run nothing after it', async () => { + jest.mocked(pkce.generatePKCEPair).mockResolvedValue(PAIR_1); + + let isSettled = false; + SessionActions.beginCloudflareAuthRedirect().then( + () => { + isSettled = true; + }, + () => { + isSettled = true; + }, + ); + await waitForBatchedUpdates(); + + expect(assignSpy).toHaveBeenCalledTimes(1); + expect(isSettled).toBe(false); + }); + + it('refuses to navigate when the flow record cannot be stored', async () => { + jest.mocked(pkce.generatePKCEPair).mockResolvedValue(PAIR_1); + // jsdom's Storage methods are not spy-able, so the whole object is swapped out + const realSessionStorage = window.sessionStorage; + Object.defineProperty(window, 'sessionStorage', { + value: { + getItem: () => null, + removeItem: () => {}, + setItem: () => { + throw new Error('QuotaExceededError'); + }, + }, + writable: true, + configurable: true, + }); + + // Navigating away without a stored verifier would strand the flow with no way to exchange + await expect(SessionActions.beginCloudflareAuthRedirect()).rejects.toThrow('QuotaExceededError'); + expect(assignSpy).not.toHaveBeenCalled(); + + Object.defineProperty(window, 'sessionStorage', {value: realSessionStorage, writable: true, configurable: true}); + }); + + it('a second press while the first navigation settles does not overwrite the stored flow', async () => { + jest.mocked(pkce.generatePKCEPair).mockResolvedValue(PAIR_1); + + SessionActions.beginCloudflareAuthRedirect(); + SessionActions.beginCloudflareAuthRedirect(); + await waitForBatchedUpdates(); + + expect(assignSpy).toHaveBeenCalledTimes(1); + expect(pkce.generatePKCEPair).toHaveBeenCalledTimes(1); + }); +}); + +describe('completeCloudflareAuthRedirect', () => { + it('caches the session before persistence but resolves only after Onyx.set completed', async () => { + jest.mocked(oAuthClient.exchangeCode).mockResolvedValue(SESSION_A); + const persistDeferred = createDeferred(); + const setSpy = jest.spyOn(Onyx, 'set').mockReturnValue(persistDeferred.promise); + + const completion = SessionActions.completeCloudflareAuthRedirect({code: 'auth-code-1', codeVerifier: PAIR_1.codeVerifier}); + let isSettled = false; + completion.then(() => { + isSettled = true; + return undefined; + }); + await waitForBatchedUpdates(); + + expect(oAuthClient.exchangeCode).toHaveBeenCalledWith({code: 'auth-code-1', codeVerifier: PAIR_1.codeVerifier}); + expect(SessionActions.getCloudflareSession()).toEqual(SESSION_A); // cache first, requests during this boot must see it + expect(isSettled).toBe(false); // but it waits for the disk write + + persistDeferred.resolve(); + await completion; + expect(setSpy).toHaveBeenCalledWith(ONYXKEYS.CF_SESSION, SESSION_A); + setSpy.mockRestore(); + }); + + it('is single-flight: a joiner shares the exchange instead of burning the single-use code twice', async () => { + const exchangeDeferred = createDeferred(); + jest.mocked(oAuthClient.exchangeCode).mockReturnValue(exchangeDeferred.promise); + + const first = SessionActions.completeCloudflareAuthRedirect({code: 'auth-code-1', codeVerifier: PAIR_1.codeVerifier}); + expect(SessionActions.getPendingCloudflareAuthCompletion()).toBe(first); + expect(SessionActions.completeCloudflareAuthRedirect({code: 'auth-code-1', codeVerifier: PAIR_1.codeVerifier})).toBe(first); + expect(oAuthClient.exchangeCode).toHaveBeenCalledTimes(1); + + exchangeDeferred.resolve(SESSION_A); + await first; + expect(SessionActions.getPendingCloudflareAuthCompletion()).toBeNull(); + }); + + it('exposes no pending completion before an exchange starts', () => { + expect(SessionActions.getPendingCloudflareAuthCompletion()).toBeNull(); + }); + + it('propagates an exchange failure and leaves the session empty', async () => { + // Onyx storage outlives jest.resetModules, so an earlier test's persisted session would hydrate here + await seedSession(null); + jest.mocked(oAuthClient.exchangeCode).mockRejectedValue(new oAuthClient.OAuthError('invalid_grant')); + + await expect(SessionActions.completeCloudflareAuthRedirect({code: 'bad-code', codeVerifier: PAIR_1.codeVerifier})).rejects.toMatchObject({code: 'invalid_grant'}); + expect(SessionActions.getCloudflareSession()).toBeNull(); + expect(SessionActions.getPendingCloudflareAuthCompletion()).toBeNull(); + }); +}); + +describe('unconfigured builds', () => { + it('subscribes to nothing and still resolves hydration, so no caller can hang', async () => { + jest.resetModules(); + const config = require('@libs/CloudflareAccess/Config'); + jest.mocked(config.isQAAuthConfigured).mockReturnValue(false); + const onyx = require<{default: typeof OnyxDefault}>('react-native-onyx').default; + const connectSpy = jest.spyOn(onyx, 'connectWithoutView'); + + const sessionActions = require('@userActions/CloudflareSession'); + + // Importing the module pulls in unrelated modules that legitimately subscribe to their own keys, + // so the claim is specifically that nothing connected to the QA session key + const connectedKeys = connectSpy.mock.calls.map(([connection]) => connection.key); + expect(connectedKeys).not.toContain(ONYXKEYS.CF_SESSION); + expect(sessionActions.getCloudflareSession()).toBeNull(); + await expect(sessionActions.waitForCloudflareSessionHydration()).resolves.toBeUndefined(); + connectSpy.mockRestore(); + }); +}); + +describe('native platform safety', () => { + it('the real getWebCrypto resolves to the native stub here: import-safe, loud when called', () => { + // jest-expo's haste config resolves index.native.ts — the same file native builds get. + // requireActual evaluating without throwing IS the import-safety claim. + const actualProvider = jest.requireActual<{default: WebCryptoProvider}>('@libs/CloudflareAccess/getWebCrypto').default; + expect(() => actualProvider.getRandomValues(new Uint8Array(1))).toThrow('not implemented on native'); + }); +}); diff --git a/tests/unit/ExportOnyxStateTest.ts b/tests/unit/ExportOnyxStateTest.ts index 0b7e1cb95a5d..7dd1d86213bf 100644 --- a/tests/unit/ExportOnyxStateTest.ts +++ b/tests/unit/ExportOnyxStateTest.ts @@ -351,6 +351,20 @@ describe('Onyx key export coverage', () => { } }); + it('removes the Cloudflare QA session from the export entirely', () => { + // The classification lists only prove the key is bucketed; this pins the actual behavior — + // both OAuth tokens must vanish from the exported state, not just get masked. + const input = { + [ONYXKEYS.CF_SESSION]: {accessToken: 'oauth:access-token', refreshToken: 'oauth:refresh-token', expiresAt: 1753600000000}, + [ONYXKEYS.IS_DEBUG_MODE_ENABLED]: true, + }; + + const result = maskOnyxState(input, true); + + expect(result[ONYXKEYS.CF_SESSION]).toBeUndefined(); + expect(Object.keys(result)).not.toContain(ONYXKEYS.CF_SESSION); + }); + it('known-sensitive keys must never be classified as safe', () => { // Anything in safeOnyxKeys is exported with no masking at all. Every key below carries // credentials, tokens, banking data or personal details, so none of them may ever end up @@ -379,6 +393,7 @@ describe('Onyx key export coverage', () => { ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN, ONYXKEYS.ONFIDO_TOKEN, ONYXKEYS.ONFIDO_APPLICANT_ID, + ONYXKEYS.CF_SESSION, ONYXKEYS.COLLECTION.BANK_ACCOUNT_SHARE_DETAILS, ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST, ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING,