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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 11:00:36 -07:00
parent 68edfaae66
commit b625913eba
2 changed files with 234 additions and 20 deletions

View File

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ============================================================================
// 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,17 +444,59 @@ export class EncryptIDLoginButton extends HTMLElement {
const sizeClass = this.size === 'medium' ? '' : this.size;
const variantClass = this.variant === 'primary' ? '' : this.variant;
if (this.loading) {
return `
<button class="login-btn ${sizeClass} ${variantClass}" ${this.loading ? 'disabled' : ''}>
${this.loading
? '<div class="loading-spinner"></div>'
: PASSKEY_ICON
}
<span>${this.loading ? 'Authenticating...' : this.label}</span>
<button class="login-btn ${sizeClass} ${variantClass}" disabled>
<div class="loading-spinner"></div>
<span>Authenticating...</span>
</button>
`;
}
const accounts = getKnownAccounts();
// No known accounts → generic passkey button
if (accounts.length === 0) {
return `
<button class="login-btn ${sizeClass} ${variantClass}">
${PASSKEY_ICON}
<span>${this.label}</span>
</button>
`;
}
// 1 known account → "Sign in as [username]" + alt link
if (accounts.length === 1) {
const name = escapeHtml(accounts[0].displayName || accounts[0].username);
return `
<button class="login-btn ${sizeClass} ${variantClass}" data-username="${escapeHtml(accounts[0].username)}">
${PASSKEY_ICON}
<span>Sign in as ${name}</span>
</button>
<button class="alt-action" data-action="different-account">Use a different account</button>
`;
}
// 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 `
<button class="account-item" data-username="${escapeHtml(a.username)}">
<span class="account-avatar">${initial}</span>
<span class="account-name">${name}</span>
</button>
`;
}).join('');
return `
<div class="account-list">
${items}
</div>
<button class="alt-action" data-action="different-account">Use a different account</button>
`;
}
private renderUserInfo(did: string, authLevel: AuthLevel): string {
const shortDID = did.slice(0, 20) + '...' + did.slice(-8);
const initial = did.slice(8, 10).toUpperCase();
@ -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<string[] | undefined> {
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 },

View File

@ -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<EncryptIDConfig> = {}
): Promise<AuthenticationResult> {
// 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 = {