fix(cors): allow credentials on options requests and update frontend auth flow

This commit is contained in:
Usman Baig
2026-01-18 21:27:22 +01:00
parent d4486f952f
commit 2fda4667ed
4 changed files with 535 additions and 531 deletions

View File

@@ -1,143 +1,144 @@
'use server' 1|'use server'
2|
import { cookies } from 'next/headers' 3|import { cookies } from 'next/headers'
4|
const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081' 5|const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8081'
6|
interface AuthResponse { 7|interface AuthResponse {
access_token: string 8| access_token: string
refresh_token: string 9| refresh_token: string
id_token: string 10| id_token: string
expires_in: number 11| expires_in: number
} 12|}
13|
interface UserPayload { 14|interface UserPayload {
sub: string 15| sub: string
email?: string 16| email?: string
totp_enabled?: boolean 17| totp_enabled?: boolean
} 18|}
19|
export async function exchangeAuthCode(code: string, codeVerifier: string, redirectUri: string) { 20|export async function exchangeAuthCode(code: string, codeVerifier: string, redirectUri: string) {
try { 21| try {
const res = await fetch(`${AUTH_API_URL}/oauth/token`, { 22| const res = await fetch(`${AUTH_API_URL}/oauth/token`, {
method: 'POST', 23| method: 'POST',
headers: { 24| headers: {
'Content-Type': 'application/json', 25| 'Content-Type': 'application/json',
}, 26| },
body: JSON.stringify({ 27| body: JSON.stringify({
grant_type: 'authorization_code', 28| grant_type: 'authorization_code',
code, 29| code,
client_id: 'analytics-app', 30| client_id: 'analytics-app',
redirect_uri: redirectUri, 31| redirect_uri: redirectUri,
code_verifier: codeVerifier, 32| code_verifier: codeVerifier,
}), 33| }),
}) 34| })
35|
if (!res.ok) { 36| if (!res.ok) {
const data = await res.json() 37| const data = await res.json()
throw new Error(data.error || 'Failed to exchange token') 38| throw new Error(data.error || 'Failed to exchange token')
} 39| }
40|
const data: AuthResponse = await res.json() 41| const data: AuthResponse = await res.json()
42|
// * Decode payload (without verification, we trust the direct channel to Auth Server) 43| // * Decode payload (without verification, we trust the direct channel to Auth Server)
const payloadPart = data.access_token.split('.')[1] 44| const payloadPart = data.access_token.split('.')[1]
const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) 45| const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString())
46|
// * Set Cookies 47| // * Set Cookies
const cookieStore = await cookies() 48| const cookieStore = await cookies()
49|
// * Access Token 50| // * Access Token
cookieStore.set('access_token', data.access_token, { 51| cookieStore.set('access_token', data.access_token, {
httpOnly: true, 52| httpOnly: true,
secure: process.env.NODE_ENV === 'production', 53| secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', 54| sameSite: 'lax',
path: '/', 55| path: '/',
maxAge: 60 * 15 // 15 minutes (short lived) 56| maxAge: 60 * 15 // 15 minutes (short lived)
}) 57| })
58|
// * Refresh Token (Long lived) 59| // * Refresh Token (Long lived)
cookieStore.set('refresh_token', data.refresh_token, { 60| cookieStore.set('refresh_token', data.refresh_token, {
httpOnly: true, 61| httpOnly: true,
secure: process.env.NODE_ENV === 'production', 62| secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', 63| sameSite: 'lax',
path: '/', 64| path: '/',
maxAge: 60 * 60 * 24 * 30 // 30 days 65| maxAge: 60 * 60 * 24 * 30 // 30 days
}) 66| })
67|
return { 68| return {
success: true, 69| success: true,
user: { 70| user: {
id: payload.sub, 71| id: payload.sub,
email: payload.email || 'user@ciphera.net', 72| email: payload.email || 'user@ciphera.net',
totp_enabled: payload.totp_enabled || false 73| totp_enabled: payload.totp_enabled || false
} 74| }
} 75| }
76|
} catch (error: any) { 77| } catch (error: any) {
console.error('Auth Exchange Error:', error) 78| console.error('Auth Exchange Error:', error)
return { success: false, error: error.message } 79| return { success: false, error: error.message }
} 80| }
} 81|}
82|
export async function setSessionAction(accessToken: string, refreshToken: string) { 83|export async function setSessionAction(accessToken: string, refreshToken: string) {
try { 84| try {
const payloadPart = accessToken.split('.')[1] 85| const payloadPart = accessToken.split('.')[1]
const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) 86| const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString())
87|
const cookieStore = await cookies() 88| const cookieStore = await cookies()
89|
cookieStore.set('access_token', accessToken, { 90| cookieStore.set('access_token', accessToken, {
httpOnly: true, 91| httpOnly: true,
secure: process.env.NODE_ENV === 'production', 92| secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', 93| sameSite: 'lax',
path: '/', 94| path: '/',
maxAge: 60 * 15 95| maxAge: 60 * 15
}) 96| })
97|
cookieStore.set('refresh_token', refreshToken, { 98| cookieStore.set('refresh_token', refreshToken, {
httpOnly: true, 99| httpOnly: true,
secure: process.env.NODE_ENV === 'production', 100| secure: process.env.NODE_ENV === 'production',
sameSite: 'lax', 101| sameSite: 'lax',
path: '/', 102| path: '/',
maxAge: 60 * 60 * 24 * 30 103| maxAge: 60 * 60 * 24 * 30
}) 104| })
105|
return { 106| return {
success: true, 107| success: true,
user: { 108| user: {
id: payload.sub, 109| id: payload.sub,
email: payload.email || 'user@ciphera.net', 110| email: payload.email || 'user@ciphera.net',
totp_enabled: payload.totp_enabled || false 111| totp_enabled: payload.totp_enabled || false
} 112| }
} 113| }
} catch (e) { 114| } catch (e) {
return { success: false, error: 'Invalid token' } 115| return { success: false, error: 'Invalid token' }
} 116| }
} 117|}
118|
export async function logoutAction() { 119|export async function logoutAction() {
const cookieStore = await cookies() 120| const cookieStore = await cookies()
cookieStore.delete('access_token') 121| cookieStore.delete('access_token')
cookieStore.delete('refresh_token') 122| cookieStore.delete('refresh_token')
return { success: true } 123| return { success: true }
} 124|}
125|
export async function getSessionAction() { 126|export async function getSessionAction() {
const cookieStore = await cookies() 127| const cookieStore = await cookies()
const token = cookieStore.get('access_token') 128| const token = cookieStore.get('access_token')
129|
if (!token) return null 130| if (!token) return null
131|
try { 132| try {
const payloadPart = token.value.split('.')[1] 133| const payloadPart = token.value.split('.')[1]
const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) 134| const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString())
return { 135| return {
id: payload.sub, 136| id: payload.sub,
email: payload.email || 'user@ciphera.net', 137| email: payload.email || 'user@ciphera.net',
totp_enabled: payload.totp_enabled || false 138| totp_enabled: payload.totp_enabled || false
} 139| }
} catch { 140| } catch {
return null 141| return null
} 142| }
} 143|}
144|

