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 { getVaultManager, resetVaultManager } from '../vault';
|
||||||
import { syncWalletsOnLogin } from '../wallet-sync';
|
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
|
// STYLES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -227,6 +260,77 @@ const styles = `
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
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 sizeClass = this.size === 'medium' ? '' : this.size;
|
||||||
const variantClass = this.variant === 'primary' ? '' : this.variant;
|
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 `
|
return `
|
||||||
<button class="login-btn ${sizeClass} ${variantClass}" ${this.loading ? 'disabled' : ''}>
|
<div class="account-list">
|
||||||
${this.loading
|
${items}
|
||||||
? '<div class="loading-spinner"></div>'
|
</div>
|
||||||
: PASSKEY_ICON
|
<button class="alt-action" data-action="different-account">Use a different account</button>
|
||||||
}
|
|
||||||
<span>${this.loading ? 'Authenticating...' : this.label}</span>
|
|
||||||
</button>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -407,22 +553,66 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Login button click
|
// Login button with scoped username
|
||||||
this.shadow.querySelector('.login-btn')?.addEventListener('click', () => {
|
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();
|
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;
|
if (this.loading) return;
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to authenticate with existing passkey
|
// If a username was selected, scope the passkey prompt to that user's credentials
|
||||||
const result = await authenticatePasskey();
|
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
|
// Initialize key manager with PRF output
|
||||||
const keyManager = getKeyManager();
|
const keyManager = getKeyManager();
|
||||||
|
|
@ -450,6 +640,12 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
wallet: !!keys.eoaAddress,
|
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
|
// Dispatch success event
|
||||||
this.dispatchEvent(new CustomEvent('login-success', {
|
this.dispatchEvent(new CustomEvent('login-success', {
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -526,6 +722,13 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
|
|
||||||
private handleLogout() {
|
private handleLogout() {
|
||||||
const sessionManager = getSessionManager();
|
const sessionManager = getSessionManager();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
@ -567,6 +770,12 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
wallet: !!keys.eoaAddress,
|
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', {
|
this.dispatchEvent(new CustomEvent('login-success', {
|
||||||
detail: {
|
detail: {
|
||||||
did: keys.did,
|
did: keys.did,
|
||||||
|
|
@ -595,6 +804,9 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
try {
|
try {
|
||||||
const credential = await registerPasskey(username, displayName);
|
const credential = await registerPasskey(username, displayName);
|
||||||
|
|
||||||
|
// Remember this account
|
||||||
|
addKnownAccount({ username, displayName });
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent('register-success', {
|
this.dispatchEvent(new CustomEvent('register-success', {
|
||||||
detail: {
|
detail: {
|
||||||
credentialId: credential.credentialId,
|
credentialId: credential.credentialId,
|
||||||
|
|
@ -604,7 +816,7 @@ export class EncryptIDLoginButton extends HTMLElement {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Auto-login after registration
|
// Auto-login after registration
|
||||||
await this.handleLogin();
|
await this.handleLogin(username);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.dispatchEvent(new CustomEvent('register-error', {
|
this.dispatchEvent(new CustomEvent('register-error', {
|
||||||
detail: { error: error.message },
|
detail: { error: error.message },
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
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> = {}
|
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
|
||||||
|
|
@ -272,12 +272,14 @@ export async function authenticatePasskey(
|
||||||
const prfSalt = await generatePRFSalt('master-key');
|
const prfSalt = await generatePRFSalt('master-key');
|
||||||
|
|
||||||
// Build allowed credentials list
|
// Build allowed credentials list
|
||||||
const allowCredentials: PublicKeyCredentialDescriptor[] | undefined = credentialId
|
let allowCredentials: PublicKeyCredentialDescriptor[] | undefined;
|
||||||
? [{
|
if (credentialIds) {
|
||||||
type: 'public-key',
|
const ids = Array.isArray(credentialIds) ? credentialIds : [credentialIds];
|
||||||
id: new Uint8Array(base64urlToBuffer(credentialId)),
|
allowCredentials = ids.map(id => ({
|
||||||
}]
|
type: 'public-key' as const,
|
||||||
: undefined; // undefined = let user choose from available passkeys
|
id: new Uint8Array(base64urlToBuffer(id)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Build authentication options
|
// Build authentication options
|
||||||
const getOptions: CredentialRequestOptions = {
|
const getOptions: CredentialRequestOptions = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue