A social knowledge tool for researchers built on ATProto
45
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement token management architecture with TokenManager

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>

+294 -41
+21 -13
docs/examples/next-js-ssr-component.md
··· 1 - - use the `getAccessTokenInServerComponent()` to access the token 2 - - pass the token in to the `ApiClient` as normal 1 + - use the `createServerApiClient()` helper function 3 2 4 3 ```tsx 5 - const accessToken = await getAccessTokenInServerComponent(); 6 - const apiClient = new ApiClient( 7 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 8 - () => accessToken, 9 - ); 4 + import { createServerApiClient } from '@/api-client'; 5 + 6 + export default async function SSRProfilePage() { 7 + const apiClient = await createServerApiClient(); 8 + 9 + let profile; 10 + let error; 11 + 12 + try { 13 + profile = await apiClient.getMyProfile(); 14 + } catch (err: any) { 15 + error = err.message || 'Failed to load profile'; 16 + } 17 + 18 + // ... rest of component 19 + } 10 20 ``` 11 21 12 22 - Full example 13 23 14 24 ```tsx 15 - import { ApiClient } from '@/api-client/ApiClient'; 16 - import { getAccessTokenInServerComponent } from '@/services/auth'; 25 + import { createServerApiClient } from '@/api-client'; 17 26 import { 18 27 Card, 19 28 Container, ··· 24 33 Group, 25 34 } from '@mantine/core'; 26 35 import { redirect } from 'next/navigation'; 36 + import { getAccessTokenInServerComponent } from '@/services/auth'; 27 37 28 38 export default async function SSRProfilePage() { 39 + // Check if user is authenticated 29 40 const accessToken = await getAccessTokenInServerComponent(); 30 41 if (!accessToken) { 31 42 redirect('/auth/signin'); 32 43 } 33 44 34 45 // Create API client for server-side usage 35 - const apiClient = new ApiClient( 36 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 37 - () => accessToken, 38 - ); 46 + const apiClient = await createServerApiClient(); 39 47 40 48 let profile; 41 49 let error;
+8 -6
src/webapp/api-client/ApiClient.ts
··· 5 5 UserClient, 6 6 FeedClient, 7 7 } from './clients'; 8 + import { TokenManager } from '../services/TokenManager'; 9 + import { createClientTokenManager } from '../services/auth'; 8 10 import type { 9 11 // Request types 10 12 AddUrlToLibraryRequest, ··· 60 62 61 63 constructor( 62 64 private baseUrl: string, 63 - private getAuthToken: () => string | null, 65 + private tokenManager: TokenManager, 64 66 ) { 65 - this.queryClient = new QueryClient(baseUrl, getAuthToken); 66 - this.cardClient = new CardClient(baseUrl, getAuthToken); 67 - this.collectionClient = new CollectionClient(baseUrl, getAuthToken); 68 - this.userClient = new UserClient(baseUrl, getAuthToken); 69 - this.feedClient = new FeedClient(baseUrl, getAuthToken); 67 + this.queryClient = new QueryClient(baseUrl, tokenManager); 68 + this.cardClient = new CardClient(baseUrl, tokenManager); 69 + this.collectionClient = new CollectionClient(baseUrl, tokenManager); 70 + this.userClient = new UserClient(baseUrl, tokenManager); 71 + this.feedClient = new FeedClient(baseUrl, tokenManager); 70 72 } 71 73 72 74 // Query operations - delegate to QueryClient
+33 -17
src/webapp/api-client/clients/BaseClient.ts
··· 1 1 import { ApiError, ApiErrorResponse } from '../types/errors'; 2 + import { TokenManager } from '../../services/TokenManager'; 2 3 3 4 export abstract class BaseClient { 4 5 constructor( 5 6 protected baseUrl: string, 6 - protected getAuthToken: () => string | null, 7 + protected tokenManager: TokenManager, 7 8 ) {} 8 9 9 10 protected async request<T>( ··· 11 12 endpoint: string, 12 13 data?: any, 13 14 ): Promise<T> { 14 - const url = `${this.baseUrl}${endpoint}`; 15 - const token = this.getAuthToken(); 15 + const makeRequest = async (): Promise<T> => { 16 + const url = `${this.baseUrl}${endpoint}`; 17 + const token = await this.tokenManager.getAccessToken(); 16 18 17 - const headers: Record<string, string> = { 18 - 'Content-Type': 'application/json', 19 - }; 19 + const headers: Record<string, string> = { 20 + 'Content-Type': 'application/json', 21 + }; 20 22 21 - if (token) { 22 - headers['Authorization'] = `Bearer ${token}`; 23 - } 23 + if (token) { 24 + headers['Authorization'] = `Bearer ${token}`; 25 + } 24 26 25 - const config: RequestInit = { 26 - method, 27 - headers, 27 + const config: RequestInit = { 28 + method, 29 + headers, 30 + }; 31 + 32 + if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { 33 + config.body = JSON.stringify(data); 34 + } 35 + 36 + const response = await fetch(url, config); 37 + return this.handleResponse<T>(response); 28 38 }; 29 39 30 - if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { 31 - config.body = JSON.stringify(data); 40 + try { 41 + return await makeRequest(); 42 + } catch (error) { 43 + // Handle 401/403 errors with automatic token refresh 44 + if ( 45 + error instanceof ApiError && 46 + (error.status === 401 || error.status === 403) 47 + ) { 48 + return this.tokenManager.handleAuthError(makeRequest); 49 + } 50 + throw error; 32 51 } 33 - 34 - const response = await fetch(url, config); 35 - return this.handleResponse<T>(response); 36 52 } 37 53 38 54 private async handleResponse<T>(response: Response): Promise<T> {
+24 -5
src/webapp/hooks/useAuth.tsx
··· 148 148 } 149 149 }, [refreshTokens]); 150 150 151 - // Set up automatic token refresh 151 + // Helper function to check if a JWT token is expired or will expire soon 152 + const isTokenExpiredWithBuffer = (token: string, bufferMinutes: number = 5): boolean => { 153 + if (!token) return true; 154 + 155 + try { 156 + const payload = JSON.parse(atob(token.split('.')[1])); 157 + const expiry = payload.exp * 1000; // Convert to milliseconds 158 + const bufferTime = bufferMinutes * 60 * 1000; // Buffer in milliseconds 159 + return Date.now() >= (expiry - bufferTime); 160 + } catch (e) { 161 + return true; 162 + } 163 + }; 164 + 165 + // PROACTIVE TOKEN REFRESH - This is the primary strategy 152 166 useEffect(() => { 153 167 if (!accessToken || !refreshToken) return; 154 168 155 - const checkTokenExpiration = async () => { 156 - if (isTokenExpired(accessToken)) { 169 + const checkAndRefreshToken = async () => { 170 + // Check if token will expire in the next 5 minutes 171 + if (isTokenExpiredWithBuffer(accessToken, 5)) { 172 + console.log('Proactively refreshing token before expiration'); 157 173 await refreshTokens(); 158 174 } 159 175 }; 160 176 161 - // Check token expiration every minute 162 - const interval = setInterval(checkTokenExpiration, 60000); 177 + // Check immediately on mount 178 + checkAndRefreshToken(); 179 + 180 + // Then check every 5 minutes 181 + const interval = setInterval(checkAndRefreshToken, 5 * 60 * 1000); 163 182 164 183 return () => clearInterval(interval); 165 184 }, [accessToken, refreshToken, refreshTokens]);
+105
src/webapp/services/TokenManager.ts
··· 1 + export interface AuthTokens { 2 + accessToken: string | null; 3 + refreshToken: string | null; 4 + } 5 + 6 + export interface TokenStorage { 7 + getTokens(): AuthTokens; 8 + setTokens(accessToken: string, refreshToken: string): Promise<void>; 9 + clearTokens(): void; 10 + } 11 + 12 + export interface TokenRefresher { 13 + refreshTokens(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }>; 14 + } 15 + 16 + export class TokenManager { 17 + private isRefreshing = false; 18 + private refreshPromise: Promise<boolean> | null = null; 19 + private failedRequestsQueue: Array<{ 20 + resolve: (value: any) => void; 21 + reject: (error: any) => void; 22 + request: () => Promise<any>; 23 + }> = []; 24 + 25 + constructor( 26 + private storage: TokenStorage, 27 + private refresher: TokenRefresher, 28 + ) {} 29 + 30 + async getAccessToken(): Promise<string | null> { 31 + const { accessToken } = this.storage.getTokens(); 32 + return accessToken; 33 + } 34 + 35 + async handleAuthError<T>(originalRequest: () => Promise<T>): Promise<T> { 36 + // If already refreshing, queue this request 37 + if (this.isRefreshing) { 38 + return new Promise((resolve, reject) => { 39 + this.failedRequestsQueue.push({ 40 + resolve, 41 + reject, 42 + request: originalRequest, 43 + }); 44 + }); 45 + } 46 + 47 + // Start refresh process 48 + this.isRefreshing = true; 49 + 50 + try { 51 + // Use existing refresh promise or create new one 52 + if (!this.refreshPromise) { 53 + this.refreshPromise = this.performRefresh(); 54 + } 55 + 56 + const refreshSuccess = await this.refreshPromise; 57 + 58 + if (refreshSuccess) { 59 + // Process queued requests 60 + const queuedRequests = [...this.failedRequestsQueue]; 61 + this.failedRequestsQueue = []; 62 + 63 + // Retry all queued requests 64 + queuedRequests.forEach(async ({ resolve, reject, request }) => { 65 + try { 66 + const result = await request(); 67 + resolve(result); 68 + } catch (error) { 69 + reject(error); 70 + } 71 + }); 72 + 73 + // Retry original request 74 + return await originalRequest(); 75 + } else { 76 + // Refresh failed, reject all queued requests 77 + const queuedRequests = [...this.failedRequestsQueue]; 78 + this.failedRequestsQueue = []; 79 + 80 + const refreshError = new Error('Token refresh failed'); 81 + queuedRequests.forEach(({ reject }) => reject(refreshError)); 82 + 83 + throw refreshError; 84 + } 85 + } finally { 86 + this.isRefreshing = false; 87 + this.refreshPromise = null; 88 + } 89 + } 90 + 91 + private async performRefresh(): Promise<boolean> { 92 + const { refreshToken } = this.storage.getTokens(); 93 + if (!refreshToken) return false; 94 + 95 + try { 96 + const newTokens = await this.refresher.refreshTokens(refreshToken); 97 + await this.storage.setTokens(newTokens.accessToken, newTokens.refreshToken); 98 + return true; 99 + } catch (error) { 100 + console.error('Token refresh failed:', error); 101 + this.storage.clearTokens(); 102 + return false; 103 + } 104 + } 105 + }
+19
src/webapp/services/TokenRefresher.ts
··· 1 + import { TokenRefresher } from './TokenManager'; 2 + 3 + export class ApiTokenRefresher implements TokenRefresher { 4 + constructor(private baseUrl: string) {} 5 + 6 + async refreshTokens(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> { 7 + const response = await fetch(`${this.baseUrl}/api/auth/refresh`, { 8 + method: 'POST', 9 + headers: { 'Content-Type': 'application/json' }, 10 + body: JSON.stringify({ refreshToken }), 11 + }); 12 + 13 + if (!response.ok) { 14 + throw new Error('Token refresh failed'); 15 + } 16 + 17 + return response.json(); 18 + } 19 + }
+23
src/webapp/services/auth.ts
··· 2 2 * Authentication utilities for the client-side application 3 3 */ 4 4 5 + import { TokenManager } from './TokenManager'; 6 + import { ClientTokenStorage } from './storage/ClientTokenStorage'; 7 + import { ServerTokenStorage } from './storage/ServerTokenStorage'; 8 + import { ApiTokenRefresher } from './TokenRefresher'; 9 + 5 10 // Check if the user is authenticated 6 11 export const isAuthenticated = (): boolean => { 7 12 if (typeof window === 'undefined') { ··· 49 54 localStorage.removeItem('accessToken'); 50 55 localStorage.removeItem('refreshToken'); 51 56 }; 57 + 58 + // Client-side token manager 59 + export const createClientTokenManager = () => { 60 + const storage = new ClientTokenStorage(); 61 + const refresher = new ApiTokenRefresher( 62 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000' 63 + ); 64 + return new TokenManager(storage, refresher); 65 + }; 66 + 67 + // Server-side token manager (read-only, no refresh capability) 68 + export const createServerTokenManager = async () => { 69 + const { cookies } = await import('next/headers'); 70 + const cookiesStore = await cookies(); 71 + const storage = new ServerTokenStorage(cookiesStore); 72 + const refresher = new ApiTokenRefresher(''); // Won't be used 73 + return new TokenManager(storage, refresher); 74 + };
+39
src/webapp/services/storage/ClientTokenStorage.ts
··· 1 + import { AuthTokens, TokenStorage } from '../TokenManager'; 2 + 3 + export class ClientTokenStorage implements TokenStorage { 4 + getTokens(): AuthTokens { 5 + if (typeof window === 'undefined') { 6 + return { accessToken: null, refreshToken: null }; 7 + } 8 + 9 + return { 10 + accessToken: localStorage.getItem('accessToken'), 11 + refreshToken: localStorage.getItem('refreshToken'), 12 + }; 13 + } 14 + 15 + async setTokens(accessToken: string, refreshToken: string): Promise<void> { 16 + if (typeof window === 'undefined') return; 17 + 18 + localStorage.setItem('accessToken', accessToken); 19 + localStorage.setItem('refreshToken', refreshToken); 20 + 21 + // Sync with server cookies 22 + try { 23 + await fetch('/api/auth/sync', { 24 + method: 'POST', 25 + headers: { 'Content-Type': 'application/json' }, 26 + body: JSON.stringify({ accessToken, refreshToken }), 27 + credentials: 'include', 28 + }); 29 + } catch (error) { 30 + console.warn('Failed to sync tokens with server:', error); 31 + } 32 + } 33 + 34 + clearTokens(): void { 35 + if (typeof window === 'undefined') return; 36 + localStorage.removeItem('accessToken'); 37 + localStorage.removeItem('refreshToken'); 38 + } 39 + }
+22
src/webapp/services/storage/ServerTokenStorage.ts
··· 1 + import { AuthTokens, TokenStorage } from '../TokenManager'; 2 + 3 + export class ServerTokenStorage implements TokenStorage { 4 + constructor(private cookiesStore: any) {} 5 + 6 + getTokens(): AuthTokens { 7 + return { 8 + accessToken: this.cookiesStore.get('accessToken')?.value || null, 9 + refreshToken: this.cookiesStore.get('refreshToken')?.value || null, 10 + }; 11 + } 12 + 13 + async setTokens(): Promise<void> { 14 + // Server-side can't set tokens - handled by client-side sync 15 + throw new Error('Server-side token refresh not supported'); 16 + } 17 + 18 + clearTokens(): void { 19 + // Server-side can't clear tokens directly 20 + throw new Error('Server-side token clearing not supported'); 21 + } 22 + }