/** * User Profile Routes * * Self-service profile page for authenticated users. * Passkey registration/management, active session management. * Protected by forward-auth session cookie. */ import { Router, Request, Response } from 'express'; import crypto from 'crypto'; import type { DatabaseService } from '../services/database.js'; import type { PasskeyService } from '../services/passkey.js'; import type { ProxySession } from '../types/proxy.js'; import { verifySessionCookie, parseCookies, SESSION_COOKIE_NAME } from '../utils/proxy-auth.js'; export function createUserProfileRoutes( db: DatabaseService, passkeyService: PasskeyService | null, sessionSecret: string ): Router { const router = Router(); const csrfSecret = crypto.createHmac('sha256', sessionSecret).update('profile-csrf').digest('hex'); function esc(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function generateCsrfToken(): string { const now = Math.floor(Date.now() / 1000); const payload = `${now}`; const sig = crypto.createHmac('sha256', csrfSecret).update(payload).digest('base64url'); return `${payload}.${sig}`; } function verifyCsrfToken(token: string): boolean { if (!token) return false; const parts = token.split('.'); if (parts.length !== 2) return false; const [payload, sig] = parts; const expected = crypto.createHmac('sha256', csrfSecret).update(payload).digest('base64url'); if (sig.length !== expected.length) return false; if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return false; const ts = parseInt(payload, 10); const now = Math.floor(Date.now() / 1000); return now - ts < 3600; } function getSession(req: Request): ProxySession | null { const cookies = parseCookies(req.headers.cookie); const sessionCookie = cookies[SESSION_COOKIE_NAME]; if (!sessionCookie) return null; const sessionId = verifySessionCookie(sessionCookie, sessionSecret); if (!sessionId) return null; const session = db.getProxySession(sessionId); if (!session || session.expires_at < Math.floor(Date.now() / 1000)) return null; return session; } function timeAgo(epochSeconds: number): string { const diff = Math.floor(Date.now() / 1000) - epochSeconds; if (diff < 60) return 'just now'; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; return `${Math.floor(diff / 86400)}d ago`; } function formatDate(epochSeconds: number): string { return new Date(epochSeconds * 1000).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; } function truncateDid(did: string): string { if (did.length <= 32) return did; return did.slice(0, 20) + '...' + did.slice(-8); } /** * GET / — Main profile page */ router.get('/', (req: Request, res: Response) => { const session = getSession(req); if (!session) { const proto = req.get('x-forwarded-proto') || req.protocol; const profileUrl = `${proto}://${req.get('host')}/auth/profile`; return res.redirect(`/auth/proxy/login?rd=${encodeURIComponent(profileUrl)}`); } const csrf = generateCsrfToken(); const passkeys = passkeyService ? passkeyService.listPasskeys(session.did) : []; const sessions = db.getAllProxySessions(session.did, 50); const msg = req.query.msg as string | undefined; const nonce = res.locals.cspNonce || crypto.randomBytes(16).toString('base64'); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(renderProfilePage(session, passkeys, sessions, csrf, msg, nonce)); }); /** * POST /passkey/rename */ router.post('/passkey/rename', (req: Request, res: Response) => { const session = getSession(req); if (!session) return res.redirect('/auth/profile'); if (!verifyCsrfToken(req.body._csrf)) return res.status(403).send('Invalid form submission'); const { passkey_id, name } = req.body; if (passkeyService && passkey_id && name?.trim()) { passkeyService.renamePasskey(session.did, passkey_id, name.trim()); } res.redirect('/auth/profile?msg=Passkey+renamed'); }); /** * POST /passkey/delete */ router.post('/passkey/delete', (req: Request, res: Response) => { const session = getSession(req); if (!session) return res.redirect('/auth/profile'); if (!verifyCsrfToken(req.body._csrf)) return res.status(403).send('Invalid form submission'); const { passkey_id } = req.body; if (passkeyService && passkey_id) { passkeyService.deletePasskey(session.did, passkey_id); } res.redirect('/auth/profile?msg=Passkey+deleted'); }); /** * POST /session/revoke */ router.post('/session/revoke', (req: Request, res: Response) => { const session = getSession(req); if (!session) return res.redirect('/auth/profile'); if (!verifyCsrfToken(req.body._csrf)) return res.status(403).send('Invalid form submission'); const { session_id } = req.body; if (session_id === session.id) { return res.redirect('/auth/profile?msg=Cannot+revoke+current+session'); } const target = db.getProxySession(session_id); if (target && target.did === session.did) { db.deleteProxySession(session_id); } res.redirect('/auth/profile?msg=Session+revoked'); }); function renderProfilePage( session: ProxySession, passkeys: Array<{ id: string; name: string | null; device_type: string | null; backed_up: boolean; last_used_at: string | null; created_at: string }>, sessions: ProxySession[], csrf: string, msg: string | undefined, nonce: string ): string { const flashHtml = msg ? `
${esc(msg)}
` : ''; const passkeyRows = passkeys.length > 0 ? passkeys.map(pk => `
${esc(pk.device_type || '--')} ${pk.backed_up ? 'Yes' : 'No'} ${pk.last_used_at ? timeAgo(Math.floor(new Date(pk.last_used_at).getTime() / 1000)) : 'Never'}
`).join('') : 'No passkeys registered'; const sessionRows = sessions.map(s => { const isCurrent = s.id === session.id; return ` ${esc(parseUserAgent(s.user_agent))}${isCurrent ? ' current' : ''} ${esc(s.ip_address || '--')} ${timeAgo(s.last_activity)} ${formatDate(s.created_at)} ${isCurrent ? '--' : `
`} `; }).join(''); return ` Profile - ATAuth
ATAuth ${esc(session.handle)}
Sign out
${flashHtml}

Account

Handle
${esc(session.handle)}
DID
${esc(truncateDid(session.did))}
Session started
${formatDate(session.created_at)}
${passkeyService ? `

Passkeys

${passkeyRows}
NameTypeBacked upLast used
` : ''}

Active Sessions

${sessionRows}
DeviceIPLast activeCreated
${passkeyService ? ` function b64urlToBuffer(s) { var b = s.replace(/-/g, '+').replace(/_/g, '/'); var pad = b.length % 4 === 0 ? '' : '='.repeat(4 - (b.length % 4)); var bin = atob(b + pad); var arr = new Uint8Array(bin.length); for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return arr.buffer; } function bufferToB64url(buf) { var bytes = new Uint8Array(buf); var bin = ''; for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, ''); } var registerBtn = document.getElementById('registerPasskeyBtn'); var nameInput = document.getElementById('passkeyName'); var statusDiv = document.getElementById('registerStatus'); if (registerBtn && window.PublicKeyCredential) { registerBtn.style.display = 'inline-block'; registerBtn.addEventListener('click', function() { registerBtn.disabled = true; registerBtn.textContent = 'Waiting for authenticator...'; statusDiv.textContent = ''; statusDiv.className = 'register-status'; fetch('/auth/passkey/register/options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({}) }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) throw new Error(data.message || 'Failed to get options'); var opts = data.options; var pubKeyOpts = { challenge: b64urlToBuffer(opts.challenge), rp: opts.rp, user: { id: b64urlToBuffer(opts.user.id), name: opts.user.name, displayName: opts.user.displayName }, pubKeyCredParams: opts.pubKeyCredParams, timeout: opts.timeout, attestation: opts.attestation, authenticatorSelection: opts.authenticatorSelection }; if (opts.excludeCredentials) { pubKeyOpts.excludeCredentials = opts.excludeCredentials.map(function(c) { return { id: b64urlToBuffer(c.id), type: c.type, transports: c.transports }; }); } return navigator.credentials.create({ publicKey: pubKeyOpts }); }) .then(function(cred) { var credential = { id: cred.id, rawId: bufferToB64url(cred.rawId), response: { clientDataJSON: bufferToB64url(cred.response.clientDataJSON), attestationObject: bufferToB64url(cred.response.attestationObject), transports: cred.response.getTransports ? cred.response.getTransports() : undefined }, type: cred.type, clientExtensionResults: cred.getClientExtensionResults(), authenticatorAttachment: cred.authenticatorAttachment }; var name = nameInput ? nameInput.value.trim() : ''; return fetch('/auth/passkey/register/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ credential: credential, name: name || undefined }) }); }) .then(function(r) { return r.json(); }) .then(function(result) { if (result.success) { statusDiv.textContent = 'Passkey registered successfully'; statusDiv.className = 'register-status success'; setTimeout(function() { window.location.reload(); }, 1000); } else { statusDiv.textContent = result.message || 'Registration failed'; statusDiv.className = 'register-status error'; registerBtn.disabled = false; registerBtn.textContent = 'Register passkey'; } }) .catch(function(err) { if (err.name !== 'NotAllowedError') { statusDiv.textContent = 'Registration failed: ' + err.message; statusDiv.className = 'register-status error'; } registerBtn.disabled = false; registerBtn.textContent = 'Register passkey'; }); }); }` : ''} `; } return router; } function parseUserAgent(ua?: string): string { if (!ua) return 'Unknown'; if (ua.includes('Firefox')) return 'Firefox'; if (ua.includes('Edg/')) return 'Edge'; if (ua.includes('Chrome')) return 'Chrome'; if (ua.includes('Safari')) return 'Safari'; if (ua.includes('curl')) return 'curl'; return ua.slice(0, 30); }