A social knowledge tool for researchers built on ATProto
1# Token Refreshing Strategy 2 3This document outlines our approach to automatic token refreshing with clean separation of concerns, combining proactive and reactive strategies to ensure seamless user experience with minimal API failures. 4 5## Overview 6 7Our token refresh strategy uses a **layered architecture** with clear separation of responsibilities: 8 91. **TokenManager**: Centralized token logic with automatic refresh 102. **Storage Abstraction**: Pluggable storage implementations for client/server 113. **Token Refresher**: Dedicated service for refresh API calls 124. **Proactive Refresh**: Automatically refresh tokens before they expire (every 5 minutes) 135. **Clean BaseClient**: Only handles HTTP requests, delegates token management 14 15## Architecture 16 17### 1. Core Interfaces 18 19```typescript 20// src/webapp/services/TokenManager.ts 21export interface AuthTokens { 22 accessToken: string | null; 23 refreshToken: string | null; 24} 25 26export interface TokenStorage { 27 getTokens(): AuthTokens; 28 setTokens(accessToken: string, refreshToken: string): Promise<void>; 29 clearTokens(): void; 30} 31 32export interface TokenRefresher { 33 refreshTokens( 34 refreshToken: string, 35 ): Promise<{ accessToken: string; refreshToken: string }>; 36} 37``` 38 39### 2. TokenManager - Central Token Logic 40 41The `TokenManager` handles token access and reactive refresh on authentication errors: 42 43```typescript 44// src/webapp/services/TokenManager.ts 45export class TokenManager { 46 private isRefreshing = false; 47 private refreshPromise: Promise<boolean> | null = null; 48 private failedRequestsQueue: Array<{ 49 resolve: (value: any) => void; 50 reject: (error: any) => void; 51 request: () => Promise<any>; 52 }> = []; 53 54 constructor( 55 private storage: TokenStorage, 56 private refresher: TokenRefresher, 57 ) {} 58 59 async getAccessToken(): Promise<string | null> { 60 const { accessToken } = this.storage.getTokens(); 61 return accessToken; 62 } 63 64 async handleAuthError<T>(originalRequest: () => Promise<T>): Promise<T> { 65 // If already refreshing, queue this request 66 if (this.isRefreshing) { 67 return new Promise((resolve, reject) => { 68 this.failedRequestsQueue.push({ 69 resolve, 70 reject, 71 request: originalRequest, 72 }); 73 }); 74 } 75 76 // Start refresh process 77 this.isRefreshing = true; 78 79 try { 80 // Use existing refresh promise or create new one 81 if (!this.refreshPromise) { 82 this.refreshPromise = this.performRefresh(); 83 } 84 85 const refreshSuccess = await this.refreshPromise; 86 87 if (refreshSuccess) { 88 // Process queued requests 89 const queuedRequests = [...this.failedRequestsQueue]; 90 this.failedRequestsQueue = []; 91 92 // Retry all queued requests 93 queuedRequests.forEach(async ({ resolve, reject, request }) => { 94 try { 95 const result = await request(); 96 resolve(result); 97 } catch (error) { 98 reject(error); 99 } 100 }); 101 102 // Retry original request 103 return await originalRequest(); 104 } else { 105 // Refresh failed, reject all queued requests 106 const queuedRequests = [...this.failedRequestsQueue]; 107 this.failedRequestsQueue = []; 108 109 const refreshError = new Error('Token refresh failed'); 110 queuedRequests.forEach(({ reject }) => reject(refreshError)); 111 112 throw refreshError; 113 } 114 } finally { 115 this.isRefreshing = false; 116 this.refreshPromise = null; 117 } 118 } 119 120 private async performRefresh(): Promise<boolean> { 121 const { refreshToken } = this.storage.getTokens(); 122 if (!refreshToken) return false; 123 124 try { 125 const newTokens = await this.refresher.refreshTokens(refreshToken); 126 await this.storage.setTokens( 127 newTokens.accessToken, 128 newTokens.refreshToken, 129 ); 130 return true; 131 } catch (error) { 132 console.error('Token refresh failed:', error); 133 this.storage.clearTokens(); 134 return false; 135 } 136 } 137} 138``` 139 140### 3. Storage Implementations 141 142**Client-side storage** with localStorage and cookie sync: 143 144```typescript 145// src/webapp/services/storage/ClientTokenStorage.ts 146export class ClientTokenStorage implements TokenStorage { 147 getTokens(): AuthTokens { 148 if (typeof window === 'undefined') { 149 return { accessToken: null, refreshToken: null }; 150 } 151 152 return { 153 accessToken: localStorage.getItem('accessToken'), 154 refreshToken: localStorage.getItem('refreshToken'), 155 }; 156 } 157 158 async setTokens(accessToken: string, refreshToken: string): Promise<void> { 159 if (typeof window === 'undefined') return; 160 161 localStorage.setItem('accessToken', accessToken); 162 localStorage.setItem('refreshToken', refreshToken); 163 164 // Sync with server cookies 165 try { 166 await fetch('/api/auth/sync', { 167 method: 'POST', 168 headers: { 'Content-Type': 'application/json' }, 169 body: JSON.stringify({ accessToken, refreshToken }), 170 credentials: 'include', 171 }); 172 } catch (error) { 173 console.warn('Failed to sync tokens with server:', error); 174 } 175 } 176 177 clearTokens(): void { 178 if (typeof window === 'undefined') return; 179 localStorage.removeItem('accessToken'); 180 localStorage.removeItem('refreshToken'); 181 } 182} 183``` 184 185**Server-side storage** (read-only from cookies): 186 187```typescript 188// src/webapp/services/storage/ServerTokenStorage.ts 189export class ServerTokenStorage implements TokenStorage { 190 constructor(private cookiesStore: any) {} 191 192 getTokens(): AuthTokens { 193 return { 194 accessToken: this.cookiesStore.get('accessToken')?.value || null, 195 refreshToken: this.cookiesStore.get('refreshToken')?.value || null, 196 }; 197 } 198 199 async setTokens(): Promise<void> { 200 // Server-side can't set tokens - handled by client-side sync 201 throw new Error('Server-side token refresh not supported'); 202 } 203 204 clearTokens(): void { 205 // Server-side can't clear tokens directly 206 throw new Error('Server-side token clearing not supported'); 207 } 208} 209``` 210 211### 4. Token Refresher Service 212 213```typescript 214// src/webapp/services/TokenRefresher.ts 215export class ApiTokenRefresher implements TokenRefresher { 216 constructor(private baseUrl: string) {} 217 218 async refreshTokens( 219 refreshToken: string, 220 ): Promise<{ accessToken: string; refreshToken: string }> { 221 const response = await fetch(`${this.baseUrl}/api/auth/refresh`, { 222 method: 'POST', 223 headers: { 'Content-Type': 'application/json' }, 224 body: JSON.stringify({ refreshToken }), 225 }); 226 227 if (!response.ok) { 228 throw new Error('Token refresh failed'); 229 } 230 231 return response.json(); 232 } 233} 234``` 235 236### 5. Clean BaseClient with Reactive Refresh 237 238The `BaseClient` handles HTTP requests and reactive token refresh on auth errors: 239 240```typescript 241// src/webapp/api-client/clients/BaseClient.ts 242export abstract class BaseClient { 243 constructor( 244 protected baseUrl: string, 245 protected tokenManager: TokenManager, 246 ) {} 247 248 protected async request<T>( 249 method: string, 250 endpoint: string, 251 data?: any, 252 ): Promise<T> { 253 const makeRequest = async (): Promise<T> => { 254 const url = `${this.baseUrl}${endpoint}`; 255 const token = await this.tokenManager.getAccessToken(); 256 257 const headers: Record<string, string> = { 258 'Content-Type': 'application/json', 259 }; 260 261 if (token) { 262 headers['Authorization'] = `Bearer ${token}`; 263 } 264 265 const config: RequestInit = { 266 method, 267 headers, 268 }; 269 270 if ( 271 data && 272 (method === 'POST' || method === 'PUT' || method === 'PATCH') 273 ) { 274 config.body = JSON.stringify(data); 275 } 276 277 const response = await fetch(url, config); 278 return this.handleResponse<T>(response); 279 }; 280 281 try { 282 return await makeRequest(); 283 } catch (error) { 284 // Handle 401/403 errors with automatic token refresh 285 if ( 286 error instanceof ApiError && 287 (error.status === 401 || error.status === 403) 288 ) { 289 return this.tokenManager.handleAuthError(makeRequest); 290 } 291 throw error; 292 } 293 } 294 295 private async handleResponse<T>(response: Response): Promise<T> { 296 if (!response.ok) { 297 let errorData: ApiErrorResponse; 298 try { 299 errorData = await response.json(); 300 } catch { 301 errorData = { message: response.statusText || 'Unknown error' }; 302 } 303 304 throw new ApiError( 305 errorData.message, 306 response.status, 307 errorData.code, 308 errorData.details, 309 ); 310 } 311 312 return response.json(); 313 } 314} 315``` 316 317### 6. Factory Functions for Easy Setup 318 319```typescript 320// src/webapp/services/auth.ts 321import { TokenManager } from './TokenManager'; 322import { ClientTokenStorage } from './storage/ClientTokenStorage'; 323import { ServerTokenStorage } from './storage/ServerTokenStorage'; 324import { ApiTokenRefresher } from './TokenRefresher'; 325 326// Client-side token manager 327export const createClientTokenManager = () => { 328 const storage = new ClientTokenStorage(); 329 const refresher = new ApiTokenRefresher( 330 process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 331 ); 332 return new TokenManager(storage, refresher); 333}; 334 335// Server-side token manager (read-only, no refresh capability) 336export const createServerTokenManager = async () => { 337 const { cookies } = await import('next/headers'); 338 const cookiesStore = await cookies(); 339 const storage = new ServerTokenStorage(cookiesStore); 340 const refresher = new ApiTokenRefresher(''); // Won't be used 341 return new TokenManager(storage, refresher); 342}; 343``` 344 345### 7. Updated ApiClient 346 347```typescript 348// src/webapp/api-client/ApiClient.ts 349export class ApiClient { 350 constructor( 351 private baseUrl: string, 352 private tokenManager: TokenManager, 353 ) { 354 this.queryClient = new QueryClient(baseUrl, tokenManager); 355 this.cardClient = new CardClient(baseUrl, tokenManager); 356 this.collectionClient = new CollectionClient(baseUrl, tokenManager); 357 this.userClient = new UserClient(baseUrl, tokenManager); 358 this.feedClient = new FeedClient(baseUrl, tokenManager); 359 } 360 // ... rest stays the same 361} 362 363// Default client instances 364export const apiClient = new ApiClient( 365 process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 366 createClientTokenManager(), 367); 368 369export const createServerApiClient = async () => { 370 const tokenManager = await createServerTokenManager(); 371 return new ApiClient( 372 process.env.API_BASE_URL || 'http://localhost:3000', 373 tokenManager, 374 ); 375}; 376``` 377 378## Refresh Strategy 379 380### Proactive Token Refresh (Primary Strategy) 381 382The **primary strategy** is proactive refresh in the auth hook to prevent most token expiration scenarios: 383 384```typescript 385// src/webapp/hooks/useAuth.tsx 386export const AuthProvider = ({ children }: { children: ReactNode }) => { 387 // ... existing state 388 389 const tokenManager = useMemo(() => createClientTokenManager(), []); 390 391 // Helper function to check if a JWT token is expired or will expire soon 392 const isTokenExpired = ( 393 token: string, 394 bufferMinutes: number = 5, 395 ): boolean => { 396 if (!token) return true; 397 398 try { 399 const payload = JSON.parse(atob(token.split('.')[1])); 400 const expiry = payload.exp * 1000; // Convert to milliseconds 401 const bufferTime = bufferMinutes * 60 * 1000; // Buffer in milliseconds 402 return Date.now() >= expiry - bufferTime; 403 } catch (e) { 404 return true; 405 } 406 }; 407 408 // PROACTIVE TOKEN REFRESH - This is the primary strategy 409 useEffect(() => { 410 if (!accessToken || !refreshToken) return; 411 412 const checkAndRefreshToken = async () => { 413 // Check if token will expire in the next 5 minutes 414 if (isTokenExpired(accessToken, 5)) { 415 console.log('Proactively refreshing token before expiration'); 416 await refreshTokens(); 417 } 418 }; 419 420 // Check immediately on mount 421 checkAndRefreshToken(); 422 423 // Then check every 5 minutes 424 const interval = setInterval(checkAndRefreshToken, 5 * 60 * 1000); 425 426 return () => clearInterval(interval); 427 }, [accessToken, refreshToken, refreshTokens]); 428 429 // ... rest of the provider 430}; 431``` 432 433### Reactive Token Refresh (Safety Net) 434 435The **secondary strategy** is reactive refresh when API calls return authentication errors (401/403). This handles edge cases where proactive refresh didn't occur: 436 437- User device was sleeping during proactive refresh window 438- Browser tab was throttled in background 439- Network issues prevented proactive refresh 440- Server-side rendering scenarios 441 442The TokenManager automatically: 443 4441. **Detects auth errors** (401/403) from API responses 4452. **Refreshes tokens** using the refresh token 4463. **Retries the original request** with fresh tokens 4474. **Queues concurrent requests** to prevent race conditions 448 449## Usage Examples 450 451### Client Components (Simplified) 452 453```typescript 454// src/webapp/app/(authenticated)/cards/[cardId]/page.tsx 455'use client'; 456 457import { apiClient } from '@/api-client'; 458 459export default function CardPage() { 460 const [card, setCard] = useState<GetUrlCardViewResponse | null>(null); 461 462 useEffect(() => { 463 const fetchCard = async () => { 464 try { 465 setLoading(true); 466 // Token refresh is automatic! 467 const response = await apiClient.getUrlCardView(cardId); 468 setCard(response); 469 } catch (error: any) { 470 console.error('Error fetching card:', error); 471 setError(error.message || 'Failed to load card'); 472 } finally { 473 setLoading(false); 474 } 475 }; 476 477 if (cardId) { 478 fetchCard(); 479 } 480 }, [cardId]); 481 482 // ... rest of component 483} 484``` 485 486### Server Components 487 488```typescript 489// Server component usage 490import { createServerApiClient } from '@/services/auth'; 491 492export default async function SSRProfilePage() { 493 const apiClient = await createServerApiClient(); 494 495 let profile; 496 let error; 497 498 try { 499 profile = await apiClient.getMyProfile(); 500 } catch (err: any) { 501 error = err.message || 'Failed to load profile'; 502 } 503 504 // ... rest of component 505} 506``` 507 508## Benefits of This Architecture 509 510**Single Responsibility**: Each class has one clear purpose 511**Testable**: Easy to mock TokenStorage and TokenRefresher 512**Flexible**: Different storage strategies for client/server 513**Clean BaseClient**: Only handles HTTP requests and auth error detection 514**Consistent API**: Same interface everywhere 515**Future-proof**: Easy to add new storage backends 516**Race Condition Safe**: TokenManager prevents concurrent refreshes 517**Automatic Recovery**: Failed requests are automatically retried after refresh 518**Request Queuing**: Multiple concurrent requests handled gracefully 519**Server-Side Support**: Works in SSR with cookie-based tokens 520 521## Server-Side Token Refresh Limitation 522 523**Current Approach**: Server components use read-only token access from cookies. If tokens are expired, the request will fail gracefully. 524 525**Why This Works**: 526 527- Proactive refresh on client-side prevents most expiration scenarios 528- Server components typically render fresh on each request 529- Failed server requests degrade gracefully (show error states) 530- Client-side components can still refresh and retry 531 532**Future Enhancements** (if needed): 533 534- Add middleware to check token expiry and redirect to refresh endpoint 535- Implement server-side refresh with secure cookie updates 536- Use hybrid approach with client-side refresh signals 537 538## Token Timing Strategy 539 540- **Access Token Lifetime**: 15 minutes (server-configured) 541- **Proactive Refresh**: Every 5 minutes, refresh if expiring within 5 minutes 542- **Reactive Refresh**: Only on 401/403 API errors (no preemptive checking) 543- **Refresh Token Lifetime**: 7 days (server-configured) 544 545This means: 546 547- **99% of cases**: Tokens refreshed proactively at 10 minutes (before 15-minute expiry) 548- **1% of cases**: Reactive refresh handles edge cases when proactive refresh missed 549- **Zero failed requests**: All auth errors automatically trigger refresh and retry 550- Users can be inactive for up to 7 days and still auto-login 551 552## Implementation Checklist 553 554- [ ] Create `TokenManager` class with refresh logic 555- [ ] Implement `ClientTokenStorage` and `ServerTokenStorage` 556- [ ] Create `ApiTokenRefresher` service 557- [ ] Update `BaseClient` to use `TokenManager` 558- [ ] Update `ApiClient` constructor 559- [ ] Create factory functions in auth service 560- [ ] Enhance `useAuth` hook with proactive refresh 561- [ ] Create default `apiClient` export 562- [ ] Update components to use default `apiClient` 563- [ ] Test token refresh scenarios 564- [ ] Monitor refresh frequency in production 565 566## How It Works 567 568### Typical Flow (99% of cases) 569 5701. **User loads app** → Auth hook starts proactive refresh timer 5712. **Every 5 minutes** → Check if token expires in next 5 minutes 5723. **At 10 minutes** → Proactively refresh token (5 minutes before 15-minute expiry) 5734. **API calls** → Use fresh token, no refresh needed 5745. **Seamless experience** → User never sees authentication errors 575 576### Edge Case Flow (1% of cases) 577 5781. **API call made** → Token happens to be expired (proactive refresh missed) 5792. **Server returns 401/403** → BaseClient catches the error 5803. **TokenManager.handleAuthError()** → Automatically refresh tokens 5814. **Retry original request** → With fresh token, request succeeds 5825. **Queue concurrent requests** → Other API calls wait for refresh, then retry 5836. **User sees success** → Brief delay, but no visible error 584 585### Race Condition Handling 586 587```typescript 588// Multiple API calls happen when token is expired 589apiClient.getProfile(); // Triggers refresh 590apiClient.getCards(); // Queued, waits for refresh 591apiClient.getCollections(); // Queued, waits for refresh 592 593// After refresh completes: 594// - All three requests retry with fresh token 595// - All succeed without user seeing errors 596``` 597 598## Monitoring 599 600Consider tracking these metrics: 601 602- **Proactive refresh frequency** (should be regular, every ~5 minutes) 603- **Reactive refresh frequency** (should be rare, <1% of API calls) 604- **Failed refresh attempts** (should investigate if >0.1%) 605- **Average time between refreshes** (should be ~5 minutes) 606- **Token refresh success rate** (should be >99.9%) 607- **Queued request count** (indicates concurrent API calls during refresh) 608 609## Migration Guide 610 611### From Old Approach 612 613**Before:** 614 615```typescript 616const apiClient = new ApiClient( 617 baseUrl, 618 () => getAccessToken(), 619 refreshTokens, // Had to pass refresh function 620); 621``` 622 623**After:** 624 625```typescript 626import { apiClient } from '@/api-client'; 627// Just use the default instance - no configuration needed! 628``` 629 630### Key Changes 631 6321. **TokenManager**: Centralized token logic replaces scattered refresh code 6332. **Storage Abstraction**: Pluggable storage for different environments 6343. **Clean BaseClient**: Only handles HTTP, delegates token management 6354. **Factory Functions**: Easy setup for client/server scenarios 6365. **Default Export**: Use `apiClient` instead of creating instances