From 3e7273363b1b1a50b98ef92276b4910d51fe1e5f Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 18 Jan 2026 21:32:47 +0100 Subject: [PATCH] fix: resolve frontend build errors and enable credentials for options requests in backend --- app/auth/callback/page.tsx | 269 +++++++++++++++++----------------- lib/api/client.ts | 293 ++++++++++++++++++------------------- lib/auth/context.tsx | 217 ++++++++++++++------------- 3 files changed, 388 insertions(+), 391 deletions(-) diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx index 7b9b7c2..0d452b7 100644 --- a/app/auth/callback/page.tsx +++ b/app/auth/callback/page.tsx @@ -1,135 +1,134 @@ - 1|'use client' - 2| - 3|import { useEffect, useState, Suspense, useRef } from 'react' - 4|import { useRouter, useSearchParams } from 'next/navigation' - 5|import { useAuth } from '@/lib/auth/context' - 6|import { AUTH_URL } from '@/lib/api/client' - 7|import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth' - 8| - 9|function AuthCallbackContent() { - 10| const router = useRouter() - 11| const searchParams = useSearchParams() - 12| const { login } = useAuth() - 13| const [error, setError] = useState(null) - 14| const processedRef = useRef(false) - 15| - 16| useEffect(() => { - 17| // * Prevent double execution (React Strict Mode or fast re-renders) - 18| if (processedRef.current) return - 19| - 20| // * Check for direct token passing (from auth-frontend direct login) - 21| // * TODO: This flow exposes tokens in URL, should be deprecated in favor of Authorization Code flow - 22| const token = searchParams.get('token') - 23| const refreshToken = searchParams.get('refresh_token') - 24| - 25| if (token && refreshToken) { - 26| processedRef.current = true - 27| - 28| const handleDirectTokens = async () => { - 29| const result = await setSessionAction(token, refreshToken) - 30| if (result.success && result.user) { - 31| login(result.user) - 32| const returnTo = searchParams.get('returnTo') || '/' - 33| router.push(returnTo) - 34| } else { - 35| setError('Invalid token received') - 36| } - 37| } - 38| handleDirectTokens() - 39| return - 40| } - 41| - 42| const code = searchParams.get('code') - 43| const state = searchParams.get('state') - 44| - 45| // * Skip if params are missing (might be initial render before params are ready) - 46| if (!code || !state) return - 47| - 48| processedRef.current = true - 49| - 50| const storedState = localStorage.getItem('oauth_state') - 51| const codeVerifier = localStorage.getItem('oauth_code_verifier') - 52| - 53| if (!code || !state) { - 54| setError('Missing code or state') - 55| return - 56| } - 57| - 58| if (state !== storedState) { - 59| console.error('State mismatch', { received: state, stored: storedState }) - 60| setError('Invalid state') - 61| return - 62| } - 63| - 64| if (!codeVerifier) { - 65| setError('Missing code verifier') - 66| return - 67| } - 68| - 69| const exchangeCode = async () => { - 70| try { - 71| const redirectUri = window.location.origin + '/auth/callback' - 72| const result = await exchangeAuthCode(code, codeVerifier, redirectUri) - 73| - 74| if (!result.success || !result.user) { - 75| throw new Error(result.error || 'Failed to exchange token') - 76| } - 77| - 78| login(result.user) - 79| - 80| // * Cleanup - 81| localStorage.removeItem('oauth_state') - 82| localStorage.removeItem('oauth_code_verifier') - 83| - 84| router.push('/') - 85| } catch (err: any) { - 86| setError(err.message) - 87| } - 88| } - 89| - 90| exchangeCode() - 91| }, [searchParams, login, router]) - 92| - 93| if (error) { - 94| return ( - 95|
- 96|
- 97| Error: {error} - 98|
- 99| - 105|
- 106|
- 107|
- 108| ) - 109| } - 110| - 111| return ( - 112|
- 113|
- 114|
- 115|

Completing sign in...

- 116|
- 117|
- 118| ) - 119|} - 120| - 121|export default function AuthCallback() { - 122| return ( - 123| - 125|
- 126|
- 127|

Loading...

- 128|
- 129| - 130| }> - 131| - 132|
- 133| ) - 134|} - 135| \ No newline at end of file +'use client' + +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() + const searchParams = useSearchParams() + const { login } = useAuth() + const [error, setError] = useState(null) + const processedRef = useRef(false) + + useEffect(() => { + // * Prevent double execution (React Strict Mode or fast re-renders) + 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 + + 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 + } + + const code = searchParams.get('code') + const state = searchParams.get('state') + + // * Skip if params are missing (might be initial render before params are ready) + if (!code || !state) return + + processedRef.current = true + + const storedState = localStorage.getItem('oauth_state') + const codeVerifier = localStorage.getItem('oauth_code_verifier') + + if (!code || !state) { + setError('Missing code or state') + return + } + + if (state !== storedState) { + console.error('State mismatch', { received: state, stored: storedState }) + setError('Invalid state') + return + } + + if (!codeVerifier) { + setError('Missing code verifier') + return + } + + const exchangeCode = async () => { + try { + const redirectUri = window.location.origin + '/auth/callback' + const result = await exchangeAuthCode(code, codeVerifier, redirectUri) + + if (!result.success || !result.user) { + throw new Error(result.error || 'Failed to exchange token') + } + + login(result.user) + + // * Cleanup + localStorage.removeItem('oauth_state') + localStorage.removeItem('oauth_code_verifier') + + router.push('/') + } catch (err: any) { + setError(err.message) + } + } + + exchangeCode() + }, [searchParams, login, router]) + + if (error) { + return ( +
+
+ Error: {error} +
+ +
+
+
+ ) + } + + return ( +
+
+
+

Completing sign in...

+
+
+ ) +} + +export default function AuthCallback() { + return ( + +
+
+

Loading...

+
+ + }> + +
+ ) +} diff --git a/lib/api/client.ts b/lib/api/client.ts index 6105940..9b725d9 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -1,147 +1,146 @@ - 1|/** - 2| * HTTP client wrapper for API calls - 3| */ - 4| - 5|export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082' - 6|export const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3000' - 7|export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003' - 8|export const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || 'https://auth-api.ciphera.net' - 9| - 10|export function getLoginUrl(redirectPath = '/auth/callback') { - 11| const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) - 12| return `${AUTH_URL}/login?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code` - 13|} - 14| - 15|export function getSignupUrl(redirectPath = '/auth/callback') { - 16| const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) - 17| return `${AUTH_URL}/signup?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code` - 18|} - 19| - 20|export class ApiError extends Error { - 21| status: number - 22| constructor(message: string, status: number) { - 23| super(message) - 24| this.status = status - 25| } - 26|} - 27| - 28|// * Mutex for token refresh - 29|let isRefreshing = false - 30|let refreshSubscribers: (() => void)[] = [] - 31| - 32|function subscribeToTokenRefresh(cb: () => void) { - 33| refreshSubscribers.push(cb) - 34|} - 35| - 36|function onRefreshed() { - 37| refreshSubscribers.map((cb) => cb()) - 38| refreshSubscribers = [] - 39|} - 40| - 41|/** - 42| * Base API client with error handling - 43| */ - 44|async function apiRequest( - 45| endpoint: string, - 46| options: RequestInit = {} - 47|): Promise { - 48| // * Determine base URL - 49| const isAuthRequest = endpoint.startsWith('/auth') - 50| const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL - 51| const url = `${baseUrl}/api/v1${endpoint}` - 52| - 53| const headers: HeadersInit = { - 54| 'Content-Type': 'application/json', - 55| ...options.headers, - 56| } - 57| - 58| // * We rely on HttpOnly cookies, so no manual Authorization header injection. - 59| // * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site). - 60| - 61| const response = await fetch(url, { - 62| ...options, - 63| headers, - 64| credentials: 'include', // * IMPORTANT: Send cookies - 65| }) - 66| - 67| if (!response.ok) { - 68| if (response.status === 401) { - 69| // * Attempt Token Refresh if 401 - 70| if (typeof window !== 'undefined') { - 71| // * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request (unlikely via apiRequest but safe to check) - 72| if (!endpoint.includes('/auth/refresh')) { - 73| if (isRefreshing) { - 74| // * If refresh is already in progress, wait for it to complete - 75| return new Promise((resolve, reject) => { - 76| subscribeToTokenRefresh(async () => { - 77| // Retry original request (browser uses new cookie) - 78| try { - 79| const retryResponse = await fetch(url, { - 80| ...options, - 81| headers, - 82| credentials: 'include', - 83| }) - 84| if (retryResponse.ok) { - 85| resolve(retryResponse.json()) - 86| } else { - 87| reject(new ApiError('Retry failed', retryResponse.status)) - 88| } - 89| } catch (e) { - 90| reject(e) - 91| } - 92| }) - 93| }) - 94| } - 95| - 96| isRefreshing = true - 97| - 98| try { - 99| // * Call our internal Next.js API route to handle refresh securely - 100| const refreshRes = await fetch('/api/auth/refresh', { - 101| method: 'POST', - 102| headers: { 'Content-Type': 'application/json' }, - 103| }) - 104| - 105| if (refreshRes.ok) { - 106| // * Refresh successful, cookies updated - 107| onRefreshed() - 108| - 109| // * Retry original request - 110| const retryResponse = await fetch(url, { - 111| ...options, - 112| headers, - 113| credentials: 'include', - 114| }) - 115| - 116| if (retryResponse.ok) { - 117| return retryResponse.json() - 118| } - 119| } else { - 120| // * Refresh failed, logout - 121| localStorage.removeItem('user') - 122| // * Redirect to login if needed, or let the app handle 401 - 123| // window.location.href = '/' - 124| } - 125| } catch (e) { - 126| // * Network error during refresh - 127| throw e - 128| } finally { - 129| isRefreshing = false - 130| } - 131| } - 132| } - 133| } - 134| - 135| const errorBody = await response.json().catch(() => ({ - 136| error: 'Unknown error', - 137| message: `HTTP ${response.status}: ${response.statusText}`, - 138| })) - 139| throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status) - 140| } - 141| - 142| return response.json() - 143|} - 144| - 145|export const authFetch = apiRequest - 146|export default apiRequest - 147| \ No newline at end of file +/** + * HTTP client wrapper for API calls + */ + +export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082' +export const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3000' +export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003' +export const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || 'https://auth-api.ciphera.net' + +export function getLoginUrl(redirectPath = '/auth/callback') { + const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) + return `${AUTH_URL}/login?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code` +} + +export function getSignupUrl(redirectPath = '/auth/callback') { + const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) + return `${AUTH_URL}/signup?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code` +} + +export class ApiError extends Error { + status: number + constructor(message: string, status: number) { + super(message) + this.status = status + } +} + +// * Mutex for token refresh +let isRefreshing = false +let refreshSubscribers: (() => void)[] = [] + +function subscribeToTokenRefresh(cb: () => void) { + refreshSubscribers.push(cb) +} + +function onRefreshed() { + refreshSubscribers.map((cb) => cb()) + refreshSubscribers = [] +} + +/** + * Base API client with error handling + */ +async function apiRequest( + endpoint: string, + options: RequestInit = {} +): Promise { + // * Determine base URL + const isAuthRequest = endpoint.startsWith('/auth') + const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL + const url = `${baseUrl}/api/v1${endpoint}` + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + } + + // * 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') { + // * 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 () => { + // Retry original request (browser uses new cookie) + try { + const retryResponse = await fetch(url, { + ...options, + headers, + credentials: 'include', + }) + if (retryResponse.ok) { + resolve(retryResponse.json()) + } else { + reject(new ApiError('Retry failed', retryResponse.status)) + } + } catch (e) { + reject(e) + } + }) + }) + } + + isRefreshing = true + + try { + // * 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' }, + }) + + if (refreshRes.ok) { + // * Refresh successful, cookies updated + onRefreshed() + + // * Retry original request + const retryResponse = await fetch(url, { + ...options, + headers, + credentials: 'include', + }) + + if (retryResponse.ok) { + return retryResponse.json() + } + } else { + // * Refresh failed, logout + localStorage.removeItem('user') + // * Redirect to login if needed, or let the app handle 401 + // window.location.href = '/' + } + } catch (e) { + // * Network error during refresh + throw e + } finally { + isRefreshing = false + } + } + } + } + + const errorBody = await response.json().catch(() => ({ + error: 'Unknown error', + message: `HTTP ${response.status}: ${response.statusText}`, + })) + throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status) + } + + return response.json() +} + +export const authFetch = apiRequest +export default apiRequest diff --git a/lib/auth/context.tsx b/lib/auth/context.tsx index 14ce63c..0cbb96e 100644 --- a/lib/auth/context.tsx +++ b/lib/auth/context.tsx @@ -1,109 +1,108 @@ - 1|'use client' - 2| - 3|import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' - 4|import { useRouter } from 'next/navigation' - 5|import apiRequest from '@/lib/api/client' - 6|import LoadingOverlay from '@/components/LoadingOverlay' - 7|import { logoutAction, getSessionAction } from '@/app/actions/auth' - 8| - 9|interface User { - 10| id: string - 11| email: string - 12| totp_enabled: boolean - 13|} - 14| - 15|interface AuthContextType { - 16| user: User | null - 17| loading: boolean - 18| login: (user: User) => void - 19| logout: () => void - 20| refresh: () => Promise - 21| refreshSession: () => Promise - 22|} - 23| - 24|const AuthContext = createContext({ - 25| user: null, - 26| loading: true, - 27| login: () => {}, - 28| logout: () => {}, - 29| refresh: async () => {}, - 30| refreshSession: async () => {}, - 31|}) - 32| - 33|export function AuthProvider({ children }: { children: React.ReactNode }) { - 34| const [user, setUser] = useState(null) - 35| const [loading, setLoading] = useState(true) - 36| const [isLoggingOut, setIsLoggingOut] = useState(false) - 37| const router = useRouter() - 38| - 39| const login = (userData: User) => { - 40| // * We still store user profile in localStorage for optimistic UI, but NOT the token - 41| localStorage.setItem('user', JSON.stringify(userData)) - 42| setUser(userData) - 43| router.refresh() - 44| } - 45| - 46| const logout = useCallback(async () => { - 47| setIsLoggingOut(true) - 48| await logoutAction() - 49| localStorage.removeItem('user') - 50| // * Clear legacy tokens if they exist - 51| localStorage.removeItem('token') - 52| localStorage.removeItem('refreshToken') - 53| - 54| setTimeout(() => { - 55| window.location.href = '/' - 56| }, 500) - 57| }, []) - 58| - 59| const refresh = useCallback(async () => { - 60| try { - 61| const userData = await apiRequest('/auth/user/me') - 62| setUser(userData) - 63| localStorage.setItem('user', JSON.stringify(userData)) - 64| } catch (e) { - 65| console.error('Failed to refresh user data', e) - 66| } - 67| router.refresh() - 68| }, [router]) - 69| - 70| const refreshSession = useCallback(async () => { - 71| await refresh() - 72| }, [refresh]) - 73| - 74| // Initial load - 75| useEffect(() => { - 76| const init = async () => { - 77| // * 1. Check server-side session (cookies) - 78| const session = await getSessionAction() - 79| - 80| if (session) { - 81| setUser(session) - 82| localStorage.setItem('user', JSON.stringify(session)) - 83| } else { - 84| // * Session invalid/expired - 85| localStorage.removeItem('user') - 86| setUser(null) - 87| } - 88| - 89| // * Clear legacy tokens if they exist (migration) - 90| if (localStorage.getItem('token')) { - 91| localStorage.removeItem('token') - 92| localStorage.removeItem('refreshToken') - 93| } - 94| - 95| setLoading(false) - 96| } - 97| init() - 98| }, []) - 99| - 100| return ( - 101| - 102| {isLoggingOut && } - 103| {children} - 104| - 105| ) - 106|} - 107| - 108|export const useAuth = () => useContext(AuthContext) - 109| \ No newline at end of file +'use client' + +import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' +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 + email: string + totp_enabled: boolean +} + +interface AuthContextType { + user: User | null + loading: boolean + login: (user: User) => void + logout: () => void + refresh: () => Promise + refreshSession: () => Promise +} + +const AuthContext = createContext({ + user: null, + loading: true, + login: () => {}, + logout: () => {}, + refresh: async () => {}, + refreshSession: async () => {}, +}) + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + const [isLoggingOut, setIsLoggingOut] = useState(false) + const router = useRouter() + + 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(async () => { + setIsLoggingOut(true) + await logoutAction() + localStorage.removeItem('user') + // * Clear legacy tokens if they exist + localStorage.removeItem('token') + localStorage.removeItem('refreshToken') + + setTimeout(() => { + window.location.href = '/' + }, 500) + }, []) + + const refresh = useCallback(async () => { + try { + const userData = await apiRequest('/auth/user/me') + setUser(userData) + localStorage.setItem('user', JSON.stringify(userData)) + } catch (e) { + console.error('Failed to refresh user data', e) + } + router.refresh() + }, [router]) + + const refreshSession = useCallback(async () => { + await refresh() + }, [refresh]) + + // Initial load + useEffect(() => { + const init = async () => { + // * 1. Check server-side session (cookies) + const session = await getSessionAction() + + 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() + }, []) + + return ( + + {isLoggingOut && } + {children} + + ) +} + +export const useAuth = () => useContext(AuthContext)