diff --git a/app/actions/auth.ts b/app/actions/auth.ts new file mode 100644 index 0000000..fca3de3 --- /dev/null +++ b/app/actions/auth.ts @@ -0,0 +1,143 @@ +'use server' + +import { cookies } from 'next/headers' + +const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081' + +interface AuthResponse { + access_token: string + refresh_token: string + id_token: string + expires_in: number +} + +interface UserPayload { + sub: string + email?: string + totp_enabled?: boolean +} + +export async function exchangeAuthCode(code: string, codeVerifier: string, redirectUri: string) { + try { + const res = await fetch(`${AUTH_API_URL}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + client_id: 'analytics-app', + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }) + + if (!res.ok) { + const data = await res.json() + throw new Error(data.error || 'Failed to exchange token') + } + + const data: AuthResponse = await res.json() + + // * Decode payload (without verification, we trust the direct channel to Auth Server) + const payloadPart = data.access_token.split('.')[1] + const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) + + // * Set Cookies + const cookieStore = await cookies() + + // * Access Token + cookieStore.set('access_token', data.access_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 15 // 15 minutes (short lived) + }) + + // * Refresh Token (Long lived) + cookieStore.set('refresh_token', data.refresh_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 30 // 30 days + }) + + return { + success: true, + user: { + id: payload.sub, + email: payload.email || 'user@ciphera.net', + totp_enabled: payload.totp_enabled || false + } + } + + } catch (error: any) { + console.error('Auth Exchange Error:', error) + return { success: false, error: error.message } + } +} + +export async function setSessionAction(accessToken: string, refreshToken: string) { + try { + const payloadPart = accessToken.split('.')[1] + const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) + + const cookieStore = await cookies() + + cookieStore.set('access_token', accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 15 + }) + + cookieStore.set('refresh_token', refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 30 + }) + + return { + success: true, + user: { + id: payload.sub, + email: payload.email || 'user@ciphera.net', + totp_enabled: payload.totp_enabled || false + } + } + } catch (e) { + return { success: false, error: 'Invalid token' } + } +} + +export async function logoutAction() { + const cookieStore = await cookies() + cookieStore.delete('access_token') + cookieStore.delete('refresh_token') + return { success: true } +} + +export async function getSessionAction() { + const cookieStore = await cookies() + const token = cookieStore.get('access_token') + + if (!token) return null + + try { + const payloadPart = token.value.split('.')[1] + const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) + return { + id: payload.sub, + email: payload.email || 'user@ciphera.net', + totp_enabled: payload.totp_enabled || false + } + } catch { + return null + } +} diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..363b2c4 --- /dev/null +++ b/app/api/auth/refresh/route.ts @@ -0,0 +1,50 @@ +import { cookies } from 'next/headers' +import { NextResponse } from 'next/server' + +const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081' + +export async function POST() { + const cookieStore = await cookies() + const refreshToken = cookieStore.get('refresh_token')?.value + + if (!refreshToken) { + return NextResponse.json({ error: 'No refresh token' }, { status: 401 }) + } + + try { + const res = await fetch(`${AUTH_API_URL}/api/v1/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }) + + if (!res.ok) { + // * If refresh fails, clear cookies + cookieStore.delete('access_token') + cookieStore.delete('refresh_token') + return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }) + } + + const data = await res.json() + + cookieStore.set('access_token', data.access_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 15 + }) + + cookieStore.set('refresh_token', data.refresh_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 30 + }) + + return NextResponse.json({ success: true, access_token: data.access_token }) + } catch (error) { + return NextResponse.json({ error: 'Internal error' }, { status: 500 }) + } +} diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index ce1c671..0d452b7 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, Suspense, useRef } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { useAuth } from '@/lib/auth/context' import { AUTH_URL } from '@/lib/api/client' +import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth' function AuthCallbackContent() { const router = useRouter() @@ -17,23 +18,24 @@ function AuthCallbackContent() { if (processedRef.current) return // * Check for direct token passing (from auth-frontend direct login) + // * TODO: This flow exposes tokens in URL, should be deprecated in favor of Authorization Code flow const token = searchParams.get('token') const refreshToken = searchParams.get('refresh_token') if (token && refreshToken) { processedRef.current = true - try { - const payload = JSON.parse(atob(token.split('.')[1])) - login(token, refreshToken, { - id: payload.sub, - email: payload.email || 'user@ciphera.net', - totp_enabled: payload.totp_enabled || false - }) - const returnTo = searchParams.get('returnTo') || '/' - router.push(returnTo) - } catch (e) { - setError('Invalid token received') + + const handleDirectTokens = async () => { + const result = await setSessionAction(token, refreshToken) + if (result.success && result.user) { + login(result.user) + const returnTo = searchParams.get('returnTo') || '/' + router.push(returnTo) + } else { + setError('Invalid token received') + } } + handleDirectTokens() return } @@ -54,43 +56,26 @@ function AuthCallbackContent() { } if (state !== storedState) { - // * Debugging: Log state mismatch to help user diagnose console.error('State mismatch', { received: state, stored: storedState }) setError('Invalid state') return } + if (!codeVerifier) { + setError('Missing code verifier') + return + } + const exchangeCode = async () => { try { - const authApiUrl = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081' - const res = await fetch(`${authApiUrl}/oauth/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - grant_type: 'authorization_code', - code, - client_id: 'analytics-app', - redirect_uri: window.location.origin + '/auth/callback', - code_verifier: codeVerifier, - }), - }) + const redirectUri = window.location.origin + '/auth/callback' + const result = await exchangeAuthCode(code, codeVerifier, redirectUri) - if (!res.ok) { - const data = await res.json() - throw new Error(data.error || 'Failed to exchange token') + if (!result.success || !result.user) { + throw new Error(result.error || 'Failed to exchange token') } - - const data = await res.json() - const payload = JSON.parse(atob(data.access_token.split('.')[1])) - - login(data.access_token, data.refresh_token, { - id: payload.sub, - email: payload.email || 'user@ciphera.net', // Fallback if email claim missing - totp_enabled: payload.totp_enabled || false - }) + login(result.user) // * Cleanup localStorage.removeItem('oauth_state') diff --git a/lib/api/client.ts b/lib/api/client.ts index e63bc68..9b725d9 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -27,14 +27,14 @@ export class ApiError extends Error { // * Mutex for token refresh let isRefreshing = false -let refreshSubscribers: ((token: string) => void)[] = [] +let refreshSubscribers: (() => void)[] = [] -function subscribeToTokenRefresh(cb: (token: string) => void) { +function subscribeToTokenRefresh(cb: () => void) { refreshSubscribers.push(cb) } -function onRefreshed(token: string) { - refreshSubscribers.map((cb) => cb(token)) +function onRefreshed() { + refreshSubscribers.map((cb) => cb()) refreshSubscribers = [] } @@ -55,40 +55,31 @@ async function apiRequest( ...options.headers, } - // Inject Auth Token if available (Client-side only) - if (typeof window !== 'undefined') { - const token = localStorage.getItem('token') - if (token) { - (headers as any)['Authorization'] = `Bearer ${token}` - } - } + // * We rely on HttpOnly cookies, so no manual Authorization header injection. + // * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site). const response = await fetch(url, { ...options, headers, + credentials: 'include', // * IMPORTANT: Send cookies }) if (!response.ok) { if (response.status === 401) { // * Attempt Token Refresh if 401 if (typeof window !== 'undefined') { - const refreshToken = localStorage.getItem('refreshToken') - - // * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request - if (refreshToken && !endpoint.includes('/auth/refresh')) { + // * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request (unlikely via apiRequest but safe to check) + if (!endpoint.includes('/auth/refresh')) { if (isRefreshing) { // * If refresh is already in progress, wait for it to complete return new Promise((resolve, reject) => { - subscribeToTokenRefresh(async (newToken) => { - // Retry original request with new token - const newHeaders = { - ...headers, - 'Authorization': `Bearer ${newToken}`, - } + subscribeToTokenRefresh(async () => { + // Retry original request (browser uses new cookie) try { const retryResponse = await fetch(url, { ...options, - headers: newHeaders, + headers, + credentials: 'include', }) if (retryResponse.ok) { resolve(retryResponse.json()) @@ -105,30 +96,21 @@ async function apiRequest( isRefreshing = true try { - const refreshRes = await fetch(`${AUTH_API_URL}/api/v1/auth/refresh`, { + // * Call our internal Next.js API route to handle refresh securely + const refreshRes = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - refresh_token: refreshToken, - }), }) if (refreshRes.ok) { - const data = await refreshRes.json() - localStorage.setItem('token', data.access_token) - localStorage.setItem('refreshToken', data.refresh_token) // Rotation - - // Notify waiting requests - onRefreshed(data.access_token) + // * Refresh successful, cookies updated + onRefreshed() - // * Retry original request with new token - const newHeaders = { - ...headers, - 'Authorization': `Bearer ${data.access_token}`, - } + // * Retry original request const retryResponse = await fetch(url, { ...options, - headers: newHeaders, + headers, + credentials: 'include', }) if (retryResponse.ok) { @@ -136,9 +118,9 @@ async function apiRequest( } } else { // * Refresh failed, logout - localStorage.removeItem('token') - localStorage.removeItem('refreshToken') localStorage.removeItem('user') + // * Redirect to login if needed, or let the app handle 401 + // window.location.href = '/' } } catch (e) { // * Network error during refresh diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index 77d9972..0cbb96e 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -4,6 +4,7 @@ import React, { createContext, useContext, useEffect, useState, useCallback } fr import { useRouter } from 'next/navigation' import apiRequest from '@/lib/api/client' import LoadingOverlay from '@/components/LoadingOverlay' +import { logoutAction, getSessionAction } from '@/app/actions/auth' interface User { id: string @@ -14,7 +15,7 @@ interface User { interface AuthContextType { user: User | null loading: boolean - login: (token: string, refreshToken: string, user: User) => void + login: (user: User) => void logout: () => void refresh: () => Promise refreshSession: () => Promise @@ -35,19 +36,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [isLoggingOut, setIsLoggingOut] = useState(false) const router = useRouter() - const login = (token: string, refreshToken: string, userData: User) => { - localStorage.setItem('token', token) - localStorage.setItem('refreshToken', refreshToken) + const login = (userData: User) => { + // * We still store user profile in localStorage for optimistic UI, but NOT the token localStorage.setItem('user', JSON.stringify(userData)) setUser(userData) router.refresh() } - const logout = useCallback(() => { + const logout = useCallback(async () => { setIsLoggingOut(true) + await logoutAction() + localStorage.removeItem('user') + // * Clear legacy tokens if they exist localStorage.removeItem('token') localStorage.removeItem('refreshToken') - localStorage.removeItem('user') setTimeout(() => { window.location.href = '/' @@ -61,13 +63,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.setItem('user', JSON.stringify(userData)) } catch (e) { console.error('Failed to refresh user data', e) - const savedUser = localStorage.getItem('user') - if (savedUser && !user) { - try { setUser(JSON.parse(savedUser)) } catch {} - } } router.refresh() - }, [router, user]) + }, [router]) const refreshSession = useCallback(async () => { await refresh() @@ -76,28 +74,24 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // Initial load useEffect(() => { const init = async () => { - const token = localStorage.getItem('token') - const savedUser = localStorage.getItem('user') + // * 1. Check server-side session (cookies) + const session = await getSessionAction() - if (token) { - // Optimistically set from local storage first - if (savedUser) { - try { - setUser(JSON.parse(savedUser)) - } catch (e) { - localStorage.removeItem('user') - } - } - - // Then fetch fresh data - try { - const userData = await apiRequest('/auth/user/me') - setUser(userData) - localStorage.setItem('user', JSON.stringify(userData)) - } catch (e) { - console.error('Failed to fetch initial user data', e) - } + if (session) { + setUser(session) + localStorage.setItem('user', JSON.stringify(session)) + } else { + // * Session invalid/expired + localStorage.removeItem('user') + setUser(null) } + + // * Clear legacy tokens if they exist (migration) + if (localStorage.getItem('token')) { + localStorage.removeItem('token') + localStorage.removeItem('refreshToken') + } + setLoading(false) } init()