/** * EncryptID Auth Store for rmaps-online * * Optional authentication — anonymous access remains the default. * When authenticated, the user gets a persistent DID-based identity. */ 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 { /** Whether the user is authenticated via EncryptID */ isAuthenticated: boolean; /** EncryptID JWT token */ token: string | null; /** User's DID (persistent identity) */ did: string | null; /** Username from EncryptID */ username: string | null; /** Whether auth is loading */ loading: boolean; /** Authenticate with EncryptID passkey */ login: () => Promise; /** Register a new EncryptID passkey */ register: (username: string) => Promise; /** Clear auth state */ 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 { // Step 1: Get auth options const startRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); const { options } = await startRes.json(); // Step 2: WebAuthn ceremony 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: any) => ({ 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; // Step 3: Complete auth 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 { // Step 1: Get registration options 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(); // Step 2: WebAuthn ceremony 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; // Step 3: Complete registration 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 any).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: 'rmaps-auth', partialize: (state) => ({ isAuthenticated: state.isAuthenticated, token: state.token, did: state.did, username: state.username, }), } ) );