// src/lib/auth-context.tsx — Auth provider using Supabase import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react' import type { ReactNode } from 'react' import { Linking } from 'react-native' import type { AuthChangeEvent, User, Session } from '@supabase/supabase-js' import { supabase, isSupabaseConfigured } from './supabase' import { completeAuthRedirect, isAuthRedirectUrl } from './auth-redirect' import { clearAllSecureAuthStorage } from './secure-auth-storage' import { purgeAllAccountLocalData } from './account-local-data' export type AuthPrivacyCleanupState = 'ready' | 'purging' | 'failed' export class AuthPrivacyBoundaryError extends Error { readonly code = 'auth_privacy_boundary_failed' constructor() { super('auth_privacy_boundary_failed') this.name = 'AuthPrivacyBoundaryError' } } interface AuthContextValue { user: User | null session: Session | null loading: boolean recoveryMode: boolean authError: 'callback_failed' | 'bootstrap_failed' | null privacyCleanupState: AuthPrivacyCleanupState finishPasswordRecovery: () => void purgeLocalSession: () => Promise retryPrivacyCleanup: () => Promise } type AuthTransitionEvent = AuthChangeEvent | 'BOOTSTRAP' | 'EXPLICIT' interface PendingAuthTransition { session: Session | null event: AuthTransitionEvent forcePurge: boolean clearSecureAuth: boolean explicit: boolean } const AuthContext = createContext({ user: null, session: null, loading: true, recoveryMode: false, authError: null, privacyCleanupState: 'ready', finishPasswordRecovery: () => undefined, purgeLocalSession: async () => undefined, retryPrivacyCleanup: async () => undefined, }) export function AuthProvider({ children }: { children: ReactNode }): React.ReactElement { const [committedUser, setCommittedUser] = useState(null) const [committedSession, setCommittedSession] = useState(null) const [bootstrapLoading, setBootstrapLoading] = useState(true) const [recoveryMode, setRecoveryMode] = useState(false) const [authError, setAuthError] = useState(null) const [privacyCleanupState, setPrivacyCleanupState] = useState('ready') const mountedRef = useRef(true) const committedSessionRef = useRef(null) const privacyCleanupStateRef = useRef('ready') const transitionGenerationRef = useRef(0) const pendingTransitionRef = useRef(null) const updatePrivacyCleanupState = useCallback((state: AuthPrivacyCleanupState): void => { privacyCleanupStateRef.current = state if (mountedRef.current) setPrivacyCleanupState(state) }, []) const commitSession = useCallback(( nextSession: Session | null, event: AuthTransitionEvent, ): void => { const previousUserId = committedSessionRef.current?.user.id ?? null const nextUserId = nextSession?.user.id ?? null committedSessionRef.current = nextSession if (!mountedRef.current) return setCommittedSession(nextSession) setCommittedUser(nextSession?.user ?? null) if (nextSession === null) { setRecoveryMode(false) } else if (event === 'PASSWORD_RECOVERY') { setRecoveryMode(true) } else if (previousUserId !== nextUserId) { setRecoveryMode(false) } if (event === 'SIGNED_IN' || event === 'PASSWORD_RECOVERY' || event === 'EXPLICIT') { setAuthError(null) } }, []) const runAuthTransition = useCallback(async ( transition: PendingAuthTransition, ): Promise => { const generation = ++transitionGenerationRef.current const previousUserId = committedSessionRef.current?.user.id ?? null const nextUserId = transition.session?.user.id ?? null const needsPurge = transition.forcePurge || privacyCleanupStateRef.current !== 'ready' || (previousUserId !== null && previousUserId !== nextUserId) if (!needsPurge) { pendingTransitionRef.current = null commitSession(transition.session, transition.event) updatePrivacyCleanupState('ready') return true } pendingTransitionRef.current = transition updatePrivacyCleanupState('purging') try { await purgeAllAccountLocalData() if (generation !== transitionGenerationRef.current) return false if (transition.clearSecureAuth) { if (!transition.explicit) { // A SIGNED_OUT notification can race with a direct A -> B account // replacement. Preserve a newer different-account session that the // auth client has already committed to secure storage. try { const { data } = await supabase.auth.getSession() if (generation !== transitionGenerationRef.current) return false const latestSession = data.session const latestUserId = latestSession?.user.id ?? null if (latestSession !== null && latestUserId !== previousUserId) { pendingTransitionRef.current = null commitSession(latestSession, 'SIGNED_IN') updatePrivacyCleanupState('ready') return true } } catch { // If the auth client cannot prove that a newer session exists, // fail closed by removing the signed-out account's secure data. } } try { await supabase.auth.stopAutoRefresh() } catch { // Secure deletion is still required when refresh shutdown fails. } try { await clearAllSecureAuthStorage() } finally { void supabase.auth.startAutoRefresh() } } if (generation !== transitionGenerationRef.current) return false pendingTransitionRef.current = null commitSession(transition.session, transition.event) updatePrivacyCleanupState('ready') return true } catch { if (generation !== transitionGenerationRef.current) return false pendingTransitionRef.current = transition updatePrivacyCleanupState('failed') return false } }, [commitSession, updatePrivacyCleanupState]) useEffect(() => { mountedRef.current = true let authSubscription: { unsubscribe: () => void } | null = null const handleUrl = async (url: string): Promise => { if (!isAuthRedirectUrl(url)) return try { const redirectType = await completeAuthRedirect(url) if (!mountedRef.current) return if (redirectType === 'recovery') setRecoveryMode(true) if (redirectType === 'recovery' || redirectType === 'signed-in' || redirectType === 'cancelled') { setAuthError(null) } } catch { if (mountedRef.current) setAuthError('callback_failed') } } const linkSubscription = Linking.addEventListener('url', ({ url }) => { void handleUrl(url) }) const subscribeToAuthChanges = (): void => { if (!mountedRef.current || authSubscription !== null) return authSubscription = supabase.auth.onAuthStateChange((event, nextSession) => { if (!mountedRef.current) return const previousUserId = committedSessionRef.current?.user.id ?? null const nextUserId = nextSession?.user.id ?? null void runAuthTransition({ session: nextSession, event, forcePurge: false, clearSecureAuth: nextSession === null && previousUserId !== null, explicit: false, }) if (event === 'SIGNED_IN' || event === 'PASSWORD_RECOVERY') setAuthError(null) if (event === 'PASSWORD_RECOVERY' && previousUserId === nextUserId) { setRecoveryMode(true) } }).data.subscription } void (async () => { if (!isSupabaseConfigured()) { await runAuthTransition({ session: null, event: 'BOOTSTRAP', forcePurge: true, clearSecureAuth: false, explicit: false, }) if (mountedRef.current) setBootstrapLoading(false) return } try { const initialUrl = await Linking.getInitialURL() if (initialUrl !== null) await handleUrl(initialUrl) const { data: { session: restoredSession }, error } = await supabase.auth.getSession() if (error !== null) throw error // A restored account owns its existing local caches. A cold boot with // no session has no trustworthy owner and must purge before Login. await runAuthTransition({ session: restoredSession, event: 'BOOTSTRAP', forcePurge: restoredSession === null, clearSecureAuth: false, explicit: false, }) } catch { if (mountedRef.current) setAuthError('bootstrap_failed') await runAuthTransition({ session: null, event: 'BOOTSTRAP', forcePurge: true, clearSecureAuth: false, explicit: false, }) } finally { subscribeToAuthChanges() if (mountedRef.current) setBootstrapLoading(false) } })() return () => { mountedRef.current = false transitionGenerationRef.current += 1 linkSubscription.remove() authSubscription?.unsubscribe() } }, [runAuthTransition]) const finishPasswordRecovery = (): void => { setRecoveryMode(false) } const purgeLocalSession = useCallback(async (): Promise => { const succeeded = await runAuthTransition({ session: null, event: 'EXPLICIT', forcePurge: true, clearSecureAuth: true, explicit: true, }) if (!succeeded && privacyCleanupStateRef.current === 'failed') { throw new AuthPrivacyBoundaryError() } }, [runAuthTransition]) const retryPrivacyCleanup = useCallback(async (): Promise => { const pending = pendingTransitionRef.current if (pending === null) { updatePrivacyCleanupState('ready') return } await runAuthTransition({ ...pending, forcePurge: true }) }, [runAuthTransition, updatePrivacyCleanupState]) const authBoundaryReady = privacyCleanupState === 'ready' const loading = bootstrapLoading || !authBoundaryReady return ( {children} ) } export function useAuth(): AuthContextValue { return useContext(AuthContext) }