A social knowledge tool for researchers built on ATProto

update useAuth to handle refreshing in nextjs server

+193 -141
+16 -36
src/webapp/api-client/clients/BaseClient.ts
··· 1 import { ApiError, ApiErrorResponse } from '../types/errors'; 2 - import { ClientCookieAuthService } from '@/services/auth'; 3 4 export abstract class BaseClient { 5 constructor(protected baseUrl: string) {} ··· 9 endpoint: string, 10 data?: any, 11 ): Promise<T> { 12 - const makeRequest = async (): Promise<T> => { 13 - const url = `${this.baseUrl}${endpoint}`; 14 - 15 - const headers: Record<string, string> = { 16 - 'Content-Type': 'application/json', 17 - }; 18 - 19 - const config: RequestInit = { 20 - method, 21 - headers, 22 - credentials: 'include', // Include cookies automatically (works for both client and server) 23 - }; 24 25 - if ( 26 - data && 27 - (method === 'POST' || method === 'PUT' || method === 'PATCH') 28 - ) { 29 - config.body = JSON.stringify(data); 30 - } 31 32 - const response = await fetch(url, config); 33 - return this.handleResponse<T>(response); 34 }; 35 36 - try { 37 - return await makeRequest(); 38 - } catch (error) { 39 - // Handle 401/403 errors with automatic token refresh (client-side only) 40 - if ( 41 - typeof window !== 'undefined' && 42 - error instanceof ApiError && 43 - (error.status === 401 || error.status === 403) 44 - ) { 45 - const refreshed = await ClientCookieAuthService.refreshTokens(); 46 - if (refreshed) { 47 - return makeRequest(); // Retry with new tokens 48 - } 49 - } 50 - throw error; 51 } 52 } 53 54 private async handleResponse<T>(response: Response): Promise<T> {
··· 1 import { ApiError, ApiErrorResponse } from '../types/errors'; 2 3 export abstract class BaseClient { 4 constructor(protected baseUrl: string) {} ··· 8 endpoint: string, 9 data?: any, 10 ): Promise<T> { 11 + const url = `${this.baseUrl}${endpoint}`; 12 13 + const headers: Record<string, string> = { 14 + 'Content-Type': 'application/json', 15 + }; 16 17 + const config: RequestInit = { 18 + method, 19 + headers, 20 + credentials: 'include', // Include cookies automatically (works for both client and server) 21 }; 22 23 + if ( 24 + data && 25 + (method === 'POST' || method === 'PUT' || method === 'PATCH') 26 + ) { 27 + config.body = JSON.stringify(data); 28 } 29 + 30 + const response = await fetch(url, config); 31 + return this.handleResponse<T>(response); 32 } 33 34 private async handleResponse<T>(response: Response): Promise<T> {
+150
src/webapp/app/api/auth/me/route.ts
···
··· 1 + import { NextRequest, NextResponse } from 'next/server'; 2 + import { cookies } from 'next/headers'; 3 + 4 + // Helper to check if token is expired or will expire soon 5 + function isTokenExpiringSoon( 6 + token: string | null | undefined, 7 + bufferMinutes: number = 5, 8 + ): boolean { 9 + if (!token) return true; 10 + 11 + try { 12 + const payload = JSON.parse( 13 + Buffer.from(token.split('.')[1], 'base64').toString(), 14 + ); 15 + const expiry = payload.exp * 1000; 16 + const bufferTime = bufferMinutes * 60 * 1000; 17 + return Date.now() >= expiry - bufferTime; 18 + } catch { 19 + return true; 20 + } 21 + } 22 + 23 + export async function GET(request: NextRequest) { 24 + try { 25 + const cookieStore = await cookies(); 26 + let accessToken = cookieStore.get('accessToken')?.value; 27 + const refreshToken = cookieStore.get('refreshToken')?.value; 28 + 29 + // No tokens at all - not authenticated 30 + if (!accessToken && !refreshToken) { 31 + return NextResponse.json( 32 + { error: 'Not authenticated' }, 33 + { status: 401 }, 34 + ); 35 + } 36 + 37 + // Check if accessToken is expired or expiring soon (< 5 min) 38 + if (isTokenExpiringSoon(accessToken, 5) && refreshToken) { 39 + try { 40 + // Call backend to refresh tokens 41 + const backendUrl = 42 + process.env.API_BASE_URL || 'http://127.0.0.1:3000'; 43 + const refreshResponse = await fetch( 44 + `${backendUrl}/api/users/oauth/refresh`, 45 + { 46 + method: 'POST', 47 + headers: { 48 + 'Content-Type': 'application/json', 49 + Cookie: `refreshToken=${refreshToken}`, 50 + }, 51 + body: JSON.stringify({ refreshToken }), 52 + }, 53 + ); 54 + 55 + if (!refreshResponse.ok) { 56 + // Refresh failed - clear cookies and return 401 57 + const response = NextResponse.json( 58 + { error: 'Token refresh failed' }, 59 + { status: 401 }, 60 + ); 61 + response.cookies.delete('accessToken'); 62 + response.cookies.delete('refreshToken'); 63 + return response; 64 + } 65 + 66 + const newTokens = await refreshResponse.json(); 67 + accessToken = newTokens.accessToken; 68 + 69 + // Fetch profile with new token 70 + const profileResponse = await fetch(`${backendUrl}/api/users/me`, { 71 + method: 'GET', 72 + headers: { 73 + 'Content-Type': 'application/json', 74 + Cookie: `accessToken=${accessToken}`, 75 + }, 76 + }); 77 + 78 + if (!profileResponse.ok) { 79 + return NextResponse.json( 80 + { error: 'Failed to fetch profile' }, 81 + { status: profileResponse.status }, 82 + ); 83 + } 84 + 85 + const user = await profileResponse.json(); 86 + 87 + // Create response with user profile and set new cookies 88 + const response = NextResponse.json({ user }); 89 + 90 + response.cookies.set('accessToken', newTokens.accessToken, { 91 + httpOnly: true, 92 + secure: process.env.NODE_ENV === 'production', 93 + sameSite: 'strict', 94 + maxAge: 900, // 15 minutes 95 + path: '/', 96 + }); 97 + 98 + response.cookies.set('refreshToken', newTokens.refreshToken, { 99 + httpOnly: true, 100 + secure: process.env.NODE_ENV === 'production', 101 + sameSite: 'strict', 102 + maxAge: 604800, // 7 days 103 + path: '/', 104 + }); 105 + 106 + return response; 107 + } catch (error) { 108 + console.error('Token refresh error:', error); 109 + return NextResponse.json( 110 + { error: 'Authentication failed' }, 111 + { status: 500 }, 112 + ); 113 + } 114 + } 115 + 116 + // AccessToken is valid - fetch profile 117 + try { 118 + const backendUrl = process.env.API_BASE_URL || 'http://127.0.0.1:3000'; 119 + const profileResponse = await fetch(`${backendUrl}/api/users/me`, { 120 + method: 'GET', 121 + headers: { 122 + 'Content-Type': 'application/json', 123 + Cookie: `accessToken=${accessToken}`, 124 + }, 125 + }); 126 + 127 + if (!profileResponse.ok) { 128 + return NextResponse.json( 129 + { error: 'Failed to fetch profile' }, 130 + { status: profileResponse.status }, 131 + ); 132 + } 133 + 134 + const user = await profileResponse.json(); 135 + return NextResponse.json({ user }); 136 + } catch (error) { 137 + console.error('Profile fetch error:', error); 138 + return NextResponse.json( 139 + { error: 'Failed to fetch profile' }, 140 + { status: 500 }, 141 + ); 142 + } 143 + } catch (error) { 144 + console.error('Auth me error:', error); 145 + return NextResponse.json( 146 + { error: 'Internal server error' }, 147 + { status: 500 }, 148 + ); 149 + } 150 + }
-31
src/webapp/app/api/auth/status/route.ts
··· 1 - import { NextRequest, NextResponse } from 'next/server'; 2 - import { cookies } from 'next/headers'; 3 - 4 - export async function GET(request: NextRequest) { 5 - try { 6 - const cookieStore = await cookies(); 7 - const accessToken = cookieStore.get('accessToken')?.value; 8 - 9 - if (!accessToken) { 10 - return NextResponse.json({ authenticated: false }, { status: 401 }); 11 - } 12 - 13 - // Check if token is expired 14 - try { 15 - const payload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString()); 16 - const expiry = payload.exp * 1000; 17 - const isExpired = Date.now() >= expiry; 18 - 19 - if (isExpired) { 20 - return NextResponse.json({ authenticated: false }, { status: 401 }); 21 - } 22 - 23 - return NextResponse.json({ authenticated: true }, { status: 200 }); 24 - } catch { 25 - return NextResponse.json({ authenticated: false }, { status: 401 }); 26 - } 27 - } catch (error) { 28 - console.error('Auth status check error:', error); 29 - return NextResponse.json({ authenticated: false }, { status: 500 }); 30 - } 31 - }
···
+6 -15
src/webapp/app/test-page/page.tsx
··· 1 'use client'; 2 3 import { useAuth } from '@/hooks/useAuth'; 4 - import { ClientCookieAuthService } from '@/services/auth'; 5 import { 6 Container, 7 Stack, ··· 29 30 const handleLogout = async () => { 31 await logout(); 32 - }; 33 - 34 - const handleCheckAuth = async () => { 35 - const isAuth = await ClientCookieAuthService.checkAuthStatus(); 36 - console.log('Auth check:', { isAuthenticated: isAuth }); 37 }; 38 39 return ( ··· 122 123 <Stack gap="xs"> 124 <Button onClick={handleRefresh} variant="light"> 125 - Refresh Test 126 - </Button> 127 - 128 - <Button onClick={handleCheckAuth} variant="outline"> 129 - Check Auth Status (Console) 130 </Button> 131 132 {authenticated && ( ··· 149 1. This page uses the <Code>useAuth</Code> hook 150 </Text> 151 <Text size="sm"> 152 - 2. Cookies are HttpOnly and cannot be read from JavaScript 153 </Text> 154 <Text size="sm"> 155 - 3. The browser automatically sends cookies with{' '} 156 <Code>credentials: 'include'</Code> 157 </Text> 158 <Text size="sm"> 159 - 4. Auth status is checked via API endpoint 160 </Text> 161 <Text size="sm"> 162 - 5. Success = client-side cookie authentication works! ✅ 163 </Text> 164 </Stack> 165 </Card>
··· 1 'use client'; 2 3 import { useAuth } from '@/hooks/useAuth'; 4 import { 5 Container, 6 Stack, ··· 28 29 const handleLogout = async () => { 30 await logout(); 31 }; 32 33 return ( ··· 116 117 <Stack gap="xs"> 118 <Button onClick={handleRefresh} variant="light"> 119 + Refresh Auth 120 </Button> 121 122 {authenticated && ( ··· 139 1. This page uses the <Code>useAuth</Code> hook 140 </Text> 141 <Text size="sm"> 142 + 2. The hook calls <Code>/api/auth/me</Code> which handles auth + 143 + refresh 144 </Text> 145 <Text size="sm"> 146 + 3. HttpOnly cookies are sent automatically with{' '} 147 <Code>credentials: 'include'</Code> 148 </Text> 149 <Text size="sm"> 150 + 4. Server refreshes tokens if needed (&lt; 5 min left) 151 </Text> 152 <Text size="sm"> 153 + 5. Success = seamless cookie authentication! ✅ 154 </Text> 155 </Stack> 156 </Card>
+11 -11
src/webapp/hooks/useAuth.tsx
··· 3 import { useState, useEffect, createContext, useContext, ReactNode, useCallback } from 'react'; 4 import { useRouter } from 'next/navigation'; 5 import { ClientCookieAuthService } from '@/services/auth'; 6 - import { ApiClient, GetProfileResponse } from '@/api-client/ApiClient'; 7 8 type UserProfile = GetProfileResponse; 9 ··· 30 31 const router = useRouter(); 32 33 - // Refresh authentication (fetch user profile with HttpOnly cookies) 34 const refreshAuth = useCallback(async (): Promise<boolean> => { 35 try { 36 - // Check if authenticated via API (HttpOnly cookies sent automatically) 37 - const isAuth = await ClientCookieAuthService.checkAuthStatus(); 38 39 - if (!isAuth) { 40 setAuthState({ 41 isAuthenticated: false, 42 user: null, ··· 45 return false; 46 } 47 48 - // Fetch user profile (cookies sent automatically with credentials: 'include') 49 - const apiClient = new ApiClient( 50 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000' 51 - ); 52 - 53 - const user = await apiClient.getMyProfile(); 54 55 setAuthState({ 56 isAuthenticated: true,
··· 3 import { useState, useEffect, createContext, useContext, ReactNode, useCallback } from 'react'; 4 import { useRouter } from 'next/navigation'; 5 import { ClientCookieAuthService } from '@/services/auth'; 6 + import { ApiClient } from '@/api-client/ApiClient'; 7 + import type { GetProfileResponse } from '@/api-client/ApiClient'; 8 9 type UserProfile = GetProfileResponse; 10 ··· 31 32 const router = useRouter(); 33 34 + // Refresh authentication (fetch user profile with automatic token refresh) 35 const refreshAuth = useCallback(async (): Promise<boolean> => { 36 try { 37 + // Call /api/auth/me which handles token refresh + profile fetch 38 + // HttpOnly cookies sent automatically with credentials: 'include' 39 + const response = await fetch('/api/auth/me', { 40 + method: 'GET', 41 + credentials: 'include', 42 + }); 43 44 + if (!response.ok) { 45 setAuthState({ 46 isAuthenticated: false, 47 user: null, ··· 50 return false; 51 } 52 53 + const { user } = await response.json(); 54 55 setAuthState({ 56 isAuthenticated: true,
-1
src/webapp/services/auth.ts
··· 4 5 // Re-export cookie auth services 6 export { ClientCookieAuthService } from './auth/CookieAuthService.client'; 7 - export type { AuthTokens } from './auth/CookieAuthService.client';
··· 4 5 // Re-export cookie auth services 6 export { ClientCookieAuthService } from './auth/CookieAuthService.client';
+8 -43
src/webapp/services/auth/CookieAuthService.client.ts
··· 1 - export interface AuthTokens { 2 - accessToken: string | null; 3 - refreshToken: string | null; 4 - } 5 - 6 export class ClientCookieAuthService { 7 // Note: With HttpOnly cookies, we cannot read tokens from document.cookie 8 - // The browser will automatically send cookies with requests using credentials: 'include' 9 - // To check auth status, make an API call instead of reading cookies directly 10 - 11 - static async checkAuthStatus(): Promise<boolean> { 12 - try { 13 - const response = await fetch('/api/auth/status', { 14 - method: 'GET', 15 - credentials: 'include', 16 - }); 17 - return response.ok; 18 - } catch { 19 - return false; 20 - } 21 - } 22 23 - // Set cookies via API 24 - static async setTokens(accessToken: string, refreshToken: string): Promise<void> { 25 await fetch('/api/auth/sync', { 26 method: 'POST', 27 headers: { 'Content-Type': 'application/json' }, ··· 30 }); 31 } 32 33 - // Clear cookies via API 34 static async clearTokens(): Promise<void> { 35 await fetch('/api/auth/logout', { 36 method: 'POST', 37 credentials: 'include', 38 }); 39 - } 40 - 41 - // Refresh tokens (HttpOnly refreshToken cookie sent automatically) 42 - static async refreshTokens(): Promise<boolean> { 43 - try { 44 - const response = await fetch('/api/users/oauth/refresh', { 45 - method: 'POST', 46 - credentials: 'include', // Sends HttpOnly cookies automatically 47 - }); 48 - 49 - if (!response.ok) { 50 - await this.clearTokens(); 51 - return false; 52 - } 53 - 54 - // New tokens are set as HttpOnly cookies by the backend 55 - return true; 56 - } catch { 57 - await this.clearTokens(); 58 - return false; 59 - } 60 } 61 }
··· 1 export class ClientCookieAuthService { 2 // Note: With HttpOnly cookies, we cannot read tokens from document.cookie 3 + // The browser automatically sends cookies with requests using credentials: 'include' 4 + // All auth logic (checking status, refreshing tokens) is handled by /api/auth/me endpoint 5 6 + // Set cookies via API (used after OAuth login) 7 + static async setTokens( 8 + accessToken: string, 9 + refreshToken: string, 10 + ): Promise<void> { 11 await fetch('/api/auth/sync', { 12 method: 'POST', 13 headers: { 'Content-Type': 'application/json' }, ··· 16 }); 17 } 18 19 + // Clear cookies via API (logout) 20 static async clearTokens(): Promise<void> { 21 await fetch('/api/auth/logout', { 22 method: 'POST', 23 credentials: 'include', 24 }); 25 } 26 }
+2 -4
src/webapp/services/auth/index.ts
··· 1 // Client-side exports 2 export { ClientCookieAuthService } from './CookieAuthService.client'; 3 4 - // Server-side exports 5 export { ServerCookieAuthService } from './CookieAuthService.server'; 6 - 7 - // Shared types 8 - export type { AuthTokens } from './CookieAuthService.client';
··· 1 // Client-side exports 2 export { ClientCookieAuthService } from './CookieAuthService.client'; 3 4 + // Server-side exports 5 export { ServerCookieAuthService } from './CookieAuthService.server'; 6 + export type { AuthTokens } from './CookieAuthService.server';