diff --git a/src/webapp/api-client/clients/BaseClient.ts b/src/webapp/api-client/clients/BaseClient.ts index 3376cbb4..017f2203 100644 --- a/src/webapp/api-client/clients/BaseClient.ts +++ b/src/webapp/api-client/clients/BaseClient.ts @@ -1,5 +1,4 @@ import { ApiError, ApiErrorResponse } from '../types/errors'; -import { ClientCookieAuthService } from '@/services/auth'; export abstract class BaseClient { constructor(protected baseUrl: string) {} @@ -9,46 +8,27 @@ export abstract class BaseClient { endpoint: string, data?: any, ): Promise { - const makeRequest = async (): Promise => { - const url = `${this.baseUrl}${endpoint}`; + const url = `${this.baseUrl}${endpoint}`; - const headers: Record = { - 'Content-Type': 'application/json', - }; - - const config: RequestInit = { - method, - headers, - credentials: 'include', // Include cookies automatically (works for both client and server) - }; - - if ( - data && - (method === 'POST' || method === 'PUT' || method === 'PATCH') - ) { - config.body = JSON.stringify(data); - } + const headers: Record = { + 'Content-Type': 'application/json', + }; - const response = await fetch(url, config); - return this.handleResponse(response); + const config: RequestInit = { + method, + headers, + credentials: 'include', // Include cookies automatically (works for both client and server) }; - try { - return await makeRequest(); - } catch (error) { - // Handle 401/403 errors with automatic token refresh (client-side only) - if ( - typeof window !== 'undefined' && - error instanceof ApiError && - (error.status === 401 || error.status === 403) - ) { - const refreshed = await ClientCookieAuthService.refreshTokens(); - if (refreshed) { - return makeRequest(); // Retry with new tokens - } - } - throw error; + if ( + data && + (method === 'POST' || method === 'PUT' || method === 'PATCH') + ) { + config.body = JSON.stringify(data); } + + const response = await fetch(url, config); + return this.handleResponse(response); } private async handleResponse(response: Response): Promise { diff --git a/src/webapp/app/api/auth/me/route.ts b/src/webapp/app/api/auth/me/route.ts new file mode 100644 index 00000000..c0482419 --- /dev/null +++ b/src/webapp/app/api/auth/me/route.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +// Helper to check if token is expired or will expire soon +function isTokenExpiringSoon( + token: string | null | undefined, + bufferMinutes: number = 5, +): boolean { + if (!token) return true; + + try { + const payload = JSON.parse( + Buffer.from(token.split('.')[1], 'base64').toString(), + ); + const expiry = payload.exp * 1000; + const bufferTime = bufferMinutes * 60 * 1000; + return Date.now() >= expiry - bufferTime; + } catch { + return true; + } +} + +export async function GET(request: NextRequest) { + try { + const cookieStore = await cookies(); + let accessToken = cookieStore.get('accessToken')?.value; + const refreshToken = cookieStore.get('refreshToken')?.value; + + // No tokens at all - not authenticated + if (!accessToken && !refreshToken) { + return NextResponse.json( + { error: 'Not authenticated' }, + { status: 401 }, + ); + } + + // Check if accessToken is expired or expiring soon (< 5 min) + if (isTokenExpiringSoon(accessToken, 5) && refreshToken) { + try { + // Call backend to refresh tokens + const backendUrl = + process.env.API_BASE_URL || 'http://127.0.0.1:3000'; + const refreshResponse = await fetch( + `${backendUrl}/api/users/oauth/refresh`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `refreshToken=${refreshToken}`, + }, + body: JSON.stringify({ refreshToken }), + }, + ); + + if (!refreshResponse.ok) { + // Refresh failed - clear cookies and return 401 + const response = NextResponse.json( + { error: 'Token refresh failed' }, + { status: 401 }, + ); + response.cookies.delete('accessToken'); + response.cookies.delete('refreshToken'); + return response; + } + + const newTokens = await refreshResponse.json(); + accessToken = newTokens.accessToken; + + // Fetch profile with new token + const profileResponse = await fetch(`${backendUrl}/api/users/me`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Cookie: `accessToken=${accessToken}`, + }, + }); + + if (!profileResponse.ok) { + return NextResponse.json( + { error: 'Failed to fetch profile' }, + { status: profileResponse.status }, + ); + } + + const user = await profileResponse.json(); + + // Create response with user profile and set new cookies + const response = NextResponse.json({ user }); + + response.cookies.set('accessToken', newTokens.accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 900, // 15 minutes + path: '/', + }); + + response.cookies.set('refreshToken', newTokens.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 604800, // 7 days + path: '/', + }); + + return response; + } catch (error) { + console.error('Token refresh error:', error); + return NextResponse.json( + { error: 'Authentication failed' }, + { status: 500 }, + ); + } + } + + // AccessToken is valid - fetch profile + try { + const backendUrl = process.env.API_BASE_URL || 'http://127.0.0.1:3000'; + const profileResponse = await fetch(`${backendUrl}/api/users/me`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Cookie: `accessToken=${accessToken}`, + }, + }); + + if (!profileResponse.ok) { + return NextResponse.json( + { error: 'Failed to fetch profile' }, + { status: profileResponse.status }, + ); + } + + const user = await profileResponse.json(); + return NextResponse.json({ user }); + } catch (error) { + console.error('Profile fetch error:', error); + return NextResponse.json( + { error: 'Failed to fetch profile' }, + { status: 500 }, + ); + } + } catch (error) { + console.error('Auth me error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 }, + ); + } +} diff --git a/src/webapp/app/api/auth/status/route.ts b/src/webapp/app/api/auth/status/route.ts deleted file mode 100644 index 9a0cb433..00000000 --- a/src/webapp/app/api/auth/status/route.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; - -export async function GET(request: NextRequest) { - try { - const cookieStore = await cookies(); - const accessToken = cookieStore.get('accessToken')?.value; - - if (!accessToken) { - return NextResponse.json({ authenticated: false }, { status: 401 }); - } - - // Check if token is expired - try { - const payload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString()); - const expiry = payload.exp * 1000; - const isExpired = Date.now() >= expiry; - - if (isExpired) { - return NextResponse.json({ authenticated: false }, { status: 401 }); - } - - return NextResponse.json({ authenticated: true }, { status: 200 }); - } catch { - return NextResponse.json({ authenticated: false }, { status: 401 }); - } - } catch (error) { - console.error('Auth status check error:', error); - return NextResponse.json({ authenticated: false }, { status: 500 }); - } -} diff --git a/src/webapp/app/test-page/page.tsx b/src/webapp/app/test-page/page.tsx index 5086bada..51e702d6 100644 --- a/src/webapp/app/test-page/page.tsx +++ b/src/webapp/app/test-page/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useAuth } from '@/hooks/useAuth'; -import { ClientCookieAuthService } from '@/services/auth'; import { Container, Stack, @@ -31,11 +30,6 @@ export default function TestPage() { await logout(); }; - const handleCheckAuth = async () => { - const isAuth = await ClientCookieAuthService.checkAuthStatus(); - console.log('Auth check:', { isAuthenticated: isAuth }); - }; - return ( @@ -122,11 +116,7 @@ export default function TestPage() { - - {authenticated && ( @@ -149,17 +139,18 @@ export default function TestPage() { 1. This page uses the useAuth hook - 2. Cookies are HttpOnly and cannot be read from JavaScript + 2. The hook calls /api/auth/me which handles auth + + refresh - 3. The browser automatically sends cookies with{' '} + 3. HttpOnly cookies are sent automatically with{' '} credentials: 'include' - 4. Auth status is checked via API endpoint + 4. Server refreshes tokens if needed (< 5 min left) - 5. Success = client-side cookie authentication works! ✅ + 5. Success = seamless cookie authentication! ✅ diff --git a/src/webapp/hooks/useAuth.tsx b/src/webapp/hooks/useAuth.tsx index 537d2615..697ffd0c 100644 --- a/src/webapp/hooks/useAuth.tsx +++ b/src/webapp/hooks/useAuth.tsx @@ -3,7 +3,8 @@ import { useState, useEffect, createContext, useContext, ReactNode, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { ClientCookieAuthService } from '@/services/auth'; -import { ApiClient, GetProfileResponse } from '@/api-client/ApiClient'; +import { ApiClient } from '@/api-client/ApiClient'; +import type { GetProfileResponse } from '@/api-client/ApiClient'; type UserProfile = GetProfileResponse; @@ -30,13 +31,17 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const router = useRouter(); - // Refresh authentication (fetch user profile with HttpOnly cookies) + // Refresh authentication (fetch user profile with automatic token refresh) const refreshAuth = useCallback(async (): Promise => { try { - // Check if authenticated via API (HttpOnly cookies sent automatically) - const isAuth = await ClientCookieAuthService.checkAuthStatus(); + // Call /api/auth/me which handles token refresh + profile fetch + // HttpOnly cookies sent automatically with credentials: 'include' + const response = await fetch('/api/auth/me', { + method: 'GET', + credentials: 'include', + }); - if (!isAuth) { + if (!response.ok) { setAuthState({ isAuthenticated: false, user: null, @@ -45,12 +50,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { return false; } - // Fetch user profile (cookies sent automatically with credentials: 'include') - const apiClient = new ApiClient( - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://127.0.0.1:3000' - ); - - const user = await apiClient.getMyProfile(); + const { user } = await response.json(); setAuthState({ isAuthenticated: true, diff --git a/src/webapp/services/auth.ts b/src/webapp/services/auth.ts index 80ebc3aa..961bfb00 100644 --- a/src/webapp/services/auth.ts +++ b/src/webapp/services/auth.ts @@ -4,4 +4,3 @@ // Re-export cookie auth services export { ClientCookieAuthService } from './auth/CookieAuthService.client'; -export type { AuthTokens } from './auth/CookieAuthService.client'; diff --git a/src/webapp/services/auth/CookieAuthService.client.ts b/src/webapp/services/auth/CookieAuthService.client.ts index 3cca5f43..612fc12a 100644 --- a/src/webapp/services/auth/CookieAuthService.client.ts +++ b/src/webapp/services/auth/CookieAuthService.client.ts @@ -1,27 +1,13 @@ -export interface AuthTokens { - accessToken: string | null; - refreshToken: string | null; -} - export class ClientCookieAuthService { // Note: With HttpOnly cookies, we cannot read tokens from document.cookie - // The browser will automatically send cookies with requests using credentials: 'include' - // To check auth status, make an API call instead of reading cookies directly - - static async checkAuthStatus(): Promise { - try { - const response = await fetch('/api/auth/status', { - method: 'GET', - credentials: 'include', - }); - return response.ok; - } catch { - return false; - } - } + // The browser automatically sends cookies with requests using credentials: 'include' + // All auth logic (checking status, refreshing tokens) is handled by /api/auth/me endpoint - // Set cookies via API - static async setTokens(accessToken: string, refreshToken: string): Promise { + // Set cookies via API (used after OAuth login) + static async setTokens( + accessToken: string, + refreshToken: string, + ): Promise { await fetch('/api/auth/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -30,32 +16,11 @@ export class ClientCookieAuthService { }); } - // Clear cookies via API + // Clear cookies via API (logout) static async clearTokens(): Promise { await fetch('/api/auth/logout', { method: 'POST', credentials: 'include', }); } - - // Refresh tokens (HttpOnly refreshToken cookie sent automatically) - static async refreshTokens(): Promise { - try { - const response = await fetch('/api/users/oauth/refresh', { - method: 'POST', - credentials: 'include', // Sends HttpOnly cookies automatically - }); - - if (!response.ok) { - await this.clearTokens(); - return false; - } - - // New tokens are set as HttpOnly cookies by the backend - return true; - } catch { - await this.clearTokens(); - return false; - } - } } diff --git a/src/webapp/services/auth/index.ts b/src/webapp/services/auth/index.ts index 7827c7bd..c7a650d6 100644 --- a/src/webapp/services/auth/index.ts +++ b/src/webapp/services/auth/index.ts @@ -1,8 +1,6 @@ // Client-side exports export { ClientCookieAuthService } from './CookieAuthService.client'; -// Server-side exports +// Server-side exports export { ServerCookieAuthService } from './CookieAuthService.server'; - -// Shared types -export type { AuthTokens } from './CookieAuthService.client'; +export type { AuthTokens } from './CookieAuthService.server';