/** * Auth context provider for AT Protocol OAuth. * Access token held in useRef (memory only, never localStorage/sessionStorage). * Silent refresh on mount via HTTP-only cookie. * @see specs/prd-web.md Section M3 (Auth Flow) */ 'use client' import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import type { AuthSession, AuthUser } from '@/lib/api/types' import { initiateLogin, initiateCrossPostAuth, refreshSession, logout as apiLogout, } from '@/lib/api/client' import { createAuthFetch } from '@/lib/api/auth-fetch' export interface AuthContextValue { /** The current authenticated user, or null */ user: AuthUser | null /** Whether the user is authenticated */ isAuthenticated: boolean /** Whether auth state is still loading (initial refresh) */ isLoading: boolean /** Whether the user has authorized cross-post scopes */ crossPostScopesGranted: boolean /** Get the current access token (stable function ref) */ getAccessToken: () => string | null /** Initiate login flow -- redirects to PDS OAuth */ login: (handle: string) => Promise /** Log out and clear auth state */ logout: () => Promise /** Set session from OAuth callback (stores token in memory) */ setSessionFromCallback: (session: AuthSession) => void /** Initiate cross-post authorization flow (redirects to PDS OAuth with expanded scopes) */ requestCrossPostAuth: () => Promise /** Auth-aware fetch that auto-refreshes on 401 */ authFetch: ( path: string, options?: { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' headers?: Record body?: unknown signal?: AbortSignal } ) => Promise } export const AuthContext = createContext(null) interface AuthProviderProps { children: ReactNode } export function AuthProvider({ children }: AuthProviderProps) { const [user, setUser] = useState(null) const [isLoading, setIsLoading] = useState(true) const [crossPostScopesGranted, setCrossPostScopesGranted] = useState(false) const tokenRef = useRef(null) const getAccessToken = useCallback(() => tokenRef.current, []) const setSession = useCallback((session: AuthSession) => { tokenRef.current = session.accessToken setUser({ did: session.did, handle: session.handle, displayName: session.displayName, avatarUrl: session.avatarUrl, role: session.role, }) setCrossPostScopesGranted(session.crossPostScopesGranted ?? false) }, []) const clearSession = useCallback(() => { tokenRef.current = null setUser(null) setCrossPostScopesGranted(false) }, []) const handleAuthFailure = useCallback(() => { clearSession() }, [clearSession]) const authFetch = useMemo( () => createAuthFetch({ getToken: () => tokenRef.current, setToken: setSession, onAuthFailure: handleAuthFailure, }), [setSession, handleAuthFailure] ) // Silent refresh on mount useEffect(() => { let cancelled = false async function attemptRefresh() { try { const session = await refreshSession() if (!cancelled) { setSession(session) } } catch { // No valid refresh cookie -- user is not logged in } finally { if (!cancelled) { setIsLoading(false) } } } void attemptRefresh() return () => { cancelled = true } }, [setSession]) const login = useCallback(async (handle: string) => { const { url } = await initiateLogin(handle) window.location.href = url }, []) const logout = useCallback(async () => { const token = tokenRef.current if (token) { try { await apiLogout(token) } catch { // Best-effort server-side logout } } clearSession() }, [clearSession]) const requestCrossPostAuth = useCallback(async () => { const token = tokenRef.current if (!token) return sessionStorage.setItem('auth_returnTo', window.location.href) const { url } = await initiateCrossPostAuth(token) window.location.href = url }, []) const value = useMemo( () => ({ user, isAuthenticated: user !== null, isLoading, crossPostScopesGranted, getAccessToken, login, logout, setSessionFromCallback: setSession, requestCrossPostAuth, authFetch, }), [ user, isLoading, crossPostScopesGranted, getAccessToken, login, logout, setSession, requestCrossPostAuth, authFetch, ] ) return {children} }