From d52e0b0c090f3c5836f7bb08773232d254729cd7 Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Tue, 24 Feb 2026 11:20:21 -0600 Subject: [PATCH] feat: user profile page and dark login theme Add self-service profile page at /auth/profile for passkey registration/management and session management. Refactor login pages to dark theme with gradient accents, no emojis. --- CHANGELOG.md | 14 + gateway/package.json | 2 +- gateway/src/index.ts | 11 +- gateway/src/routes/oidc/authorize.test.ts | 4 +- gateway/src/routes/oidc/authorize.ts | 103 ++-- gateway/src/routes/passkey.ts | 37 +- gateway/src/routes/proxy-auth.ts | 62 ++- gateway/src/routes/user-profile.test.ts | 211 ++++++++ gateway/src/routes/user-profile.ts | 622 ++++++++++++++++++++++ 9 files changed, 996 insertions(+), 70 deletions(-) create mode 100644 gateway/src/routes/user-profile.test.ts create mode 100644 gateway/src/routes/user-profile.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc333c..5519f13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.1.0] - 2026-02-24 + +### Added + +- User profile page at `/auth/profile` with passkey registration, passkey management (rename/delete), and active session management (view/revoke) +- Forward-auth session cookie authentication for passkey API routes (enables profile page WebAuthn registration) + +### Changed + +- Login page theme: dark mode, no emojis, gradient accent (blue-violet), matching admin dashboard aesthetic +- Both OIDC and forward-auth login pages share the new dark theme +- Passkey button text simplified to "Sign in with passkey" with SVG key icon +- Brand text "ATAuth" replaces emoji lock logo + ## [2.0.3] - 2026-02-23 ### Added diff --git a/gateway/package.json b/gateway/package.json index efa7129..70cf59f 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -1,6 +1,6 @@ { "name": "atauth-gateway", - "version": "2.0.3", + "version": "2.1.0", "description": "AT Protocol OAuth gateway for application authentication", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 86ddb4a..eecea51 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -28,6 +28,7 @@ import { createPasskeyRouter } from './routes/passkey.js'; import { createMFARouter } from './routes/mfa.js'; import { createEmailRouter } from './routes/email.js'; import { createProxyAuthRoutes } from './routes/proxy-auth.js'; +import { createUserProfileRoutes } from './routes/user-profile.js'; import { authRateLimit, apiRateLimit, adminRateLimit } from './middleware/rateLimit.js'; import { HttpError } from './utils/errors.js'; @@ -269,7 +270,8 @@ async function main(): Promise { // Passkey routes (if enabled) if (passkeyService) { - app.use('/auth/passkey', authRateLimit, createPasskeyRouter(db, passkeyService, oidcService)); + const passkeySessionSecret = config.forwardAuth.enabled ? config.forwardAuth.sessionSecret : undefined; + app.use('/auth/passkey', authRateLimit, createPasskeyRouter(db, passkeyService, oidcService, passkeySessionSecret)); console.log('Passkey routes enabled'); } @@ -287,6 +289,13 @@ async function main(): Promise { // Forward-auth proxy routes were mounted earlier (before /auth rate limiter) + // User profile routes (forward-auth session authenticated) + if (config.forwardAuth.enabled) { + const profileRouter = createUserProfileRoutes(db, passkeyService, config.forwardAuth.sessionSecret!); + app.use('/auth/profile', authRateLimit, profileRouter); + console.log('User profile routes enabled'); + } + // OAuth client metadata (for AT Protocol discovery) app.get('/client-metadata.json', (_req, res) => { // Build redirect URIs array - include both legacy and OIDC callback paths diff --git a/gateway/src/routes/oidc/authorize.test.ts b/gateway/src/routes/oidc/authorize.test.ts index 0bc7f48..b82e275 100644 --- a/gateway/src/routes/oidc/authorize.test.ts +++ b/gateway/src/routes/oidc/authorize.test.ts @@ -245,7 +245,7 @@ describe('GET /oauth/authorize - login page', () => { expect(res.status).toBe(200); expect(res.text).toContain('passkeyBtn'); - expect(res.text).toContain('Sign in with a passkey'); + expect(res.text).toContain('Sign in with passkey'); expect(res.text).toContain('b64urlToBuffer'); }); @@ -264,7 +264,7 @@ describe('GET /oauth/authorize - login page', () => { expect(res.status).toBe(200); expect(res.text).not.toContain('passkeyBtn'); - expect(res.text).not.toContain('Sign in with a passkey'); + expect(res.text).not.toContain('Sign in with passkey'); // Handle form should still be present expect(res.text).toContain('loginForm'); expect(res.text).toContain('you.bsky.social'); diff --git a/gateway/src/routes/oidc/authorize.ts b/gateway/src/routes/oidc/authorize.ts index 7e162ba..2edb6f7 100644 --- a/gateway/src/routes/oidc/authorize.ts +++ b/gateway/src/routes/oidc/authorize.ts @@ -45,7 +45,11 @@ export function createAuthorizeRouter( * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: #0f172a; + background-image: + radial-gradient(ellipse at 20% 50%, rgba(59, 130, 246, 0.15) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.12) 0%, transparent 50%), + radial-gradient(ellipse at 50% 80%, rgba(14, 165, 233, 0.1) 0%, transparent 50%); min-height: 100vh; display: flex; align-items: center; @@ -53,39 +57,57 @@ export function createAuthorizeRouter( padding: 20px; } .container { - background: white; + background: #1e293b; + border: 1px solid #334155; border-radius: 16px; padding: 40px; width: 100%; - max-width: 400px; - box-shadow: 0 20px 60px rgba(0,0,0,0.3); + max-width: 420px; + box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.03); } - h1 { font-size: 24px; margin-bottom: 8px; color: #1a1a2e; } - .subtitle { color: #666; margin-bottom: 32px; font-size: 14px; } + .brand { + text-align: center; + margin-bottom: 28px; + } + .brand-name { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.5px; + background: linear-gradient(135deg, #3b82f6, #8b5cf6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + h1 { font-size: 22px; margin-bottom: 8px; color: #e2e8f0; font-weight: 600; } + .subtitle { color: #94a3b8; margin-bottom: 28px; font-size: 14px; } .client-info { - background: #f5f5f5; + background: rgba(15, 23, 42, 0.6); + border: 1px solid #334155; border-radius: 8px; padding: 12px; margin-bottom: 24px; font-size: 13px; - color: #444; + color: #94a3b8; } - .client-info strong { color: #1a1a2e; } - label { display: block; font-weight: 500; margin-bottom: 8px; color: #333; } + .client-info strong { color: #e2e8f0; } + label { display: block; font-weight: 500; margin-bottom: 8px; color: #cbd5e1; font-size: 14px; } input[type="text"] { width: 100%; padding: 14px 16px; - border: 2px solid #e0e0e0; + background: #0f172a; + border: 1px solid #334155; border-radius: 8px; font-size: 16px; - transition: border-color 0.2s; + color: #e2e8f0; + transition: border-color 0.2s, box-shadow 0.2s; } - input[type="text"]:focus { outline: none; border-color: #667eea; } - .hint { font-size: 12px; color: #888; margin-top: 8px; } + input[type="text"]::placeholder { color: #475569; } + input[type="text"]:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); } + .hint { font-size: 12px; color: #64748b; margin-top: 8px; } button { width: 100%; padding: 14px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); color: white; border: none; border-radius: 8px; @@ -93,50 +115,52 @@ export function createAuthorizeRouter( font-weight: 600; cursor: pointer; margin-top: 24px; - transition: transform 0.2s, box-shadow 0.2s; + transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s; } - button:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); } - button:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } + button:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(59, 130, 246, 0.3); } + button:active { transform: translateY(0); } + button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; } .error { - background: #fee; - color: #c00; + background: rgba(127, 29, 29, 0.4); + border: 1px solid rgba(252, 165, 165, 0.2); + color: #fca5a5; padding: 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; display: none; } - .logo { text-align: center; margin-bottom: 24px; font-size: 32px; } .divider { display: flex; align-items: center; margin: 24px 0; gap: 12px; } - .divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: #e0e0e0; } - .divider span { color: #999; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; } + .divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: #334155; } + .divider span { color: #64748b; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; } .passkey-btn { width: 100%; padding: 14px; - background: white; - color: #333; - border: 2px solid #e0e0e0; + background: transparent; + color: #e2e8f0; + border: 1px solid #334155; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; - transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s; + transition: border-color 0.2s, transform 0.15s, box-shadow 0.15s, background 0.2s; display: flex; align-items: center; justify-content: center; gap: 8px; + margin-top: 0; } - .passkey-btn:hover { border-color: #667eea; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2); } - .passkey-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } - .passkey-icon { font-size: 20px; line-height: 1; } - .privacy { font-size: 12px; color: #888; margin-top: 20px; line-height: 1.5; text-align: center; } - .privacy strong { color: #555; } + .passkey-btn:hover { border-color: #3b82f6; background: rgba(59, 130, 246, 0.06); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1); } + .passkey-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; } + .passkey-btn svg { width: 18px; height: 18px; } + .privacy { font-size: 12px; color: #64748b; margin-top: 24px; line-height: 1.6; text-align: center; } + .privacy strong { color: #94a3b8; }
- -

