refactor(mobile): AuthProvider exposes userKeyState instead of device record

This commit is contained in:
byGalax
2026-05-16 16:47:55 +02:00
parent 472665b980
commit adc9686035
+73 -66
View File
@@ -1,23 +1,31 @@
import type { Session, User } from '@supabase/supabase-js'; import type { Session, User } from '@supabase/supabase-js';
import { auth, crypto } from '@chat-app/shared'; import { fetchUserKeyBlob } from '@chat-app/shared/auth';
import type { DeviceRecord } from '@chat-app/shared/auth'; import {
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; type ReactNode,
import { Platform } from 'react-native'; createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react';
import { secretStore } from './secretStore';
import { supabase } from './supabase'; import { supabase } from './supabase';
import { cachedUserKey, ensureLegacyMigrated } from './userIdentity';
// Locally-stored secrets keyed by stable names. Mirrors the desktop export type UserKeyState =
// convention so the migration tests (later) can compare snapshots. | { status: 'loading' }
const KEY_DEVICE_ID = 'device.id'; | { status: 'needs-setup' }
const KEY_DEVICE_PRIVKEY = 'device.privateKey'; | { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
| { status: 'unlocked' };
interface AuthContextValue { interface AuthContextValue {
session: Session | null; session: Session | null;
user: User | null; user: User | null;
device: DeviceRecord | null; userId: string | null;
ownPrivateKey: Uint8Array | null; ownPrivateKey: Uint8Array | null;
loading: boolean; userKeyState: UserKeyState;
ready: boolean;
refreshUserKeyState: () => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
} }
@@ -29,44 +37,48 @@ export function useAuth(): AuthContextValue {
return v; return v;
} }
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
const [device, setDevice] = useState<DeviceRecord | null>(null);
const [ownPrivateKey, setOwnPrivateKey] = useState<Uint8Array | null>(null); const [ownPrivateKey, setOwnPrivateKey] = useState<Uint8Array | null>(null);
const [loading, setLoading] = useState(true); const [userKeyState, setUserKeyState] = useState<UserKeyState>({ status: 'loading' });
const [ready, setReady] = useState(false);
// Resolve or create the device record for this install given an active const refreshUserKeyState = useCallback(async () => {
// session. Stores the private key in expo-secure-store on first run. const s = session;
const ensureDevice = useCallback(async (_currentSession: Session): Promise<void> => { if (!s) {
const savedDeviceId = await secretStore.getSecret(KEY_DEVICE_ID); setUserKeyState({ status: 'loading' });
const savedPrivKey = await secretStore.getSecret(KEY_DEVICE_PRIVKEY); setOwnPrivateKey(null);
return;
if (savedDeviceId && savedPrivKey) {
const devices = await auth.listOwnDevices(supabase);
const deviceIdStr = new TextDecoder().decode(savedDeviceId);
const match = devices.find((d) => d.id === deviceIdStr);
if (match) {
setDevice(match);
setOwnPrivateKey(savedPrivKey);
return;
}
// Stored id no longer matches any device on the server (revoked,
// wiped). Fall through to register a fresh one.
} }
setUserKeyState({ status: 'loading' });
const backend = crypto.getCryptoBackend(); const cached = await cachedUserKey(s.user.id);
const kp = backend.generateKeyPair(); if (cached) {
const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'linux'; setOwnPrivateKey(cached);
const record = await auth.registerDevice(supabase, { setUserKeyState({ status: 'unlocked' });
name: `Netralax Mobile (${Platform.OS})`, void ensureLegacyMigrated(s.user.id).catch((err) => {
platform, console.warn('legacy migration on auth-resume failed', err);
publicKey: kp.publicKey, });
return;
}
const blob = await fetchUserKeyBlob(supabase, s.user.id);
if (!blob || !blob.exists) {
setUserKeyState({ status: 'needs-setup' });
return;
}
if (blob.locked) {
setUserKeyState({
status: 'needs-unlock',
lockedUntil: blob.lockedUntil,
hasRecovery: false,
});
return;
}
setUserKeyState({
status: 'needs-unlock',
lockedUntil: null,
hasRecovery: blob.recoverySealedPrivateKey !== null,
}); });
await secretStore.setSecret(KEY_DEVICE_ID, new TextEncoder().encode(record.id)); }, [session]);
await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey);
setDevice(record);
setOwnPrivateKey(kp.privateKey);
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -74,48 +86,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data } = await supabase.auth.getSession(); const { data } = await supabase.auth.getSession();
if (cancelled) return; if (cancelled) return;
setSession(data.session); setSession(data.session);
if (data.session) { setReady(true);
try {
await ensureDevice(data.session);
} catch (err) {
console.warn('[auth] ensureDevice failed', err);
}
}
setLoading(false);
})(); })();
const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => { const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession); setSession(nextSession);
setReady(true);
if (!nextSession) { if (!nextSession) {
setDevice(null);
setOwnPrivateKey(null); setOwnPrivateKey(null);
} else { setUserKeyState({ status: 'loading' });
void ensureDevice(nextSession).catch((err) =>
console.warn('[auth] ensureDevice (state change) failed', err),
);
} }
}); });
return () => { return () => {
cancelled = true; cancelled = true;
sub.subscription.unsubscribe(); sub.subscription.unsubscribe();
}; };
}, [ensureDevice]); }, []);
const signOut = useCallback(async (): Promise<void> => { useEffect(() => {
void refreshUserKeyState().catch((err) => {
console.warn('refreshUserKeyState failed', err);
setUserKeyState({ status: 'needs-setup' });
});
}, [session, refreshUserKeyState]);
const signOut = useCallback(async () => {
await supabase.auth.signOut(); await supabase.auth.signOut();
await secretStore.removeSecret(KEY_DEVICE_ID);
await secretStore.removeSecret(KEY_DEVICE_PRIVKEY);
setDevice(null);
setOwnPrivateKey(null); setOwnPrivateKey(null);
setUserKeyState({ status: 'loading' });
}, []); }, []);
const value: AuthContextValue = { const value: AuthContextValue = {
session, session,
user: session?.user ?? null, user: session?.user ?? null,
device, userId: session?.user.id ?? null,
ownPrivateKey, ownPrivateKey,
loading, userKeyState,
ready,
refreshUserKeyState,
signOut, signOut,
}; };
return <Ctx.Provider value={value}>{children}</Ctx.Provider>; return <Ctx.Provider value={value}>{children}</Ctx.Provider>;