Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/CONFIG.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/components/QAAuthTestToolRows/index.native.tsx
Original file line number Diff line number Diff line change
@@ -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;
110 changes: 110 additions & 0 deletions src/components/QAAuthTestToolRows/index.tsx
Original file line number Diff line number Diff line change
@@ -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<CloudflareAuthProbeStatus, string>;

/** 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<CloudflareAuthProbeResult | null>(getFailedRedirectResult);
// Consecutive probes produce identical results, so without a changing element the button reads as dead
const [probeCompletedAt, setProbeCompletedAt] = useState<Date | null>(null);

if (!isQAAuthConfigured()) {
return null;
}

return (
<>
<TestToolRow title={translate('initialSettingsPage.troubleshoot.qaAuth')}>
<Button
size={CONST.BUTTON_SIZE.SMALL}
isDisabled={isOperationRunning}
isLoading={isOperationRunning}
onPress={() => {
setIsOperationRunning(true);
// Never rejects — failures come back as semantic results
runCloudflareAuthProbe()
.then((result) => {
setProbeResult(result);
setProbeCompletedAt(new Date());
})
.finally(() => setIsOperationRunning(false));
}}
>
<Button.Text>{translate('initialSettingsPage.troubleshoot.qaAuthRunProbe')}</Button.Text>
</Button>
</TestToolRow>
<TestToolRow title={translate('initialSettingsPage.troubleshoot.qaAuthSession')}>
<Button
size={CONST.BUTTON_SIZE.SMALL}
isDisabled={isOperationRunning}
onPress={() => {
setIsOperationRunning(true);
clearCloudflareSession()
.then(() => {
setProbeResult(null);
setProbeCompletedAt(null);
})
.catch((error: unknown) => {
setProbeResult({status: 'error', detail: error instanceof Error ? error.message : undefined});
setProbeCompletedAt(new Date());
})
.finally(() => setIsOperationRunning(false));
}}
>
<Button.Text>{translate('initialSettingsPage.troubleshoot.qaAuthClearSession')}</Button.Text>
</Button>
</TestToolRow>
{!!probeResult && (
<Text style={styles.textLabelSupporting}>
{translate(`initialSettingsPage.troubleshoot.${PROBE_STATUS_TRANSLATION_KEYS[probeResult.status]}`)}
{probeResult.detail ? ` (${probeResult.detail})` : ''}
{probeCompletedAt ? ` — ${probeCompletedAt.toLocaleTimeString()}` : ''}
</Text>
)}
</>
);
}

QAAuthTestToolRows.displayName = 'QAAuthTestToolRows';

export default QAAuthTestToolRows;
4 changes: 4 additions & 0 deletions src/components/TestToolMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -131,6 +132,9 @@ function TestToolMenu() {
</TestToolRow>
)}

{/* QA server auth flow — web only, and only when it is configured. */}
<QAAuthTestToolRows />

{/* When toggled the app will be forced offline. */}
<TestToolRow
title={translate('initialSettingsPage.troubleshoot.forceOffline')}
Expand Down
7 changes: 7 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2275,6 +2275,13 @@ const translations = {
releaseOptions: 'Release options',
testingPreferences: 'Testing preferences',
useStagingServer: 'Use Staging Server',
qaAuth: 'QA auth (Cloudflare)',
qaAuthRunProbe: 'Run probe',
qaAuthSession: 'QA auth session',
qaAuthClearSession: 'Clear session',
qaAuthStatusSuccess: 'Probe succeeded',
qaAuthStatusReauthRequired: 'Session expired — run again to sign in',
qaAuthStatusError: 'Probe failed',
forceOffline: 'Force offline',
simulatePoorConnection: 'Simulate poor internet connection',
simulateFailingNetworkRequests: 'Simulate failing network requests',
Expand Down
63 changes: 63 additions & 0 deletions src/libs/CloudflareAccess/Config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Config and request classification for the Cloudflare Access-protected QA server. The security boundary:
* nothing else decides whether a URL may carry the QA bearer token.
*/
import CONFIG from '@src/CONFIG';

/** A bare hostname: no scheme, no slash, no port. Loose about labels (custom Access domains exist). */
const TEAM_DOMAIN_SHAPE = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}$/;

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

if (!API_ROOT || !TEAM_DOMAIN || !CLIENT_ID) {
return false;
}

if (!TEAM_DOMAIN_SHAPE.test(TEAM_DOMAIN)) {
return false;
}

try {
return new URL(API_ROOT).protocol === 'https:';
} catch {
return false;
}
}

/** Origin form of the QA API root. Doubles as the RFC 8707 `resource` — CF binds the token to this string. */
function getQAOrigin(): string {
return new URL(CONFIG.QA_AUTH.API_ROOT).origin;
}

/**
* Exact-origin match, never a substring, and never true on an incomplete config. More Cloudflare-protected
* QA hosts have to be added here deliberately.
*/
function isQAServerRequest(url: string): boolean {
if (!isQAAuthConfigured()) {
return false;
}

try {
return new URL(url).origin === getQAOrigin();
} catch {
return false;
}
}

function getAuthorizationEndpoint(): string {
return `https://${CONFIG.QA_AUTH.TEAM_DOMAIN}/cdn-cgi/access/oauth/authorization`;
}

function getTokenEndpoint(): string {
return `https://${CONFIG.QA_AUTH.TEAM_DOMAIN}/cdn-cgi/access/oauth/token`;
}

/** Must be registered as an allowed redirect URI on the Access application. Read lazily: no `window` on native. */
function getOAuthRedirectURI(): string {
return `${window.location.origin}/oauth/callback`;
}

export {getAuthorizationEndpoint, getOAuthRedirectURI, getQAOrigin, getTokenEndpoint, isQAAuthConfigured, isQAServerRequest};
101 changes: 101 additions & 0 deletions src/libs/CloudflareAccess/OAuthClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Strictly-validating client for Cloudflare Access's Managed OAuth endpoints. Protocol failures surface as
* OAuthError, so callers can tell terminal outcomes from transient transport errors.
*/
import {isRecord} from '@libs/ObjectUtils';

import CONFIG from '@src/CONFIG';
import type CloudflareSession from '@src/types/onyx/CloudflareSession';

import {getAuthorizationEndpoint, getOAuthRedirectURI, getQAOrigin, getTokenEndpoint} from './Config';

/** A protocol-reported error (or a malformed response); `code` is the OAuth code, e.g. `invalid_grant` */
class OAuthError extends Error {
constructor(
readonly code: string,
message?: string,
) {
super(message ?? code);
}
}

/** POSTs form-encoded params to the token endpoint and validates the response into a CloudflareSession */
async function postTokenEndpoint(body: URLSearchParams): Promise<CloudflareSession> {
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<CloudflareSession> {
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<CloudflareSession> {
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};
Loading
Loading