import { oauthClient } from "#/lib/atproto"; import { Agent } from "@atproto/api"; import { TokenRefreshError } from "@atproto/oauth-client-expo"; import { deleteItemAsync, getItemAsync, setItemAsync } from "expo-secure-store"; import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from "react"; import { NitroSecret, SecretSize } from "react-native-nitro-totp"; const DID_KEY = "club.touchfluffytail.did"; const PRIVATE_KEY = "club.touchfluffytail.privkey"; const PUBKEY_TID_KEY = "club.touchfluffytail.pubkey_tid"; const SEED_KEY = "club.touchfluffytail.otp_seed"; const nitroSecret = new NitroSecret(); export type AuthRequiredReason = "no-session" | "unavailable"; export class AuthRequiredError extends Error { readonly reason: AuthRequiredReason; constructor(reason: AuthRequiredReason, message: string, options?: ErrorOptions) { super(message, options); this.name = "AuthRequiredError"; this.reason = reason; } } export type AuthStatus = "loading" | "anonymous" | "saved" | "authenticated"; type AuthContextType = { agent: Agent | null; did: string | null; status: AuthStatus; isLoading: boolean; isAuthed: boolean; hasSavedDid: boolean; seed: string | null; privateKey: Uint8Array | null; pubkeyTid: string | null; signIn: (handleOrDid: string) => Promise; saveSigningKeys: (privateKey: Uint8Array, tid: string) => Promise; signOut: () => Promise; requireSession: () => Promise; }; const AuthContext = createContext(undefined); async function restoreAgent(sub: string): Promise { const session = await oauthClient.restore(sub); return new Agent(session); } function toAuthRequiredError(error: unknown): AuthRequiredError { if (error instanceof TokenRefreshError && error.cause === undefined) { return new AuthRequiredError("no-session", "No usable session is stored on this device.", { cause: error, }); } return new AuthRequiredError( "unavailable", error instanceof Error ? error.message : String(error), { cause: error }, ); } export function AuthProvider({ children }: { children: ReactNode }) { const [agent, setAgent] = useState(null); const [did, setDid] = useState(null); const [isLoading, setIsLoading] = useState(true); const [seed, setSeed] = useState(null); const [privateKey, setPrivateKey] = useState | null>(null); const [pubkeyTid, setPubkeyTid] = useState(null); useEffect(() => { let active = true; const loadSavedData = async () => { try { const savedDid = await getItemAsync(DID_KEY); if (active && savedDid) setDid(savedDid); if (active && savedDid) { const [privateKeyString, savedTid] = await Promise.all([ getItemAsync(PRIVATE_KEY), getItemAsync(PUBKEY_TID_KEY), ]); if (active && savedTid) setPubkeyTid(savedTid); if (!privateKeyString) { throw new Error("Unable to find privatekey"); } const pkey = new Uint8Array(Buffer.from(privateKeyString, "hex")); setPrivateKey(pkey); } } catch (error) { console.error("Failed to read the saved DID:", error); } finally { try { const savedSeed = await getItemAsync(SEED_KEY); const nextSeed = savedSeed ?? nitroSecret.generate({ size: SecretSize.EXTENDED }); if (!savedSeed) await setItemAsync(SEED_KEY, nextSeed); if (active) setSeed(nextSeed); } catch (error) { console.error("Failed to initialize the OTP seed:", error); } } if (active) setIsLoading(false); }; void loadSavedData(); return () => { active = false; }; }, []); const forgetIdentity = useCallback(async () => { if (did) { try { await oauthClient.revoke(did); } catch (error) { console.warn("Failed to revoke the AT Protocol session:", error); } } await deleteItemAsync(DID_KEY); await deleteItemAsync(PRIVATE_KEY); await deleteItemAsync(PUBKEY_TID_KEY); await deleteItemAsync(SEED_KEY); setPrivateKey(null); setPubkeyTid(null); }, [did]); const requireSession = useCallback(async (): Promise => { if (agent) return agent; if (!did) { throw new AuthRequiredError("no-session", "No account is saved on this device."); } try { const nextAgent = await restoreAgent(did); setAgent(nextAgent); return nextAgent; } catch (error) { console.error("Failed to restore the AT Protocol session:", error); throw toAuthRequiredError(error); } }, [agent, did]); const signIn = useCallback( async (handleOrDid: string): Promise => { const session = await oauthClient.signIn(handleOrDid.trim()); console.log(`Logged in as ${session.did}`); if (did && did !== session.did) await forgetIdentity(); await setItemAsync(DID_KEY, session.did); setDid(session.did); const nextAgent = new Agent(session); setAgent(nextAgent); return nextAgent; }, [did, forgetIdentity], ); const saveSigningKeys = useCallback( async (privateKey: Uint8Array, tid: string) => { const privateKeyString = Buffer.from(privateKey).toString("hex"); await setItemAsync(PRIVATE_KEY, privateKeyString); await setItemAsync(PUBKEY_TID_KEY, tid); setPrivateKey(privateKey); setPubkeyTid(tid); }, [], ); const signOut = useCallback(async () => { await forgetIdentity(); setAgent(null); setDid(null); setPrivateKey(null); setPubkeyTid(null); }, [forgetIdentity]); const status: AuthStatus = isLoading ? "loading" : agent ? "authenticated" : did ? "saved" : "anonymous"; return ( {children} ); } export function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error("useAuth must be used inside AuthProvider"); } return context; }