Sign in with ATAuth

+
ATAuth
+

Sign in

Use your Bluesky or AT Protocol identity

Signing in to ${esc(clientName)}
${errorHtml} @@ -150,9 +174,10 @@ export function createAuthorizeRouter( ${passkeyEnabled ? `
or
` : ''} -

Bluesky will ask to authorize broad access — this is a limitation of the AT Protocol OAuth standard. ${esc(clientName)} only reads your identity (handle). It will never post, follow, or access your data.

+

Bluesky will ask to authorize broad access -- this is a limitation of the AT Protocol OAuth standard. ${esc(clientName)} only reads your identity (handle). It will never post, follow, or access your data.

var form = document.getElementById('loginForm'); @@ -189,7 +214,7 @@ export function createAuthorizeRouter( passkeyBtn.style.display = 'flex'; passkeyBtn.addEventListener('click', function() { passkeyBtn.disabled = true; - passkeyBtn.innerHTML = '🔑 Authenticating...'; + passkeyBtn.textContent = 'Authenticating...'; if (errorDiv) errorDiv.style.display = 'none'; fetch('/auth/passkey/authenticate/options', { method: 'POST', @@ -243,7 +268,7 @@ export function createAuthorizeRouter( } else { showError(result.error_description || 'Passkey authentication failed'); passkeyBtn.disabled = false; - passkeyBtn.innerHTML = '🔑 Sign in with a passkey'; + passkeyBtn.textContent = 'Sign in with passkey'; } }) .catch(function(err) { @@ -251,7 +276,7 @@ export function createAuthorizeRouter( showError('Passkey authentication failed. Try signing in with your handle.'); } passkeyBtn.disabled = false; - passkeyBtn.innerHTML = '🔑 Sign in with a passkey'; + passkeyBtn.textContent = 'Sign in with passkey'; }); }); }` : ''} diff --git a/gateway/src/routes/passkey.ts b/gateway/src/routes/passkey.ts index b48424f..7df1b86 100644 --- a/gateway/src/routes/passkey.ts +++ b/gateway/src/routes/passkey.ts @@ -10,11 +10,13 @@ import type { DatabaseService } from '../services/database.js'; import type { PasskeyService } from '../services/passkey.js'; import type { OIDCService } from '../services/oidc/index.js'; import { HttpError } from '../utils/errors.js'; +import { verifySessionCookie, parseCookies, SESSION_COOKIE_NAME } from '../utils/proxy-auth.js'; export function createPasskeyRouter( db: DatabaseService, passkeyService: PasskeyService, - oidcService: OIDCService | null + oidcService: OIDCService | null, + sessionSecret?: string ): Router { const router = Router(); @@ -25,7 +27,7 @@ export function createPasskeyRouter( */ router.post('/register/options', async (req: Request, res: Response) => { try { - const { did, handle } = await authenticateRequest(req, db, oidcService); + const { did, handle } = await authenticateRequest(req, db, oidcService, sessionSecret); if (!did) { throw new HttpError(401, 'unauthorized', 'Authentication required'); @@ -48,7 +50,7 @@ export function createPasskeyRouter( */ router.post('/register/verify', async (req: Request, res: Response) => { try { - const { did, handle } = await authenticateRequest(req, db, oidcService); + const { did, handle } = await authenticateRequest(req, db, oidcService, sessionSecret); if (!did) { throw new HttpError(401, 'unauthorized', 'Authentication required'); @@ -165,7 +167,7 @@ export function createPasskeyRouter( */ router.get('/list', async (req: Request, res: Response) => { try { - const { did } = await authenticateRequest(req, db, oidcService); + const { did } = await authenticateRequest(req, db, oidcService, sessionSecret); if (!did) { throw new HttpError(401, 'unauthorized', 'Authentication required'); @@ -188,7 +190,7 @@ export function createPasskeyRouter( */ router.put('/:id', async (req: Request, res: Response) => { try { - const { did } = await authenticateRequest(req, db, oidcService); + const { did } = await authenticateRequest(req, db, oidcService, sessionSecret); if (!did) { throw new HttpError(401, 'unauthorized', 'Authentication required'); @@ -219,7 +221,7 @@ export function createPasskeyRouter( */ router.delete('/:id', async (req: Request, res: Response) => { try { - const { did } = await authenticateRequest(req, db, oidcService); + const { did } = await authenticateRequest(req, db, oidcService, sessionSecret); if (!did) { throw new HttpError(401, 'unauthorized', 'Authentication required'); @@ -256,7 +258,8 @@ export function createPasskeyRouter( async function authenticateRequest( req: Request, db: DatabaseService, - oidcService: OIDCService | null + oidcService: OIDCService | null, + sessionSecret?: string ): Promise<{ did?: string; handle?: string }> { // Try to authenticate via access token const authHeader = req.headers.authorization; @@ -273,7 +276,7 @@ async function authenticateRequest( } } - // Try to authenticate via session cookie + // Try to authenticate via session header const sessionId = req.headers['x-session-id'] as string; if (sessionId) { const session = db.getSession(sessionId); @@ -285,6 +288,24 @@ async function authenticateRequest( } } + // Try to authenticate via forward-auth session cookie + if (sessionSecret) { + const cookies = parseCookies(req.headers.cookie); + const sessionCookie = cookies[SESSION_COOKIE_NAME]; + if (sessionCookie) { + const proxySessionId = verifySessionCookie(sessionCookie, sessionSecret); + if (proxySessionId) { + const proxySession = db.getProxySession(proxySessionId); + if (proxySession && proxySession.expires_at > Math.floor(Date.now() / 1000)) { + return { + did: proxySession.did, + handle: proxySession.handle, + }; + } + } + } + } + return {}; } diff --git a/gateway/src/routes/proxy-auth.ts b/gateway/src/routes/proxy-auth.ts index fcbcbb6..1bbe450 100644 --- a/gateway/src/routes/proxy-auth.ts +++ b/gateway/src/routes/proxy-auth.ts @@ -453,6 +453,7 @@ function renderProxyLoginPage(authRequestId: string, nonce?: string, errorMessag
+
ATAuth

Sign in to continue

Authenticate with your Bluesky account to access this service

${errorHtml} @@ -563,7 +564,11 @@ function sharedStyles(): string { * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: #0f172a; + background-image: + radial-gradient(ellipse at 20% 50%, rgba(59, 130, 246, 0.15) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.12) 0%, transparent 50%), + radial-gradient(ellipse at 50% 80%, rgba(14, 165, 233, 0.1) 0%, transparent 50%); min-height: 100vh; display: flex; align-items: center; @@ -571,30 +576,47 @@ function sharedStyles(): string { padding: 20px; } .container { - background: white; + background: #1e293b; + border: 1px solid #334155; border-radius: 16px; padding: 40px; width: 100%; - max-width: 400px; - box-shadow: 0 20px 60px rgba(0,0,0,0.3); + max-width: 420px; + box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.03); } - h1 { font-size: 24px; margin-bottom: 8px; color: #1a1a2e; } - .subtitle { color: #666; margin-bottom: 32px; font-size: 14px; } - label { display: block; font-weight: 500; margin-bottom: 8px; color: #333; } + .brand { + text-align: center; + margin-bottom: 28px; + } + .brand-name { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.5px; + background: linear-gradient(135deg, #3b82f6, #8b5cf6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + h1 { font-size: 22px; margin-bottom: 8px; color: #e2e8f0; font-weight: 600; } + .subtitle { color: #94a3b8; margin-bottom: 28px; font-size: 14px; } + label { display: block; font-weight: 500; margin-bottom: 8px; color: #cbd5e1; font-size: 14px; } input[type="text"] { width: 100%; padding: 14px 16px; - border: 2px solid #e0e0e0; + background: #0f172a; + border: 1px solid #334155; border-radius: 8px; font-size: 16px; - transition: border-color 0.2s; + color: #e2e8f0; + transition: border-color 0.2s, box-shadow 0.2s; } - input[type="text"]:focus { outline: none; border-color: #667eea; } - .hint { font-size: 12px; color: #888; margin-top: 8px; } + input[type="text"]::placeholder { color: #475569; } + input[type="text"]:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); } + .hint { font-size: 12px; color: #64748b; margin-top: 8px; } button { width: 100%; padding: 14px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); color: white; border: none; border-radius: 8px; @@ -602,20 +624,22 @@ function sharedStyles(): string { font-weight: 600; cursor: pointer; margin-top: 24px; - transition: transform 0.2s, box-shadow 0.2s; + transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s; } - button:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); } - button:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } + button:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(59, 130, 246, 0.3); } + button:active { transform: translateY(0); } + button:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; } .error { - background: #fee; - color: #c00; + background: rgba(127, 29, 29, 0.4); + border: 1px solid rgba(252, 165, 165, 0.2); + color: #fca5a5; padding: 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; display: none; } - .privacy { font-size: 12px; color: #888; margin-top: 20px; line-height: 1.5; text-align: center; } - .privacy strong { color: #555; } + .privacy { font-size: 12px; color: #64748b; margin-top: 24px; line-height: 1.6; text-align: center; } + .privacy strong { color: #94a3b8; } `; } diff --git a/gateway/src/routes/user-profile.test.ts b/gateway/src/routes/user-profile.test.ts new file mode 100644 index 0000000..8bae68a --- /dev/null +++ b/gateway/src/routes/user-profile.test.ts @@ -0,0 +1,211 @@ +/** + * User Profile Tests + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import crypto from 'crypto'; +import { createUserProfileRoutes } from './user-profile.js'; +import { DatabaseService } from '../services/database.js'; +import { createSessionCookie, SESSION_COOKIE_NAME } from '../utils/proxy-auth.js'; + +const SESSION_SECRET = 'test-session-secret-for-profile-32chars'; + +function createTestApp(db: DatabaseService, passkeyService: any = null) { + const app = express(); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + app.use((_req, res, next) => { + res.locals.cspNonce = crypto.randomBytes(16).toString('base64'); + next(); + }); + const router = createUserProfileRoutes(db, passkeyService, SESSION_SECRET); + app.use('/auth/profile', router); + return app; +} + +function createProxySession(db: DatabaseService, did = 'did:plc:test123', handle = 'test.bsky.social') { + const sessionId = crypto.randomBytes(16).toString('hex'); + db.createProxySession({ + id: sessionId, + did, + handle, + created_at: Math.floor(Date.now() / 1000), + expires_at: Math.floor(Date.now() / 1000) + 604800, + last_activity: Math.floor(Date.now() / 1000), + user_agent: 'Mozilla/5.0 Chrome/120', + ip_address: '127.0.0.1', + }); + return sessionId; +} + +function sessionCookie(sessionId: string): string { + return `${SESSION_COOKIE_NAME}=${createSessionCookie(sessionId, SESSION_SECRET, 604800)}`; +} + +describe('User Profile Routes', () => { + let db: DatabaseService; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + }); + + describe('GET /auth/profile', () => { + it('redirects to login when no session cookie', async () => { + const app = createTestApp(db); + const res = await request(app).get('/auth/profile'); + expect(res.status).toBe(302); + expect(res.headers.location).toContain('/auth/proxy/login'); + }); + + it('redirects to login when session cookie is invalid', async () => { + const app = createTestApp(db); + const res = await request(app) + .get('/auth/profile') + .set('Cookie', `${SESSION_COOKIE_NAME}=invalid-cookie`); + expect(res.status).toBe(302); + expect(res.headers.location).toContain('/auth/proxy/login'); + }); + + it('renders profile page when authenticated', async () => { + const sessionId = createProxySession(db); + const app = createTestApp(db); + const res = await request(app) + .get('/auth/profile') + .set('Cookie', sessionCookie(sessionId)); + expect(res.status).toBe(200); + expect(res.text).toContain('test.bsky.social'); + expect(res.text).toContain('did:plc:test123'); + expect(res.text).toContain('Active Sessions'); + }); + + it('shows passkey section when passkeyService is provided', async () => { + const sessionId = createProxySession(db); + const mockPasskey = { + listPasskeys: () => [], + }; + const app = createTestApp(db, mockPasskey); + const res = await request(app) + .get('/auth/profile') + .set('Cookie', sessionCookie(sessionId)); + expect(res.status).toBe(200); + expect(res.text).toContain('Passkeys'); + expect(res.text).toContain('registerPasskeyBtn'); + }); + + it('hides passkey section when passkeyService is null', async () => { + const sessionId = createProxySession(db); + const app = createTestApp(db, null); + const res = await request(app) + .get('/auth/profile') + .set('Cookie', sessionCookie(sessionId)); + expect(res.status).toBe(200); + expect(res.text).not.toContain('registerPasskeyBtn'); + }); + + it('shows flash message from query param', async () => { + const sessionId = createProxySession(db); + const app = createTestApp(db); + const res = await request(app) + .get('/auth/profile?msg=Passkey+deleted') + .set('Cookie', sessionCookie(sessionId)); + expect(res.status).toBe(200); + expect(res.text).toContain('Passkey deleted'); + }); + + it('shows registered passkeys', async () => { + const sessionId = createProxySession(db); + const mockPasskey = { + listPasskeys: () => [ + { id: 'pk-1', name: 'My Yubikey', device_type: 'cross-platform', backed_up: false, last_used_at: null, created_at: '2026-01-01T00:00:00Z' }, + ], + }; + const app = createTestApp(db, mockPasskey); + const res = await request(app) + .get('/auth/profile') + .set('Cookie', sessionCookie(sessionId)); + expect(res.status).toBe(200); + expect(res.text).toContain('My Yubikey'); + expect(res.text).toContain('cross-platform'); + }); + + it('marks current session in session list', async () => { + const sessionId = createProxySession(db); + const app = createTestApp(db); + const res = await request(app) + .get('/auth/profile') + .set('Cookie', sessionCookie(sessionId)); + expect(res.status).toBe(200); + expect(res.text).toContain('current-session'); + expect(res.text).toContain('current'); + }); + }); + + describe('POST /auth/profile/passkey/delete', () => { + it('rejects without CSRF token', async () => { + const sessionId = createProxySession(db); + const mockPasskey = { deletePasskey: () => true }; + const app = createTestApp(db, mockPasskey); + const res = await request(app) + .post('/auth/profile/passkey/delete') + .set('Cookie', sessionCookie(sessionId)) + .send({ passkey_id: 'pk-1' }); + expect(res.status).toBe(403); + }); + + it('redirects to login without session', async () => { + const app = createTestApp(db); + const res = await request(app) + .post('/auth/profile/passkey/delete') + .send({ passkey_id: 'pk-1', _csrf: 'invalid' }); + expect(res.status).toBe(302); + expect(res.headers.location).toBe('/auth/profile'); + }); + }); + + describe('POST /auth/profile/session/revoke', () => { + it('prevents revoking current session', async () => { + const sessionId = createProxySession(db); + // Need a second session so the page has at least one revoke form with a CSRF token + createProxySession(db); + + const app = createTestApp(db); + const page = await request(app) + .get('/auth/profile') + .set('Cookie', sessionCookie(sessionId)); + const csrfMatch = page.text.match(/name="_csrf" value="([^"]+)"/); + expect(csrfMatch).toBeTruthy(); + + const res = await request(app) + .post('/auth/profile/session/revoke') + .set('Cookie', sessionCookie(sessionId)) + .type('form') + .send({ session_id: sessionId, _csrf: csrfMatch![1] }); + expect(res.status).toBe(302); + expect(res.headers.location).toContain('Cannot+revoke+current+session'); + }); + + it('revokes other session successfully', async () => { + const sessionId = createProxySession(db); + const otherSessionId = createProxySession(db); + + const app = createTestApp(db); + const page = await request(app) + .get('/auth/profile') + .set('Cookie', sessionCookie(sessionId)); + const csrfMatch = page.text.match(/name="_csrf" value="([^"]+)"/); + + const res = await request(app) + .post('/auth/profile/session/revoke') + .set('Cookie', sessionCookie(sessionId)) + .type('form') + .send({ session_id: otherSessionId, _csrf: csrfMatch![1] }); + expect(res.status).toBe(302); + expect(res.headers.location).toContain('Session+revoked'); + + // Verify it was actually deleted + const deletedSession = db.getProxySession(otherSessionId); + expect(deletedSession).toBeNull(); + }); + }); +}); diff --git a/gateway/src/routes/user-profile.ts b/gateway/src/routes/user-profile.ts new file mode 100644 index 0000000..6b97b48 --- /dev/null +++ b/gateway/src/routes/user-profile.ts @@ -0,0 +1,622 @@ +/** + * 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 profileUrl = `${req.protocol}://${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); +} -- knotmirror 0.0.1