/** * EncryptID Login Button Component * * A customizable login button that handles WebAuthn authentication * and key derivation. Can be embedded in any r-ecosystem app. */ import { registerPasskey, authenticatePasskey, base64urlToBuffer, bufferToBase64url, detectCapabilities, startConditionalUI, WebAuthnCapabilities, } from '../webauthn'; import { getKeyManager, EncryptIDKeyManager } from '../key-derivation'; import { getSessionManager, AuthLevel } from '../session'; import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryptid-bridge'; 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, '"'); } // ============================================================================ // STYLES // ============================================================================ const styles = ` :host { --eid-primary: #06b6d4; --eid-primary-hover: #0891b2; --eid-bg: #0f172a; --eid-bg-hover: #1e293b; --eid-text: #f1f5f9; --eid-text-secondary: #94a3b8; --eid-radius: 8px; --eid-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3); display: inline-block; font-family: system-ui, -apple-system, sans-serif; } .login-container { position: relative; } .login-btn { display: flex; align-items: center; gap: 12px; padding: 12px 24px; background: var(--eid-primary); color: white; border: none; border-radius: var(--eid-radius); font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s; box-shadow: var(--eid-shadow); } .login-btn:hover { background: var(--eid-primary-hover); transform: translateY(-1px); } .login-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } .login-btn.outline { background: transparent; border: 2px solid var(--eid-primary); color: var(--eid-primary); } .login-btn.outline:hover { background: var(--eid-primary); color: white; } .login-btn.small { padding: 8px 16px; font-size: 0.875rem; } .login-btn.large { padding: 16px 32px; font-size: 1.125rem; } .passkey-icon { width: 24px; height: 24px; } .login-btn.small .passkey-icon { width: 18px; height: 18px; } .login-btn.large .passkey-icon { width: 28px; height: 28px; } .user-info { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--eid-bg); border-radius: var(--eid-radius); color: var(--eid-text); } .user-avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--eid-primary); display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.875rem; } .user-details { flex: 1; min-width: 0; } .user-did { font-size: 0.75rem; color: var(--eid-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; } .auth-level { font-size: 0.625rem; padding: 2px 6px; border-radius: 4px; background: var(--eid-bg-hover); } .auth-level.elevated { background: rgba(34, 197, 94, 0.2); color: #22c55e; } .auth-level.standard { background: rgba(234, 179, 8, 0.2); color: #eab308; } .logout-btn { padding: 6px 12px; background: transparent; border: 1px solid var(--eid-text-secondary); border-radius: var(--eid-radius); color: var(--eid-text-secondary); font-size: 0.75rem; cursor: pointer; transition: all 0.2s; } .logout-btn:hover { border-color: #ef4444; color: #ef4444; } .dropdown { position: absolute; top: 100%; right: 0; margin-top: 8px; background: var(--eid-bg); border-radius: var(--eid-radius); box-shadow: var(--eid-shadow); min-width: 200px; z-index: 100; overflow: hidden; } .dropdown-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; color: var(--eid-text); text-decoration: none; cursor: pointer; transition: background 0.2s; } .dropdown-item:hover { background: var(--eid-bg-hover); } .dropdown-item.danger { color: #ef4444; } .dropdown-divider { height: 1px; background: #334155; margin: 4px 0; } .loading-spinner { width: 20px; height: 20px; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .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; } .username-form { display: flex; flex-direction: column; gap: 8px; min-width: 200px; } .username-input { padding: 10px 14px; background: var(--eid-bg); border: 1px solid var(--eid-text-secondary); border-radius: var(--eid-radius); color: var(--eid-text); font-size: 0.95rem; font-family: inherit; outline: none; transition: border-color 0.2s; } .username-input:focus { border-color: var(--eid-primary); } .username-input::placeholder { color: var(--eid-text-secondary); opacity: 0.7; } .email-fallback-section { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.1); } .email-sent-msg { font-size: 0.85rem; color: #22c55e; text-align: center; padding: 8px; } .error-msg { font-size: 0.85rem; color: #f87171; background: rgba(248,113,113,0.1); border: 1px solid rgba(248,113,113,0.25); border-radius: var(--eid-radius); padding: 10px 14px; margin-bottom: 8px; line-height: 1.45; } .compat-note { font-size: 0.85rem; color: #fbbf24; background: rgba(251,191,36,0.1); border: 1px solid rgba(251,191,36,0.25); border-radius: var(--eid-radius); padding: 10px 14px; margin-bottom: 8px; line-height: 1.45; text-align: center; } .passphrase-overlay { position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; } .passphrase-modal { background: var(--eid-bg, #0f172a); border: 1px solid rgba(255,255,255,0.15); border-radius: 12px; padding: 24px; max-width: 380px; width: 90vw; box-shadow: 0 8px 32px rgba(0,0,0,0.5); } `; // ============================================================================ // PASSKEY SVG ICON // ============================================================================ const PASSKEY_ICON = ` `; // ============================================================================ // WEB COMPONENT // ============================================================================ export class EncryptIDLoginButton extends HTMLElement { private shadow: ShadowRoot; private loading: boolean = false; private showDropdown: boolean = false; private showEmailFallback: boolean = false; private emailSent: boolean = false; private errorMessage: string = ''; private showPassphrasePrompt: boolean = false; private passphraseResolver: ((passphrase: string) => void) | null = null; private capabilities: WebAuthnCapabilities | null = null; // Configurable attributes static get observedAttributes() { return ['size', 'variant', 'label', 'show-user']; } constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } private _onSessionRevoked = () => { // Another browser session logged out — clear local state and re-render const keyManager = getKeyManager(); keyManager.clear(); resetVaultManager(); resetDocBridge(); this.dispatchEvent(new CustomEvent('logout', { bubbles: true })); this.render(); }; async connectedCallback() { // Listen for cross-session logout revocation window.addEventListener('encryptid:session-revoked', this._onSessionRevoked); // Detect capabilities this.capabilities = await detectCapabilities(); // Start conditional UI if available if (this.capabilities.conditionalUI) { this.startConditionalAuth(); } this.render(); // Close dropdown on outside click document.addEventListener('click', (e) => { if (!this.contains(e.target as Node)) { this.showDropdown = false; this.render(); } }); } attributeChangedCallback() { this.render(); } private get size(): 'small' | 'medium' | 'large' { return (this.getAttribute('size') as any) || 'medium'; } private get variant(): 'primary' | 'outline' { return (this.getAttribute('variant') as any) || 'primary'; } private get label(): string { return this.getAttribute('label') || 'Sign in with Passkey'; } private get showUser(): boolean { return this.hasAttribute('show-user'); } disconnectedCallback() { window.removeEventListener('encryptid:session-revoked', this._onSessionRevoked); } private render() { const session = getSessionManager(); const isLoggedIn = session.isValid(); const did = session.getDID(); const authLevel = session.getAuthLevel(); this.shadow.innerHTML = `
${isLoggedIn && this.showUser ? this.renderUserInfo(did!, authLevel) : this.renderLoginButton()} ${this.showDropdown ? this.renderDropdown() : ''}
${this.showPassphrasePrompt ? this.renderPassphraseModal() : ''} `; this.attachEventListeners(); } private renderLoginButton(): string { const sizeClass = this.size === 'medium' ? '' : this.size; const variantClass = this.variant === 'primary' ? '' : this.variant; if (this.loading) { return ` `; } // WebAuthn unavailable — show email-only login if (this.capabilities?.webauthn === false) { return this.renderEmailOnlyFallback(sizeClass, variantClass); } const errorDiv = this.errorMessage ? `
${escapeHtml(this.errorMessage)}
` : ''; const accounts = getKnownAccounts(); // No known accounts → passkey-first button + email fallback if (accounts.length === 0) { const emailSection = this.showEmailFallback ? (this.emailSent ? `
Login link sent! Check your inbox.
` : `
`) : ''; return `
${errorDiv} ${this.showEmailFallback ? '' : ``} ${emailSection}
`; } // 1 known account → "Sign in as [username]" + alt link if (accounts.length === 1) { const name = escapeHtml(accounts[0].displayName || accounts[0].username); return ` ${errorDiv} `; } // 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 ` `; }).join(''); return ` ${errorDiv}
${items}
`; } private renderUserInfo(did: string, authLevel: AuthLevel): string { const shortDID = did.slice(0, 20) + '...' + did.slice(-8); const initial = did.slice(8, 10).toUpperCase(); const levelName = AuthLevel[authLevel].toLowerCase(); return `
${initial}
${shortDID}
${levelName}
`; } private renderDropdown(): string { return ` `; } /** * Show passphrase modal and return the entered passphrase. * Resolves when user submits; returns empty string if dismissed. */ private promptPassphrase(): Promise { return new Promise((resolve) => { this.passphraseResolver = resolve; this.showPassphrasePrompt = true; this.loading = false; // allow modal interaction this.render(); }); } private renderPassphraseModal(): string { return `
Your browser doesn't support hardware encryption.
Enter your encryption passphrase to unlock your documents.
This passphrase encrypts your documents. It's never sent to any server.
`; } private renderEmailOnlyFallback(sizeClass: string, variantClass: string): string { if (this.emailSent) { return `
Passkeys are not available in this browser.
`; } return `
Passkeys are not available in this browser.
`; } private attachEventListeners() { const session = getSessionManager(); const isLoggedIn = session.isValid(); if (isLoggedIn && this.showUser) { // User info click - toggle dropdown this.shadow.querySelector('.user-info')?.addEventListener('click', () => { this.showDropdown = !this.showDropdown; this.render(); }); // Dropdown actions this.shadow.querySelectorAll('.dropdown-item').forEach(item => { item.addEventListener('click', (e) => { e.stopPropagation(); const action = (item as HTMLElement).dataset.action; this.handleDropdownAction(action!); }); }); } else { // Passkey-first button (no known accounts → unscoped auth) this.shadow.querySelector('[data-action="passkey-login"]')?.addEventListener('click', () => { this.handleLogin(); }); // "No passkey? Sign in with email" toggle this.shadow.querySelector('[data-action="show-email"]')?.addEventListener('click', () => { this.showEmailFallback = true; this.render(); }); // Send magic link button + enter key this.shadow.querySelector('[data-action="send-magic-link"]')?.addEventListener('click', () => { this.sendMagicLink(); }); const emailInput = this.shadow.querySelector('[data-email-input]') as HTMLInputElement; emailInput?.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') this.sendMagicLink(); }); // Login button with scoped username (1 known account) const loginBtn = this.shadow.querySelector('.login-btn:not([data-action])'); 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(); }); } // Passphrase modal submit this.shadow.querySelector('[data-action="submit-passphrase"]')?.addEventListener('click', () => { const input = this.shadow.querySelector('[data-passphrase-input]') as HTMLInputElement; const passphrase = input?.value || ''; if (passphrase && this.passphraseResolver) { this.showPassphrasePrompt = false; this.passphraseResolver(passphrase); this.passphraseResolver = null; } }); const ppInput = this.shadow.querySelector('[data-passphrase-input]') as HTMLInputElement; ppInput?.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') { this.shadow.querySelector('[data-action="submit-passphrase"]')?.click(); } }); // Auto-focus passphrase input when modal is shown ppInput?.focus(); } /** * 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 { 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 body = await res.json().catch(() => null); if (!body) return undefined; const { options, userFound } = body; if (!userFound || !options.allowCredentials?.length) return undefined; return options.allowCredentials.map((c: any) => ({ type: 'public-key' as const, id: base64urlToBuffer(c.id), ...(c.transports?.length ? { transports: c.transports } : {}), })); } catch { return undefined; } } private async handleLogin(username?: string) { if (this.loading) return; this.errorMessage = ''; this.loading = true; this.render(); try { // If a username was selected, scope the passkey prompt to that user's credentials let scopedCredentials: PublicKeyCredentialDescriptor[] | undefined; if (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 credentials, unscoped otherwise const result = await authenticatePasskey(scopedCredentials); // Initialize key manager with PRF output const keyManager = getKeyManager(); if (result.prfOutput) { await keyManager.initFromPRF(result.prfOutput); // Initialize doc encryption bridge for local-first encrypted storage await getDocBridge().initFromAuth(result.prfOutput); // Load encrypted account vault in background const docCrypto = getDocBridge().getDocCrypto(); if (docCrypto) { getVaultManager(docCrypto).load() .then(() => syncWalletsOnLogin(docCrypto)) .catch(err => console.warn('Vault load failed:', err)); } } // Get derived keys const keys = await keyManager.getKeys(); // Create session first (need JWT for salt endpoint) const sessionManager = getSessionManager(); await sessionManager.createSession(result, keys.did, { encrypt: true, sign: true, wallet: !!keys.eoaAddress, }); // Non-PRF path: passphrase-based encryption for Firefox and other browsers if (!result.prfOutput) { const session = sessionManager.getSession(); if (session?.token) { try { const saltRes = await fetch(`${ENCRYPTID_AUTH}/api/account/passphrase-salt`, { headers: { 'Authorization': `Bearer ${session.token}` }, }); if (saltRes.ok) { const { salt } = await saltRes.json(); // Prompt for passphrase const passphrase = await this.promptPassphrase(); if (passphrase) { const saltBytes = base64urlToBuffer(salt); await keyManager.initFromPassphrase(passphrase, new Uint8Array(saltBytes)); // Get raw key material for DocBridge const rawKey = await keyManager.getKeyMaterial(); await getDocBridge().initFromKey(new Uint8Array(rawKey)); // Load vault const docCrypto = getDocBridge().getDocCrypto(); if (docCrypto) { getVaultManager(docCrypto).load() .then(() => syncWalletsOnLogin(docCrypto)) .catch(err => console.warn('Vault load failed:', err)); } } } // 404 = user doesn't have a passphrase salt yet (new registration, will be set up) } catch (e) { console.warn('Passphrase salt fetch failed:', e); } } } // 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: { did: keys.did, credentialId: result.credentialId, prfAvailable: !!result.prfOutput, }, bubbles: true, })); } catch (error: any) { const name = error?.name || ''; switch (name) { case 'AbortError': // Conditional UI abort — silent, expected break; case 'NotAllowedError': this.errorMessage = 'Passkey dismissed. Try again, or sign in with email.'; this.showEmailFallback = true; this.dispatchEvent(new CustomEvent('login-register-needed', { bubbles: true })); break; case 'NotSupportedError': this.errorMessage = 'Passkeys are not supported on this browser. Sign in with email.'; this.showEmailFallback = true; break; case 'InvalidStateError': this.errorMessage = 'This passkey is already registered. Try signing in instead.'; break; case 'SecurityError': this.errorMessage = 'Security error: this page cannot use your passkey.'; break; default: this.errorMessage = error?.message || 'Authentication failed. Please try again.'; this.dispatchEvent(new CustomEvent('login-error', { detail: { error: error?.message }, bubbles: true, })); break; } } finally { this.loading = false; this.render(); } } private async sendMagicLink() { const emailInput = this.shadow.querySelector('[data-email-input]') as HTMLInputElement; const email = emailInput?.value.trim(); if (!email || !email.includes('@')) { emailInput?.focus(); return; } try { await fetch(`${ENCRYPTID_AUTH}/api/auth/magic-link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); this.emailSent = true; this.render(); } catch (error: any) { console.error('Magic link failed:', error); } } private async handleDropdownAction(action: string) { this.showDropdown = false; switch (action) { case 'profile': this.dispatchEvent(new CustomEvent('navigate', { detail: { path: '/profile' }, bubbles: true, })); break; case 'recovery': this.dispatchEvent(new CustomEvent('navigate', { detail: { path: '/recovery' }, bubbles: true, })); break; case 'upgrade': await this.handleUpgradeAuth(); break; case 'logout': this.handleLogout(); break; } this.render(); } private async handleUpgradeAuth() { try { const result = await authenticatePasskey(); const sessionManager = getSessionManager(); sessionManager.upgradeAuthLevel(AuthLevel.ELEVATED); this.dispatchEvent(new CustomEvent('auth-upgraded', { detail: { level: AuthLevel.ELEVATED }, bubbles: true, })); } catch (error) { console.error('Failed to upgrade auth:', error); } } private handleLogout() { const sessionManager = getSessionManager(); // Keep known account in localStorage so the picker shows on next login sessionManager.clearSession(); const keyManager = getKeyManager(); keyManager.clear(); // Clear vault and doc encryption bridge key material resetVaultManager(); resetDocBridge(); this.dispatchEvent(new CustomEvent('logout', { bubbles: true })); this.render(); } private async startConditionalAuth() { // Start listening for conditional UI (passkey autofill) try { const result = await startConditionalUI(); if (result) { // User selected a passkey from autofill const keyManager = getKeyManager(); if (result.prfOutput) { await keyManager.initFromPRF(result.prfOutput); await getDocBridge().initFromAuth(result.prfOutput); // Load encrypted account vault in background const docCrypto = getDocBridge().getDocCrypto(); if (docCrypto) { getVaultManager(docCrypto).load() .then(() => syncWalletsOnLogin(docCrypto)) .catch(err => console.warn('Vault load failed:', err)); } } const keys = await keyManager.getKeys(); const sessionManager = getSessionManager(); await sessionManager.createSession(result, keys.did, { encrypt: true, sign: true, 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, credentialId: result.credentialId, prfAvailable: !!result.prfOutput, viaConditionalUI: true, }, bubbles: true, })); this.render(); } } catch (error) { // Conditional UI cancelled or not available console.log('Conditional UI not completed:', error); } } /** * Public method to trigger registration flow */ async register(username: string, displayName: string): Promise { this.loading = true; this.render(); try { const credential = await registerPasskey(username, displayName); // Remember this account addKnownAccount({ username, displayName }); this.dispatchEvent(new CustomEvent('register-success', { detail: { credentialId: credential.credentialId, prfSupported: credential.prfSupported, }, bubbles: true, })); // Auto-login after registration await this.handleLogin(username); // If PRF not supported, set up passphrase-based encryption if (!credential.prfSupported) { const session = getSessionManager().getSession(); if (session?.token) { const passphrase = await this.promptPassphrase(); if (passphrase) { const salt = EncryptIDKeyManager.generateSalt(); const saltB64 = bufferToBase64url(salt); // Store salt server-side await fetch(`${ENCRYPTID_AUTH}/api/account/passphrase-salt`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.token}`, }, body: JSON.stringify({ salt: saltB64 }), }); // Init encryption from passphrase const keyManager = getKeyManager(); await keyManager.initFromPassphrase(passphrase, salt); const rawKey = await keyManager.getKeyMaterial(); await getDocBridge().initFromKey(new Uint8Array(rawKey)); } } } } catch (error: any) { this.dispatchEvent(new CustomEvent('register-error', { detail: { error: error.message }, bubbles: true, })); } finally { this.loading = false; this.render(); } } } // Register the custom element customElements.define('encryptid-login', EncryptIDLoginButton);