diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 97f1160..2c29aea 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -28,7 +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 { authRateLimit, apiRateLimit, adminRateLimit, proxyVerifyRateLimit } from './middleware/rateLimit.js'; +import { authRateLimit, apiRateLimit, adminRateLimit } from './middleware/rateLimit.js'; import { HttpError } from './utils/errors.js'; // Configuration from environment @@ -217,10 +217,20 @@ async function main(): Promise { }); }); + // Forward-auth proxy routes MUST be mounted before the general /auth rate limiter. + // /auth/verify is called by nginx auth_request on every subrequest from a single + // pod IP, so per-IP rate limiting would block legitimate traffic. + if (config.forwardAuth.enabled) { + const proxyRouter = createProxyAuthRoutes(db, oauth, config.forwardAuth, config.oidc.issuer); + // Mount entire proxy router at /auth -- no rate limit on /auth/verify + app.use('/auth', proxyRouter); + console.log('Forward-auth proxy enabled'); + } + // Routes with rate limiting app.use('/auth', authRateLimit, createAuthRoutes(db, oauth)); app.use('/token', apiRateLimit, createTokenRoutes(db)); - app.use('/admin', adminRateLimit, createAdminRoutes(db, config.adminToken, oidcService, passkeyService, mfaService)); + app.use('/admin', adminRateLimit, createAdminRoutes(db, config.adminToken, oidcService, passkeyService, mfaService, config.forwardAuth.enabled ? config.forwardAuth.sessionSecret : undefined)); app.use('/session', apiRateLimit, createSessionRoutes(db)); // OIDC routes (if enabled) @@ -249,25 +259,7 @@ async function main(): Promise { console.log('Email routes enabled'); } - // Forward-auth proxy routes (if enabled) - if (config.forwardAuth.enabled) { - // /auth/verify needs high throughput rate limit (called per-request by nginx) - // Other proxy routes use standard auth rate limit - const proxyRouter = createProxyAuthRoutes(db, oauth, config.forwardAuth, config.oidc.issuer); - app.use('/auth', proxyVerifyRateLimit, proxyRouter); - console.log('Forward-auth proxy enabled'); - } - - // Admin UI static files (if available) - const adminUiPath = path.join(process.cwd(), 'public', 'admin'); - if (fs.existsSync(adminUiPath)) { - app.use('/admin', express.static(adminUiPath)); - // SPA fallback for admin routes (path-to-regexp v8 requires named wildcards) - app.get('/admin/{*splat}', (_req, res) => { - res.sendFile(path.join(adminUiPath, 'index.html')); - }); - console.log('Admin UI enabled at /admin'); - } + // Forward-auth proxy routes were mounted earlier (before /auth rate limiter) // OAuth client metadata (for AT Protocol discovery) app.get('/client-metadata.json', (_req, res) => { diff --git a/gateway/src/routes/admin-dashboard.test.ts b/gateway/src/routes/admin-dashboard.test.ts new file mode 100644 index 0000000..e10a4f2 --- /dev/null +++ b/gateway/src/routes/admin-dashboard.test.ts @@ -0,0 +1,376 @@ +/** + * Admin Dashboard Routes Tests + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import crypto from 'crypto'; +import { createAdminRoutes } from './admin.js'; +import { DatabaseService } from '../services/database.js'; +import { createAdminCookie, ADMIN_COOKIE_NAME } from '../utils/proxy-auth.js'; + +const ADMIN_TOKEN = 'test-admin-token-secret'; +const SESSION_SECRET = 'test-session-secret-for-admin-32!'; + +function createTestApp(db: DatabaseService) { + const app = express(); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + const router = createAdminRoutes(db, ADMIN_TOKEN, null, null, null, SESSION_SECRET); + app.use('/admin', router); + return app; +} + +function adminCookie(): string { + return `${ADMIN_COOKIE_NAME}=${createAdminCookie(SESSION_SECRET, 86400)}`; +} + +describe('Dashboard Overview', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + }); + + afterEach(() => { + db.close(); + }); + + it('should render overview page with stats', async () => { + db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + + const res = await request(app) + .get('/admin/dashboard') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('Overview'); + expect(res.text).toContain('Protected Origins'); + expect(res.text).toContain('Access Rules'); + expect(res.text).toContain('Active Sessions'); + }); + + it('should redirect to login without cookie', async () => { + const res = await request(app) + .get('/admin/dashboard') + .set('Accept', 'text/html'); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe('/admin/login'); + }); +}); + +describe('Dashboard Origins', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + }); + + afterEach(() => { + db.close(); + }); + + it('should render origins page', async () => { + const res = await request(app) + .get('/admin/dashboard/origins') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('Protected Origins'); + expect(res.text).toContain('Add Origin'); + }); + + it('should list existing origins', async () => { + db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + + const res = await request(app) + .get('/admin/dashboard/origins') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('SearXNG'); + expect(res.text).toContain('search.arcnode.xyz'); + }); + + it('should add an origin via form POST', async () => { + // First get the page to extract CSRF token + const page = await request(app) + .get('/admin/dashboard/origins') + .set('Cookie', adminCookie()); + + const csrfMatch = page.text.match(/name="_csrf" value="([^"]+)"/); + expect(csrfMatch).not.toBeNull(); + const csrf = csrfMatch![1]; + + const res = await request(app) + .post('/admin/dashboard/origins') + .set('Cookie', adminCookie()) + .type('form') + .send({ _csrf: csrf, origin: 'https://test.example.com', name: 'Test' }); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('/admin/dashboard/origins'); + + // Verify it was added + const origins = db.listProxyAllowedOrigins(); + expect(origins).toHaveLength(1); + expect(origins[0].origin).toBe('https://test.example.com'); + }); + + it('should reject form without CSRF token', async () => { + const res = await request(app) + .post('/admin/dashboard/origins') + .set('Cookie', adminCookie()) + .type('form') + .send({ origin: 'https://test.example.com', name: 'Test' }); + + expect(res.status).toBe(403); + expect(res.text).toContain('Invalid or expired form submission'); + }); + + it('should delete an origin via form POST', async () => { + const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + + const page = await request(app) + .get('/admin/dashboard/origins') + .set('Cookie', adminCookie()); + const csrfMatch = page.text.match(/name="_csrf" value="([^"]+)"/); + const csrf = csrfMatch![1]; + + const res = await request(app) + .post(`/admin/dashboard/origins/${origin.id}/delete`) + .set('Cookie', adminCookie()) + .type('form') + .send({ _csrf: csrf }); + + expect(res.status).toBe(302); + expect(db.listProxyAllowedOrigins()).toHaveLength(0); + }); +}); + +describe('Dashboard Access Rules', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + }); + + afterEach(() => { + db.close(); + }); + + it('should render access rules page', async () => { + const res = await request(app) + .get('/admin/dashboard/access') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('Access Rules'); + expect(res.text).toContain('Add Rule'); + }); + + it('should add a rule via form POST', async () => { + const page = await request(app) + .get('/admin/dashboard/access') + .set('Cookie', adminCookie()); + const csrfMatch = page.text.match(/name="_csrf" value="([^"]+)"/); + const csrf = csrfMatch![1]; + + const res = await request(app) + .post('/admin/dashboard/access') + .set('Cookie', adminCookie()) + .type('form') + .send({ + _csrf: csrf, + origin_id: '', + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.arcnode.xyz', + description: 'PDS users', + }); + + expect(res.status).toBe(302); + const rules = db.listProxyAccessRules(); + expect(rules).toHaveLength(1); + expect(rules[0].subject_value).toBe('*.arcnode.xyz'); + }); + + it('should display existing rules', async () => { + db.createProxyAccessRule({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.arcnode.xyz', + description: 'PDS users', + }); + + const res = await request(app) + .get('/admin/dashboard/access') + .set('Cookie', adminCookie()); + + expect(res.text).toContain('*.arcnode.xyz'); + expect(res.text).toContain('PDS users'); + expect(res.text).toContain('allow'); + }); +}); + +describe('Dashboard Sessions', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + }); + + afterEach(() => { + db.close(); + }); + + it('should render sessions page', async () => { + const res = await request(app) + .get('/admin/dashboard/sessions') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('Proxy Sessions'); + }); + + it('should list active sessions', async () => { + const now = Math.floor(Date.now() / 1000); + db.createProxySession({ + id: 'sess-1', + did: 'did:plc:test123', + handle: 'user.bsky.social', + created_at: now, + expires_at: now + 604800, + last_activity: now, + }); + + const res = await request(app) + .get('/admin/dashboard/sessions') + .set('Cookie', adminCookie()); + + expect(res.text).toContain('user.bsky.social'); + }); + + it('should revoke a session via form POST', async () => { + const now = Math.floor(Date.now() / 1000); + db.createProxySession({ + id: 'sess-del', + did: 'did:plc:test123', + handle: 'user.bsky.social', + created_at: now, + expires_at: now + 604800, + last_activity: now, + }); + + const page = await request(app) + .get('/admin/dashboard/sessions') + .set('Cookie', adminCookie()); + const csrfMatch = page.text.match(/name="_csrf" value="([^"]+)"/); + const csrf = csrfMatch![1]; + + const res = await request(app) + .post('/admin/dashboard/sessions/sess-del/delete') + .set('Cookie', adminCookie()) + .type('form') + .send({ _csrf: csrf }); + + expect(res.status).toBe(302); + expect(db.getProxySession('sess-del')).toBeNull(); + }); +}); + +describe('Dashboard Access Check', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + }); + + afterEach(() => { + db.close(); + }); + + it('should render access check page', async () => { + const res = await request(app) + .get('/admin/dashboard/check') + .set('Cookie', adminCookie()); + + expect(res.status).toBe(200); + expect(res.text).toContain('Access Check Tool'); + }); + + it('should show access check result', async () => { + const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.createProxyAccessRule({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.arcnode.xyz', + description: null, + }); + + const page = await request(app) + .get('/admin/dashboard/check') + .set('Cookie', adminCookie()); + const csrfMatch = page.text.match(/name="_csrf" value="([^"]+)"/); + const csrf = csrfMatch![1]; + + const res = await request(app) + .post('/admin/dashboard/check') + .set('Cookie', adminCookie()) + .type('form') + .send({ + _csrf: csrf, + did: 'did:plc:test123', + handle: 'bkb.arcnode.xyz', + origin_id: origin.id.toString(), + }); + + expect(res.status).toBe(200); + expect(res.text).toContain('ACCESS ALLOWED'); + }); + + it('should show denied result', async () => { + const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.createProxyAccessRule({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.arcnode.xyz', + description: null, + }); + + const page = await request(app) + .get('/admin/dashboard/check') + .set('Cookie', adminCookie()); + const csrfMatch = page.text.match(/name="_csrf" value="([^"]+)"/); + const csrf = csrfMatch![1]; + + const res = await request(app) + .post('/admin/dashboard/check') + .set('Cookie', adminCookie()) + .type('form') + .send({ + _csrf: csrf, + did: 'did:plc:outsider', + handle: 'user.bsky.social', + origin_id: origin.id.toString(), + }); + + expect(res.status).toBe(200); + expect(res.text).toContain('ACCESS DENIED'); + }); +}); diff --git a/gateway/src/routes/admin-dashboard.ts b/gateway/src/routes/admin-dashboard.ts new file mode 100644 index 0000000..04fecb3 --- /dev/null +++ b/gateway/src/routes/admin-dashboard.ts @@ -0,0 +1,763 @@ +/** + * Admin Dashboard Routes + * + * Server-rendered HTML dashboard for managing ATAuth forward-auth proxy. + * All routes are protected by requireAdmin middleware (cookie or bearer). + * Uses POST-redirect-GET pattern for all form submissions. + */ + +import crypto from 'crypto'; +import { Router, Request, Response } from 'express'; +import { DatabaseService } from '../services/database.js'; +import { checkAccess } from '../utils/access-check.js'; + +export function createAdminDashboardRoutes( + db: DatabaseService, + csrfSecret: string, +): Router { + const router = Router(); + + // ===== CSRF Protection ===== + + 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; // 1 hour validity + } + + function requireCsrf(req: Request, res: Response): boolean { + const token = req.body._csrf; + if (!verifyCsrfToken(token)) { + res.status(403).setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('Error', '