View File

@@ -1,134 +1,135 @@
'use client' 1|'use client'
2|
import { useEffect, useState, Suspense, useRef } from 'react' 3|import { useEffect, useState, Suspense, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation' 4|import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/lib/auth/context' 5|import { useAuth } from '@/lib/auth/context'
import { AUTH_URL } from '@/lib/api/client' 6|import { AUTH_URL } from '@/lib/api/client'
import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth' 7|import { exchangeAuthCode, setSessionAction } from '@/app/actions/auth'
8|
function AuthCallbackContent() { 9|function AuthCallbackContent() {
const router = useRouter() 10| const router = useRouter()
const searchParams = useSearchParams() 11| const searchParams = useSearchParams()
const { login } = useAuth() 12| const { login } = useAuth()
const [error, setError] = useState<string | null>(null) 13| const [error, setError] = useState<string | null>(null)
const processedRef = useRef(false) 14| const processedRef = useRef(false)
15|
useEffect(() => { 16| useEffect(() => {
// * Prevent double execution (React Strict Mode or fast re-renders) 17| // * Prevent double execution (React Strict Mode or fast re-renders)
if (processedRef.current) return 18| if (processedRef.current) return
19|
// * Check for direct token passing (from auth-frontend direct login) 20| // * 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 21| // * TODO: This flow exposes tokens in URL, should be deprecated in favor of Authorization Code flow
const token = searchParams.get('token') 22| const token = searchParams.get('token')
const refreshToken = searchParams.get('refresh_token') 23| const refreshToken = searchParams.get('refresh_token')
24|
if (token && refreshToken) { 25| if (token && refreshToken) {
processedRef.current = true 26| processedRef.current = true
27|
const handleDirectTokens = async () => { 28| const handleDirectTokens = async () => {
const result = await setSessionAction(token, refreshToken) 29| const result = await setSessionAction(token, refreshToken)
if (result.success && result.user) { 30| if (result.success && result.user) {
login(result.user) 31| login(result.user)
const returnTo = searchParams.get('returnTo') || '/' 32| const returnTo = searchParams.get('returnTo') || '/'
router.push(returnTo) 33| router.push(returnTo)
} else { 34| } else {
setError('Invalid token received') 35| setError('Invalid token received')
} 36| }
} 37| }
handleDirectTokens() 38| handleDirectTokens()
return 39| return
} 40| }
41|
const code = searchParams.get('code') 42| const code = searchParams.get('code')
const state = searchParams.get('state') 43| const state = searchParams.get('state')
44|
// * Skip if params are missing (might be initial render before params are ready) 45| // * Skip if params are missing (might be initial render before params are ready)
if (!code || !state) return 46| if (!code || !state) return
47|
processedRef.current = true 48| processedRef.current = true
49|
const storedState = localStorage.getItem('oauth_state') 50| const storedState = localStorage.getItem('oauth_state')
const codeVerifier = localStorage.getItem('oauth_code_verifier') 51| const codeVerifier = localStorage.getItem('oauth_code_verifier')
52|
if (!code || !state) { 53| if (!code || !state) {
setError('Missing code or state') 54| setError('Missing code or state')
return 55| return
} 56| }
57|
if (state !== storedState) { 58| if (state !== storedState) {
console.error('State mismatch', { received: state, stored: storedState }) 59| console.error('State mismatch', { received: state, stored: storedState })
setError('Invalid state') 60| setError('Invalid state')
return 61| return
} 62| }
63|
if (!codeVerifier) { 64| if (!codeVerifier) {
setError('Missing code verifier') 65| setError('Missing code verifier')
return 66| return
} 67| }
68|
const exchangeCode = async () => { 69| const exchangeCode = async () => {
try { 70| try {
const redirectUri = window.location.origin + '/auth/callback' 71| const redirectUri = window.location.origin + '/auth/callback'
const result = await exchangeAuthCode(code, codeVerifier, redirectUri) 72| const result = await exchangeAuthCode(code, codeVerifier, redirectUri)
73|
if (!result.success || !result.user) { 74| if (!result.success || !result.user) {
throw new Error(result.error || 'Failed to exchange token') 75| throw new Error(result.error || 'Failed to exchange token')
} 76| }
77|
login(result.user) 78| login(result.user)
79|
// * Cleanup 80| // * Cleanup
localStorage.removeItem('oauth_state') 81| localStorage.removeItem('oauth_state')
localStorage.removeItem('oauth_code_verifier') 82| localStorage.removeItem('oauth_code_verifier')
83|
router.push('/') 84| router.push('/')
} catch (err: any) { 85| } catch (err: any) {
setError(err.message) 86| setError(err.message)
} 87| }
} 88| }
89|
exchangeCode() 90| exchangeCode()
}, [searchParams, login, router]) 91| }, [searchParams, login, router])
92|
if (error) { 93| if (error) {
return ( 94| return (
<div className="flex min-h-screen items-center justify-center p-4"> 95| <div className="flex min-h-screen items-center justify-center p-4">
<div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4 text-red-500"> 96| <div className="rounded-md bg-red-50 dark:bg-red-900/20 p-4 text-red-500">
Error: {error} 97| Error: {error}
<div className="mt-4"> 98| <div className="mt-4">
<button 99| <button
onClick={() => window.location.href = `${AUTH_URL}/login`} 100| onClick={() => window.location.href = `${AUTH_URL}/login`}
className="text-sm underline" 101| className="text-sm underline"
> 102| >
Back to Login 103| Back to Login
</button> 104| </button>
</div> 105| </div>
</div> 106| </div>
</div> 107| </div>
) 108| )
} 109| }
110|
return ( 111| return (
<div className="flex min-h-screen items-center justify-center p-4"> 112| <div className="flex min-h-screen items-center justify-center p-4">
<div className="text-center"> 113| <div className="text-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-neutral-800 mx-auto mb-4"></div> 114| <div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-neutral-800 mx-auto mb-4"></div>
<p className="text-neutral-600 dark:text-neutral-400">Completing sign in...</p> 115| <p className="text-neutral-600 dark:text-neutral-400">Completing sign in...</p>
</div> 116| </div>
</div> 117| </div>
) 118| )
} 119|}
120|
export default function AuthCallback() { 121|export default function AuthCallback() {
return ( 122| return (
<Suspense fallback={ 123| <Suspense fallback={
<div className="flex min-h-screen items-center justify-center p-4"> 124| <div className="flex min-h-screen items-center justify-center p-4">
<div className="text-center"> 125| <div className="text-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-neutral-800 mx-auto mb-4"></div> 126| <div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-neutral-800 mx-auto mb-4"></div>
<p className="text-neutral-600 dark:text-neutral-400">Loading...</p> 127| <p className="text-neutral-600 dark:text-neutral-400">Loading...</p>
</div> 128| </div>
</div> 129| </div>
}> 130| }>
<AuthCallbackContent /> 131| <AuthCallbackContent />
</Suspense> 132| </Suspense>
) 133| )
} 134|}
135|

View File

@@ -1,146 +1,147 @@
/** 1|/**
* HTTP client wrapper for API calls 2| * HTTP client wrapper for API calls
*/ 3| */
4|
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082' 5|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' 6|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' 7|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' 8|export const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || 'https://auth-api.ciphera.net'
9|
export function getLoginUrl(redirectPath = '/auth/callback') { 10|export function getLoginUrl(redirectPath = '/auth/callback') {
const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) 11| const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`)
return `${AUTH_URL}/login?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code` 12| return `${AUTH_URL}/login?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code`
} 13|}
14|
export function getSignupUrl(redirectPath = '/auth/callback') { 15|export function getSignupUrl(redirectPath = '/auth/callback') {
const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`) 16| const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`)
return `${AUTH_URL}/signup?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code` 17| return `${AUTH_URL}/signup?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code`
} 18|}
19|
export class ApiError extends Error { 20|export class ApiError extends Error {
status: number 21| status: number
constructor(message: string, status: number) { 22| constructor(message: string, status: number) {
super(message) 23| super(message)
this.status = status 24| this.status = status
} 25| }
} 26|}
27|
// * Mutex for token refresh 28|// * Mutex for token refresh
let isRefreshing = false 29|let isRefreshing = false
let refreshSubscribers: (() => void)[] = [] 30|let refreshSubscribers: (() => void)[] = []
31|
function subscribeToTokenRefresh(cb: () => void) { 32|function subscribeToTokenRefresh(cb: () => void) {
refreshSubscribers.push(cb) 33| refreshSubscribers.push(cb)
} 34|}
35|
function onRefreshed() { 36|function onRefreshed() {
refreshSubscribers.map((cb) => cb()) 37| refreshSubscribers.map((cb) => cb())
refreshSubscribers = [] 38| refreshSubscribers = []
} 39|}
40|
/** 41|/**
* Base API client with error handling 42| * Base API client with error handling
*/ 43| */
async function apiRequest<T>( 44|async function apiRequest<T>(
endpoint: string, 45| endpoint: string,
options: RequestInit = {} 46| options: RequestInit = {}
): Promise<T> { 47|): Promise<T> {
// * Determine base URL 48| // * Determine base URL
const isAuthRequest = endpoint.startsWith('/auth') 49| const isAuthRequest = endpoint.startsWith('/auth')
const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL 50| const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL
const url = `${baseUrl}/api/v1${endpoint}` 51| const url = `${baseUrl}/api/v1${endpoint}`
52|
const headers: HeadersInit = { 53| const headers: HeadersInit = {
'Content-Type': 'application/json', 54| 'Content-Type': 'application/json',
...options.headers, 55| ...options.headers,
} 56| }
57|
// * We rely on HttpOnly cookies, so no manual Authorization header injection. 58| // * 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). 59| // * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site).
60|
const response = await fetch(url, { 61| const response = await fetch(url, {
...options, 62| ...options,
headers, 63| headers,
credentials: 'include', // * IMPORTANT: Send cookies 64| credentials: 'include', // * IMPORTANT: Send cookies
}) 65| })
66|
if (!response.ok) { 67| if (!response.ok) {
if (response.status === 401) { 68| if (response.status === 401) {
// * Attempt Token Refresh if 401 69| // * Attempt Token Refresh if 401
if (typeof window !== 'undefined') { 70| 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) 71| // * 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')) { 72| if (!endpoint.includes('/auth/refresh')) {
if (isRefreshing) { 73| if (isRefreshing) {
// * If refresh is already in progress, wait for it to complete 74| // * If refresh is already in progress, wait for it to complete
return new Promise((resolve, reject) => { 75| return new Promise((resolve, reject) => {
subscribeToTokenRefresh(async () => { 76| subscribeToTokenRefresh(async () => {
// Retry original request (browser uses new cookie) 77| // Retry original request (browser uses new cookie)
try { 78| try {
const retryResponse = await fetch(url, { 79| const retryResponse = await fetch(url, {
...options, 80| ...options,
headers, 81| headers,
credentials: 'include', 82| credentials: 'include',
}) 83| })
if (retryResponse.ok) { 84| if (retryResponse.ok) {
resolve(retryResponse.json()) 85| resolve(retryResponse.json())
} else { 86| } else {
reject(new ApiError('Retry failed', retryResponse.status)) 87| reject(new ApiError('Retry failed', retryResponse.status))
} 88| }
} catch (e) { 89| } catch (e) {
reject(e) 90| reject(e)
} 91| }
}) 92| })
}) 93| })
} 94| }
95|
isRefreshing = true 96| isRefreshing = true
97|
try { 98| try {
// * Call our internal Next.js API route to handle refresh securely 99| // * Call our internal Next.js API route to handle refresh securely
const refreshRes = await fetch('/api/auth/refresh', { 100| const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST', 101| method: 'POST',
headers: { 'Content-Type': 'application/json' }, 102| headers: { 'Content-Type': 'application/json' },
}) 103| })
104|
if (refreshRes.ok) { 105| if (refreshRes.ok) {
// * Refresh successful, cookies updated 106| // * Refresh successful, cookies updated
onRefreshed() 107| onRefreshed()
108|
// * Retry original request 109| // * Retry original request
const retryResponse = await fetch(url, { 110| const retryResponse = await fetch(url, {
...options, 111| ...options,
headers, 112| headers,
credentials: 'include', 113| credentials: 'include',
}) 114| })
115|
if (retryResponse.ok) { 116| if (retryResponse.ok) {
return retryResponse.json() 117| return retryResponse.json()
} 118| }
} else { 119| } else {
// * Refresh failed, logout 120| // * Refresh failed, logout
localStorage.removeItem('user') 121| localStorage.removeItem('user')
// * Redirect to login if needed, or let the app handle 401 122| // * Redirect to login if needed, or let the app handle 401
// window.location.href = '/' 123| // window.location.href = '/'
} 124| }
} catch (e) { 125| } catch (e) {
// * Network error during refresh 126| // * Network error during refresh
throw e 127| throw e
} finally { 128| } finally {
isRefreshing = false 129| isRefreshing = false
} 130| }
} 131| }
} 132| }
} 133| }
134|
const errorBody = await response.json().catch(() => ({ 135| const errorBody = await response.json().catch(() => ({
error: 'Unknown error', 136| error: 'Unknown error',
message: `HTTP ${response.status}: ${response.statusText}`, 137| message: `HTTP ${response.status}: ${response.statusText}`,
})) 138| }))
throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status) 139| throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status)
} 140| }
141|
return response.json() 142| return response.json()
} 143|}
144|
export const authFetch = apiRequest 145|export const authFetch = apiRequest
export default apiRequest 146|export default apiRequest
147|

View File

@@ -1,108 +1,109 @@
'use client' 1|'use client'
2|
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react' 3|import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation' 4|import { useRouter } from 'next/navigation'
import apiRequest from '@/lib/api/client' 5|import apiRequest from '@/lib/api/client'
import LoadingOverlay from '@/components/LoadingOverlay' 6|import LoadingOverlay from '@/components/LoadingOverlay'
import { logoutAction, getSessionAction } from '@/app/actions/auth' 7|import { logoutAction, getSessionAction } from '@/app/actions/auth'
8|
interface User { 9|interface User {
id: string 10| id: string
email: string 11| email: string
totp_enabled: boolean 12| totp_enabled: boolean
} 13|}
14|
interface AuthContextType { 15|interface AuthContextType {
user: User | null 16| user: User | null
loading: boolean 17| loading: boolean
login: (user: User) => void 18| login: (user: User) => void
logout: () => void 19| logout: () => void
refresh: () => Promise<void> 20| refresh: () => Promise<void>
refreshSession: () => Promise<void> 21| refreshSession: () => Promise<void>
} 22|}
23|
const AuthContext = createContext<AuthContextType>({ 24|const AuthContext = createContext<AuthContextType>({
user: null, 25| user: null,
loading: true, 26| loading: true,
login: () => {}, 27| login: () => {},
logout: () => {}, 28| logout: () => {},
refresh: async () => {}, 29| refresh: async () => {},
refreshSession: async () => {}, 30| refreshSession: async () => {},
}) 31|})
32|
export function AuthProvider({ children }: { children: React.ReactNode }) { 33|export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null) 34| const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true) 35| const [loading, setLoading] = useState(true)
const [isLoggingOut, setIsLoggingOut] = useState(false) 36| const [isLoggingOut, setIsLoggingOut] = useState(false)
const router = useRouter() 37| const router = useRouter()
38|
const login = (userData: User) => { 39| const login = (userData: User) => {
// * We still store user profile in localStorage for optimistic UI, but NOT the token 40| // * We still store user profile in localStorage for optimistic UI, but NOT the token
localStorage.setItem('user', JSON.stringify(userData)) 41| localStorage.setItem('user', JSON.stringify(userData))
setUser(userData) 42| setUser(userData)
router.refresh() 43| router.refresh()
} 44| }
45|
const logout = useCallback(async () => { 46| const logout = useCallback(async () => {
setIsLoggingOut(true) 47| setIsLoggingOut(true)
await logoutAction() 48| await logoutAction()
localStorage.removeItem('user') 49| localStorage.removeItem('user')
// * Clear legacy tokens if they exist 50| // * Clear legacy tokens if they exist
localStorage.removeItem('token') 51| localStorage.removeItem('token')
localStorage.removeItem('refreshToken') 52| localStorage.removeItem('refreshToken')
53|
setTimeout(() => { 54| setTimeout(() => {
window.location.href = '/' 55| window.location.href = '/'
}, 500) 56| }, 500)
}, []) 57| }, [])
58|
const refresh = useCallback(async () => { 59| const refresh = useCallback(async () => {
try { 60| try {
const userData = await apiRequest<User>('/auth/user/me') 61| const userData = await apiRequest<User>('/auth/user/me')
setUser(userData) 62| setUser(userData)
localStorage.setItem('user', JSON.stringify(userData)) 63| localStorage.setItem('user', JSON.stringify(userData))
} catch (e) { 64| } catch (e) {
console.error('Failed to refresh user data', e) 65| console.error('Failed to refresh user data', e)
} 66| }
router.refresh() 67| router.refresh()
}, [router]) 68| }, [router])
69|
const refreshSession = useCallback(async () => { 70| const refreshSession = useCallback(async () => {
await refresh() 71| await refresh()
}, [refresh]) 72| }, [refresh])
73|
// Initial load 74| // Initial load
useEffect(() => { 75| useEffect(() => {
const init = async () => { 76| const init = async () => {
// * 1. Check server-side session (cookies) 77| // * 1. Check server-side session (cookies)
const session = await getSessionAction() 78| const session = await getSessionAction()
79|
if (session) { 80| if (session) {
setUser(session) 81| setUser(session)
localStorage.setItem('user', JSON.stringify(session)) 82| localStorage.setItem('user', JSON.stringify(session))
} else { 83| } else {
// * Session invalid/expired 84| // * Session invalid/expired
localStorage.removeItem('user') 85| localStorage.removeItem('user')
setUser(null) 86| setUser(null)
} 87| }
88|
// * Clear legacy tokens if they exist (migration) 89| // * Clear legacy tokens if they exist (migration)
if (localStorage.getItem('token')) { 90| if (localStorage.getItem('token')) {
localStorage.removeItem('token') 91| localStorage.removeItem('token')
localStorage.removeItem('refreshToken') 92| localStorage.removeItem('refreshToken')
} 93| }
94|
setLoading(false) 95| setLoading(false)
} 96| }
init() 97| init()
}, []) 98| }, [])
99|
return ( 100| return (
<AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}> 101| <AuthContext.Provider value={{ user, loading, login, logout, refresh, refreshSession }}>
{isLoggingOut && <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Analytics" />} 102| {isLoggingOut && <LoadingOverlay logoSrc="/ciphera_icon_no_margins.png" title="Ciphera Analytics" />}
{children} 103| {children}
</AuthContext.Provider> 104| </AuthContext.Provider>
) 105| )
} 106|}
107|
export const useAuth = () => useContext(AuthContext) 108|export const useAuth = () => useContext(AuthContext)
109|