318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
// 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<void>
|
|
retryPrivacyCleanup: () => Promise<void>
|
|
}
|
|
|
|
type AuthTransitionEvent = AuthChangeEvent | 'BOOTSTRAP' | 'EXPLICIT'
|
|
|
|
interface PendingAuthTransition {
|
|
session: Session | null
|
|
event: AuthTransitionEvent
|
|
forcePurge: boolean
|
|
clearSecureAuth: boolean
|
|
explicit: boolean
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextValue>({
|
|
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<User | null>(null)
|
|
const [committedSession, setCommittedSession] = useState<Session | null>(null)
|
|
const [bootstrapLoading, setBootstrapLoading] = useState(true)
|
|
const [recoveryMode, setRecoveryMode] = useState(false)
|
|
const [authError, setAuthError] = useState<AuthContextValue['authError']>(null)
|
|
const [privacyCleanupState, setPrivacyCleanupState] = useState<AuthPrivacyCleanupState>('ready')
|
|
const mountedRef = useRef(true)
|
|
const committedSessionRef = useRef<Session | null>(null)
|
|
const privacyCleanupStateRef = useRef<AuthPrivacyCleanupState>('ready')
|
|
const transitionGenerationRef = useRef(0)
|
|
const pendingTransitionRef = useRef<PendingAuthTransition | null>(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<boolean> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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 (
|
|
<AuthContext.Provider
|
|
value={{
|
|
user: authBoundaryReady ? committedUser : null,
|
|
session: authBoundaryReady ? committedSession : null,
|
|
loading,
|
|
recoveryMode: authBoundaryReady ? recoveryMode : false,
|
|
authError,
|
|
privacyCleanupState,
|
|
finishPasswordRecovery,
|
|
purgeLocalSession,
|
|
retryPrivacyCleanup,
|
|
}}
|
|
>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useAuth(): AuthContextValue {
|
|
return useContext(AuthContext)
|
|
}
|