From 88aebcd99768481cec19319ae912475cea475dae Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 13 Feb 2026 07:34:28 -0700 Subject: [PATCH] feat: add optional EncryptID passkey authentication Add optional passkey identity (anonymous access remains default): - Add Zustand auth store with EncryptID login/register/logout - Add AuthButton component to home page - Auto-fill name from EncryptID when authenticated - Use DID as persistent participant ID in rooms when signed in - Update useRoom hook to accept optional encryptIdDid Co-Authored-By: Claude Opus 4.6 --- src/app/[slug]/page.tsx | 3 + src/app/page.tsx | 13 +++ src/components/AuthButton.tsx | 95 ++++++++++++++++ src/hooks/useRoom.ts | 17 ++- src/stores/auth.ts | 199 ++++++++++++++++++++++++++++++++++ 5 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 src/components/AuthButton.tsx create mode 100644 src/stores/auth.ts diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index 2196a70..48efe6c 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useRef, useCallback } from 'react'; import { useParams, useRouter } from 'next/navigation'; import dynamic from 'next/dynamic'; import { useRoom } from '@/hooks/useRoom'; +import { useAuthStore } from '@/stores/auth'; import { useLocationSharing } from '@/hooks/useLocationSharing'; import { useServiceWorkerMessages } from '@/hooks/useServiceWorkerMessages'; import { usePushNotifications } from '@/hooks/usePushNotifications'; @@ -32,6 +33,7 @@ export default function RoomPage() { const params = useParams(); const router = useRouter(); const slug = params.slug as string; + const { did: encryptIdDid } = useAuthStore(); const [showShare, setShowShare] = useState(false); const [showParticipants, setShowParticipants] = useState(true); @@ -92,6 +94,7 @@ export default function RoomPage() { slug, userName: currentUser?.name || '', userEmoji: currentUser?.emoji || '👤', + encryptIdDid, }); // Use refs to avoid stale closures in callbacks diff --git a/src/app/page.tsx b/src/app/page.tsx index 37ddc3a..08ed137 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,6 +3,8 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { nanoid } from 'nanoid'; +import { AuthButton } from '@/components/AuthButton'; +import { useAuthStore } from '@/stores/auth'; // Emoji options for avatars const EMOJI_OPTIONS = ['🐙', '🦊', '🐻', '🐱', '🦝', '🐸', '🦉', '🐧', '🦋', '🐝']; @@ -14,6 +16,7 @@ function generateSlug(): string { export default function HomePage() { const router = useRouter(); + const { isAuthenticated, username: authUsername } = useAuthStore(); const [isCreating, setIsCreating] = useState(false); const [joinSlug, setJoinSlug] = useState(''); const [name, setName] = useState(''); @@ -50,6 +53,13 @@ export default function HomePage() { setIsLoaded(true); }, []); + // Auto-fill name from EncryptID when authenticated + useEffect(() => { + if (isAuthenticated && authUsername && !name) { + setName(authUsername); + } + }, [isAuthenticated, authUsername, name]); + const handleCreateRoom = async () => { if (!name.trim()) return; @@ -91,6 +101,9 @@ export default function HomePage() { rMaps

Find your friends at events

+
+ +
{/* Quick Rejoin Card - show when user has saved info and last room */} diff --git a/src/components/AuthButton.tsx b/src/components/AuthButton.tsx new file mode 100644 index 0000000..146259f --- /dev/null +++ b/src/components/AuthButton.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useState } from 'react'; +import { useAuthStore } from '@/stores/auth'; + +export function AuthButton() { + const { isAuthenticated, username, did, loading, login, register, logout } = useAuthStore(); + const [showRegister, setShowRegister] = useState(false); + const [regUsername, setRegUsername] = useState(''); + const [error, setError] = useState(''); + + if (isAuthenticated) { + return ( +
+
+ Signed in as + {username || did?.slice(0, 16) + '...'} +
+ +
+ ); + } + + if (showRegister) { + return ( +
+ setRegUsername(e.target.value)} + placeholder="Choose a username" + className="input text-sm py-1 px-2 w-40" + maxLength={20} + /> + + + {error && {error}} +
+ ); + } + + return ( +
+ + {error && {error}} +
+ ); +} diff --git a/src/hooks/useRoom.ts b/src/hooks/useRoom.ts index a8c5bb0..fd4beb9 100644 --- a/src/hooks/useRoom.ts +++ b/src/hooks/useRoom.ts @@ -62,9 +62,16 @@ async function loadRoomStateFromSW(slug: string): Promise<{ participants: Partic /** * Get or create a persistent participant ID for this browser/room combination. - * This prevents creating duplicate "ghost" participants on page reload. + * Uses EncryptID DID when authenticated for cross-session persistence, + * otherwise falls back to localStorage nanoid. */ -function getOrCreateParticipantId(slug: string): string { +function getOrCreateParticipantId(slug: string, encryptIdDid?: string | null): string { + // If authenticated with EncryptID, use DID as stable identity + if (encryptIdDid) { + console.log('Using EncryptID DID as participant ID:', encryptIdDid.slice(0, 20) + '...'); + return encryptIdDid; + } + const storageKey = `rmaps_participant_id_${slug}`; try { @@ -90,6 +97,8 @@ interface UseRoomOptions { slug: string; userName: string; userEmoji: string; + /** EncryptID DID for persistent cross-session identity (optional) */ + encryptIdDid?: string | null; } interface UseRoomReturn { @@ -110,7 +119,7 @@ interface UseRoomReturn { setLocationRequestCallback: (callback: () => void) => void; } -export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomReturn { +export function useRoom({ slug, userName, userEmoji, encryptIdDid }: UseRoomOptions): UseRoomReturn { const [isConnected, setIsConnected] = useState(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -169,7 +178,7 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR setError(null); // Use persistent participant ID to avoid creating duplicate "ghost" participants - const participantId = getOrCreateParticipantId(slug); + const participantId = getOrCreateParticipantId(slug, encryptIdDid); participantIdRef.current = participantId; const color = COLORS[Math.floor(Math.random() * COLORS.length)]; diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..1430139 --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1,199 @@ +/** + * 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), + 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), + 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), + rp: options.rp, + user: { + id: fromBase64url(options.user.id), + 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, + }), + } + ) +);