Skip to content
Merged
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
33 changes: 33 additions & 0 deletions CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,39 @@ try {
}
```

### Security: Masking 401/403 with 404

**Security Best Practice:** To prevent information disclosure, the backend typically returns `404 Not Found` instead of `401 Unauthorized` or `403 Forbidden` when a user lacks access to a resource.

**Why?**
- `401`/`403` reveals that the resource exists but you can't access it
- Attackers can enumerate which resources exist by observing status codes
- `404` is ambiguous - resource may not exist OR you lack permission

**Implementation:**

```typescript
// ✅ Backend returns 404 for unauthorized access
GET /api/accounts/secret-account-id
→ 404 Not Found (even if account exists but user lacks access)

// ✅ Frontend treats both as "not found" for UX
if (error.status === 404) {
showMessage("Account not found");
// User cannot distinguish between "doesn't exist" and "no permission"
}

// ❌ Don't expose different messages for 401/403
if (error.status === 403) {
showMessage("Access denied"); // Leaks that resource exists!
}
```

**Exceptions:**
- Authentication endpoints (login/logout) can use `401` appropriately
- Admin dashboards where security is less critical
- When explicitly documented as acceptable

---

## TestID Patterns
Expand Down
72 changes: 72 additions & 0 deletions app/src/__tests__/utils/retryAuth0.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { retryAuth0Operation, ErrorWithStatus } from '@/utils/retryAuth0';

jest.mock('@/services/appInsightsService', () => ({
appInsightsService: {
trackEvent: jest.fn(),
},
}));

describe('retryAuth0Operation', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should return result on first attempt if successful', async () => {
const mockFn = jest.fn().mockResolvedValue('success');

const result = await retryAuth0Operation(mockFn, 'testOperation');

expect(result).toBe('success');
expect(mockFn).toHaveBeenCalledTimes(1);
});

it('should retry on retryable errors and succeed eventually', async () => {
const serviceError: ErrorWithStatus = new Error('Service Unavailable');
serviceError.status = 503;

const mockFn = jest
.fn()
.mockRejectedValueOnce(serviceError)
.mockRejectedValueOnce(serviceError)
.mockResolvedValue('success');

const result = await retryAuth0Operation(mockFn, 'testOperation', {
maxRetries: 3,
initialDelayMs: 10,
});

expect(result).toBe('success');
expect(mockFn).toHaveBeenCalledTimes(3);
});

it('should not retry on non-retryable errors', async () => {
const authError: ErrorWithStatus = new Error('invalid_grant');
authError.name = 'invalid_grant';

const mockFn = jest.fn().mockRejectedValue(authError);

await expect(
retryAuth0Operation(mockFn, 'testOperation', {
maxRetries: 3,
initialDelayMs: 10,
}),
).rejects.toThrow('invalid_grant');

expect(mockFn).toHaveBeenCalledTimes(1);
});

it('should throw after max retries exceeded', async () => {
const networkError: ErrorWithStatus = new Error('network');

const mockFn = jest.fn().mockRejectedValue(networkError);

await expect(
retryAuth0Operation(mockFn, 'testOperation', {
maxRetries: 2,
initialDelayMs: 10,
}),
).rejects.toThrow('network');

expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
});
});
2 changes: 2 additions & 0 deletions app/src/constants/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export const MAX_RECENT_ACCOUNTS = 10;
export const FLATLIST_END_REACHED_THRESHOLD = 0.6;

export const AUTH0_REQUEST_TIMEOUT_MS = 20000;
export const AUTH0_REFRESH_TOKEN_MAX_RETRIES = 5;
export const AUTH0_REFRESH_TOKEN_INITIAL_DELAY_MS = 1000;
18 changes: 16 additions & 2 deletions app/src/services/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { jwtDecode } from 'jwt-decode';
import Auth0 from 'react-native-auth0';

import { configService } from '@/config/env.config';
import { AUTH0_REQUEST_TIMEOUT_MS } from '@/constants/api';
import {
AUTH0_REQUEST_TIMEOUT_MS,
AUTH0_REFRESH_TOKEN_MAX_RETRIES,
AUTH0_REFRESH_TOKEN_INITIAL_DELAY_MS,
} from '@/constants/api';
import { appInsightsService } from '@/services/appInsightsService';
import { retryAuth0Operation } from '@/utils/retryAuth0';

export interface AuthTokens {
accessToken: string;
Expand Down Expand Up @@ -138,7 +143,16 @@ class AuthenticationService {

async refreshAccessToken(refreshToken: string): Promise<AuthTokens> {
try {
const result = await this.auth0.auth.refreshToken({ refreshToken });
const result = await retryAuth0Operation(
async () => {
return await this.auth0.auth.refreshToken({ refreshToken });
},
'refreshAccessToken',
{
maxRetries: AUTH0_REFRESH_TOKEN_MAX_RETRIES,
initialDelayMs: AUTH0_REFRESH_TOKEN_INITIAL_DELAY_MS,
},
);
const expiresAt = this.getExpiryFromJWT(result.accessToken);

return {
Expand Down
188 changes: 188 additions & 0 deletions app/src/utils/retryAuth0.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { appInsightsService } from '@/services/appInsightsService';

const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_INITIAL_DELAY_MS = 1000;
const DEFAULT_MAX_DELAY_MS = 10000;
const DEFAULT_BACKOFF_MULTIPLIER = 2;
const DEFAULT_RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504];

const FIRST_ATTEMPT = 1;

export interface RetryConfig {
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffMultiplier?: number;
retryableStatusCodes?: number[];
}

export interface ErrorWithStatus extends Error {
status?: number;
statusCode?: number;
code?: string;
}

const DEFAULT_RETRY_CONFIG: Required<RetryConfig> = {
maxRetries: DEFAULT_MAX_RETRIES,
initialDelayMs: DEFAULT_INITIAL_DELAY_MS,
maxDelayMs: DEFAULT_MAX_DELAY_MS,
backoffMultiplier: DEFAULT_BACKOFF_MULTIPLIER,
retryableStatusCodes: DEFAULT_RETRYABLE_STATUS_CODES,
};

function isRetryableError(error: unknown, retryableStatusCodes: number[]): boolean {
if (!error) return false;

const err = error as ErrorWithStatus;

const statusCode = err.status || err.statusCode;
if (statusCode && retryableStatusCodes.includes(statusCode)) {
return true;
}

const errorText = [err.message, err.name, err.code].join(' ').toLowerCase();
const patterns = ['network', 'timeout', 'connection', 'service', 'not found'];

return patterns.some((pattern) => errorText.includes(pattern));
}

function calculateDelay(
attemptNumber: number,
initialDelayMs: number,
maxDelayMs: number,
backoffMultiplier: number,
): number {
const exponentialDelay =
initialDelayMs * Math.pow(backoffMultiplier, attemptNumber - FIRST_ATTEMPT);
return Math.min(exponentialDelay, maxDelayMs);
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function shouldRetry(
attempt: number,
maxAttempts: number,
error: unknown,
retryableStatusCodes: number[],
): boolean {
const hasRetriesLeft = attempt < maxAttempts;
const isRetryable = isRetryableError(error, retryableStatusCodes);
return hasRetriesLeft && isRetryable;
}

function logRetryAttempt(operationName: string, attempt: number, maxAttempts: number): void {
if (attempt > FIRST_ATTEMPT) {
console.info(`[Retry] Attempting ${operationName} (attempt ${attempt}/${maxAttempts})`);
}
}

function logRetrySuccess(operationName: string, attempt: number): void {
if (attempt > FIRST_ATTEMPT) {
console.info(`[Retry] ${operationName} succeeded on attempt ${attempt}`);
appInsightsService.trackEvent({
name: 'Auth0RetrySuccess',
properties: {
operation: operationName,
attempt: attempt.toString(),
totalRetries: (attempt - FIRST_ATTEMPT).toString(),
},
});
}
}

function logAndTrackFailure(
operationName: string,
attempt: number,
error: unknown,
isRetryable: boolean,
): void {
if (!isRetryable) {
console.error(`[Retry] ${operationName} failed with non-retryable error`, error);
} else {
console.error(`[Retry] ${operationName} failed after ${attempt} attempts`, error);
appInsightsService.trackEvent({
name: 'Auth0RetryFailure',
properties: {
operation: operationName,
totalAttempts: attempt.toString(),
errorMessage: error instanceof Error ? error.message : 'Unknown error',
},
});
}
}

async function executeRetryDelay(
operationName: string,
attempt: number,
initialDelayMs: number,
maxDelayMs: number,
backoffMultiplier: number,
error: unknown,
): Promise<void> {
const delayMs = calculateDelay(attempt, initialDelayMs, maxDelayMs, backoffMultiplier);
console.info(
`[Retry] ${operationName} failed on attempt ${attempt}, retrying in ${delayMs}ms...`,
);

appInsightsService.trackEvent({
name: 'Auth0RetryAttempt',
properties: {
operation: operationName,
attempt: attempt.toString(),
delayMs: delayMs.toString(),
errorMessage: error instanceof Error ? error.message : 'Unknown error',
},
});

await delay(delayMs);
}

export async function retryAuth0Operation<T>(
fn: () => Promise<T>,
operationName: string,
config?: RetryConfig,
): Promise<T> {
const { maxRetries, initialDelayMs, maxDelayMs, backoffMultiplier, retryableStatusCodes } = {
...DEFAULT_RETRY_CONFIG,
...config,
};

let attempt = FIRST_ATTEMPT;
const maxAttempts = maxRetries + FIRST_ATTEMPT;

while (attempt <= maxAttempts) {
try {
logRetryAttempt(operationName, attempt, maxAttempts);

const result = await fn();

logRetrySuccess(operationName, attempt);

return result;
} catch (error) {
const canRetry = shouldRetry(attempt, maxAttempts, error, retryableStatusCodes);

if (!canRetry) {
const isRetryable = isRetryableError(error, retryableStatusCodes);
logAndTrackFailure(operationName, attempt, error, isRetryable);
throw error;
}

await executeRetryDelay(
operationName,
attempt,
initialDelayMs,
maxDelayMs,
backoffMultiplier,
error,
);

attempt++;
}
}

// This should never be reached due to the logic above, but TypeScript needs it
throw new Error(`[Retry] ${operationName} exhausted all attempts without throwing`);
}