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:
parent
68edfaae66
commit
b625913eba
|
|
@ -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, '>').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 `
|
||||
<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 `
|
||||
<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>
|
||||
<div class="account-list">
|
||||
${items}
|
||||
</div>
|
||||
<button class="alt-action" data-action="different-account">Use a different account</button>
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue