Barazo default frontend barazo.forum
at main 175 lines 4.8 kB view raw
1/** 2 * Auth context provider for AT Protocol OAuth. 3 * Access token held in useRef (memory only, never localStorage/sessionStorage). 4 * Silent refresh on mount via HTTP-only cookie. 5 * @see specs/prd-web.md Section M3 (Auth Flow) 6 */ 7 8'use client' 9 10import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react' 11import type { ReactNode } from 'react' 12import type { AuthSession, AuthUser } from '@/lib/api/types' 13import { 14 initiateLogin, 15 initiateCrossPostAuth, 16 refreshSession, 17 logout as apiLogout, 18} from '@/lib/api/client' 19import { createAuthFetch } from '@/lib/api/auth-fetch' 20 21export interface AuthContextValue { 22 /** The current authenticated user, or null */ 23 user: AuthUser | null 24 /** Whether the user is authenticated */ 25 isAuthenticated: boolean 26 /** Whether auth state is still loading (initial refresh) */ 27 isLoading: boolean 28 /** Whether the user has authorized cross-post scopes */ 29 crossPostScopesGranted: boolean 30 /** Get the current access token (stable function ref) */ 31 getAccessToken: () => string | null 32 /** Initiate login flow -- redirects to PDS OAuth */ 33 login: (handle: string) => Promise<void> 34 /** Log out and clear auth state */ 35 logout: () => Promise<void> 36 /** Set session from OAuth callback (stores token in memory) */ 37 setSessionFromCallback: (session: AuthSession) => void 38 /** Initiate cross-post authorization flow (redirects to PDS OAuth with expanded scopes) */ 39 requestCrossPostAuth: () => Promise<void> 40 /** Auth-aware fetch that auto-refreshes on 401 */ 41 authFetch: <T>( 42 path: string, 43 options?: { 44 method?: 'GET' | 'POST' | 'PUT' | 'DELETE' 45 headers?: Record<string, string> 46 body?: unknown 47 signal?: AbortSignal 48 } 49 ) => Promise<T> 50} 51 52export const AuthContext = createContext<AuthContextValue | null>(null) 53 54interface AuthProviderProps { 55 children: ReactNode 56} 57 58export function AuthProvider({ children }: AuthProviderProps) { 59 const [user, setUser] = useState<AuthUser | null>(null) 60 const [isLoading, setIsLoading] = useState(true) 61 const [crossPostScopesGranted, setCrossPostScopesGranted] = useState(false) 62 const tokenRef = useRef<string | null>(null) 63 64 const getAccessToken = useCallback(() => tokenRef.current, []) 65 66 const setSession = useCallback((session: AuthSession) => { 67 tokenRef.current = session.accessToken 68 setUser({ 69 did: session.did, 70 handle: session.handle, 71 displayName: session.displayName, 72 avatarUrl: session.avatarUrl, 73 role: session.role, 74 }) 75 setCrossPostScopesGranted(session.crossPostScopesGranted ?? false) 76 }, []) 77 78 const clearSession = useCallback(() => { 79 tokenRef.current = null 80 setUser(null) 81 setCrossPostScopesGranted(false) 82 }, []) 83 84 const handleAuthFailure = useCallback(() => { 85 clearSession() 86 }, [clearSession]) 87 88 const authFetch = useMemo( 89 () => 90 createAuthFetch({ 91 getToken: () => tokenRef.current, 92 setToken: setSession, 93 onAuthFailure: handleAuthFailure, 94 }), 95 [setSession, handleAuthFailure] 96 ) 97 98 // Silent refresh on mount 99 useEffect(() => { 100 let cancelled = false 101 102 async function attemptRefresh() { 103 try { 104 const session = await refreshSession() 105 if (!cancelled) { 106 setSession(session) 107 } 108 } catch { 109 // No valid refresh cookie -- user is not logged in 110 } finally { 111 if (!cancelled) { 112 setIsLoading(false) 113 } 114 } 115 } 116 117 void attemptRefresh() 118 return () => { 119 cancelled = true 120 } 121 }, [setSession]) 122 123 const login = useCallback(async (handle: string) => { 124 const { url } = await initiateLogin(handle) 125 window.location.href = url 126 }, []) 127 128 const logout = useCallback(async () => { 129 const token = tokenRef.current 130 if (token) { 131 try { 132 await apiLogout(token) 133 } catch { 134 // Best-effort server-side logout 135 } 136 } 137 clearSession() 138 }, [clearSession]) 139 140 const requestCrossPostAuth = useCallback(async () => { 141 const token = tokenRef.current 142 if (!token) return 143 sessionStorage.setItem('auth_returnTo', window.location.href) 144 const { url } = await initiateCrossPostAuth(token) 145 window.location.href = url 146 }, []) 147 148 const value = useMemo<AuthContextValue>( 149 () => ({ 150 user, 151 isAuthenticated: user !== null, 152 isLoading, 153 crossPostScopesGranted, 154 getAccessToken, 155 login, 156 logout, 157 setSessionFromCallback: setSession, 158 requestCrossPostAuth, 159 authFetch, 160 }), 161 [ 162 user, 163 isLoading, 164 crossPostScopesGranted, 165 getAccessToken, 166 login, 167 logout, 168 setSession, 169 requestCrossPostAuth, 170 authFetch, 171 ] 172 ) 173 174 return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> 175}