refactor(mobile): AuthProvider exposes userKeyState instead of device record
This commit is contained in:
@@ -1,23 +1,31 @@
|
||||
import type { Session, User } from '@supabase/supabase-js';
|
||||
import { auth, crypto } from '@chat-app/shared';
|
||||
import type { DeviceRecord } from '@chat-app/shared/auth';
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { fetchUserKeyBlob } from '@chat-app/shared/auth';
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { secretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
import { cachedUserKey, ensureLegacyMigrated } from './userIdentity';
|
||||
|
||||
// Locally-stored secrets keyed by stable names. Mirrors the desktop
|
||||
// convention so the migration tests (later) can compare snapshots.
|
||||
const KEY_DEVICE_ID = 'device.id';
|
||||
const KEY_DEVICE_PRIVKEY = 'device.privateKey';
|
||||
export type UserKeyState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'needs-setup' }
|
||||
| { status: 'needs-unlock'; lockedUntil: string | null; hasRecovery: boolean }
|
||||
| { status: 'unlocked' };
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
device: DeviceRecord | null;
|
||||
userId: string | null;
|
||||
ownPrivateKey: Uint8Array | null;
|
||||
loading: boolean;
|
||||
userKeyState: UserKeyState;
|
||||
ready: boolean;
|
||||
refreshUserKeyState: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -29,44 +37,48 @@ export function useAuth(): AuthContextValue {
|
||||
return v;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [device, setDevice] = useState<DeviceRecord | 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
|
||||
// session. Stores the private key in expo-secure-store on first run.
|
||||
const ensureDevice = useCallback(async (_currentSession: Session): Promise<void> => {
|
||||
const savedDeviceId = await secretStore.getSecret(KEY_DEVICE_ID);
|
||||
const savedPrivKey = await secretStore.getSecret(KEY_DEVICE_PRIVKEY);
|
||||
|
||||
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.
|
||||
const refreshUserKeyState = useCallback(async () => {
|
||||
const s = session;
|
||||
if (!s) {
|
||||
setUserKeyState({ status: 'loading' });
|
||||
setOwnPrivateKey(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = crypto.getCryptoBackend();
|
||||
const kp = backend.generateKeyPair();
|
||||
const platform = Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'linux';
|
||||
const record = await auth.registerDevice(supabase, {
|
||||
name: `Netralax Mobile (${Platform.OS})`,
|
||||
platform,
|
||||
publicKey: kp.publicKey,
|
||||
setUserKeyState({ status: 'loading' });
|
||||
const cached = await cachedUserKey(s.user.id);
|
||||
if (cached) {
|
||||
setOwnPrivateKey(cached);
|
||||
setUserKeyState({ status: 'unlocked' });
|
||||
void ensureLegacyMigrated(s.user.id).catch((err) => {
|
||||
console.warn('legacy migration on auth-resume failed', err);
|
||||
});
|
||||
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));
|
||||
await secretStore.setSecret(KEY_DEVICE_PRIVKEY, kp.privateKey);
|
||||
setDevice(record);
|
||||
setOwnPrivateKey(kp.privateKey);
|
||||
}, []);
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -74,48 +86,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
if (cancelled) return;
|
||||
setSession(data.session);
|
||||
if (data.session) {
|
||||
try {
|
||||
await ensureDevice(data.session);
|
||||
} catch (err) {
|
||||
console.warn('[auth] ensureDevice failed', err);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
setReady(true);
|
||||
})();
|
||||
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, nextSession) => {
|
||||
setSession(nextSession);
|
||||
setReady(true);
|
||||
if (!nextSession) {
|
||||
setDevice(null);
|
||||
setOwnPrivateKey(null);
|
||||
} else {
|
||||
void ensureDevice(nextSession).catch((err) =>
|
||||
console.warn('[auth] ensureDevice (state change) failed', err),
|
||||
);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
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 secretStore.removeSecret(KEY_DEVICE_ID);
|
||||
await secretStore.removeSecret(KEY_DEVICE_PRIVKEY);
|
||||
setDevice(null);
|
||||
setOwnPrivateKey(null);
|
||||
setUserKeyState({ status: 'loading' });
|
||||
}, []);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
device,
|
||||
userId: session?.user.id ?? null,
|
||||
ownPrivateKey,
|
||||
loading,
|
||||
userKeyState,
|
||||
ready,
|
||||
refreshUserKeyState,
|
||||
signOut,
|
||||
};
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
||||
Reference in New Issue
Block a user