/**
* 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
${flashHtml}
Account
- Handle
- ${esc(session.handle)}
- DID
${esc(truncateDid(session.did))}
- Session started
- ${formatDate(session.created_at)}
${passkeyService ? `
Passkeys
| Name | Type | Backed up | Last used | |
${passkeyRows}
` : ''}
Active Sessions
| Device | IP | Last active | Created | |
${sessionRows}
`;
}
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);
}