fix(auth): keep known accounts on logout, pass transports in scoped auth

Logout no longer removes the account from the picker — users see
"Sign in as [username]" on next visit. fetchScopedCredentials now
returns full PublicKeyCredentialDescriptor with transports so the
browser can locate the right authenticator without showing a picker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 11:26:33 -07:00
parent b625913eba
commit 3ead9b4ca0
2 changed files with 27 additions and 22 deletions

View File

@ -8,6 +8,7 @@
import { import {
registerPasskey, registerPasskey,
authenticatePasskey, authenticatePasskey,
base64urlToBuffer,
detectCapabilities, detectCapabilities,
startConditionalUI, startConditionalUI,
WebAuthnCapabilities, WebAuthnCapabilities,
@ -578,10 +579,10 @@ export class EncryptIDLoginButton extends HTMLElement {
} }
/** /**
* Fetch scoped credential IDs from the auth server for a given username. * Fetch scoped credentials from the auth server for a given username.
* Returns the credential ID array, or undefined to fall back to unscoped. * Returns PublicKeyCredentialDescriptor[] with transports, or undefined to fall back to unscoped.
*/ */
private async fetchScopedCredentials(username: string): Promise<string[] | undefined> { private async fetchScopedCredentials(username: string): Promise<PublicKeyCredentialDescriptor[] | undefined> {
try { try {
const res = await fetch(`${ENCRYPTID_AUTH}/api/auth/start`, { const res = await fetch(`${ENCRYPTID_AUTH}/api/auth/start`, {
method: 'POST', method: 'POST',
@ -591,7 +592,11 @@ export class EncryptIDLoginButton extends HTMLElement {
if (!res.ok) return undefined; if (!res.ok) return undefined;
const { options, userFound } = await res.json(); const { options, userFound } = await res.json();
if (!userFound || !options.allowCredentials?.length) return undefined; if (!userFound || !options.allowCredentials?.length) return undefined;
return options.allowCredentials.map((c: any) => c.id); return options.allowCredentials.map((c: any) => ({
type: 'public-key' as const,
id: base64urlToBuffer(c.id),
...(c.transports?.length ? { transports: c.transports } : {}),
}));
} catch { } catch {
return undefined; return undefined;
} }
@ -605,14 +610,14 @@ export class EncryptIDLoginButton extends HTMLElement {
try { try {
// If a username was selected, scope the passkey prompt to that user's credentials // If a username was selected, scope the passkey prompt to that user's credentials
let credentialIds: string[] | undefined; let scopedCredentials: PublicKeyCredentialDescriptor[] | undefined;
if (username) { if (username) {
credentialIds = await this.fetchScopedCredentials(username); scopedCredentials = await this.fetchScopedCredentials(username);
// If user not found on server, fall back to unscoped (still let them try) // If user not found on server, fall back to unscoped (still let them try)
} }
// Authenticate — scoped if we have credential IDs, unscoped otherwise // Authenticate — scoped if we have credentials, unscoped otherwise
const result = await authenticatePasskey(credentialIds); const result = await authenticatePasskey(scopedCredentials);
// Initialize key manager with PRF output // Initialize key manager with PRF output
const keyManager = getKeyManager(); const keyManager = getKeyManager();
@ -722,13 +727,7 @@ export class EncryptIDLoginButton extends HTMLElement {
private handleLogout() { private handleLogout() {
const sessionManager = getSessionManager(); const sessionManager = getSessionManager();
// Keep known account in localStorage so the picker shows on next login
// Remove this account from known accounts list
const session = sessionManager.getSession();
if (session?.claims.username) {
removeKnownAccount(session.claims.username);
}
sessionManager.clearSession(); sessionManager.clearSession();
const keyManager = getKeyManager(); const keyManager = getKeyManager();

View File

@ -252,7 +252,7 @@ export async function registerPasskey(
* (if the authenticator supports PRF). * (if the authenticator supports PRF).
*/ */
export async function authenticatePasskey( export async function authenticatePasskey(
credentialIds?: string | string[], // Optional: one or more credential IDs to scope, or let user choose credentials?: string | string[] | PublicKeyCredentialDescriptor[],
config: Partial<EncryptIDConfig> = {} config: Partial<EncryptIDConfig> = {}
): Promise<AuthenticationResult> { ): Promise<AuthenticationResult> {
// Abort any pending conditional UI to prevent "request already pending" error // Abort any pending conditional UI to prevent "request already pending" error
@ -273,12 +273,18 @@ export async function authenticatePasskey(
// Build allowed credentials list // Build allowed credentials list
let allowCredentials: PublicKeyCredentialDescriptor[] | undefined; let allowCredentials: PublicKeyCredentialDescriptor[] | undefined;
if (credentialIds) { if (credentials) {
const ids = Array.isArray(credentialIds) ? credentialIds : [credentialIds]; if (typeof credentials === 'string') {
allowCredentials = ids.map(id => ({ allowCredentials = [{ type: 'public-key', id: new Uint8Array(base64urlToBuffer(credentials)) }];
type: 'public-key' as const, } else if (credentials.length > 0 && typeof credentials[0] === 'string') {
id: new Uint8Array(base64urlToBuffer(id)), allowCredentials = (credentials as string[]).map(id => ({
})); type: 'public-key' as const,
id: new Uint8Array(base64urlToBuffer(id)),
}));
} else {
// Already PublicKeyCredentialDescriptor[]
allowCredentials = credentials as PublicKeyCredentialDescriptor[];
}
} }
// Build authentication options // Build authentication options