/** * EncryptID Auth Store for rfunds-online * * Provides passkey-based identity for space creation and editing. * Uses Zustand with localStorage persistence. */ import { create } from 'zustand' import { persist } from 'zustand/middleware' const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com' interface AuthState { isAuthenticated: boolean token: string | null did: string | null username: string | null loading: boolean login: () => Promise register: (username: string) => Promise logout: () => void } function toBase64url(buffer: ArrayBuffer): string { return btoa(String.fromCharCode(...new Uint8Array(buffer))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') } function fromBase64url(str: string): Uint8Array { return Uint8Array.from(atob(str.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) } export const useAuthStore = create()( persist( (set) => ({ isAuthenticated: false, token: null, did: null, username: null, loading: false, login: async () => { set({ loading: true }) try { const startRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }) const { options } = await startRes.json() const publicKeyOptions: PublicKeyCredentialRequestOptions = { challenge: fromBase64url(options.challenge).buffer as ArrayBuffer, rpId: options.rpId, userVerification: options.userVerification as UserVerificationRequirement, timeout: options.timeout, allowCredentials: options.allowCredentials?.map((c: { type: string; id: string; transports: string[] }) => ({ type: c.type as PublicKeyCredentialType, id: fromBase64url(c.id).buffer as ArrayBuffer, transports: c.transports as AuthenticatorTransport[], })), } const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions }) as PublicKeyCredential if (!assertion) throw new Error('Authentication cancelled') const response = assertion.response as AuthenticatorAssertionResponse const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challenge: options.challenge, credential: { credentialId: assertion.id, authenticatorData: toBase64url(response.authenticatorData), clientDataJSON: toBase64url(response.clientDataJSON), signature: toBase64url(response.signature), userHandle: response.userHandle ? toBase64url(response.userHandle) : null, }, }), }) const result = await completeRes.json() if (!result.success) throw new Error(result.error || 'Authentication failed') set({ isAuthenticated: true, token: result.token, did: result.did, username: result.username, loading: false, }) } catch (error) { set({ loading: false }) throw error } }, register: async (username: string) => { set({ loading: true }) try { const startRes = await fetch(`${ENCRYPTID_SERVER}/api/register/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, displayName: username }), }) const { options, userId } = await startRes.json() const publicKeyOptions: PublicKeyCredentialCreationOptions = { challenge: fromBase64url(options.challenge).buffer as ArrayBuffer, rp: options.rp, user: { id: fromBase64url(options.user.id).buffer as ArrayBuffer, name: options.user.name, displayName: options.user.displayName, }, pubKeyCredParams: options.pubKeyCredParams, authenticatorSelection: options.authenticatorSelection, timeout: options.timeout, attestation: options.attestation as AttestationConveyancePreference, } const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }) as PublicKeyCredential if (!credential) throw new Error('Registration cancelled') const response = credential.response as AuthenticatorAttestationResponse const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/register/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challenge: options.challenge, userId, username, credential: { credentialId: credential.id, publicKey: toBase64url(response.getPublicKey?.() || response.attestationObject), attestationObject: toBase64url(response.attestationObject), clientDataJSON: toBase64url(response.clientDataJSON), transports: (response as AuthenticatorAttestationResponse & { getTransports?: () => string[] }).getTransports?.() || [], }, }), }) const result = await completeRes.json() if (!result.success) throw new Error(result.error || 'Registration failed') set({ isAuthenticated: true, token: result.token, did: result.did, username, loading: false, }) } catch (error) { set({ loading: false }) throw error } }, logout: () => { set({ isAuthenticated: false, token: null, did: null, username: null, loading: false, }) }, }), { name: 'rfunds-auth', partialize: (state) => ({ isAuthenticated: state.isAuthenticated, token: state.token, did: state.did, username: state.username, }), } ) )