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 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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user