Invalid or expired form submission

Back to dashboard

')); + return false; + } + return true; + } + + // ===== Dashboard Overview ===== + + router.get('/', (_req: Request, res: Response) => { + const origins = db.listProxyAllowedOrigins(); + const rules = db.listProxyAccessRules(); + const sessions = db.getAllProxySessions(undefined, 1000); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('Dashboard', ` +

Overview

+
+
+
${origins.length}
+
Protected Origins
+ Manage +
+
+
${rules.length}
+
Access Rules
+ Manage +
+
+
${sessions.length}
+
Active Sessions
+ Manage +
+
+
+

Quick Actions

+
+ Test Access Check + Add Origin + Add Rule +
+
+ `)); + }); + + // ===== Origins Management ===== + + router.get('/origins', (req: Request, res: Response) => { + const origins = db.listProxyAllowedOrigins(); + const csrf = generateCsrfToken(); + const msg = req.query.msg as string | undefined; + + const ruleCountMap = new Map(); + const allRules = db.listProxyAccessRules(); + for (const rule of allRules) { + if (rule.origin_id !== null) { + ruleCountMap.set(rule.origin_id, (ruleCountMap.get(rule.origin_id) || 0) + 1); + } + } + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('Origins', ` + ${msg ? `
${esc(msg)}
` : ''} +

Protected Origins

+
+

Add Origin

+
+ +
+ + +
+
+ + +
+ +
+
+ ${origins.length === 0 ? '

No origins configured. Add one above.

' : ` + + + + + + + + + + + + ${origins.map(o => ` + + + + + + + + `).join('')} + +
NameOriginRulesCreated
${esc(o.name)}${esc(o.origin)}${ruleCountMap.get(o.id) || 0}${formatDate(o.created_at)} +
+ + +
+
`} + `)); + }); + + router.post('/origins', (req: Request, res: Response) => { + if (!requireCsrf(req, res)) return; + const { origin, name } = req.body; + if (!origin || !name) { + return res.redirect('/admin/dashboard/origins?msg=Origin+and+name+are+required'); + } + try { + const parsed = new URL(origin); + if (parsed.origin !== origin) { + return res.redirect('/admin/dashboard/origins?msg=Invalid+origin+format'); + } + } catch { + return res.redirect('/admin/dashboard/origins?msg=Invalid+URL'); + } + try { + db.addProxyAllowedOrigin(origin, name); + res.redirect('/admin/dashboard/origins?msg=Origin+added'); + } catch (e) { + const msg = e instanceof Error && e.message.includes('UNIQUE') ? 'Origin+already+exists' : 'Failed+to+add+origin'; + res.redirect(`/admin/dashboard/origins?msg=${msg}`); + } + }); + + router.post('/origins/:id/delete', (req: Request, res: Response) => { + if (!requireCsrf(req, res)) return; + db.removeProxyAllowedOrigin(parseInt(req.params.id, 10)); + res.redirect('/admin/dashboard/origins?msg=Origin+deleted'); + }); + + // ===== Access Rules Management ===== + + router.get('/access', (req: Request, res: Response) => { + const filterOriginId = req.query.origin_id !== undefined ? parseInt(req.query.origin_id as string, 10) : undefined; + const origins = db.listProxyAllowedOrigins(); + const rules = filterOriginId !== undefined ? db.listProxyAccessRules(filterOriginId) : db.listProxyAccessRules(); + const csrf = generateCsrfToken(); + const msg = req.query.msg as string | undefined; + + const originMap = new Map(origins.map(o => [o.id, o])); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('Access Rules', ` + ${msg ? `
${esc(msg)}
` : ''} +

Access Rules

+
+

Add Rule

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ Filter by origin: + All + ${origins.map(o => ` + ${esc(o.name)} + `).join(' ')} +
+ + ${rules.length === 0 ? '

No access rules configured. All authenticated users have access (open mode).

' : ` + + + + + + + + + + + + + ${rules.map(r => ` + + + + + + + + + `).join('')} + +
TypeSubjectValueScopeDescription
${r.rule_type}${r.subject_type === 'did' ? 'DID' : 'Handle'}${esc(r.subject_value)}${r.origin_id === null ? 'Global' : esc(originMap.get(r.origin_id)?.name || `#${r.origin_id}`)}${r.description ? esc(r.description) : '--'} +
+ + +
+
`} + `)); + }); + + router.post('/access', (req: Request, res: Response) => { + if (!requireCsrf(req, res)) return; + const { origin_id, rule_type, subject_type, subject_value, description } = req.body; + + if (!rule_type || !subject_type || !subject_value) { + return res.redirect('/admin/dashboard/access?msg=Missing+required+fields'); + } + if (!['allow', 'deny'].includes(rule_type)) { + return res.redirect('/admin/dashboard/access?msg=Invalid+rule+type'); + } + if (!['did', 'handle_pattern'].includes(subject_type)) { + return res.redirect('/admin/dashboard/access?msg=Invalid+subject+type'); + } + if (subject_type === 'did' && !subject_value.startsWith('did:')) { + return res.redirect('/admin/dashboard/access?msg=DID+must+start+with+did:'); + } + if (subject_type === 'handle_pattern' && subject_value !== '*' && !subject_value.match(/^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/)) { + return res.redirect('/admin/dashboard/access?msg=Invalid+handle+pattern'); + } + + const parsedOriginId = origin_id ? parseInt(origin_id, 10) : null; + + try { + db.createProxyAccessRule({ + origin_id: parsedOriginId, + rule_type, + subject_type, + subject_value, + description: description || null, + }); + res.redirect('/admin/dashboard/access?msg=Rule+added'); + } catch (e) { + res.redirect('/admin/dashboard/access?msg=Failed+to+add+rule'); + } + }); + + router.post('/access/:id/delete', (req: Request, res: Response) => { + if (!requireCsrf(req, res)) return; + db.deleteProxyAccessRule(parseInt(req.params.id, 10)); + res.redirect('/admin/dashboard/access?msg=Rule+deleted'); + }); + + // ===== Sessions Management ===== + + router.get('/sessions', (req: Request, res: Response) => { + const filterDid = req.query.did as string | undefined; + const sessions = db.getAllProxySessions(filterDid, 200); + const csrf = generateCsrfToken(); + const msg = req.query.msg as string | undefined; + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('Sessions', ` + ${msg ? `
${esc(msg)}
` : ''} +

Proxy Sessions

+
+
+
+ + +
+ + ${filterDid ? 'Clear' : ''} +
+
+ ${sessions.length === 0 ? '

No active proxy sessions.

' : ` + + + + + + + + + + + + + ${sessions.map(s => ` + + + + + + + + + `).join('')} + +
HandleDIDCreatedLast ActivityIP
${esc(s.handle)}${truncateDid(s.did)}${formatDate(s.created_at)}${formatDate(s.last_activity)}${s.ip_address ? esc(s.ip_address) : '--'} +
+ + +
+
`} + `)); + }); + + router.post('/sessions/:id/delete', (req: Request, res: Response) => { + if (!requireCsrf(req, res)) return; + db.deleteProxySession(req.params.id); + res.redirect('/admin/dashboard/sessions?msg=Session+revoked'); + }); + + // ===== Access Check Tool ===== + + router.get('/check', (_req: Request, res: Response) => { + const origins = db.listProxyAllowedOrigins(); + const csrf = generateCsrfToken(); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('Access Check', ` +

Access Check Tool

+
+

Test whether a DID/handle combination would be granted access to an origin.

+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+ `)); + }); + + router.post('/check', (req: Request, res: Response) => { + if (!requireCsrf(req, res)) return; + const { did, handle, origin_id } = req.body; + const origins = db.listProxyAllowedOrigins(); + const csrf = generateCsrfToken(); + + if (!did || !handle || origin_id === undefined) { + return res.redirect('/admin/dashboard/check'); + } + + const parsedOriginId = parseInt(origin_id, 10); + const rules = db.getProxyAccessRulesForCheck(parsedOriginId); + const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length; + + let result; + if (totalRules === 0) { + result = { allowed: true, matched_rule_id: null as number | null, reason: 'No access rules configured (open access)' }; + } else { + result = checkAccess(did, handle, rules); + } + + const origin = origins.find(o => o.id === parsedOriginId); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(layout('Access Check', ` +

Access Check Tool

+
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+

${result.allowed ? 'ACCESS ALLOWED' : 'ACCESS DENIED'}

