import type { AuthChangeEvent, Factor, Session, User } from '../../supabase/functions/_backend/utils/console_contract' /** * Console auth client. Speaks the GoTrue protocol to `/private/console/auth/*` * on the Capgo API, which relays each call to the auth provider with the caller's * address, so the browser never holds an auth-provider host or key. The surface * and result shapes mirror the subset of the previous SDK auth client the console uses. */ export const SESSION_STORAGE_KEY = 'capgo.console.auth' const CODE_VERIFIER_STORAGE_KEY = 'capgo.console.code-verifier' // Sessions written by the previous SDK client, adopted once on first load. const LEGACY_SESSION_KEY = /^sb-[\w-]+-auth-token$/ const EXPIRY_MARGIN_SECONDS = 30 const AUTO_REFRESH_TICK_MS = 30_000 const AUTO_REFRESH_WINDOW_SECONDS = 90 export class ConsoleAuthError extends Error { status: number code: string | undefined constructor(message: string, status: number, code?: string, name = 'AuthApiError') { super(message) this.name = name this.status = status this.code = code } } function sessionMissing() { return new ConsoleAuthError('Auth session missing!', 400, 'session_not_found', 'AuthSessionMissingError') } type Result = { data: T, error: null } | { data: T, error: ConsoleAuthError } type Listener = (event: AuthChangeEvent, session: Session | null) => void export interface ConsoleAuthOptions { /** Base URL of the Capgo API (VITE_API_HOST). */ baseURL: string /** Keep the session in localStorage and refresh it in the background. */ persistSession?: boolean } export interface MfaVerifyResult { access_token: string token_type: string expires_in: number refresh_token: string user: User } interface TokenResponse { access_token: string token_type?: string expires_in?: number expires_at?: number refresh_token: string user: User weak_password?: { reasons: string[], message?: string } } export function resolveConsoleApiBase(configured: string | undefined) { const base = (configured ?? '').replace(/\/$/, '') return base.startsWith('/') ? new URL(base, location.origin).href.replace(/\/$/, '') : base } export function decodeJwtPayload(jwt: string): Record | null { const payload = jwt.split('.')[1] if (!payload) return null try { const base64 = payload.replace(/-/g, '+').replace(/_/g, '/') const text = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')) return JSON.parse(decodeURIComponent(Array.from(text, char => `%${(char.codePointAt(0) ?? 0).toString(16).padStart(2, '0')}`).join(''))) } catch { return null } } function decodeJwtHeader(jwt: string): Record | null { const header = jwt.split('.')[0] try { return JSON.parse(atob(header.replace(/-/g, '+').replace(/_/g, '/'))) } catch { return null } } function storage(): Storage | null { try { return globalThis.localStorage ?? null } catch { return null } } function readStoredSession(key: string): Session | null { const raw = storage()?.getItem(key) if (!raw) return null try { const parsed = JSON.parse(raw) as Partial return typeof parsed?.access_token === 'string' && typeof parsed.refresh_token === 'string' && parsed.user ? parsed as Session : null } catch { return null } } function captcha(token?: string) { return token ? { gotrue_meta_security: { captcha_token: token } } : {} } function withRedirect(path: string, redirectTo?: string) { if (!redirectTo) return path const separator = path.includes('?') ? '&' : '?' return `${path}${separator}redirect_to=${encodeURIComponent(redirectTo)}` } export function createConsoleAuthClient(options: ConsoleAuthOptions) { const persist = options.persistSession ?? true const listeners = new Set() let current: Session | null = null let refreshing: Promise> | null = null let refreshTimer: ReturnType | undefined // Access token last confirmed by the provider; claims are only trusted after that check. let verifiedToken: string | null = null interface ClaimsPayload { claims: Record, header: Record, signature: string } // One in-flight /user check per token so concurrent getClaims share the round trip. let verifying: Promise> | null = null let verifyingToken: string | null = null function emit(event: AuthChangeEvent, session: Session | null) { for (const listener of listeners) { try { listener(event, session) } catch (error) { console.error('Auth listener failed', error) } } } function save(session: Session | null) { current = session if (!session) { verifiedToken = null verifying = null verifyingToken = null } if (!persist) return const store = storage() if (!store) return if (session) store.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)) else store.removeItem(SESSION_STORAGE_KEY) } function load() { if (!persist) return const store = storage() if (!store) return current = readStoredSession(SESSION_STORAGE_KEY) if (current) return // Adopt and retire a session persisted by the former SDK client. for (let index = 0; index < store.length; index++) { const key = store.key(index) if (!key || !LEGACY_SESSION_KEY.test(key)) continue const legacy = readStoredSession(key) store.removeItem(key) store.removeItem(`${key}-code-verifier`) if (legacy) { save(legacy) return } } } function toSession(response: TokenResponse): Session { const expiresIn = response.expires_in ?? 3600 const exp = decodeJwtPayload(response.access_token)?.exp return { access_token: response.access_token, token_type: response.token_type ?? 'bearer', expires_in: expiresIn, expires_at: response.expires_at ?? (typeof exp === 'number' ? exp : Math.floor(Date.now() / 1000) + expiresIn), refresh_token: response.refresh_token, user: response.user, ...(response.weak_password ? { weak_password: response.weak_password } : {}), } as Session } async function request(method: string, path: string, init: { body?: unknown, token?: string | null } = {}): Promise> { const headers: Record = { 'Content-Type': 'application/json', 'X-Client-Info': 'capgo-console' } if (init.token) headers.Authorization = `Bearer ${init.token}` let response: Response try { response = await fetch(`${options.baseURL}/private/console/auth/${path}`, { method, headers, body: init.body === undefined ? undefined : JSON.stringify(init.body), }) } catch (error) { return { data: null, error: new ConsoleAuthError(error instanceof Error ? error.message : 'Failed to fetch', 0, 'network_error', 'AuthRetryableFetchError') } } const text = await response.text() let body: any = null if (text) { try { body = JSON.parse(text) } catch { body = { msg: text } } } if (!response.ok) { const message = body?.msg ?? body?.message ?? body?.error_description ?? body?.error ?? `Auth request failed with status ${response.status}` const name = response.status >= 500 || response.status === 429 ? 'AuthRetryableFetchError' : 'AuthApiError' return { data: null, error: new ConsoleAuthError(String(message), response.status, body?.error_code ?? body?.code ?? undefined, name) } } return { data: body as T, error: null } } function isExpiring(session: Session, marginSeconds: number) { return typeof session.expires_at === 'number' && session.expires_at - Date.now() / 1000 < marginSeconds } async function refreshWith(refreshToken: string, event: AuthChangeEvent = 'TOKEN_REFRESHED'): Promise> { if (refreshing) return refreshing refreshing = (async () => { const result = await request('POST', 'token?grant_type=refresh_token', { body: { refresh_token: refreshToken } }) if (result.error) { // The refresh token is gone (rotated elsewhere, revoked, expired): the // session is over. Transient failures keep the current session. if (result.error.name === 'AuthApiError') { save(null) emit('SIGNED_OUT', null) } return { data: { session: null }, error: result.error } } const session = toSession(result.data!) save(session) verifiedToken = session.access_token emit(event, session) return { data: { session }, error: null } })().finally(() => { refreshing = null }) return refreshing } async function getSession(): Promise> { const session = current if (!session) return { data: { session: null }, error: null } if (!isExpiring(session, EXPIRY_MARGIN_SECONDS)) return { data: { session }, error: null } const refreshed = await refreshWith(session.refresh_token) if (refreshed.error && refreshed.error.name !== 'AuthApiError') return { data: { session: current }, error: null } return refreshed } async function accessToken() { return (await getSession()).data.session?.access_token ?? null } function installSession(response: TokenResponse, event: AuthChangeEvent) { const session = toSession(response) save(session) // Tokens freshly issued by the provider are already confirmed. verifiedToken = session.access_token emit(event, session) return session } async function verifyAccessToken(token: string, claims: Record): Promise> { const payload: ClaimsPayload = { claims, header: decodeJwtHeader(token) ?? {}, signature: token.split('.')[2] ?? '' } if (verifiedToken === token) return { data: payload, error: null } if (verifying && verifyingToken === token) return verifying verifyingToken = token const pending = (async (): Promise> => { const { error: userError } = await getUser(token) // Sign-out may have cleared the session while /user was in flight. if (current?.access_token !== token) return { data: null, error: sessionMissing() } if (userError) { // AuthApiError means the provider rejected the token. Transient outages // keep the still-valid local JWT so a brief blip does not bounce to /login. if (userError.name === 'AuthApiError') return { data: null, error: userError } return { data: payload, error: null } } verifiedToken = token return { data: payload, error: null } })().finally(() => { if (verifyingToken === token) { verifying = null verifyingToken = null } }) verifying = pending return pending } async function getUser(jwt?: string): Promise> { const token = jwt ?? await accessToken() if (!token) return { data: { user: null }, error: sessionMissing() } const result = await request('GET', 'user', { token }) if (result.error) return { data: { user: null }, error: result.error } if (!jwt && current && result.data) { current = { ...current, user: result.data } save(current) } return { data: { user: result.data }, error: null } } function startAutoRefresh() { if (!persist || refreshTimer || typeof setInterval !== 'function') return refreshTimer = setInterval(() => { if (current && isExpiring(current, AUTO_REFRESH_WINDOW_SECONDS)) void refreshWith(current.refresh_token) }, AUTO_REFRESH_TICK_MS) if (typeof window !== 'undefined') { window.addEventListener('storage', (event) => { if (event.key !== SESSION_STORAGE_KEY) return const previous = current current = readStoredSession(SESSION_STORAGE_KEY) if (!current && previous) emit('SIGNED_OUT', null) else if (current && (!previous || previous.user?.id !== current.user?.id)) emit('SIGNED_IN', current) else if (current && previous && previous.access_token !== current.access_token) emit('TOKEN_REFRESHED', current) }) } } load() startAutoRefresh() const mfa = { async enroll(params: { factorType: 'totp', friendlyName?: string, issuer?: string }) { const result = await request<{ id: string, type: string, friendly_name?: string, totp: { qr_code: string, secret: string, uri: string } }>('POST', 'factors', { token: await accessToken(), body: { factor_type: params.factorType, friendly_name: params.friendlyName, issuer: params.issuer }, }) if (result.error || !result.data) return { data: null, error: result.error ?? sessionMissing() } const data = result.data // Same inline SVG data URL the previous client produced for usage. if (data.totp?.qr_code && !data.totp.qr_code.startsWith('data:')) data.totp.qr_code = `data:image/svg+xml;utf-8,${encodeURIComponent(data.totp.qr_code)}` return { data, error: null } }, async challenge(params: { factorId: string }) { const result = await request<{ id: string, type: string, expires_at: number }>('POST', `factors/${encodeURIComponent(params.factorId)}/challenge`, { token: await accessToken(), body: {} }) return result.error ? { data: null, error: result.error } : { data: result.data!, error: null } }, async verify(params: { factorId: string, challengeId: string, code: string }) { const result = await request('POST', `factors/${encodeURIComponent(params.factorId)}/verify`, { token: await accessToken(), body: { challenge_id: params.challengeId, code: params.code }, }) if (result.error || !result.data) return { data: null, error: result.error ?? sessionMissing() } installSession(result.data, 'MFA_CHALLENGE_VERIFIED') return { data: result.data, error: null } }, async unenroll(params: { factorId: string }) { const result = await request<{ id: string }>('DELETE', `factors/${encodeURIComponent(params.factorId)}`, { token: await accessToken() }) return result.error ? { data: null, error: result.error } : { data: result.data!, error: null } }, async listFactors() { const { data, error } = await getUser() if (error || !data.user) return { data: null, error: error ?? sessionMissing() } const all = (data.user.factors ?? []) as Factor[] return { data: { all, totp: all.filter(factor => factor.factor_type === 'totp' && factor.status === 'verified'), phone: all.filter(factor => factor.factor_type === 'phone' && factor.status === 'verified'), }, error: null, } }, async getAuthenticatorAssuranceLevel() { const { data, error } = await getSession() if (error) return { data: null, error } if (!data.session) return { data: { currentLevel: null, nextLevel: null, currentAuthenticationMethods: [] as { method: string, timestamp: number }[] }, error: null } const payload = decodeJwtPayload(data.session.access_token) ?? {} const currentLevel: 'aal1' | 'aal2' | null = payload.aal ?? null const verified = (data.session.user.factors ?? []).some(factor => factor.status === 'verified') return { data: { currentLevel, nextLevel: verified ? 'aal2' as const : currentLevel, currentAuthenticationMethods: (payload.amr ?? []) as { method: string, timestamp: number }[], }, error: null, } }, } return { getSession, getUser, async getClaims() { const { data, error } = await getSession() if (error) return { data: null, error } const token = data.session?.access_token if (!token) return { data: null, error: null } const claims = decodeJwtPayload(token) if (!claims) return { data: null, error: new ConsoleAuthError('Invalid session token', 400, 'bad_jwt') } // A session kept through a transient refresh failure must not count as signed in once its token expired. if (typeof claims.exp === 'number' && claims.exp <= Date.now() / 1000) return { data: null, error: new ConsoleAuthError('JWT has expired', 401, 'bad_jwt', 'AuthInvalidJwtError') } // Adopted or restored tokens are confirmed once; provider-issued ones are already marked. return verifyAccessToken(token, claims) }, onAuthStateChange(callback: Listener) { listeners.add(callback) // Like the previous client, report the current state once, asynchronously. void Promise.resolve().then(() => { if (listeners.has(callback)) callback('INITIAL_SESSION', current) }) return { data: { subscription: { id: crypto.randomUUID(), callback, unsubscribe: () => listeners.delete(callback) } } } }, async signInWithPassword(credentials: { email: string, password: string, options?: { captchaToken?: string } }) { const result = await request('POST', 'token?grant_type=password', { body: { email: credentials.email, password: credentials.password, ...captcha(credentials.options?.captchaToken) }, }) if (result.error || !result.data) return { data: { user: null, session: null }, error: result.error ?? new ConsoleAuthError('Empty auth response', 500) } const session = installSession(result.data, 'SIGNED_IN') return { data: { user: session.user, session, weakPassword: result.data.weak_password }, error: null } }, async signUp(credentials: { email: string, password: string, options?: { captchaToken?: string, data?: Record, emailRedirectTo?: string } }) { const result = await request('POST', withRedirect('signup', credentials.options?.emailRedirectTo), { body: { email: credentials.email, password: credentials.password, data: credentials.options?.data ?? {}, ...captcha(credentials.options?.captchaToken) }, }) if (result.error || !result.data) return { data: { user: null, session: null }, error: result.error ?? new ConsoleAuthError('Empty auth response', 500) } if ('access_token' in result.data) { const session = installSession(result.data, 'SIGNED_IN') return { data: { user: session.user, session }, error: null } } return { data: { user: result.data, session: null }, error: null } }, async signOut(params: { scope?: 'global' | 'local' | 'others' } = {}) { const scope = params.scope ?? 'global' const token = await accessToken() let error: ConsoleAuthError | null = null if (token) { const result = await request('POST', `logout?scope=${scope}`, { token, body: {} }) // A session the server already forgot is as signed out as it gets. if (result.error && ![401, 403, 404].includes(result.error.status)) error = result.error } // Signing out is the user's decision: the local session goes even when the // provider could not be reached, so a reload never restores it. if (scope !== 'others') { save(null) emit('SIGNED_OUT', null) } return { error } }, async setSession(tokens: { access_token: string, refresh_token: string }) { if (!tokens.access_token || !tokens.refresh_token) return { data: { user: null, session: null }, error: sessionMissing() } const exp = decodeJwtPayload(tokens.access_token)?.exp if (typeof exp === 'number' && exp - Date.now() / 1000 < EXPIRY_MARGIN_SECONDS) { const refreshed = await refreshWith(tokens.refresh_token, 'SIGNED_IN') return { data: { user: refreshed.data.session?.user ?? null, session: refreshed.data.session }, error: refreshed.error } } const { data, error } = await getUser(tokens.access_token) if (error || !data.user) return { data: { user: null, session: null }, error: error ?? sessionMissing() } const session = installSession({ ...tokens, user: data.user, expires_in: typeof exp === 'number' ? Math.max(0, Math.floor(exp - Date.now() / 1000)) : undefined, expires_at: typeof exp === 'number' ? exp : undefined }, 'SIGNED_IN') return { data: { user: session.user, session }, error: null } }, async refreshSession(tokens?: { refresh_token: string }) { const refreshToken = tokens?.refresh_token ?? current?.refresh_token if (!refreshToken) return { data: { user: null, session: null }, error: sessionMissing() } const refreshed = await refreshWith(refreshToken) return { data: { user: refreshed.data.session?.user ?? null, session: refreshed.data.session }, error: refreshed.error } }, async exchangeCodeForSession(authCode: string) { const store = storage() const verifier = store?.getItem(CODE_VERIFIER_STORAGE_KEY) ?? '' const result = await request('POST', 'token?grant_type=pkce', { body: { auth_code: authCode, code_verifier: verifier } }) store?.removeItem(CODE_VERIFIER_STORAGE_KEY) if (result.error || !result.data) return { data: { user: null, session: null }, error: result.error ?? new ConsoleAuthError('Empty auth response', 500) } const session = installSession(result.data, 'SIGNED_IN') return { data: { user: session.user, session }, error: null } }, async updateUser(attributes: { email?: string, password?: string, current_password?: string, data?: Record, nonce?: string }, options: { emailRedirectTo?: string } = {}) { const token = await accessToken() if (!token) return { data: { user: null }, error: sessionMissing() } const result = await request('PUT', withRedirect('user', options.emailRedirectTo), { token, body: attributes }) if (result.error || !result.data) return { data: { user: null }, error: result.error ?? sessionMissing() } if (current) { current = { ...current, user: result.data } save(current) emit('USER_UPDATED', current) } return { data: { user: result.data }, error: null } }, async resetPasswordForEmail(email: string, options: { redirectTo?: string, captchaToken?: string } = {}) { const result = await request('POST', withRedirect('recover', options.redirectTo), { body: { email, ...captcha(options.captchaToken) } }) return { data: result.error ? null : {}, error: result.error } }, async resend(params: { type: 'signup' | 'email_change', email: string, options?: { captchaToken?: string, emailRedirectTo?: string } }) { const result = await request<{ message_id?: string }>('POST', withRedirect('resend', params.options?.emailRedirectTo), { body: { email: params.email, type: params.type, ...captcha(params.options?.captchaToken) }, }) return { data: { user: null, session: null, messageId: result.data?.message_id }, error: result.error } }, async signInWithOtp(params: { email: string, options?: { shouldCreateUser?: boolean, captchaToken?: string, emailRedirectTo?: string, data?: Record } }) { const result = await request<{ message_id?: string }>('POST', withRedirect('otp', params.options?.emailRedirectTo), { body: { email: params.email, data: params.options?.data ?? {}, create_user: params.options?.shouldCreateUser ?? true, ...captcha(params.options?.captchaToken) }, }) return { data: { user: null, session: null, messageId: result.data?.message_id }, error: result.error } }, async signInWithSSO(params: { domain?: string, providerId?: string, options?: { redirectTo?: string, captchaToken?: string, skipBrowserRedirect?: boolean } }) { const result = await request<{ url: string }>('POST', 'sso', { body: { ...(params.providerId ? { provider_id: params.providerId } : {}), ...(params.domain ? { domain: params.domain } : {}), redirect_to: params.options?.redirectTo, skip_http_redirect: true, ...captcha(params.options?.captchaToken), }, }) if (result.error || !result.data?.url) return { data: null, error: result.error ?? new ConsoleAuthError('SSO provider returned no URL', 500) } if (!params.options?.skipBrowserRedirect && typeof window !== 'undefined') window.location.assign(result.data.url) return { data: { url: result.data.url }, error: null } }, mfa, /** Test and teardown hook: stop timers and forget listeners. */ dispose() { if (refreshTimer) clearInterval(refreshTimer) refreshTimer = undefined listeners.clear() }, } }