Skip to content

Commit e3c66a7

Browse files
authored
feat(auth0): add retry service for refresh token (#186)
1 parent b184a7a commit e3c66a7

File tree

5 files changed

+311
-2
lines changed

5 files changed

+311
-2
lines changed

CONVENTIONS.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,39 @@ try {
783783
}
784784
```
785785

786+
### Security: Masking 401/403 with 404
787+
788+
**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.
789+
790+
**Why?**
791+
- `401`/`403` reveals that the resource exists but you can't access it
792+
- Attackers can enumerate which resources exist by observing status codes
793+
- `404` is ambiguous - resource may not exist OR you lack permission
794+
795+
**Implementation:**
796+
797+
```typescript
798+
// ✅ Backend returns 404 for unauthorized access
799+
GET /api/accounts/secret-account-id
800+
404 Not Found (even if account exists but user lacks access)
801+
802+
// ✅ Frontend treats both as "not found" for UX
803+
if (error.status === 404) {
804+
showMessage("Account not found");
805+
// User cannot distinguish between "doesn't exist" and "no permission"
806+
}
807+
808+
// ❌ Don't expose different messages for 401/403
809+
if (error.status === 403) {
810+
showMessage("Access denied"); // Leaks that resource exists!
811+
}
812+
```
813+
814+
**Exceptions:**
815+
- Authentication endpoints (login/logout) can use `401` appropriately
816+
- Admin dashboards where security is less critical
817+
- When explicitly documented as acceptable
818+
786819
---
787820

788821
## TestID Patterns
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { retryAuth0Operation, ErrorWithStatus } from '@/utils/retryAuth0';
2+
3+
jest.mock('@/services/appInsightsService', () => ({
4+
appInsightsService: {
5+
trackEvent: jest.fn(),
6+
},
7+
}));
8+
9+
describe('retryAuth0Operation', () => {
10+
beforeEach(() => {
11+
jest.clearAllMocks();
12+
});
13+
14+
it('should return result on first attempt if successful', async () => {
15+
const mockFn = jest.fn().mockResolvedValue('success');
16+
17+
const result = await retryAuth0Operation(mockFn, 'testOperation');
18+
19+
expect(result).toBe('success');
20+
expect(mockFn).toHaveBeenCalledTimes(1);
21+
});
22+
23+
it('should retry on retryable errors and succeed eventually', async () => {
24+
const serviceError: ErrorWithStatus = new Error('Service Unavailable');
25+
serviceError.status = 503;
26+
27+
const mockFn = jest
28+
.fn()
29+
.mockRejectedValueOnce(serviceError)
30+
.mockRejectedValueOnce(serviceError)
31+
.mockResolvedValue('success');
32+
33+
const result = await retryAuth0Operation(mockFn, 'testOperation', {
34+
maxRetries: 3,
35+
initialDelayMs: 10,
36+
});
37+
38+
expect(result).toBe('success');
39+
expect(mockFn).toHaveBeenCalledTimes(3);
40+
});
41+
42+
it('should not retry on non-retryable errors', async () => {
43+
const authError: ErrorWithStatus = new Error('invalid_grant');
44+
authError.name = 'invalid_grant';
45+
46+
const mockFn = jest.fn().mockRejectedValue(authError);
47+
48+
await expect(
49+
retryAuth0Operation(mockFn, 'testOperation', {
50+
maxRetries: 3,
51+
initialDelayMs: 10,
52+
}),
53+
).rejects.toThrow('invalid_grant');
54+
55+
expect(mockFn).toHaveBeenCalledTimes(1);
56+
});
57+
58+
it('should throw after max retries exceeded', async () => {
59+
const networkError: ErrorWithStatus = new Error('network');
60+
61+
const mockFn = jest.fn().mockRejectedValue(networkError);
62+
63+
await expect(
64+
retryAuth0Operation(mockFn, 'testOperation', {
65+
maxRetries: 2,
66+
initialDelayMs: 10,
67+
}),
68+
).rejects.toThrow('network');
69+
70+
expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2 retries
71+
});
72+
});

app/src/constants/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ export const MAX_RECENT_ACCOUNTS = 10;
88
export const FLATLIST_END_REACHED_THRESHOLD = 0.6;
99

1010
export const AUTH0_REQUEST_TIMEOUT_MS = 20000;
11+
export const AUTH0_REFRESH_TOKEN_MAX_RETRIES = 5;
12+
export const AUTH0_REFRESH_TOKEN_INITIAL_DELAY_MS = 1000;

app/src/services/authService.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ import { jwtDecode } from 'jwt-decode';
22
import Auth0 from 'react-native-auth0';
33

44
import { configService } from '@/config/env.config';
5-
import { AUTH0_REQUEST_TIMEOUT_MS } from '@/constants/api';
5+
import {
6+
AUTH0_REQUEST_TIMEOUT_MS,
7+
AUTH0_REFRESH_TOKEN_MAX_RETRIES,
8+
AUTH0_REFRESH_TOKEN_INITIAL_DELAY_MS,
9+
} from '@/constants/api';
610
import { appInsightsService } from '@/services/appInsightsService';
11+
import { retryAuth0Operation } from '@/utils/retryAuth0';
712

813
export interface AuthTokens {
914
accessToken: string;
@@ -138,7 +143,16 @@ class AuthenticationService {
138143

139144
async refreshAccessToken(refreshToken: string): Promise<AuthTokens> {
140145
try {
141-
const result = await this.auth0.auth.refreshToken({ refreshToken });
146+
const result = await retryAuth0Operation(
147+
async () => {
148+
return await this.auth0.auth.refreshToken({ refreshToken });
149+
},
150+
'refreshAccessToken',
151+
{
152+
maxRetries: AUTH0_REFRESH_TOKEN_MAX_RETRIES,
153+
initialDelayMs: AUTH0_REFRESH_TOKEN_INITIAL_DELAY_MS,
154+
},
155+
);
142156
const expiresAt = this.getExpiryFromJWT(result.accessToken);
143157

144158
return {

app/src/utils/retryAuth0.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { appInsightsService } from '@/services/appInsightsService';
2+
3+
const DEFAULT_MAX_RETRIES = 3;
4+
const DEFAULT_INITIAL_DELAY_MS = 1000;
5+
const DEFAULT_MAX_DELAY_MS = 10000;
6+
const DEFAULT_BACKOFF_MULTIPLIER = 2;
7+
const DEFAULT_RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 503, 504];
8+
9+
const FIRST_ATTEMPT = 1;
10+
11+
export interface RetryConfig {
12+
maxRetries?: number;
13+
initialDelayMs?: number;
14+
maxDelayMs?: number;
15+
backoffMultiplier?: number;
16+
retryableStatusCodes?: number[];
17+
}
18+
19+
export interface ErrorWithStatus extends Error {
20+
status?: number;
21+
statusCode?: number;
22+
code?: string;
23+
}
24+
25+
const DEFAULT_RETRY_CONFIG: Required<RetryConfig> = {
26+
maxRetries: DEFAULT_MAX_RETRIES,
27+
initialDelayMs: DEFAULT_INITIAL_DELAY_MS,
28+
maxDelayMs: DEFAULT_MAX_DELAY_MS,
29+
backoffMultiplier: DEFAULT_BACKOFF_MULTIPLIER,
30+
retryableStatusCodes: DEFAULT_RETRYABLE_STATUS_CODES,
31+
};
32+
33+
function isRetryableError(error: unknown, retryableStatusCodes: number[]): boolean {
34+
if (!error) return false;
35+
36+
const err = error as ErrorWithStatus;
37+
38+
const statusCode = err.status || err.statusCode;
39+
if (statusCode && retryableStatusCodes.includes(statusCode)) {
40+
return true;
41+
}
42+
43+
const errorText = [err.message, err.name, err.code].join(' ').toLowerCase();
44+
const patterns = ['network', 'timeout', 'connection', 'service', 'not found'];
45+
46+
return patterns.some((pattern) => errorText.includes(pattern));
47+
}
48+
49+
function calculateDelay(
50+
attemptNumber: number,
51+
initialDelayMs: number,
52+
maxDelayMs: number,
53+
backoffMultiplier: number,
54+
): number {
55+
const exponentialDelay =
56+
initialDelayMs * Math.pow(backoffMultiplier, attemptNumber - FIRST_ATTEMPT);
57+
return Math.min(exponentialDelay, maxDelayMs);
58+
}
59+
60+
function delay(ms: number): Promise<void> {
61+
return new Promise((resolve) => setTimeout(resolve, ms));
62+
}
63+
64+
function shouldRetry(
65+
attempt: number,
66+
maxAttempts: number,
67+
error: unknown,
68+
retryableStatusCodes: number[],
69+
): boolean {
70+
const hasRetriesLeft = attempt < maxAttempts;
71+
const isRetryable = isRetryableError(error, retryableStatusCodes);
72+
return hasRetriesLeft && isRetryable;
73+
}
74+
75+
function logRetryAttempt(operationName: string, attempt: number, maxAttempts: number): void {
76+
if (attempt > FIRST_ATTEMPT) {
77+
console.info(`[Retry] Attempting ${operationName} (attempt ${attempt}/${maxAttempts})`);
78+
}
79+
}
80+
81+
function logRetrySuccess(operationName: string, attempt: number): void {
82+
if (attempt > FIRST_ATTEMPT) {
83+
console.info(`[Retry] ${operationName} succeeded on attempt ${attempt}`);
84+
appInsightsService.trackEvent({
85+
name: 'Auth0RetrySuccess',
86+
properties: {
87+
operation: operationName,
88+
attempt: attempt.toString(),
89+
totalRetries: (attempt - FIRST_ATTEMPT).toString(),
90+
},
91+
});
92+
}
93+
}
94+
95+
function logAndTrackFailure(
96+
operationName: string,
97+
attempt: number,
98+
error: unknown,
99+
isRetryable: boolean,
100+
): void {
101+
if (!isRetryable) {
102+
console.error(`[Retry] ${operationName} failed with non-retryable error`, error);
103+
} else {
104+
console.error(`[Retry] ${operationName} failed after ${attempt} attempts`, error);
105+
appInsightsService.trackEvent({
106+
name: 'Auth0RetryFailure',
107+
properties: {
108+
operation: operationName,
109+
totalAttempts: attempt.toString(),
110+
errorMessage: error instanceof Error ? error.message : 'Unknown error',
111+
},
112+
});
113+
}
114+
}
115+
116+
async function executeRetryDelay(
117+
operationName: string,
118+
attempt: number,
119+
initialDelayMs: number,
120+
maxDelayMs: number,
121+
backoffMultiplier: number,
122+
error: unknown,
123+
): Promise<void> {
124+
const delayMs = calculateDelay(attempt, initialDelayMs, maxDelayMs, backoffMultiplier);
125+
console.info(
126+
`[Retry] ${operationName} failed on attempt ${attempt}, retrying in ${delayMs}ms...`,
127+
);
128+
129+
appInsightsService.trackEvent({
130+
name: 'Auth0RetryAttempt',
131+
properties: {
132+
operation: operationName,
133+
attempt: attempt.toString(),
134+
delayMs: delayMs.toString(),
135+
errorMessage: error instanceof Error ? error.message : 'Unknown error',
136+
},
137+
});
138+
139+
await delay(delayMs);
140+
}
141+
142+
export async function retryAuth0Operation<T>(
143+
fn: () => Promise<T>,
144+
operationName: string,
145+
config?: RetryConfig,
146+
): Promise<T> {
147+
const { maxRetries, initialDelayMs, maxDelayMs, backoffMultiplier, retryableStatusCodes } = {
148+
...DEFAULT_RETRY_CONFIG,
149+
...config,
150+
};
151+
152+
let attempt = FIRST_ATTEMPT;
153+
const maxAttempts = maxRetries + FIRST_ATTEMPT;
154+
155+
while (attempt <= maxAttempts) {
156+
try {
157+
logRetryAttempt(operationName, attempt, maxAttempts);
158+
159+
const result = await fn();
160+
161+
logRetrySuccess(operationName, attempt);
162+
163+
return result;
164+
} catch (error) {
165+
const canRetry = shouldRetry(attempt, maxAttempts, error, retryableStatusCodes);
166+
167+
if (!canRetry) {
168+
const isRetryable = isRetryableError(error, retryableStatusCodes);
169+
logAndTrackFailure(operationName, attempt, error, isRetryable);
170+
throw error;
171+
}
172+
173+
await executeRetryDelay(
174+
operationName,
175+
attempt,
176+
initialDelayMs,
177+
maxDelayMs,
178+
backoffMultiplier,
179+
error,
180+
);
181+
182+
attempt++;
183+
}
184+
}
185+
186+
// This should never be reached due to the logic above, but TypeScript needs it
187+
throw new Error(`[Retry] ${operationName} exhausted all attempts without throwing`);
188+
}

0 commit comments

Comments
 (0)