+
+
DID
${esc(did)}
+
Handle
${esc(handle)}
+
Origin
${origin ? esc(origin.name) : `#${parsedOriginId}`}
+
Reason
${esc(result.reason)}
+ ${result.matched_rule_id !== null ? `
Matched Rule
#${result.matched_rule_id}
` : ''} +
+
+ `)); + }); + + return router; +} + +// ===== Template Helpers ===== + +function esc(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function formatDate(ts: number): string { + return new Date(ts * 1000).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'); +} + +function truncateDid(did: string): string { + if (did.length <= 24) return did; + return did.substring(0, 20) + '...'; +} + +// ===== Layout Template ===== + +function layout(title: string, content: string): string { + return ` + + + + + ${esc(title)} - ATAuth Admin + + + +
+ +
+ ${content} +
+
+ +`; +} diff --git a/gateway/src/routes/admin.proxy.test.ts b/gateway/src/routes/admin.proxy.test.ts new file mode 100644 index 0000000..eb544f3 --- /dev/null +++ b/gateway/src/routes/admin.proxy.test.ts @@ -0,0 +1,527 @@ +/** + * Admin Proxy API Tests + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import crypto from 'crypto'; +import { createAdminRoutes } from './admin.js'; +import { DatabaseService } from '../services/database.js'; + +import { createAdminCookie, ADMIN_COOKIE_NAME } from '../utils/proxy-auth.js'; + +const ADMIN_TOKEN = 'test-admin-token-secret'; +const SESSION_SECRET = 'test-session-secret-for-admin-32!'; + +function createTestApp(db: DatabaseService, sessionSecret?: string) { + const app = express(); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + const router = createAdminRoutes(db, ADMIN_TOKEN, null, null, null, sessionSecret); + app.use('/admin', router); + return app; +} + +function authHeader() { + return { Authorization: `Bearer ${ADMIN_TOKEN}` }; +} + +describe('Admin Proxy Origins', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + }); + + afterEach(() => { + db.close(); + }); + + it('should require admin auth', async () => { + const res = await request(app).get('/admin/proxy/origins'); + expect(res.status).toBe(401); + }); + + it('should reject invalid admin token', async () => { + const res = await request(app) + .get('/admin/proxy/origins') + .set('Authorization', 'Bearer wrong-token'); + expect(res.status).toBe(403); + }); + + it('should list empty origins initially', async () => { + const res = await request(app) + .get('/admin/proxy/origins') + .set(authHeader()); + + expect(res.status).toBe(200); + expect(res.body.origins).toEqual([]); + }); + + it('should add an allowed origin', async () => { + const res = await request(app) + .post('/admin/proxy/origins') + .set(authHeader()) + .send({ origin: 'https://search.arcnode.xyz', name: 'SearXNG' }); + + expect(res.status).toBe(201); + expect(res.body.origin).toBe('https://search.arcnode.xyz'); + expect(res.body.name).toBe('SearXNG'); + expect(res.body.id).toBeGreaterThan(0); + }); + + it('should reject origin without name', async () => { + const res = await request(app) + .post('/admin/proxy/origins') + .set(authHeader()) + .send({ origin: 'https://test.example.com' }); + + expect(res.status).toBe(400); + }); + + it('should reject invalid origin format', async () => { + const res = await request(app) + .post('/admin/proxy/origins') + .set(authHeader()) + .send({ origin: 'not-a-url', name: 'Bad' }); + + expect(res.status).toBe(400); + }); + + it('should reject origin with trailing path', async () => { + const res = await request(app) + .post('/admin/proxy/origins') + .set(authHeader()) + .send({ origin: 'https://search.arcnode.xyz/path', name: 'Bad' }); + + expect(res.status).toBe(400); + }); + + it('should reject duplicate origin', async () => { + await request(app) + .post('/admin/proxy/origins') + .set(authHeader()) + .send({ origin: 'https://search.arcnode.xyz', name: 'SearXNG' }); + + const res = await request(app) + .post('/admin/proxy/origins') + .set(authHeader()) + .send({ origin: 'https://search.arcnode.xyz', name: 'Duplicate' }); + + expect(res.status).toBe(409); + }); + + it('should list origins after adding', async () => { + await request(app) + .post('/admin/proxy/origins') + .set(authHeader()) + .send({ origin: 'https://search.arcnode.xyz', name: 'SearXNG' }); + await request(app) + .post('/admin/proxy/origins') + .set(authHeader()) + .send({ origin: 'https://element.arcnode.xyz', name: 'Element' }); + + const res = await request(app) + .get('/admin/proxy/origins') + .set(authHeader()); + + expect(res.status).toBe(200); + expect(res.body.origins).toHaveLength(2); + }); + + it('should delete an origin', async () => { + const created = await request(app) + .post('/admin/proxy/origins') + .set(authHeader()) + .send({ origin: 'https://search.arcnode.xyz', name: 'SearXNG' }); + + const res = await request(app) + .delete(`/admin/proxy/origins/${created.body.id}`) + .set(authHeader()); + + expect(res.status).toBe(200); + + const list = await request(app) + .get('/admin/proxy/origins') + .set(authHeader()); + expect(list.body.origins).toHaveLength(0); + }); +}); + +describe('Admin Proxy Sessions', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + }); + + afterEach(() => { + db.close(); + }); + + function createSession(overrides: Partial<{ id: string; did: string; handle: string }> = {}) { + const now = Math.floor(Date.now() / 1000); + const session = { + id: overrides.id || crypto.randomBytes(16).toString('base64url'), + did: overrides.did || 'did:plc:test123', + handle: overrides.handle || 'test.bsky.social', + created_at: now, + expires_at: now + 604800, + last_activity: now, + }; + db.createProxySession(session); + return session; + } + + it('should list proxy sessions', async () => { + createSession({ id: 's1', did: 'did:plc:a' }); + createSession({ id: 's2', did: 'did:plc:b' }); + + const res = await request(app) + .get('/admin/proxy/sessions') + .set(authHeader()); + + expect(res.status).toBe(200); + expect(res.body.sessions).toHaveLength(2); + }); + + it('should filter sessions by DID', async () => { + createSession({ id: 's1', did: 'did:plc:a' }); + createSession({ id: 's2', did: 'did:plc:b' }); + + const res = await request(app) + .get('/admin/proxy/sessions') + .query({ did: 'did:plc:a' }) + .set(authHeader()); + + expect(res.status).toBe(200); + expect(res.body.sessions).toHaveLength(1); + expect(res.body.sessions[0].did).toBe('did:plc:a'); + }); + + it('should delete a proxy session', async () => { + const session = createSession(); + + const res = await request(app) + .delete(`/admin/proxy/sessions/${session.id}`) + .set(authHeader()); + + expect(res.status).toBe(200); + expect(db.getProxySession(session.id)).toBeNull(); + }); +}); + +describe('Admin Access Rules API', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db); + }); + + afterEach(() => { + db.close(); + }); + + it('should list empty rules', async () => { + const res = await request(app) + .get('/admin/proxy/access') + .set(authHeader()); + + expect(res.status).toBe(200); + expect(res.body.rules).toEqual([]); + }); + + it('should create an allow rule', async () => { + const res = await request(app) + .post('/admin/proxy/access') + .set(authHeader()) + .send({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.arcnode.xyz', + description: 'PDS users', + }); + + expect(res.status).toBe(201); + expect(res.body.rule_type).toBe('allow'); + expect(res.body.subject_value).toBe('*.arcnode.xyz'); + }); + + it('should create a deny rule', async () => { + const res = await request(app) + .post('/admin/proxy/access') + .set(authHeader()) + .send({ + origin_id: null, + rule_type: 'deny', + subject_type: 'did', + subject_value: 'did:plc:banned', + }); + + expect(res.status).toBe(201); + expect(res.body.rule_type).toBe('deny'); + }); + + it('should reject invalid rule_type', async () => { + const res = await request(app) + .post('/admin/proxy/access') + .set(authHeader()) + .send({ + rule_type: 'invalid', + subject_type: 'did', + subject_value: 'did:plc:test', + }); + + expect(res.status).toBe(400); + }); + + it('should reject invalid subject_type', async () => { + const res = await request(app) + .post('/admin/proxy/access') + .set(authHeader()) + .send({ + rule_type: 'allow', + subject_type: 'email', + subject_value: 'test@test.com', + }); + + expect(res.status).toBe(400); + }); + + it('should reject DID without did: prefix', async () => { + const res = await request(app) + .post('/admin/proxy/access') + .set(authHeader()) + .send({ + rule_type: 'allow', + subject_type: 'did', + subject_value: 'plc:test123', + }); + + expect(res.status).toBe(400); + }); + + it('should reject invalid handle pattern', async () => { + const res = await request(app) + .post('/admin/proxy/access') + .set(authHeader()) + .send({ + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '***', + }); + + expect(res.status).toBe(400); + }); + + it('should reject non-existent origin_id', async () => { + const res = await request(app) + .post('/admin/proxy/access') + .set(authHeader()) + .send({ + origin_id: 9999, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*', + }); + + expect(res.status).toBe(404); + }); + + it('should delete a rule', async () => { + const created = await request(app) + .post('/admin/proxy/access') + .set(authHeader()) + .send({ + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*', + }); + + const res = await request(app) + .delete(`/admin/proxy/access/${created.body.id}`) + .set(authHeader()); + + expect(res.status).toBe(200); + + const list = await request(app) + .get('/admin/proxy/access') + .set(authHeader()); + expect(list.body.rules).toHaveLength(0); + }); + + it('should filter rules by origin_id', async () => { + const origin = await request(app) + .post('/admin/proxy/origins') + .set(authHeader()) + .send({ origin: 'https://search.arcnode.xyz', name: 'SearXNG' }); + + await request(app) + .post('/admin/proxy/access') + .set(authHeader()) + .send({ + origin_id: origin.body.id, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*', + }); + await request(app) + .post('/admin/proxy/access') + .set(authHeader()) + .send({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.arcnode.xyz', + }); + + const res = await request(app) + .get('/admin/proxy/access') + .query({ origin_id: origin.body.id }) + .set(authHeader()); + + expect(res.status).toBe(200); + // Should include origin-specific + global (NULL) rules + expect(res.body.rules).toHaveLength(2); + }); + + it('should run a dry-run access check', async () => { + const origin = await request(app) + .post('/admin/proxy/origins') + .set(authHeader()) + .send({ origin: 'https://search.arcnode.xyz', name: 'SearXNG' }); + + await request(app) + .post('/admin/proxy/access') + .set(authHeader()) + .send({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.arcnode.xyz', + }); + + const allowed = await request(app) + .post('/admin/proxy/access/check') + .set(authHeader()) + .send({ did: 'did:plc:test', handle: 'bkb.arcnode.xyz', origin_id: origin.body.id }); + + expect(allowed.status).toBe(200); + expect(allowed.body.allowed).toBe(true); + + const denied = await request(app) + .post('/admin/proxy/access/check') + .set(authHeader()) + .send({ did: 'did:plc:test', handle: 'random.bsky.social', origin_id: origin.body.id }); + + expect(denied.status).toBe(200); + expect(denied.body.allowed).toBe(false); + }); + + it('should return open access when no rules exist', async () => { + const origin = await request(app) + .post('/admin/proxy/origins') + .set(authHeader()) + .send({ origin: 'https://search.arcnode.xyz', name: 'SearXNG' }); + + const res = await request(app) + .post('/admin/proxy/access/check') + .set(authHeader()) + .send({ did: 'did:plc:anyone', handle: 'anyone.bsky.social', origin_id: origin.body.id }); + + expect(res.status).toBe(200); + expect(res.body.allowed).toBe(true); + expect(res.body.reason).toContain('open access'); + }); +}); + +describe('Admin Cookie Auth', () => { + let db: DatabaseService; + let app: express.Application; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + app = createTestApp(db, SESSION_SECRET); + }); + + afterEach(() => { + db.close(); + }); + + it('should render login page', async () => { + const res = await request(app).get('/admin/login'); + expect(res.status).toBe(200); + expect(res.text).toContain('Admin Token'); + expect(res.text).toContain('form'); + }); + + it('should reject invalid login token', async () => { + const res = await request(app) + .post('/admin/login') + .type('form') + .send({ token: 'wrong-token' }); + + expect(res.status).toBe(401); + expect(res.text).toContain('Invalid admin token'); + }); + + it('should set cookie on valid login', async () => { + const res = await request(app) + .post('/admin/login') + .type('form') + .send({ token: ADMIN_TOKEN }); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe('/admin/dashboard'); + expect(res.headers['set-cookie']).toBeDefined(); + expect(res.headers['set-cookie'][0]).toContain(ADMIN_COOKIE_NAME); + expect(res.headers['set-cookie'][0]).toContain('HttpOnly'); + expect(res.headers['set-cookie'][0]).toContain('SameSite=Strict'); + }); + + it('should clear cookie on logout', async () => { + const res = await request(app).get('/admin/logout'); + expect(res.status).toBe(302); + expect(res.headers.location).toBe('/admin/login'); + expect(res.headers['set-cookie'][0]).toContain('Max-Age=0'); + }); + + it('should accept admin cookie for protected endpoints', async () => { + const cookie = createAdminCookie(SESSION_SECRET, 86400); + + const res = await request(app) + .get('/admin/proxy/origins') + .set('Cookie', `${ADMIN_COOKIE_NAME}=${cookie}`); + + expect(res.status).toBe(200); + }); + + it('should reject invalid admin cookie', async () => { + const res = await request(app) + .get('/admin/proxy/origins') + .set('Cookie', `${ADMIN_COOKIE_NAME}=invalid.cookie`) + .set('Accept', 'application/json'); + + expect(res.status).toBe(401); + }); + + it('should reject expired admin cookie', async () => { + // Create cookie with 0s TTL (already expired) + const cookie = createAdminCookie(SESSION_SECRET, -1); + + const res = await request(app) + .get('/admin/proxy/origins') + .set('Cookie', `${ADMIN_COOKIE_NAME}=${cookie}`) + .set('Accept', 'application/json'); + + expect(res.status).toBe(401); + }); +}); diff --git a/gateway/src/routes/admin.ts b/gateway/src/routes/admin.ts index be760aa..bca1dd7 100644 --- a/gateway/src/routes/admin.ts +++ b/gateway/src/routes/admin.ts @@ -11,6 +11,9 @@ import { Router, Request, Response, NextFunction } from 'express'; import { DatabaseService } from '../services/database.js'; import { generateHmacSecret } from '../utils/hmac.js'; import { httpError } from '../utils/errors.js'; +import { checkAccess } from '../utils/access-check.js'; +import { parseCookies, ADMIN_COOKIE_NAME, createAdminCookie, verifyAdminCookie } from '../utils/proxy-auth.js'; +import { createAdminDashboardRoutes } from './admin-dashboard.js'; import type { OIDCService } from '../services/oidc/index.js'; import type { PasskeyService } from '../services/passkey.js'; import type { MFAService } from '../services/mfa.js'; @@ -34,28 +37,104 @@ export function createAdminRoutes( adminToken?: string, oidcService?: OIDCService | null, passkeyService?: PasskeyService | null, - mfaService?: MFAService | null + mfaService?: MFAService | null, + sessionSecret?: string, ): Router { const router = Router(); - const requireAdmin = (req: Request, _res: Response, next: NextFunction) => { + const ADMIN_COOKIE_TTL = 86400; // 24 hours + + /** + * Admin authentication middleware. + * Accepts EITHER a Bearer token in the Authorization header + * OR a valid _atauth_admin cookie (for dashboard sessions). + */ + const requireAdmin = (req: Request, res: Response, next: NextFunction) => { if (!adminToken) { throw httpError.forbidden('admin_disabled', 'Admin endpoints are disabled (set ADMIN_TOKEN)'); } + // Check Bearer token first const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw httpError.unauthorized('missing_auth', 'Authorization header required'); + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + if (!secureCompare(token, adminToken)) { + throw httpError.forbidden('invalid_token', 'Invalid admin token'); + } + return next(); } - const token = authHeader.substring(7); - if (!secureCompare(token, adminToken)) { - throw httpError.forbidden('invalid_token', 'Invalid admin token'); + // Check admin cookie + if (sessionSecret) { + const cookies = parseCookies(req.headers.cookie); + const adminCookieValue = cookies[ADMIN_COOKIE_NAME]; + if (adminCookieValue && verifyAdminCookie(adminCookieValue, sessionSecret)) { + return next(); + } } - next(); + // If this looks like a browser request (Accept: text/html), redirect to login + if (req.accepts('html') && !req.accepts('json')) { + return res.redirect('/admin/login'); + } + + throw httpError.unauthorized('missing_auth', 'Authorization required'); }; + // ===== Admin Login/Logout (no auth required) ===== + + /** + * GET /admin/login + * Render the admin login page. + */ + router.get('/login', (_req: Request, res: Response) => { + if (!adminToken) { + return res.status(403).send('Admin endpoints are disabled'); + } + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(renderLoginPage()); + }); + + /** + * POST /admin/login + * Validate admin token and set session cookie. + */ + router.post('/login', (req: Request, res: Response) => { + if (!adminToken) { + throw httpError.forbidden('admin_disabled', 'Admin endpoints are disabled'); + } + + const { token } = req.body; + if (!token || !secureCompare(token, adminToken)) { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + return res.status(401).send(renderLoginPage('Invalid admin token')); + } + + if (!sessionSecret) { + throw httpError.internalServerError('config_error', 'Session secret not configured'); + } + + const cookieValue = createAdminCookie(sessionSecret, ADMIN_COOKIE_TTL); + res.setHeader('Set-Cookie', `${ADMIN_COOKIE_NAME}=${cookieValue}; Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=${ADMIN_COOKIE_TTL}`); + res.redirect('/admin/dashboard'); + }); + + /** + * GET /admin/logout + * Clear admin session cookie and redirect to login. + */ + router.get('/logout', (_req: Request, res: Response) => { + res.setHeader('Set-Cookie', `${ADMIN_COOKIE_NAME}=; Path=/admin; HttpOnly; Secure; SameSite=Strict; Max-Age=0`); + res.redirect('/admin/login'); + }); + + // ===== Dashboard (server-rendered HTML) ===== + if (sessionSecret) { + const dashboardRouter = createAdminDashboardRoutes(db, sessionSecret); + router.use('/dashboard', requireAdmin, dashboardRouter); + } + /** * POST /admin/apps * Register a new application @@ -620,6 +699,114 @@ export function createAdminRoutes( res.json({ message: 'Proxy session revoked' }); }); + // ===== Forward-Auth Access Rules ===== + + /** + * GET /admin/proxy/access + * List access rules. Optional ?origin_id=N filter. + */ + router.get('/proxy/access', requireAdmin, async (req: Request, res: Response) => { + const originIdParam = req.query.origin_id; + let rules; + if (originIdParam !== undefined) { + const originId = parseInt(originIdParam as string, 10); + rules = db.listProxyAccessRules(originId); + } else { + rules = db.listProxyAccessRules(); + } + res.json({ rules }); + }); + + /** + * POST /admin/proxy/access + * Create an access rule. + * + * Body: + * - origin_id: number | null (null = global rule) + * - rule_type: "allow" | "deny" + * - subject_type: "did" | "handle_pattern" + * - subject_value: string (DID or pattern like "*.arcnode.xyz") + * - description: string (optional label) + */ + router.post('/proxy/access', requireAdmin, async (req: Request, res: Response) => { + const { origin_id, rule_type, subject_type, subject_value, description } = req.body; + + if (!rule_type || !subject_type || !subject_value) { + throw httpError.badRequest('missing_params', 'rule_type, subject_type, and subject_value are required'); + } + + if (!['allow', 'deny'].includes(rule_type)) { + throw httpError.badRequest('invalid_rule_type', 'rule_type must be "allow" or "deny"'); + } + + if (!['did', 'handle_pattern'].includes(subject_type)) { + throw httpError.badRequest('invalid_subject_type', 'subject_type must be "did" or "handle_pattern"'); + } + + if (subject_type === 'did' && !subject_value.startsWith('did:')) { + throw httpError.badRequest('invalid_did', 'DID must start with "did:"'); + } + + if (subject_type === 'handle_pattern') { + if (subject_value !== '*' && !subject_value.match(/^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/)) { + throw httpError.badRequest('invalid_pattern', 'Handle pattern must be "*", "*.domain", or an exact handle'); + } + } + + if (origin_id !== null && origin_id !== undefined) { + const origins = db.listProxyAllowedOrigins(); + if (!origins.some(o => o.id === origin_id)) { + throw httpError.notFound('origin_not_found', `Origin with id ${origin_id} not found`); + } + } + + const rule = db.createProxyAccessRule({ + origin_id: origin_id ?? null, + rule_type, + subject_type, + subject_value, + description: description || null, + }); + + res.status(201).json(rule); + }); + + /** + * DELETE /admin/proxy/access/:id + * Delete an access rule. + */ + router.delete('/proxy/access/:id', requireAdmin, async (req: Request, res: Response) => { + db.deleteProxyAccessRule(parseInt(req.params.id, 10)); + res.json({ message: 'Access rule deleted' }); + }); + + /** + * POST /admin/proxy/access/check + * Test if a DID/handle would be allowed for an origin. + * Admin debugging tool -- does not modify state. + */ + router.post('/proxy/access/check', requireAdmin, async (req: Request, res: Response) => { + const { did, handle, origin_id } = req.body; + + if (!did || !handle || origin_id === undefined) { + throw httpError.badRequest('missing_params', 'did, handle, and origin_id are required'); + } + + const rules = db.getProxyAccessRulesForCheck(origin_id); + const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length; + + if (totalRules === 0) { + return res.json({ + allowed: true, + matched_rule_id: null, + reason: 'No access rules configured (open access)', + }); + } + + const result = checkAccess(did, handle, rules); + res.json(result); + }); + // ===== Stats ===== /** @@ -633,3 +820,108 @@ export function createAdminRoutes( return router; } + +// ===== Admin Login Page Template ===== + +function escapeHtml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function renderLoginPage(error?: string): string { + return ` + + + + + ATAuth Admin Login + + + +
+

