From b625913ebaaa19226ff80f6941c36e2d7d886a64 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 11:00:36 -0700 Subject: [PATCH] feat(auth): username-first passkey login with account picker Scoped passkey prompts via /api/auth/start so the browser only shows matching credentials for the selected account. Known accounts stored in localStorage and surfaced as a picker (1 account = named button, multiple = list). "Use a different account" falls back to unscoped. Co-Authored-By: Claude Opus 4.6 --- src/encryptid/ui/login-button.ts | 238 +++++++++++++++++++++++++++++-- src/encryptid/webauthn.ts | 16 ++- 2 files changed, 234 insertions(+), 20 deletions(-) diff --git a/src/encryptid/ui/login-button.ts b/src/encryptid/ui/login-button.ts index a94172a..0f7b9fa 100644 --- a/src/encryptid/ui/login-button.ts +++ b/src/encryptid/ui/login-button.ts @@ -18,6 +18,39 @@ import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryp import { getVaultManager, resetVaultManager } from '../vault'; import { syncWalletsOnLogin } from '../wallet-sync'; +// ============================================================================ +// KNOWN ACCOUNTS (localStorage) +// ============================================================================ + +const KNOWN_ACCOUNTS_KEY = 'encryptid-known-accounts'; +const ENCRYPTID_AUTH = 'https://auth.rspace.online'; + +interface KnownAccount { + username: string; + displayName?: string; +} + +function getKnownAccounts(): KnownAccount[] { + try { + return JSON.parse(localStorage.getItem(KNOWN_ACCOUNTS_KEY) || '[]'); + } catch { return []; } +} + +function addKnownAccount(account: KnownAccount): void { + const accounts = getKnownAccounts().filter(a => a.username !== account.username); + accounts.unshift(account); // most recent first + localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts)); +} + +function removeKnownAccount(username: string): void { + const accounts = getKnownAccounts().filter(a => a.username !== username); + localStorage.setItem(KNOWN_ACCOUNTS_KEY, JSON.stringify(accounts)); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + // ============================================================================ // STYLES // ============================================================================ @@ -227,6 +260,77 @@ const styles = ` .hidden { display: none; } + + .account-list { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 200px; + } + + .account-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: var(--eid-bg); + border: 1px solid transparent; + border-radius: var(--eid-radius); + color: var(--eid-text); + cursor: pointer; + transition: all 0.2s; + font-size: 0.9rem; + font-family: inherit; + text-align: left; + width: 100%; + box-sizing: border-box; + } + + .account-item:hover { + background: var(--eid-bg-hover); + border-color: var(--eid-primary); + } + + .account-item .account-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--eid-primary); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.75rem; + color: white; + flex-shrink: 0; + } + + .account-item .account-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .alt-action { + display: block; + margin-top: 8px; + padding: 0; + background: none; + border: none; + color: var(--eid-text-secondary); + font-size: 0.8rem; + cursor: pointer; + text-align: center; + width: 100%; + font-family: inherit; + } + + .alt-action:hover { + color: var(--eid-primary); + text-decoration: underline; + } `; // ============================================================================ @@ -340,14 +444,56 @@ export class EncryptIDLoginButton extends HTMLElement { const sizeClass = this.size === 'medium' ? '' : this.size; const variantClass = this.variant === 'primary' ? '' : this.variant; + if (this.loading) { + return ` + + `; + } + + const accounts = getKnownAccounts(); + + // No known accounts → generic passkey button + if (accounts.length === 0) { + return ` + + `; + } + + // 1 known account → "Sign in as [username]" + alt link + if (accounts.length === 1) { + const name = escapeHtml(accounts[0].displayName || accounts[0].username); + return ` + + + `; + } + + // Multiple accounts → picker list + const items = accounts.map(a => { + const name = escapeHtml(a.displayName || a.username); + const initial = escapeHtml((a.displayName || a.username).slice(0, 2).toUpperCase()); + return ` + + `; + }).join(''); + return ` - + + `; } @@ -407,22 +553,66 @@ export class EncryptIDLoginButton extends HTMLElement { }); }); } else { - // Login button click - this.shadow.querySelector('.login-btn')?.addEventListener('click', () => { + // Login button with scoped username + const loginBtn = this.shadow.querySelector('.login-btn'); + if (loginBtn) { + loginBtn.addEventListener('click', () => { + const username = (loginBtn as HTMLElement).dataset.username; + this.handleLogin(username); + }); + } + + // Account list items (multiple accounts) + this.shadow.querySelectorAll('.account-item').forEach(item => { + item.addEventListener('click', () => { + const username = (item as HTMLElement).dataset.username; + if (username) this.handleLogin(username); + }); + }); + + // "Use a different account" → unscoped auth + this.shadow.querySelector('[data-action="different-account"]')?.addEventListener('click', () => { this.handleLogin(); }); } } - private async handleLogin() { + /** + * Fetch scoped credential IDs from the auth server for a given username. + * Returns the credential ID array, or undefined to fall back to unscoped. + */ + private async fetchScopedCredentials(username: string): Promise { + try { + const res = await fetch(`${ENCRYPTID_AUTH}/api/auth/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }), + }); + if (!res.ok) return undefined; + const { options, userFound } = await res.json(); + if (!userFound || !options.allowCredentials?.length) return undefined; + return options.allowCredentials.map((c: any) => c.id); + } catch { + return undefined; + } + } + + private async handleLogin(username?: string) { if (this.loading) return; this.loading = true; this.render(); try { - // Try to authenticate with existing passkey - const result = await authenticatePasskey(); + // If a username was selected, scope the passkey prompt to that user's credentials + let credentialIds: string[] | undefined; + if (username) { + credentialIds = await this.fetchScopedCredentials(username); + // If user not found on server, fall back to unscoped (still let them try) + } + + // Authenticate — scoped if we have credential IDs, unscoped otherwise + const result = await authenticatePasskey(credentialIds); // Initialize key manager with PRF output const keyManager = getKeyManager(); @@ -450,6 +640,12 @@ export class EncryptIDLoginButton extends HTMLElement { wallet: !!keys.eoaAddress, }); + // Remember this account for next time + // Use the username from JWT claims if available, otherwise the one selected + const session = sessionManager.getSession(); + const resolvedUsername = session?.claims.username || username || keys.did.slice(0, 16); + addKnownAccount({ username: resolvedUsername }); + // Dispatch success event this.dispatchEvent(new CustomEvent('login-success', { detail: { @@ -526,6 +722,13 @@ export class EncryptIDLoginButton extends HTMLElement { private handleLogout() { const sessionManager = getSessionManager(); + + // Remove this account from known accounts list + const session = sessionManager.getSession(); + if (session?.claims.username) { + removeKnownAccount(session.claims.username); + } + sessionManager.clearSession(); const keyManager = getKeyManager(); @@ -567,6 +770,12 @@ export class EncryptIDLoginButton extends HTMLElement { wallet: !!keys.eoaAddress, }); + // Remember this account + const sess = sessionManager.getSession(); + if (sess?.claims.username) { + addKnownAccount({ username: sess.claims.username }); + } + this.dispatchEvent(new CustomEvent('login-success', { detail: { did: keys.did, @@ -595,6 +804,9 @@ export class EncryptIDLoginButton extends HTMLElement { try { const credential = await registerPasskey(username, displayName); + // Remember this account + addKnownAccount({ username, displayName }); + this.dispatchEvent(new CustomEvent('register-success', { detail: { credentialId: credential.credentialId, @@ -604,7 +816,7 @@ export class EncryptIDLoginButton extends HTMLElement { })); // Auto-login after registration - await this.handleLogin(); + await this.handleLogin(username); } catch (error: any) { this.dispatchEvent(new CustomEvent('register-error', { detail: { error: error.message }, diff --git a/src/encryptid/webauthn.ts b/src/encryptid/webauthn.ts index a346752..9891a11 100644 --- a/src/encryptid/webauthn.ts +++ b/src/encryptid/webauthn.ts @@ -252,7 +252,7 @@ export async function registerPasskey( * (if the authenticator supports PRF). */ export async function authenticatePasskey( - credentialId?: string, // Optional: specify credential, or let user choose + credentialIds?: string | string[], // Optional: one or more credential IDs to scope, or let user choose config: Partial = {} ): Promise { // Abort any pending conditional UI to prevent "request already pending" error @@ -272,12 +272,14 @@ export async function authenticatePasskey( const prfSalt = await generatePRFSalt('master-key'); // Build allowed credentials list - const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = credentialId - ? [{ - type: 'public-key', - id: new Uint8Array(base64urlToBuffer(credentialId)), - }] - : undefined; // undefined = let user choose from available passkeys + let allowCredentials: PublicKeyCredentialDescriptor[] | undefined; + if (credentialIds) { + const ids = Array.isArray(credentialIds) ? credentialIds : [credentialIds]; + allowCredentials = ids.map(id => ({ + type: 'public-key' as const, + id: new Uint8Array(base64urlToBuffer(id)), + })); + } // Build authentication options const getOptions: CredentialRequestOptions = {