From 33f0ef40778d1dcbedc57c727f9ca344eed4cf89 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 22:26:50 +0000 Subject: [PATCH] fix: server-backed auth + show username instead of DID key Rewrote auth flow to go through EncryptID server instead of client-side unsigned JWTs. Fixes "Invalid or expired authentication token" on space creation, and shows username in header. Co-Authored-By: Claude Opus 4.6 --- lib/rspace-header.ts | 210 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 170 insertions(+), 40 deletions(-) diff --git a/lib/rspace-header.ts b/lib/rspace-header.ts index dcd329c..1df80f0 100644 --- a/lib/rspace-header.ts +++ b/lib/rspace-header.ts @@ -11,8 +11,10 @@ const ENCRYPTID_URL = 'https://auth.rspace.online'; interface SessionState { accessToken: string; claims: { - sub: string; // DID + sub: string; // user ID exp: number; + username?: string; + did?: string; eid: { authLevel: number; capabilities: { @@ -36,6 +38,7 @@ function getSession(): SessionState | null { const now = Math.floor(Date.now() / 1000); if (now >= session.claims.exp) { localStorage.removeItem(SESSION_KEY); + localStorage.removeItem('rspace-username'); return null; } return session; @@ -46,6 +49,7 @@ function getSession(): SessionState | null { function clearSession(): void { localStorage.removeItem(SESSION_KEY); + localStorage.removeItem('rspace-username'); } export function isAuthenticated(): boolean { @@ -57,7 +61,68 @@ export function getAccessToken(): string | null { } export function getUserDID(): string | null { - return getSession()?.claims.sub ?? null; + return getSession()?.claims.did ?? getSession()?.claims.sub ?? null; +} + +export function getUsername(): string | null { + return getSession()?.claims.username ?? null; +} + +// ============================================================================ +// BASE64URL / JWT HELPERS +// ============================================================================ + +function base64urlToBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padding = '='.repeat((4 - (base64.length % 4)) % 4); + const binary = atob(base64 + padding); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +function bufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function parseJWT(token: string): Record { + const parts = token.split('.'); + if (parts.length < 2) return {}; + try { + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padding = '='.repeat((4 - (b64.length % 4)) % 4); + return JSON.parse(atob(b64 + padding)); + } catch { + return {}; + } +} + +function storeSession(token: string, username: string, did: string): void { + const payload = parseJWT(token); + const session: SessionState = { + accessToken: token, + claims: { + sub: payload.sub || '', + exp: payload.exp || 0, + username, + did, + eid: payload.eid || { + authLevel: 3, + capabilities: { encrypt: true, sign: true, wallet: false }, + }, + }, + }; + localStorage.setItem(SESSION_KEY, JSON.stringify(session)); + if (username) { + localStorage.setItem('rspace-username', username); + } } // ============================================================================ @@ -511,23 +576,44 @@ export function showAuthModal(callbacks?: Partial): void { btn.innerHTML = ' Authenticating...'; try { - // Dynamic import to avoid loading WebAuthn unless needed - const { authenticatePasskey } = await import('@encryptid/webauthn'); - const { getKeyManager } = await import('@encryptid/key-derivation'); - const { getSessionManager } = await import('@encryptid/session'); - - const result = await authenticatePasskey(); - const keyManager = getKeyManager(); - if (result.prfOutput) { - await keyManager.initFromPRF(result.prfOutput); - } - const keys = await keyManager.getKeys(); - const sessionManager = getSessionManager(); - await sessionManager.createSession(result, keys.did, { - encrypt: true, - sign: true, - wallet: false, + // 1. Get server challenge + const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), }); + if (!startRes.ok) throw new Error('Failed to start authentication'); + const { options: serverOptions } = await startRes.json(); + + // 2. WebAuthn ceremony with server challenge + const credential = await navigator.credentials.get({ + publicKey: { + challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)), + rpId: serverOptions.rpId || 'rspace.online', + userVerification: 'required', + timeout: 60000, + }, + }) as PublicKeyCredential; + if (!credential) throw new Error('Authentication failed'); + + // 3. Complete auth on EncryptID server → get signed JWT + username + const completeRes = await fetch(`${ENCRYPTID_URL}/api/auth/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: serverOptions.challenge, + credential: { + credentialId: bufferToBase64url(credential.rawId), + }, + }), + }); + const data = await completeRes.json(); + if (!completeRes.ok || !data.success) { + throw new Error(data.error || 'Authentication failed'); + } + + // 4. Store server-signed token with username + storeSession(data.token, data.username || '', data.did || ''); closeModal(); callbacks?.onSuccess?.(); @@ -559,25 +645,68 @@ export function showAuthModal(callbacks?: Partial): void { btn.innerHTML = ' Creating passkey...'; try { - const { registerPasskey, authenticatePasskey } = await import('@encryptid/webauthn'); - const { getKeyManager } = await import('@encryptid/key-derivation'); - const { getSessionManager } = await import('@encryptid/session'); - - await registerPasskey(username, username); - - // Auto sign-in after registration - const result = await authenticatePasskey(); - const keyManager = getKeyManager(); - if (result.prfOutput) { - await keyManager.initFromPRF(result.prfOutput); - } - const keys = await keyManager.getKeys(); - const sessionManager = getSessionManager(); - await sessionManager.createSession(result, keys.did, { - encrypt: true, - sign: true, - wallet: false, + // 1. Get registration options from server + const startRes = await fetch(`${ENCRYPTID_URL}/api/register/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, displayName: username }), }); + if (!startRes.ok) throw new Error('Failed to start registration'); + const { options: serverOptions, userId } = await startRes.json(); + + // 2. WebAuthn ceremony with server challenge + const credential = await navigator.credentials.create({ + publicKey: { + challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)), + rp: { + id: serverOptions.rp?.id || 'rspace.online', + name: serverOptions.rp?.name || 'EncryptID', + }, + user: { + id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), + name: username, + displayName: username, + }, + pubKeyCredParams: serverOptions.pubKeyCredParams || [ + { alg: -7, type: 'public-key' as const }, + { alg: -257, type: 'public-key' as const }, + ], + authenticatorSelection: { + residentKey: 'required', + requireResidentKey: true, + userVerification: 'required', + }, + attestation: 'none', + timeout: 60000, + }, + }) as PublicKeyCredential; + if (!credential) throw new Error('Failed to create credential'); + + const response = credential.response as AuthenticatorAttestationResponse; + const publicKey = response.getPublicKey?.(); + + // 3. Complete registration on EncryptID server + const completeRes = await fetch(`${ENCRYPTID_URL}/api/register/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: serverOptions.challenge, + credential: { + credentialId: bufferToBase64url(credential.rawId), + publicKey: publicKey ? bufferToBase64url(publicKey) : '', + transports: response.getTransports?.() || [], + }, + userId, + username, + }), + }); + const data = await completeRes.json(); + if (!completeRes.ok || !data.success) { + throw new Error(data.error || 'Registration failed'); + } + + // 4. Store server-signed token with username + storeSession(data.token, username, data.did || ''); closeModal(); callbacks?.onSuccess?.(); @@ -676,16 +805,17 @@ export function mountHeader(options: HeaderOptions): void { : '
'; if (isLoggedIn) { - const did = session!.claims.sub; - const shortDID = did.length > 24 ? did.slice(0, 16) + '...' + did.slice(-6) : did; - const initial = did.slice(8, 10).toUpperCase(); + const username = session!.claims.username || ''; + const did = session!.claims.did || session!.claims.sub; + const displayName = username || (did.length > 24 ? did.slice(0, 16) + '...' + did.slice(-6) : did); + const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase(); header.innerHTML = ` ${brandHTML}
${initial}
- ${shortDID} + ${displayName}