ATAuth Admin

+

Enter your admin token to continue

+ ${error ? `
${escapeHtml(error)}
` : ''} +
+ + + +
+
+ +`; +} diff --git a/gateway/src/routes/proxy-auth.test.ts b/gateway/src/routes/proxy-auth.test.ts index 4d45239..91105e2 100644 --- a/gateway/src/routes/proxy-auth.test.ts +++ b/gateway/src/routes/proxy-auth.test.ts @@ -9,6 +9,7 @@ import { createProxyAuthRoutes } from './proxy-auth.js'; import { DatabaseService } from '../services/database.js'; import { createSessionCookie, + createProxyCookie, createAuthTicket, SESSION_COOKIE_NAME, PROXY_COOKIE_NAME, @@ -81,7 +82,7 @@ describe('GET /auth/verify', () => { it('should return 200 with valid proxy cookie', async () => { const session = createTestSession(db); - const cookie = createSessionCookie(session.id, TEST_SECRET, 86400); + const cookie = createProxyCookie(session.id, TEST_SECRET, 86400); const res = await request(app) .get('/auth/verify') @@ -96,7 +97,7 @@ describe('GET /auth/verify', () => { it('should return 401 with expired proxy cookie', async () => { const now = Math.floor(Date.now() / 1000); const session = createTestSession(db, { expires_at: now - 100 }); - const cookie = createSessionCookie(session.id, TEST_SECRET, 86400); + const cookie = createProxyCookie(session.id, TEST_SECRET, 86400); const res = await request(app) .get('/auth/verify') @@ -180,6 +181,45 @@ describe('GET /auth/proxy/login', () => { expect(res.headers.location).toContain('_atauth_ticket='); }); + it('should deny silent SSO when access rules block user', async () => { + db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + + // Only allow arcnode.xyz handles + db.createProxyAccessRule({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.arcnode.xyz', + description: null, + }); + + const session = createTestSession(db, { handle: 'outsider.bsky.social' }); + const sessionCookie = createSessionCookie(session.id, TEST_SECRET, 604800); + + const res = await request(app) + .get('/auth/proxy/login') + .query({ rd: 'https://search.arcnode.xyz/path' }) + .set('Cookie', `${SESSION_COOKIE_NAME}=${sessionCookie}`); + + expect(res.status).toBe(403); + expect(res.text).toContain('Not Authorized'); + }); + + it('should allow silent SSO when no access rules exist (open mode)', async () => { + db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + + const session = createTestSession(db); + const sessionCookie = createSessionCookie(session.id, TEST_SECRET, 604800); + + const res = await request(app) + .get('/auth/proxy/login') + .query({ rd: 'https://search.arcnode.xyz/path' }) + .set('Cookie', `${SESSION_COOKIE_NAME}=${sessionCookie}`); + + expect(res.status).toBe(302); + expect(res.headers.location).toContain('_atauth_ticket='); + }); + it('should render login page when no session exists', async () => { db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); @@ -223,12 +263,341 @@ describe('GET /auth/proxy/logout', () => { expect(db.getProxySession(session.id)).toBeNull(); }); - it('should redirect to rd param after logout', async () => { + it('should redirect to rd param after logout when origin is allowed', async () => { + db.addProxyAllowedOrigin('https://example.com', 'Example'); + const res = await request(app) + .get('/auth/proxy/logout') + .query({ rd: 'https://example.com/page' }); + + expect(res.status).toBe(302); + expect(res.headers.location).toBe('https://example.com/page'); + }); + + it('should not redirect to disallowed rd param after logout', async () => { const res = await request(app) .get('/auth/proxy/logout') - .query({ rd: 'https://example.com' }); + .query({ rd: 'https://evil.example.com' }); + + expect(res.status).toBe(200); + expect(res.text).toContain('Signed Out'); + }); +}); + +describe('POST /auth/proxy/login', () => { + let db: DatabaseService; + let app: express.Application; + let mockOAuth: { generateAuthUrl: ReturnType; handleCallback: ReturnType }; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + ({ app, mockOAuth } = createTestApp(db)); + }); + + afterEach(() => { + db.close(); + }); + + it('should return 400 if auth_request_id or handle is missing', async () => { + const res = await request(app) + .post('/auth/proxy/login') + .send({ handle: 'test.bsky.social' }); + + expect(res.status).toBe(400); + }); + + it('should return 400 if auth request does not exist', async () => { + const res = await request(app) + .post('/auth/proxy/login') + .send({ auth_request_id: 'nonexistent', handle: 'test.bsky.social' }); + + expect(res.status).toBe(400); + expect(res.text).toContain('expired or invalid'); + }); + + it('should return 400 if auth request is expired', async () => { + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'expired-req', + redirect_uri: 'https://search.arcnode.xyz/', + created_at: now - 700, + expires_at: now - 100, + }); + + const res = await request(app) + .post('/auth/proxy/login') + .send({ auth_request_id: 'expired-req', handle: 'test.bsky.social' }); + + expect(res.status).toBe(400); + expect(res.text).toContain('expired'); + }); + + it('should redirect to AT Proto OAuth URL on success', async () => { + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'valid-req', + redirect_uri: 'https://search.arcnode.xyz/', + created_at: now, + expires_at: now + 600, + }); + + mockOAuth.generateAuthUrl.mockResolvedValue({ + url: 'https://bsky.social/oauth/authorize?state=abc123', + state: 'abc123', + }); + + const res = await request(app) + .post('/auth/proxy/login') + .type('form') + .send({ auth_request_id: 'valid-req', handle: 'test.bsky.social' }); expect(res.status).toBe(302); - expect(res.headers.location).toBe('https://example.com'); + expect(res.headers.location).toContain('bsky.social/oauth/authorize'); + expect(mockOAuth.generateAuthUrl).toHaveBeenCalledWith( + 'proxy-auth', + 'test.bsky.social', + `${TEST_ISSUER}/auth/proxy/callback`, + ); + }); + + it('should sanitize handle - strip @ prefix', async () => { + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'req1', + redirect_uri: 'https://search.arcnode.xyz/', + created_at: now, + expires_at: now + 600, + }); + + mockOAuth.generateAuthUrl.mockResolvedValue({ + url: 'https://bsky.social/oauth/authorize', + state: 'st1', + }); + + await request(app) + .post('/auth/proxy/login') + .send({ auth_request_id: 'req1', handle: '@user.bsky.social' }); + + expect(mockOAuth.generateAuthUrl).toHaveBeenCalledWith( + 'proxy-auth', + 'user.bsky.social', + expect.any(String), + ); + }); + + it('should append .bsky.social for handles without dots', async () => { + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'req2', + redirect_uri: 'https://search.arcnode.xyz/', + created_at: now, + expires_at: now + 600, + }); + + mockOAuth.generateAuthUrl.mockResolvedValue({ + url: 'https://bsky.social/oauth/authorize', + state: 'st2', + }); + + await request(app) + .post('/auth/proxy/login') + .send({ auth_request_id: 'req2', handle: 'username' }); + + expect(mockOAuth.generateAuthUrl).toHaveBeenCalledWith( + 'proxy-auth', + 'username.bsky.social', + expect.any(String), + ); + }); + + it('should show error page on OAuth failure', async () => { + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'req3', + redirect_uri: 'https://search.arcnode.xyz/', + created_at: now, + expires_at: now + 600, + }); + + mockOAuth.generateAuthUrl.mockRejectedValue(new Error('resolve identity failed')); + + const res = await request(app) + .post('/auth/proxy/login') + .send({ auth_request_id: 'req3', handle: 'nonexistent.handle' }); + + expect(res.status).toBe(400); + expect(res.text).toContain('Could not find that handle'); + }); + + it('should return JSON error when request is JSON', async () => { + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'req4', + redirect_uri: 'https://search.arcnode.xyz/', + created_at: now, + expires_at: now + 600, + }); + + mockOAuth.generateAuthUrl.mockRejectedValue(new Error('resolve identity failed')); + + const res = await request(app) + .post('/auth/proxy/login') + .set('Content-Type', 'application/json') + .send({ auth_request_id: 'req4', handle: 'nonexistent' }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('Could not find that handle'); + }); +}); + +describe('GET /auth/proxy/callback', () => { + let db: DatabaseService; + let app: express.Application; + let mockOAuth: { generateAuthUrl: ReturnType; handleCallback: ReturnType }; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + ({ app, mockOAuth } = createTestApp(db)); + }); + + afterEach(() => { + db.close(); + }); + + it('should return 400 if AT Proto returns an error', async () => { + const res = await request(app) + .get('/auth/proxy/callback') + .query({ error: 'access_denied', error_description: 'User denied' }); + + expect(res.status).toBe(400); + expect(res.text).toContain('User denied'); + }); + + it('should return 400 if code or state is missing', async () => { + const res = await request(app) + .get('/auth/proxy/callback') + .query({ code: 'abc' }); + + expect(res.status).toBe(400); + }); + + it('should return 400 for invalid state', async () => { + const res = await request(app) + .get('/auth/proxy/callback') + .query({ code: 'abc', state: 'nonexistent' }); + + expect(res.status).toBe(400); + expect(res.text).toContain('Invalid or expired state'); + }); + + it('should complete full callback flow', async () => { + const now = Math.floor(Date.now() / 1000); + + // Register the allowed origin (required for access check) + db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + + // Set up proxy auth request + db.saveProxyAuthRequest({ + id: 'auth-req-1', + redirect_uri: 'https://search.arcnode.xyz/path?q=test', + created_at: now, + expires_at: now + 600, + }); + + // Set up OAuth state linking to proxy auth request + db.saveOAuthState({ + state: 'oauth-state-1', + code_verifier: 'auth-req-1', + app_id: 'proxy-auth', + redirect_uri: 'https://search.arcnode.xyz/path?q=test', + created_at: now, + }); + + mockOAuth.handleCallback.mockResolvedValue({ + did: 'did:plc:testuser', + handle: 'testuser.bsky.social', + }); + + const res = await request(app) + .get('/auth/proxy/callback') + .query({ code: 'auth-code', state: 'oauth-state-1', iss: 'https://bsky.social' }); + + // Should redirect back to original URL with ticket + expect(res.status).toBe(302); + expect(res.headers.location).toContain('search.arcnode.xyz'); + expect(res.headers.location).toContain('_atauth_ticket='); + + // Should set session cookie on ATAuth domain + expect(res.headers['set-cookie']).toBeDefined(); + expect(res.headers['set-cookie'][0]).toContain(SESSION_COOKIE_NAME); + + // Should pass redirect_uri to handleCallback + expect(mockOAuth.handleCallback).toHaveBeenCalledWith( + expect.any(URLSearchParams), + `${TEST_ISSUER}/auth/proxy/callback`, + ); + + // OAuth state should be cleaned up + expect(db.getOAuthState('oauth-state-1')).toBeNull(); + + // Auth request should be cleaned up + expect(db.getProxyAuthRequest('auth-req-1')).toBeNull(); + + // Proxy session should exist + const sessions = db.getAllProxySessions('did:plc:testuser'); + expect(sessions).toHaveLength(1); + expect(sessions[0].handle).toBe('testuser.bsky.social'); + }); + + it('should return 400 if auth request expired', async () => { + const now = Math.floor(Date.now() / 1000); + + // State exists but auth request was deleted/expired + db.saveOAuthState({ + state: 'orphan-state', + code_verifier: 'missing-auth-req', + app_id: 'proxy-auth', + redirect_uri: 'https://search.arcnode.xyz/', + created_at: now, + }); + + mockOAuth.handleCallback.mockResolvedValue({ + did: 'did:plc:testuser', + handle: 'testuser.bsky.social', + }); + + const res = await request(app) + .get('/auth/proxy/callback') + .query({ code: 'auth-code', state: 'orphan-state' }); + + expect(res.status).toBe(400); + expect(res.text).toContain('Login request expired'); + }); + + it('should return 500 page if handleCallback throws', async () => { + const now = Math.floor(Date.now() / 1000); + + db.saveProxyAuthRequest({ + id: 'auth-req-err', + redirect_uri: 'https://search.arcnode.xyz/', + created_at: now, + expires_at: now + 600, + }); + + db.saveOAuthState({ + state: 'error-state', + code_verifier: 'auth-req-err', + app_id: 'proxy-auth', + redirect_uri: 'https://search.arcnode.xyz/', + created_at: now, + }); + + mockOAuth.handleCallback.mockRejectedValue(new Error('Token exchange failed')); + + const res = await request(app) + .get('/auth/proxy/callback') + .query({ code: 'bad-code', state: 'error-state' }); + + expect(res.status).toBe(500); + expect(res.text).toContain('unexpected error'); }); }); diff --git a/gateway/src/routes/proxy-auth.ts b/gateway/src/routes/proxy-auth.ts index 1d3ca1f..ed22d0b 100644 --- a/gateway/src/routes/proxy-auth.ts +++ b/gateway/src/routes/proxy-auth.ts @@ -18,7 +18,8 @@ import { Router, Request, Response } from 'express'; import crypto from 'crypto'; import type { DatabaseService } from '../services/database.js'; import type { OAuthService } from '../services/oauth.js'; -import type { ForwardAuthConfig } from '../types/proxy.js'; +import type { ForwardAuthConfig, AccessCheckResult } from '../types/proxy.js'; +import { checkAccess } from '../utils/access-check.js'; import { SESSION_COOKIE_NAME, PROXY_COOKIE_NAME, @@ -42,6 +43,34 @@ export function createProxyAuthRoutes( const router = Router(); const secret = forwardAuthConfig.sessionSecret; + /** + * Check access rules for a user attempting to access a protected origin. + * Returns allowed if no rules are configured (backward compat). + */ + function enforceAccess(did: string, handle: string, redirectUri: string): AccessCheckResult { + const targetOrigin = extractOrigin(redirectUri); + if (!targetOrigin) { + return { allowed: false, matched_rule_id: null, reason: 'Invalid redirect URI' }; + } + + const originId = db.getOriginIdByOrigin(targetOrigin); + if (originId === null) { + return { allowed: false, matched_rule_id: null, reason: 'Origin not registered' }; + } + + const rules = db.getProxyAccessRulesForCheck(originId); + const totalRules = rules.denyRules.length + rules.originAllowRules.length + rules.globalAllowRules.length; + if (totalRules === 0) { + return { allowed: true, matched_rule_id: null, reason: 'No access rules configured (open access)' }; + } + + const result = checkAccess(did, handle, rules); + if (!result.allowed) { + console.log(`[Proxy ACL] Access denied for ${handle} (${did}) to ${targetOrigin}: ${result.reason}`); + } + return result; + } + // ===== GET /auth/verify ===== // Called by nginx auth_request on every request to a protected service. router.get('/verify', async (req: Request, res: Response) => { @@ -131,6 +160,12 @@ export function createProxyAuthRoutes( if (sessionId) { const session = db.getProxySession(sessionId); if (session && session.expires_at > Math.floor(Date.now() / 1000)) { + // Check access rules before issuing silent SSO ticket + const accessResult = enforceAccess(session.did, session.handle, rd); + if (!accessResult.allowed) { + return res.status(403).type('html').send(renderAccessDeniedPage(res.locals.cspNonce)); + } + // Silent SSO -- generate ticket and redirect back const targetOrigin = extractOrigin(rd); if (targetOrigin) { @@ -283,7 +318,10 @@ export function createProxyAuthRoutes( callbackParams.set('state', state); if (iss) callbackParams.set('iss', iss); - const callbackResult = await oauthService.handleCallback(callbackParams); + const callbackResult = await oauthService.handleCallback( + callbackParams, + `${oidcIssuer}/auth/proxy/callback`, + ); if (!callbackResult) { return res.status(500).send('Failed to complete authentication'); } @@ -298,6 +336,14 @@ export function createProxyAuthRoutes( return res.status(400).send('Login request expired'); } + // Check access rules before creating session + const accessResult = enforceAccess(did, handle, authRequest.redirect_uri); + if (!accessResult.allowed) { + db.deleteOAuthState(state); + db.deleteProxyAuthRequest(authRequestId); + return res.status(403).type('html').send(renderAccessDeniedPage(res.locals.cspNonce)); + } + // Create a proxy session const now = Math.floor(Date.now() / 1000); const sessionId = crypto.randomBytes(32).toString('base64url'); @@ -364,7 +410,12 @@ export function createProxyAuthRoutes( const rd = req.query.rd as string; if (rd) { - return res.redirect(rd); + // Validate redirect against allowed origins to prevent open redirect + const allowedOrigins = db.listProxyAllowedOrigins().map(o => o.origin); + if (isAllowedRedirect(rd, allowedOrigins)) { + return res.redirect(rd); + } + // If not allowed, fall through to the logged-out page } res.type('html').send(renderLoggedOutPage(res.locals.cspNonce)); @@ -375,9 +426,21 @@ export function createProxyAuthRoutes( // ===== HTML Templates ===== +/** + * Escape HTML special characters to prevent XSS. + */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function renderProxyLoginPage(authRequestId: string, nonce?: string, errorMessage?: string): string { const errorHtml = errorMessage - ? `
${errorMessage}
` + ? `
${escapeHtml(errorMessage)}
` : '
'; return ` @@ -394,7 +457,7 @@ function renderProxyLoginPage(authRequestId: string, nonce?: string, errorMessag

Authenticate with your Bluesky account to access this service

${errorHtml}
- +

Enter your Bluesky handle or custom domain

@@ -438,6 +501,25 @@ function renderForbiddenPage(nonce?: string): string { `; } +function renderAccessDeniedPage(nonce?: string): string { + return ` + + + + + + Not Authorized - ATAuth + ${sharedStyles()} + + +
+

Not Authorized

+

Your account does not have access to this service. Contact your administrator if you believe this is an error.

+
+ +`; +} + function renderErrorPage(title: string, message: string, nonce?: string): string { return ` @@ -445,13 +527,13 @@ function renderErrorPage(title: string, message: string, nonce?: string): string - ${title} - ATAuth + ${escapeHtml(title)} - ATAuth ${sharedStyles()}
-

${title}

-

${message}

+

${escapeHtml(title)}

+

${escapeHtml(message)}

`; diff --git a/gateway/src/services/database.proxy.test.ts b/gateway/src/services/database.proxy.test.ts new file mode 100644 index 0000000..e8426cc --- /dev/null +++ b/gateway/src/services/database.proxy.test.ts @@ -0,0 +1,378 @@ +/** + * Database Service - Proxy Methods Tests + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import crypto from 'crypto'; +import { DatabaseService } from './database.js'; + +describe('Proxy Sessions', () => { + let db: DatabaseService; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + }); + + afterEach(() => { + db.close(); + }); + + function makeSession(overrides: Partial<{ + id: string; did: string; handle: string; + created_at: number; expires_at: number; last_activity: number; + user_agent: string; ip_address: string; + }> = {}) { + const now = Math.floor(Date.now() / 1000); + return { + id: overrides.id || crypto.randomBytes(16).toString('base64url'), + did: overrides.did || 'did:plc:test123', + handle: overrides.handle || 'test.bsky.social', + created_at: overrides.created_at || now, + expires_at: overrides.expires_at || now + 604800, + last_activity: overrides.last_activity || now, + user_agent: overrides.user_agent, + ip_address: overrides.ip_address, + }; + } + + it('should create and retrieve a proxy session', () => { + const session = makeSession({ ip_address: '1.2.3.4', user_agent: 'TestAgent/1.0' }); + db.createProxySession(session); + + const retrieved = db.getProxySession(session.id); + expect(retrieved).not.toBeNull(); + expect(retrieved!.id).toBe(session.id); + expect(retrieved!.did).toBe(session.did); + expect(retrieved!.handle).toBe(session.handle); + expect(retrieved!.ip_address).toBe('1.2.3.4'); + expect(retrieved!.user_agent).toBe('TestAgent/1.0'); + }); + + it('should return null for non-existent session', () => { + expect(db.getProxySession('nonexistent')).toBeNull(); + }); + + it('should update session activity timestamp', () => { + const session = makeSession(); + db.createProxySession(session); + + // Wait a tick so timestamp differs + const before = db.getProxySession(session.id)!.last_activity; + db.updateProxySessionActivity(session.id); + const after = db.getProxySession(session.id)!.last_activity; + + expect(after).toBeGreaterThanOrEqual(before); + }); + + it('should delete a proxy session', () => { + const session = makeSession(); + db.createProxySession(session); + expect(db.getProxySession(session.id)).not.toBeNull(); + + db.deleteProxySession(session.id); + expect(db.getProxySession(session.id)).toBeNull(); + }); + + it('should delete all sessions for a user', () => { + const did = 'did:plc:userA'; + db.createProxySession(makeSession({ id: 's1', did })); + db.createProxySession(makeSession({ id: 's2', did })); + db.createProxySession(makeSession({ id: 's3', did: 'did:plc:userB' })); + + const deleted = db.deleteProxySessionsForUser(did); + expect(deleted).toBe(2); + expect(db.getProxySession('s1')).toBeNull(); + expect(db.getProxySession('s2')).toBeNull(); + expect(db.getProxySession('s3')).not.toBeNull(); + }); + + it('should clean up expired proxy sessions', () => { + const now = Math.floor(Date.now() / 1000); + db.createProxySession(makeSession({ id: 'expired1', expires_at: now - 100 })); + db.createProxySession(makeSession({ id: 'expired2', expires_at: now - 1 })); + db.createProxySession(makeSession({ id: 'active', expires_at: now + 3600 })); + + const deleted = db.cleanupExpiredProxySessions(); + expect(deleted).toBe(2); + expect(db.getProxySession('expired1')).toBeNull(); + expect(db.getProxySession('expired2')).toBeNull(); + expect(db.getProxySession('active')).not.toBeNull(); + }); + + it('should list active proxy sessions', () => { + const now = Math.floor(Date.now() / 1000); + db.createProxySession(makeSession({ id: 'a1', did: 'did:plc:u1', expires_at: now + 3600 })); + db.createProxySession(makeSession({ id: 'a2', did: 'did:plc:u2', expires_at: now + 3600 })); + db.createProxySession(makeSession({ id: 'expired', did: 'did:plc:u1', expires_at: now - 10 })); + + const all = db.getAllProxySessions(); + expect(all).toHaveLength(2); + + const filtered = db.getAllProxySessions('did:plc:u1'); + expect(filtered).toHaveLength(1); + expect(filtered[0].id).toBe('a1'); + }); + + it('should respect limit on getAllProxySessions', () => { + const now = Math.floor(Date.now() / 1000); + for (let i = 0; i < 5; i++) { + db.createProxySession(makeSession({ id: `s${i}`, expires_at: now + 3600 })); + } + + const limited = db.getAllProxySessions(undefined, 3); + expect(limited).toHaveLength(3); + }); +}); + +describe('Proxy Allowed Origins', () => { + let db: DatabaseService; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + }); + + afterEach(() => { + db.close(); + }); + + it('should add and list allowed origins', () => { + db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.addProxyAllowedOrigin('https://element.arcnode.xyz', 'Element'); + + const origins = db.listProxyAllowedOrigins(); + expect(origins).toHaveLength(2); + // Sorted by name ASC + expect(origins[0].name).toBe('Element'); + expect(origins[1].name).toBe('SearXNG'); + }); + + it('should return created origin with id', () => { + const created = db.addProxyAllowedOrigin('https://test.example.com', 'Test'); + expect(created.id).toBeGreaterThan(0); + expect(created.origin).toBe('https://test.example.com'); + expect(created.name).toBe('Test'); + expect(created.created_at).toBeGreaterThan(0); + }); + + it('should reject duplicate origins', () => { + db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + expect(() => { + db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG Copy'); + }).toThrow(/UNIQUE/); + }); + + it('should remove an allowed origin', () => { + const created = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + db.removeProxyAllowedOrigin(created.id); + expect(db.listProxyAllowedOrigins()).toHaveLength(0); + }); + + it('should check if origin is allowed', () => { + db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + + expect(db.isProxyOriginAllowed('https://search.arcnode.xyz')).toBe(true); + expect(db.isProxyOriginAllowed('https://evil.example.com')).toBe(false); + }); +}); + +describe('Proxy Auth Requests', () => { + let db: DatabaseService; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + }); + + afterEach(() => { + db.close(); + }); + + it('should save and retrieve an auth request', () => { + const now = Math.floor(Date.now() / 1000); + const req = { + id: 'auth-req-123', + redirect_uri: 'https://search.arcnode.xyz/path', + created_at: now, + expires_at: now + 600, + }; + db.saveProxyAuthRequest(req); + + const retrieved = db.getProxyAuthRequest('auth-req-123'); + expect(retrieved).not.toBeNull(); + expect(retrieved!.redirect_uri).toBe('https://search.arcnode.xyz/path'); + expect(retrieved!.expires_at).toBe(now + 600); + }); + + it('should return null for non-existent auth request', () => { + expect(db.getProxyAuthRequest('nonexistent')).toBeNull(); + }); + + it('should delete an auth request', () => { + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'del-me', + redirect_uri: 'https://example.com', + created_at: now, + expires_at: now + 600, + }); + + db.deleteProxyAuthRequest('del-me'); + expect(db.getProxyAuthRequest('del-me')).toBeNull(); + }); + + it('should clean up expired auth requests', () => { + const now = Math.floor(Date.now() / 1000); + db.saveProxyAuthRequest({ + id: 'expired1', + redirect_uri: 'https://example.com', + created_at: now - 700, + expires_at: now - 100, + }); + db.saveProxyAuthRequest({ + id: 'active', + redirect_uri: 'https://example.com', + created_at: now, + expires_at: now + 600, + }); + + const deleted = db.cleanupExpiredProxyAuthRequests(); + expect(deleted).toBe(1); + expect(db.getProxyAuthRequest('expired1')).toBeNull(); + expect(db.getProxyAuthRequest('active')).not.toBeNull(); + }); +}); + +describe('Proxy Access Rules', () => { + let db: DatabaseService; + + beforeEach(() => { + db = new DatabaseService(':memory:'); + }); + + afterEach(() => { + db.close(); + }); + + it('should create and list access rules', () => { + const rule = db.createProxyAccessRule({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.arcnode.xyz', + description: 'PDS users', + }); + + expect(rule.id).toBeGreaterThan(0); + expect(rule.rule_type).toBe('allow'); + expect(rule.subject_value).toBe('*.arcnode.xyz'); + + const rules = db.listProxyAccessRules(); + expect(rules).toHaveLength(1); + expect(rules[0].description).toBe('PDS users'); + }); + + it('should delete an access rule', () => { + const rule = db.createProxyAccessRule({ + origin_id: null, + rule_type: 'allow', + subject_type: 'did', + subject_value: 'did:plc:test123', + description: null, + }); + + db.deleteProxyAccessRule(rule.id); + expect(db.listProxyAccessRules()).toHaveLength(0); + }); + + it('should filter rules by origin_id', () => { + const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + + db.createProxyAccessRule({ + origin_id: origin.id, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*', + description: 'Origin rule', + }); + + db.createProxyAccessRule({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.arcnode.xyz', + description: 'Global rule', + }); + + // Filter by origin ID should include origin-specific + global rules + const filtered = db.listProxyAccessRules(origin.id); + expect(filtered).toHaveLength(2); + + // All rules + const all = db.listProxyAccessRules(); + expect(all).toHaveLength(2); + }); + + it('should partition rules for access check', () => { + const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + + db.createProxyAccessRule({ + origin_id: origin.id, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*.arcnode.xyz', + description: null, + }); + db.createProxyAccessRule({ + origin_id: origin.id, + rule_type: 'deny', + subject_type: 'did', + subject_value: 'did:plc:banned', + description: null, + }); + db.createProxyAccessRule({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*', + description: null, + }); + + const result = db.getProxyAccessRulesForCheck(origin.id); + expect(result.denyRules).toHaveLength(1); + expect(result.denyRules[0].subject_value).toBe('did:plc:banned'); + expect(result.originAllowRules).toHaveLength(1); + expect(result.originAllowRules[0].subject_value).toBe('*.arcnode.xyz'); + expect(result.globalAllowRules).toHaveLength(1); + expect(result.globalAllowRules[0].subject_value).toBe('*'); + }); + + it('should cascade delete rules when origin is removed', () => { + const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + + db.createProxyAccessRule({ + origin_id: origin.id, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*', + description: null, + }); + + db.createProxyAccessRule({ + origin_id: null, + rule_type: 'allow', + subject_type: 'handle_pattern', + subject_value: '*', + description: 'Global survives', + }); + + db.removeProxyAllowedOrigin(origin.id); + + const rules = db.listProxyAccessRules(); + expect(rules).toHaveLength(1); + expect(rules[0].description).toBe('Global survives'); + }); + + it('should look up origin ID by origin URL', () => { + const origin = db.addProxyAllowedOrigin('https://search.arcnode.xyz', 'SearXNG'); + expect(db.getOriginIdByOrigin('https://search.arcnode.xyz')).toBe(origin.id); + expect(db.getOriginIdByOrigin('https://nonexistent.example.com')).toBeNull(); + }); +}); diff --git a/gateway/src/services/database.ts b/gateway/src/services/database.ts index 946c8e6..05e021e 100644 --- a/gateway/src/services/database.ts +++ b/gateway/src/services/database.ts @@ -26,6 +26,7 @@ import type { ProxySession, ProxyAllowedOrigin, ProxyAuthRequest, + ProxyAccessRule, } from '../types/index.js'; export class DatabaseService { @@ -291,6 +292,17 @@ export class DatabaseService { created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); + + CREATE TABLE IF NOT EXISTS proxy_access_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + origin_id INTEGER, + rule_type TEXT NOT NULL CHECK(rule_type IN ('allow', 'deny')), + subject_type TEXT NOT NULL CHECK(subject_type IN ('did', 'handle_pattern')), + subject_value TEXT NOT NULL, + description TEXT, + created_at INTEGER DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_proxy_access_rules_origin ON proxy_access_rules(origin_id); `); // Ensure sentinel proxy-auth app exists for forward-auth OAuth flows @@ -1399,8 +1411,8 @@ export class DatabaseService { } removeProxyAllowedOrigin(id: number): void { - const stmt = this.db.prepare('DELETE FROM proxy_allowed_origins WHERE id = ?'); - stmt.run(id); + this.db.prepare('DELETE FROM proxy_access_rules WHERE origin_id = ?').run(id); + this.db.prepare('DELETE FROM proxy_allowed_origins WHERE id = ?').run(id); } listProxyAllowedOrigins(): ProxyAllowedOrigin[] { @@ -1439,4 +1451,76 @@ export class DatabaseService { const result = stmt.run(now); return result.changes; } + + // ===== Forward-Auth Access Rules Methods ===== + + createProxyAccessRule(rule: Omit): ProxyAccessRule { + const stmt = this.db.prepare(` + INSERT INTO proxy_access_rules (origin_id, rule_type, subject_type, subject_value, description) + VALUES (?, ?, ?, ?, ?) + `); + const result = stmt.run( + rule.origin_id, rule.rule_type, rule.subject_type, + rule.subject_value, rule.description, + ); + return { + id: result.lastInsertRowid as number, + origin_id: rule.origin_id, + rule_type: rule.rule_type, + subject_type: rule.subject_type, + subject_value: rule.subject_value, + description: rule.description, + created_at: Math.floor(Date.now() / 1000), + }; + } + + deleteProxyAccessRule(id: number): void { + this.db.prepare('DELETE FROM proxy_access_rules WHERE id = ?').run(id); + } + + listProxyAccessRules(originId?: number): ProxyAccessRule[] { + if (originId !== undefined) { + const stmt = this.db.prepare( + 'SELECT * FROM proxy_access_rules WHERE origin_id = ? OR origin_id IS NULL ORDER BY rule_type ASC, created_at ASC', + ); + return stmt.all(originId) as ProxyAccessRule[]; + } + const stmt = this.db.prepare( + 'SELECT * FROM proxy_access_rules ORDER BY origin_id ASC, rule_type ASC, created_at ASC', + ); + return stmt.all() as ProxyAccessRule[]; + } + + getProxyAccessRulesForCheck(originId: number): { + denyRules: ProxyAccessRule[]; + originAllowRules: ProxyAccessRule[]; + globalAllowRules: ProxyAccessRule[]; + } { + const stmt = this.db.prepare( + 'SELECT * FROM proxy_access_rules WHERE origin_id = ? OR origin_id IS NULL', + ); + const rules = stmt.all(originId) as ProxyAccessRule[]; + + const denyRules: ProxyAccessRule[] = []; + const originAllowRules: ProxyAccessRule[] = []; + const globalAllowRules: ProxyAccessRule[] = []; + + for (const rule of rules) { + if (rule.rule_type === 'deny') { + denyRules.push(rule); + } else if (rule.origin_id !== null) { + originAllowRules.push(rule); + } else { + globalAllowRules.push(rule); + } + } + + return { denyRules, originAllowRules, globalAllowRules }; + } + + getOriginIdByOrigin(origin: string): number | null { + const stmt = this.db.prepare('SELECT id FROM proxy_allowed_origins WHERE origin = ? LIMIT 1'); + const row = stmt.get(origin) as { id: number } | undefined; + return row?.id ?? null; + } } diff --git a/gateway/src/services/oauth.ts b/gateway/src/services/oauth.ts index ed84a2a..3734213 100644 --- a/gateway/src/services/oauth.ts +++ b/gateway/src/services/oauth.ts @@ -35,12 +35,16 @@ export class OAuthService { } async initialize(): Promise { + // Derive proxy callback from the primary redirect URI's base + const baseUrl = this.redirectUri.replace(/\/[^/]*$/, ''); + const proxyCallbackUri = `${baseUrl}/proxy/callback`; + this.client = new NodeOAuthClient({ clientMetadata: { client_id: this.clientId, client_name: 'ATAuth Gateway', client_uri: this.clientId, - redirect_uris: [this.redirectUri], + redirect_uris: [this.redirectUri, proxyCallbackUri], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], scope: 'atproto transition:generic', @@ -95,9 +99,14 @@ export class OAuthService { pendingStateKey = null; - const url = await this.client.authorize(handle, { + const authorizeOptions: Record = { scope: 'atproto transition:generic', - }); + }; + if (customRedirect) { + authorizeOptions.redirect_uri = customRedirect; + } + + const url = await this.client.authorize(handle, authorizeOptions); const state = pendingStateKey; if (!state) { @@ -120,13 +129,21 @@ export class OAuthService { /** * Handle OAuth callback and exchange code for tokens + * + * @param params - URL search params from the callback + * @param redirectUri - The redirect_uri used during authorization (must match) */ - async handleCallback(params: URLSearchParams): Promise { + async handleCallback(params: URLSearchParams, redirectUri?: string): Promise { if (!this.client) { throw new Error('OAuth client not initialized'); } - const { session } = await this.client.callback(params); + const callbackOptions: Record = {}; + if (redirectUri) { + callbackOptions.redirect_uri = redirectUri; + } + + const { session } = await this.client.callback(params, callbackOptions); const did: string = session.did; diff --git a/gateway/src/types/proxy.ts b/gateway/src/types/proxy.ts index 74bca63..a9d3196 100644 --- a/gateway/src/types/proxy.ts +++ b/gateway/src/types/proxy.ts @@ -33,8 +33,27 @@ export interface ProxyAuthRequest { expires_at: number; } +/** An access control rule for forward-auth proxy */ +export interface ProxyAccessRule { + id: number; + origin_id: number | null; // null = global rule + rule_type: 'allow' | 'deny'; + subject_type: 'did' | 'handle_pattern'; + subject_value: string; + description: string | null; + created_at: number; +} + +/** Result of an access check */ +export interface AccessCheckResult { + allowed: boolean; + matched_rule_id: number | null; + reason: string; // for logging/admin, NOT for user display +} + /** Payload inside the _atauth_session cookie (on ATAuth domain) */ export interface ProxySessionCookiePayload { + typ: 'session' | 'proxy' | 'admin'; sid: string; iat: number; exp: number; diff --git a/gateway/src/utils/access-check.test.ts b/gateway/src/utils/access-check.test.ts new file mode 100644 index 0000000..e9055c7 --- /dev/null +++ b/gateway/src/utils/access-check.test.ts @@ -0,0 +1,148 @@ +/** + * Access Check Utility Tests + */ +import { describe, it, expect } from 'vitest'; +import { matchHandlePattern, checkAccess } from './access-check.js'; +import type { ProxyAccessRule } from '../types/proxy.js'; + +function makeRule(overrides: Partial & Pick): ProxyAccessRule { + return { + id: overrides.id ?? 1, + origin_id: overrides.origin_id ?? null, + rule_type: overrides.rule_type, + subject_type: overrides.subject_type, + subject_value: overrides.subject_value, + description: overrides.description ?? null, + created_at: overrides.created_at ?? Math.floor(Date.now() / 1000), + }; +} + +describe('matchHandlePattern', () => { + it('should match wildcard "*" against any handle', () => { + expect(matchHandlePattern('*', 'user.bsky.social')).toBe(true); + expect(matchHandlePattern('*', 'anything.at.all')).toBe(true); + }); + + it('should match suffix pattern "*.domain"', () => { + expect(matchHandlePattern('*.arcnode.xyz', 'bkb.arcnode.xyz')).toBe(true); + expect(matchHandlePattern('*.arcnode.xyz', 'other.arcnode.xyz')).toBe(true); + }); + + it('should not match suffix pattern against different domain', () => { + expect(matchHandlePattern('*.arcnode.xyz', 'user.bsky.social')).toBe(false); + expect(matchHandlePattern('*.arcnode.xyz', 'arcnode.xyz')).toBe(false); + }); + + it('should match exact handle', () => { + expect(matchHandlePattern('bkb.arcnode.xyz', 'bkb.arcnode.xyz')).toBe(true); + }); + + it('should not match different exact handle', () => { + expect(matchHandlePattern('bkb.arcnode.xyz', 'other.arcnode.xyz')).toBe(false); + }); + + it('should be case-sensitive', () => { + expect(matchHandlePattern('User.bsky.social', 'user.bsky.social')).toBe(false); + }); + + it('should handle nested subdomains in suffix pattern', () => { + expect(matchHandlePattern('*.bsky.social', 'deep.sub.bsky.social')).toBe(true); + }); +}); + +describe('checkAccess', () => { + const testDid = 'did:plc:test123'; + const testHandle = 'user.bsky.social'; + + it('should deny by default when no rules match', () => { + const result = checkAccess(testDid, testHandle, { + denyRules: [], + originAllowRules: [makeRule({ id: 1, rule_type: 'allow', subject_type: 'did', subject_value: 'did:plc:other' })], + globalAllowRules: [], + }); + expect(result.allowed).toBe(false); + expect(result.matched_rule_id).toBeNull(); + expect(result.reason).toContain('default deny'); + }); + + it('should deny when a deny rule matches', () => { + const result = checkAccess(testDid, testHandle, { + denyRules: [makeRule({ id: 10, rule_type: 'deny', subject_type: 'did', subject_value: testDid })], + originAllowRules: [makeRule({ id: 20, rule_type: 'allow', subject_type: 'handle_pattern', subject_value: '*' })], + globalAllowRules: [], + }); + expect(result.allowed).toBe(false); + expect(result.matched_rule_id).toBe(10); + }); + + it('should allow when origin allow rule matches', () => { + const result = checkAccess(testDid, testHandle, { + denyRules: [], + originAllowRules: [makeRule({ id: 5, rule_type: 'allow', subject_type: 'did', subject_value: testDid })], + globalAllowRules: [], + }); + expect(result.allowed).toBe(true); + expect(result.matched_rule_id).toBe(5); + expect(result.reason).toContain('origin rule'); + }); + + it('should allow when global allow rule matches and no origin rule does', () => { + const result = checkAccess(testDid, testHandle, { + denyRules: [], + originAllowRules: [], + globalAllowRules: [makeRule({ id: 7, rule_type: 'allow', subject_type: 'handle_pattern', subject_value: '*.bsky.social' })], + }); + expect(result.allowed).toBe(true); + expect(result.matched_rule_id).toBe(7); + expect(result.reason).toContain('global rule'); + }); + + it('should prefer origin allow over global allow (both match)', () => { + const result = checkAccess(testDid, testHandle, { + denyRules: [], + originAllowRules: [makeRule({ id: 3, rule_type: 'allow', subject_type: 'handle_pattern', subject_value: '*' })], + globalAllowRules: [makeRule({ id: 4, rule_type: 'allow', subject_type: 'handle_pattern', subject_value: '*' })], + }); + expect(result.allowed).toBe(true); + expect(result.matched_rule_id).toBe(3); + expect(result.reason).toContain('origin rule'); + }); + + it('should deny even if allow rules exist when deny rule matches', () => { + const result = checkAccess(testDid, testHandle, { + denyRules: [makeRule({ id: 1, rule_type: 'deny', subject_type: 'handle_pattern', subject_value: '*.bsky.social' })], + originAllowRules: [makeRule({ id: 2, rule_type: 'allow', subject_type: 'handle_pattern', subject_value: '*' })], + globalAllowRules: [makeRule({ id: 3, rule_type: 'allow', subject_type: 'handle_pattern', subject_value: '*' })], + }); + expect(result.allowed).toBe(false); + expect(result.matched_rule_id).toBe(1); + }); + + it('should match DID-based rules', () => { + const result = checkAccess('did:plc:specific', 'any.handle', { + denyRules: [], + originAllowRules: [makeRule({ id: 1, rule_type: 'allow', subject_type: 'did', subject_value: 'did:plc:specific' })], + globalAllowRules: [], + }); + expect(result.allowed).toBe(true); + }); + + it('should not match DID-based rule against wrong DID', () => { + const result = checkAccess('did:plc:other', 'any.handle', { + denyRules: [], + originAllowRules: [makeRule({ id: 1, rule_type: 'allow', subject_type: 'did', subject_value: 'did:plc:specific' })], + globalAllowRules: [], + }); + expect(result.allowed).toBe(false); + expect(result.matched_rule_id).toBeNull(); + }); + + it('should handle wildcard allow for all users', () => { + const result = checkAccess('did:plc:anyone', 'random.handle', { + denyRules: [], + originAllowRules: [], + globalAllowRules: [makeRule({ id: 1, rule_type: 'allow', subject_type: 'handle_pattern', subject_value: '*' })], + }); + expect(result.allowed).toBe(true); + }); +}); diff --git a/gateway/src/utils/access-check.ts b/gateway/src/utils/access-check.ts new file mode 100644 index 0000000..090188f --- /dev/null +++ b/gateway/src/utils/access-check.ts @@ -0,0 +1,92 @@ +/** + * Forward-Auth Proxy Access Control + * + * Evaluates access rules to determine if a user (identified by DID and handle) + * is allowed to access a protected service. + * + * Evaluation order: + * 1. Deny rules (per-origin + global) - if any match, reject + * 2. Per-origin allow rules - if any match, allow + * 3. Global allow rules - if any match, allow + * 4. Default deny + */ + +import type { ProxyAccessRule, AccessCheckResult } from '../types/proxy.js'; + +/** + * Match a handle against a pattern. + * - "*" matches everything + * - "*.domain.tld" matches any handle ending with ".domain.tld" + * - "exact.handle" matches only that exact handle + */ +export function matchHandlePattern(pattern: string, handle: string): boolean { + if (pattern === '*') return true; + if (pattern.startsWith('*.')) { + const suffix = pattern.slice(1); // ".domain.tld" + return handle.endsWith(suffix); + } + return pattern === handle; +} + +/** + * Check if a DID or handle matches a given access rule. + */ +function matchesRule(rule: ProxyAccessRule, did: string, handle: string): boolean { + if (rule.subject_type === 'did') { + return rule.subject_value === did; + } + return matchHandlePattern(rule.subject_value, handle); +} + +/** + * Evaluate access rules for a user attempting to access a protected origin. + */ +export function checkAccess( + did: string, + handle: string, + rules: { + denyRules: ProxyAccessRule[]; + originAllowRules: ProxyAccessRule[]; + globalAllowRules: ProxyAccessRule[]; + }, +): AccessCheckResult { + // 1. Check deny rules (both per-origin and global) + for (const rule of rules.denyRules) { + if (matchesRule(rule, did, handle)) { + return { + allowed: false, + matched_rule_id: rule.id, + reason: `Denied by rule #${rule.id}: ${rule.description || rule.subject_value}`, + }; + } + } + + // 2. Check per-origin allow rules + for (const rule of rules.originAllowRules) { + if (matchesRule(rule, did, handle)) { + return { + allowed: true, + matched_rule_id: rule.id, + reason: `Allowed by origin rule #${rule.id}: ${rule.description || rule.subject_value}`, + }; + } + } + + // 3. Check global allow rules + for (const rule of rules.globalAllowRules) { + if (matchesRule(rule, did, handle)) { + return { + allowed: true, + matched_rule_id: rule.id, + reason: `Allowed by global rule #${rule.id}: ${rule.description || rule.subject_value}`, + }; + } + } + + // 4. Default deny + return { + allowed: false, + matched_rule_id: null, + reason: 'No matching allow rule (default deny)', + }; +} diff --git a/gateway/src/utils/errors.ts b/gateway/src/utils/errors.ts index bfa2532..0a5302c 100644 --- a/gateway/src/utils/errors.ts +++ b/gateway/src/utils/errors.ts @@ -37,6 +37,7 @@ export const httpError = { forbidden: (code: string, message: string) => new HttpError(403, code, message), notFound: (code: string, message: string) => new HttpError(404, code, message), conflict: (code: string, message: string) => new HttpError(409, code, message), + internalServerError: (code: string, message: string) => new HttpError(500, code, message), }; /** diff --git a/gateway/src/utils/proxy-auth.test.ts b/gateway/src/utils/proxy-auth.test.ts index c50258b..8ddb878 100644 --- a/gateway/src/utils/proxy-auth.test.ts +++ b/gateway/src/utils/proxy-auth.test.ts @@ -7,6 +7,8 @@ import { verifySessionCookie, createProxyCookie, verifyProxyCookie, + createAdminCookie, + verifyAdminCookie, createAuthTicket, verifyAuthTicket, parseCookies, @@ -14,6 +16,7 @@ import { extractOrigin, SESSION_COOKIE_NAME, PROXY_COOKIE_NAME, + ADMIN_COOKIE_NAME, } from './proxy-auth.js'; const TEST_SECRET = 'test-secret-for-hmac-signing-32b!'; @@ -57,13 +60,27 @@ describe('Session Cookie', () => { }); describe('Proxy Cookie', () => { - it('should create and verify (same as session cookie format)', () => { + it('should create and verify a proxy cookie', () => { const cookie = createProxyCookie('session-456', TEST_SECRET, 86400); const result = verifyProxyCookie(cookie, TEST_SECRET); expect(result).toBe('session-456'); }); }); +describe('Cookie Confusion Prevention', () => { + it('should reject a proxy cookie used as a session cookie', () => { + const proxyCookie = createProxyCookie('session-123', TEST_SECRET, 3600); + const result = verifySessionCookie(proxyCookie, TEST_SECRET); + expect(result).toBeNull(); + }); + + it('should reject a session cookie used as a proxy cookie', () => { + const sessionCookie = createSessionCookie('session-123', TEST_SECRET, 3600); + const result = verifyProxyCookie(sessionCookie, TEST_SECRET); + expect(result).toBeNull(); + }); +}); + describe('Auth Ticket', () => { it('should create and verify an auth ticket', () => { const ticket = createAuthTicket( @@ -181,9 +198,40 @@ describe('extractOrigin', () => { }); }); +describe('Admin Cookie', () => { + it('should create and verify an admin cookie', () => { + const cookie = createAdminCookie(TEST_SECRET, 86400); + expect(verifyAdminCookie(cookie, TEST_SECRET)).toBe(true); + }); + + it('should reject an expired admin cookie', () => { + vi.useFakeTimers(); + const cookie = createAdminCookie(TEST_SECRET, 60); + vi.advanceTimersByTime(61 * 1000); + expect(verifyAdminCookie(cookie, TEST_SECRET)).toBe(false); + vi.useRealTimers(); + }); + + it('should reject admin cookie with wrong secret', () => { + const cookie = createAdminCookie(TEST_SECRET, 86400); + expect(verifyAdminCookie(cookie, 'wrong-secret')).toBe(false); + }); + + it('should reject session cookie as admin cookie', () => { + const sessionCookie = createSessionCookie('sid', TEST_SECRET, 3600); + expect(verifyAdminCookie(sessionCookie, TEST_SECRET)).toBe(false); + }); + + it('should reject proxy cookie as admin cookie', () => { + const proxyCookie = createProxyCookie('sid', TEST_SECRET, 3600); + expect(verifyAdminCookie(proxyCookie, TEST_SECRET)).toBe(false); + }); +}); + describe('Cookie names', () => { it('should export expected cookie names', () => { expect(SESSION_COOKIE_NAME).toBe('_atauth_session'); expect(PROXY_COOKIE_NAME).toBe('_atauth_proxy'); + expect(ADMIN_COOKIE_NAME).toBe('_atauth_admin'); }); }); diff --git a/gateway/src/utils/proxy-auth.ts b/gateway/src/utils/proxy-auth.ts index 7fb640b..6127bf0 100644 --- a/gateway/src/utils/proxy-auth.ts +++ b/gateway/src/utils/proxy-auth.ts @@ -12,16 +12,17 @@ const ALGORITHM = 'sha256'; export const SESSION_COOKIE_NAME = '_atauth_session'; export const PROXY_COOKIE_NAME = '_atauth_proxy'; +export const ADMIN_COOKIE_NAME = '_atauth_admin'; // ===== Cookie Utilities ===== /** - * Create an HMAC-signed session cookie value. - * Payload contains only the session ID (small, opaque). + * Create an HMAC-signed session cookie value (for ATAuth domain). + * Includes typ:'session' to prevent cookie confusion with proxy cookies. */ export function createSessionCookie(sessionId: string, secret: string, ttlSeconds: number): string { const now = Math.floor(Date.now() / 1000); - const payload: ProxySessionCookiePayload = { sid: sessionId, iat: now, exp: now + ttlSeconds }; + const payload: ProxySessionCookiePayload = { typ: 'session', sid: sessionId, iat: now, exp: now + ttlSeconds }; const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); const signature = crypto.createHmac(ALGORITHM, secret).update(payloadBase64).digest('base64url'); return `${payloadBase64}.${signature}`; @@ -29,28 +30,59 @@ export function createSessionCookie(sessionId: string, secret: string, ttlSecond /** * Verify an HMAC-signed session cookie. - * Returns the session ID if valid, null otherwise. + * Rejects cookies with wrong type to prevent cookie confusion attacks. */ export function verifySessionCookie(cookie: string, secret: string): string | null { const payload = verifyHmacToken(cookie, secret); - return payload?.sid ?? null; + if (!payload || payload.typ !== 'session') return null; + return payload.sid; } // ===== Proxy Cookie Utilities ===== /** * Create an HMAC-signed proxy cookie (set on the protected service domain). - * Same format as session cookie but with independent TTL. + * Includes typ:'proxy' to prevent cookie confusion with session cookies. */ export function createProxyCookie(sessionId: string, secret: string, ttlSeconds: number): string { - return createSessionCookie(sessionId, secret, ttlSeconds); + const now = Math.floor(Date.now() / 1000); + const payload: ProxySessionCookiePayload = { typ: 'proxy', sid: sessionId, iat: now, exp: now + ttlSeconds }; + const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signature = crypto.createHmac(ALGORITHM, secret).update(payloadBase64).digest('base64url'); + return `${payloadBase64}.${signature}`; } /** * Verify an HMAC-signed proxy cookie. + * Rejects cookies with wrong type to prevent cookie confusion attacks. */ export function verifyProxyCookie(cookie: string, secret: string): string | null { - return verifySessionCookie(cookie, secret); + const payload = verifyHmacToken(cookie, secret); + if (!payload || payload.typ !== 'proxy') return null; + return payload.sid; +} + +// ===== Admin Cookie Utilities ===== + +/** + * Create an HMAC-signed admin session cookie (24h TTL). + * Proves the bearer successfully authenticated with the admin token. + */ +export function createAdminCookie(secret: string, ttlSeconds: number): string { + const now = Math.floor(Date.now() / 1000); + const payload: ProxySessionCookiePayload = { typ: 'admin', sid: 'admin', iat: now, exp: now + ttlSeconds }; + const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signature = crypto.createHmac(ALGORITHM, secret).update(payloadBase64).digest('base64url'); + return `${payloadBase64}.${signature}`; +} + +/** + * Verify an HMAC-signed admin session cookie. + * Returns true if valid, false otherwise. + */ +export function verifyAdminCookie(cookie: string, secret: string): boolean { + const payload = verifyHmacToken(cookie, secret); + return payload !== null && payload.typ === 'admin'; } // ===== Auth Ticket Utilities =====