d3ro-voice/apps/mobile-rn/src/lib/auth-context.tsx
2026-08-29 18:33:45 +09:00

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)
}