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

View File

@ -252,7 +252,7 @@ export async function registerPasskey(
* (if the authenticator supports PRF).
*/
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> = {}
): Promise<AuthenticationResult> {
// Abort any pending conditional UI to prevent "request already pending" error
@ -273,12 +273,18 @@ export async function authenticatePasskey(
// Build allowed credentials list
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)),
}));
if (credentials) {
if (typeof credentials === 'string') {
allowCredentials = [{ type: 'public-key', id: new Uint8Array(base64urlToBuffer(credentials)) }];
} else if (credentials.length > 0 && typeof credentials[0] === 'string') {
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