From 49b817b085670e189c2cf663908c980b820366df Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Fri, 20 Feb 2026 17:27:35 -0600 Subject: [PATCH] feat: add forward-auth SSO gateway for protecting arbitrary services Implements nginx auth_request compatible endpoints so any service behind the ingress can be gated behind ATAuth login without code changes. New endpoints: - GET /auth/verify - nginx auth_request target (checks cookie or ticket) - GET/POST /auth/proxy/login - login entry point with silent SSO - GET /auth/proxy/callback - AT Proto OAuth callback for proxy flow - GET /auth/proxy/logout - centralized session logout Admin API additions: - CRUD for allowed origins (proxy/origins) - List/revoke proxy sessions (proxy/sessions) Also fixes CSP nonce on OIDC login page inline scripts. --- gateway/k8s/base/configmap.yaml | 5 + gateway/src/index.ts | 43 +- gateway/src/middleware/rateLimit.ts | 9 + gateway/src/routes/admin.ts | 80 ++++ gateway/src/routes/oidc/authorize.ts | 11 +- gateway/src/routes/proxy-auth.test.ts | 234 +++++++++++ gateway/src/routes/proxy-auth.ts | 539 ++++++++++++++++++++++++++ gateway/src/services/database.ts | 151 ++++++++ gateway/src/types/index.ts | 1 + gateway/src/types/proxy.ts | 59 +++ gateway/src/utils/proxy-auth.test.ts | 189 +++++++++ gateway/src/utils/proxy-auth.ts | 162 ++++++++ 12 files changed, 1476 insertions(+), 7 deletions(-) create mode 100644 gateway/src/routes/proxy-auth.test.ts create mode 100644 gateway/src/routes/proxy-auth.ts create mode 100644 gateway/src/types/proxy.ts create mode 100644 gateway/src/utils/proxy-auth.test.ts create mode 100644 gateway/src/utils/proxy-auth.ts diff --git a/gateway/k8s/base/configmap.yaml b/gateway/k8s/base/configmap.yaml index aadd238..f46e281 100644 --- a/gateway/k8s/base/configmap.yaml +++ b/gateway/k8s/base/configmap.yaml @@ -41,3 +41,8 @@ data: EMAIL_PROVIDER: "smtp" EMAIL_FROM: "ATAuth " EMAIL_CODE_EXPIRY: "900" + + # Forward-auth proxy configuration + FORWARD_AUTH_ENABLED: "true" + FORWARD_AUTH_SESSION_TTL: "604800" + FORWARD_AUTH_PROXY_COOKIE_TTL: "86400" diff --git a/gateway/src/index.ts b/gateway/src/index.ts index ad663e2..97f1160 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -11,6 +11,7 @@ import cors from 'cors'; import helmet from 'helmet'; import path from 'path'; import fs from 'fs'; +import crypto from 'crypto'; import { DatabaseService } from './services/database.js'; import { OAuthService } from './services/oauth.js'; @@ -26,7 +27,8 @@ import { createOIDCRouter } from './routes/oidc/index.js'; import { createPasskeyRouter } from './routes/passkey.js'; import { createMFARouter } from './routes/mfa.js'; import { createEmailRouter } from './routes/email.js'; -import { authRateLimit, apiRateLimit, adminRateLimit } from './middleware/rateLimit.js'; +import { createProxyAuthRoutes } from './routes/proxy-auth.js'; +import { authRateLimit, apiRateLimit, adminRateLimit, proxyVerifyRateLimit } from './middleware/rateLimit.js'; import { HttpError } from './utils/errors.js'; // Configuration from environment @@ -87,6 +89,14 @@ const config = { apiKey: process.env.EMAIL_API_KEY, codeExpiry: parseInt(process.env.EMAIL_CODE_EXPIRY || '900', 10), // 15 minutes }, + + // Forward-auth proxy configuration + forwardAuth: { + enabled: process.env.FORWARD_AUTH_ENABLED === 'true', + sessionSecret: process.env.FORWARD_AUTH_SESSION_SECRET || 'change-me-in-production-32-bytes!', + sessionTtl: parseInt(process.env.FORWARD_AUTH_SESSION_TTL || '604800', 10), // 7 days + proxyCookieTtl: parseInt(process.env.FORWARD_AUTH_PROXY_COOKIE_TTL || '86400', 10), // 24h + }, }; async function main(): Promise { @@ -164,6 +174,11 @@ async function main(): Promise { const app = express(); // Middleware + // Generate a per-request nonce for inline scripts (CSP script-src) + app.use((_req, res, next) => { + res.locals.cspNonce = crypto.randomBytes(16).toString('base64'); + next(); + }); app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' }, contentSecurityPolicy: { @@ -173,6 +188,8 @@ async function main(): Promise { // The OIDC login form POSTs to self, but the response redirects to bsky.social // or other PDS hosts. Without this, Chrome blocks the redirect silently. 'form-action': null, + // Allow inline scripts with per-request nonce (for login page) + 'script-src': ["'self'", (_req: any, res: any) => `'nonce-${res.locals.cspNonce}'`], }, }, })); @@ -232,6 +249,15 @@ 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)) { @@ -251,6 +277,10 @@ async function main(): Promise { if (config.oidc.enabled && config.oidc.issuer) { redirectUris.push(`${config.oidc.issuer}/oauth/callback`); } + // Add forward-auth proxy callback if enabled + if (config.forwardAuth.enabled && config.oidc.issuer) { + redirectUris.push(`${config.oidc.issuer}/auth/proxy/callback`); + } res.json({ client_id: config.clientId, @@ -301,10 +331,12 @@ async function main(): Promise { const authCodesDeleted = db.cleanupExpiredAuthorizationCodes(); const refreshTokensDeleted = db.cleanupExpiredRefreshTokens(); const emailCodesDeleted = db.cleanupExpiredEmailVerificationCodes(); + const proxySessionsDeleted = db.cleanupExpiredProxySessions(); + const proxyAuthRequestsDeleted = db.cleanupExpiredProxyAuthRequests(); - const total = statesDeleted + sessionsDeleted + authCodesDeleted + refreshTokensDeleted + emailCodesDeleted; + const total = statesDeleted + sessionsDeleted + authCodesDeleted + refreshTokensDeleted + emailCodesDeleted + proxySessionsDeleted + proxyAuthRequestsDeleted; if (total > 0) { - console.log(`Cleanup: ${statesDeleted} OAuth states, ${sessionsDeleted} sessions, ${authCodesDeleted} auth codes, ${refreshTokensDeleted} refresh tokens, ${emailCodesDeleted} email codes`); + console.log(`Cleanup: ${statesDeleted} OAuth states, ${sessionsDeleted} sessions, ${authCodesDeleted} auth codes, ${refreshTokensDeleted} refresh tokens, ${emailCodesDeleted} email codes, ${proxySessionsDeleted} proxy sessions, ${proxyAuthRequestsDeleted} proxy auth requests`); } }, 60 * 60 * 1000); @@ -339,6 +371,11 @@ async function main(): Promise { } else { console.log('Email disabled (set EMAIL_ENABLED=true to enable)'); } + if (config.forwardAuth.enabled) { + console.log(`Forward-auth proxy enabled - Session TTL: ${config.forwardAuth.sessionTtl}s`); + } else { + console.log('Forward-auth proxy disabled (set FORWARD_AUTH_ENABLED=true to enable)'); + } }); } diff --git a/gateway/src/middleware/rateLimit.ts b/gateway/src/middleware/rateLimit.ts index a876752..ff3fbe5 100644 --- a/gateway/src/middleware/rateLimit.ts +++ b/gateway/src/middleware/rateLimit.ts @@ -179,3 +179,12 @@ export const adminRateLimit = rateLimit({ windowMs: 60 * 1000, // 1 minute maxRequests: 20, // 20 requests per minute }); + +/** + * High-throughput rate limit for forward-auth verify endpoint. + * Called by nginx on every request to a protected service. + */ +export const proxyVerifyRateLimit = rateLimit({ + windowMs: 60 * 1000, // 1 minute + maxRequests: 300, // 300 requests per minute per IP +}); diff --git a/gateway/src/routes/admin.ts b/gateway/src/routes/admin.ts index 5896e75..be760aa 100644 --- a/gateway/src/routes/admin.ts +++ b/gateway/src/routes/admin.ts @@ -540,6 +540,86 @@ export function createAdminRoutes( }); }); + // ===== Forward-Auth Proxy Management ===== + + /** + * GET /admin/proxy/origins + * List allowed origins for forward-auth + */ + router.get('/proxy/origins', requireAdmin, async (_req: Request, res: Response) => { + const origins = db.listProxyAllowedOrigins(); + res.json({ origins }); + }); + + /** + * POST /admin/proxy/origins + * Add an allowed origin for forward-auth + * + * Body: + * - origin: Full origin URL (e.g. "https://search.arcnode.xyz") + * - name: Display name (e.g. "SearXNG") + */ + router.post('/proxy/origins', requireAdmin, async (req: Request, res: Response) => { + const { origin, name } = req.body; + + if (!origin || !name) { + throw httpError.badRequest('missing_params', 'origin and name are required'); + } + + // Validate origin format + try { + const parsed = new URL(origin); + if (parsed.origin !== origin) { + throw httpError.badRequest('invalid_origin', 'origin must be a valid URL origin (scheme://host[:port])'); + } + } catch (e) { + if (e instanceof Error && 'statusCode' in e) throw e; + throw httpError.badRequest('invalid_origin', 'origin must be a valid URL'); + } + + try { + const created = db.addProxyAllowedOrigin(origin, name); + res.status(201).json(created); + } catch (e) { + const msg = e instanceof Error ? e.message : ''; + if (msg.includes('UNIQUE constraint')) { + throw httpError.conflict('origin_exists', `Origin '${origin}' already exists`); + } + throw e; + } + }); + + /** + * DELETE /admin/proxy/origins/:id + * Remove an allowed origin + */ + router.delete('/proxy/origins/:id', requireAdmin, async (req: Request, res: Response) => { + db.removeProxyAllowedOrigin(parseInt(req.params.id, 10)); + res.json({ message: 'Origin removed' }); + }); + + /** + * GET /admin/proxy/sessions + * List active proxy sessions + */ + router.get('/proxy/sessions', requireAdmin, async (req: Request, res: Response) => { + const { did, limit } = req.query; + const sessions = db.getAllProxySessions( + did as string | undefined, + limit ? parseInt(limit as string, 10) : 100, + ); + res.json({ sessions }); + }); + + /** + * DELETE /admin/proxy/sessions/:id + * Revoke a proxy session + */ + router.delete('/proxy/sessions/:id', requireAdmin, async (req: Request, res: Response) => { + db.deleteProxySession(req.params.id); + res.json({ message: 'Proxy session revoked' }); + }); + // ===== Stats ===== /** diff --git a/gateway/src/routes/oidc/authorize.ts b/gateway/src/routes/oidc/authorize.ts index 8b1671b..8f9a2e0 100644 --- a/gateway/src/routes/oidc/authorize.ts +++ b/gateway/src/routes/oidc/authorize.ts @@ -19,7 +19,7 @@ export function createAuthorizeRouter( ): Router { const router = Router(); - function renderLoginPage(clientName: string, authCode: string, state: string, errorMessage?: string): string { + function renderLoginPage(clientName: string, authCode: string, state: string, errorMessage?: string, nonce?: string): string { const errorHtml = errorMessage ? `
${errorMessage}
` : '
'; @@ -96,6 +96,8 @@ export function createAuthorizeRouter( display: none; } .logo { text-align: center; margin-bottom: 24px; font-size: 32px; } + .privacy { font-size: 12px; color: #888; margin-top: 20px; line-height: 1.5; text-align: center; } + .privacy strong { color: #555; } @@ -113,8 +115,9 @@ export function createAuthorizeRouter(

Enter your Bluesky handle or custom domain

+

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

- + +`; +} + +function renderForbiddenPage(nonce?: string): string { + return ` + + + + + + Access Denied - ATAuth + ${sharedStyles()} + + +
+

Access Denied

+

This service is not configured for SSO access. Contact your administrator.

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

${title}

+

${message}

+
+ +`; +} + +function renderLoggedOutPage(nonce?: string): string { + return ` + + + + + + Signed Out - ATAuth + ${sharedStyles()} + + +
+

Signed Out

+

You have been signed out of ATAuth SSO.

+
+ +`; +} + +function sharedStyles(): string { + return ``; +} diff --git a/gateway/src/services/database.ts b/gateway/src/services/database.ts index c8d95ce..6685dcb 100644 --- a/gateway/src/services/database.ts +++ b/gateway/src/services/database.ts @@ -23,6 +23,9 @@ import type { MFABackupCode, UserEmail, EmailVerificationCode, + ProxySession, + ProxyAllowedOrigin, + ProxyAuthRequest, } from '../types/index.js'; export class DatabaseService { @@ -259,6 +262,36 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_verify_email ON email_verification_codes(email); CREATE INDEX IF NOT EXISTS idx_verify_expires ON email_verification_codes(expires_at); `); + + // Forward-auth proxy tables + this.db.exec(` + CREATE TABLE IF NOT EXISTS proxy_sessions ( + id TEXT PRIMARY KEY, + did TEXT NOT NULL, + handle TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_activity INTEGER NOT NULL, + user_agent TEXT, + ip_address TEXT + ); + CREATE INDEX IF NOT EXISTS idx_proxy_sessions_expires ON proxy_sessions(expires_at); + CREATE INDEX IF NOT EXISTS idx_proxy_sessions_did ON proxy_sessions(did); + + CREATE TABLE IF NOT EXISTS proxy_allowed_origins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + origin TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at INTEGER DEFAULT (unixepoch()) + ); + + CREATE TABLE IF NOT EXISTS proxy_auth_requests ( + id TEXT PRIMARY KEY, + redirect_uri TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + `); } // App configuration methods @@ -1279,4 +1312,122 @@ export class DatabaseService { const result = stmt.run(...params); return result.changes; } + + // ===== Forward-Auth Proxy Session Methods ===== + + createProxySession(session: ProxySession): void { + const stmt = this.db.prepare(` + INSERT INTO proxy_sessions (id, did, handle, created_at, expires_at, last_activity, user_agent, ip_address) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + stmt.run( + session.id, session.did, session.handle, + session.created_at, session.expires_at, session.last_activity, + session.user_agent, session.ip_address, + ); + } + + getProxySession(id: string): ProxySession | null { + const stmt = this.db.prepare('SELECT * FROM proxy_sessions WHERE id = ?'); + const row = stmt.get(id) as ProxySession | undefined; + return row || null; + } + + updateProxySessionActivity(id: string): void { + const now = Math.floor(Date.now() / 1000); + const stmt = this.db.prepare('UPDATE proxy_sessions SET last_activity = ? WHERE id = ?'); + stmt.run(now, id); + } + + deleteProxySession(id: string): void { + const stmt = this.db.prepare('DELETE FROM proxy_sessions WHERE id = ?'); + stmt.run(id); + } + + deleteProxySessionsForUser(did: string): number { + const stmt = this.db.prepare('DELETE FROM proxy_sessions WHERE did = ?'); + const result = stmt.run(did); + return result.changes; + } + + cleanupExpiredProxySessions(): number { + const now = Math.floor(Date.now() / 1000); + const stmt = this.db.prepare('DELETE FROM proxy_sessions WHERE expires_at < ?'); + const result = stmt.run(now); + return result.changes; + } + + getAllProxySessions(did?: string, limit = 100): ProxySession[] { + let sql = 'SELECT * FROM proxy_sessions WHERE expires_at > ?'; + const now = Math.floor(Date.now() / 1000); + const params: unknown[] = [now]; + + if (did) { + sql += ' AND did = ?'; + params.push(did); + } + + sql += ' ORDER BY last_activity DESC LIMIT ?'; + params.push(limit); + + const stmt = this.db.prepare(sql); + return stmt.all(...params) as ProxySession[]; + } + + // ===== Forward-Auth Allowed Origins Methods ===== + + addProxyAllowedOrigin(origin: string, name: string): ProxyAllowedOrigin { + const stmt = this.db.prepare(` + INSERT INTO proxy_allowed_origins (origin, name) VALUES (?, ?) + `); + const result = stmt.run(origin, name); + return { + id: result.lastInsertRowid as number, + origin, + name, + created_at: Math.floor(Date.now() / 1000), + }; + } + + removeProxyAllowedOrigin(id: number): void { + const stmt = this.db.prepare('DELETE FROM proxy_allowed_origins WHERE id = ?'); + stmt.run(id); + } + + listProxyAllowedOrigins(): ProxyAllowedOrigin[] { + const stmt = this.db.prepare('SELECT * FROM proxy_allowed_origins ORDER BY name ASC'); + return stmt.all() as ProxyAllowedOrigin[]; + } + + isProxyOriginAllowed(origin: string): boolean { + const stmt = this.db.prepare('SELECT 1 FROM proxy_allowed_origins WHERE origin = ? LIMIT 1'); + return !!stmt.get(origin); + } + + // ===== Forward-Auth Auth Request Methods ===== + + saveProxyAuthRequest(request: ProxyAuthRequest): void { + const stmt = this.db.prepare(` + INSERT INTO proxy_auth_requests (id, redirect_uri, created_at, expires_at) VALUES (?, ?, ?, ?) + `); + stmt.run(request.id, request.redirect_uri, request.created_at, request.expires_at); + } + + getProxyAuthRequest(id: string): ProxyAuthRequest | null { + const stmt = this.db.prepare('SELECT * FROM proxy_auth_requests WHERE id = ?'); + const row = stmt.get(id) as ProxyAuthRequest | undefined; + return row || null; + } + + deleteProxyAuthRequest(id: string): void { + const stmt = this.db.prepare('DELETE FROM proxy_auth_requests WHERE id = ?'); + stmt.run(id); + } + + cleanupExpiredProxyAuthRequests(): number { + const now = Math.floor(Date.now() / 1000); + const stmt = this.db.prepare('DELETE FROM proxy_auth_requests WHERE expires_at < ?'); + const result = stmt.run(now); + return result.changes; + } } diff --git a/gateway/src/types/index.ts b/gateway/src/types/index.ts index e598f70..d65c0d8 100644 --- a/gateway/src/types/index.ts +++ b/gateway/src/types/index.ts @@ -9,6 +9,7 @@ export * from './oidc.js'; export * from './passkey.js'; export * from './mfa.js'; export * from './email.js'; +export * from './proxy.js'; export interface GatewayTokenPayload { /** AT Protocol DID (e.g., "did:plc:xyz...") */ diff --git a/gateway/src/types/proxy.ts b/gateway/src/types/proxy.ts new file mode 100644 index 0000000..74bca63 --- /dev/null +++ b/gateway/src/types/proxy.ts @@ -0,0 +1,59 @@ +/** + * Forward-Auth Proxy Types + * + * Types for the forward-auth/SSO gateway that protects + * arbitrary services via nginx auth_request. + */ + +/** A gateway-level SSO session (stored in SQLite) */ +export interface ProxySession { + id: string; + did: string; + handle: string; + created_at: number; + expires_at: number; + last_activity: number; + user_agent?: string; + ip_address?: string; +} + +/** An origin allowed to use forward-auth */ +export interface ProxyAllowedOrigin { + id: number; + origin: string; // e.g. "https://search.arcnode.xyz" + name: string; // e.g. "SearXNG" + created_at: number; +} + +/** Pending forward-auth login request (stored while user does AT Proto OAuth) */ +export interface ProxyAuthRequest { + id: string; + redirect_uri: string; + created_at: number; + expires_at: number; +} + +/** Payload inside the _atauth_session cookie (on ATAuth domain) */ +export interface ProxySessionCookiePayload { + sid: string; + iat: number; + exp: number; +} + +/** Payload inside the _atauth_ticket URL parameter (short-lived redirect token) */ +export interface ProxyTicketPayload { + sid: string; + did: string; + handle: string; + origin: string; + iat: number; + exp: number; +} + +/** Forward-auth configuration */ +export interface ForwardAuthConfig { + enabled: boolean; + sessionSecret: string; + sessionTtl: number; // seconds, default 7 days + proxyCookieTtl: number; // seconds, default 24h +} diff --git a/gateway/src/utils/proxy-auth.test.ts b/gateway/src/utils/proxy-auth.test.ts new file mode 100644 index 0000000..c50258b --- /dev/null +++ b/gateway/src/utils/proxy-auth.test.ts @@ -0,0 +1,189 @@ +/** + * Forward-Auth Proxy Utilities Tests + */ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { + createSessionCookie, + verifySessionCookie, + createProxyCookie, + verifyProxyCookie, + createAuthTicket, + verifyAuthTicket, + parseCookies, + isAllowedRedirect, + extractOrigin, + SESSION_COOKIE_NAME, + PROXY_COOKIE_NAME, +} from './proxy-auth.js'; + +const TEST_SECRET = 'test-secret-for-hmac-signing-32b!'; + +describe('Session Cookie', () => { + it('should create and verify a session cookie', () => { + const cookie = createSessionCookie('session-123', TEST_SECRET, 3600); + const result = verifySessionCookie(cookie, TEST_SECRET); + expect(result).toBe('session-123'); + }); + + it('should reject an expired cookie', () => { + vi.useFakeTimers(); + const cookie = createSessionCookie('session-123', TEST_SECRET, 60); + // Advance past expiry + vi.advanceTimersByTime(61 * 1000); + const result = verifySessionCookie(cookie, TEST_SECRET); + expect(result).toBeNull(); + vi.useRealTimers(); + }); + + it('should reject a tampered cookie', () => { + const cookie = createSessionCookie('session-123', TEST_SECRET, 3600); + const tampered = cookie.slice(0, -1) + 'x'; + const result = verifySessionCookie(tampered, TEST_SECRET); + expect(result).toBeNull(); + }); + + it('should reject a cookie with wrong secret', () => { + const cookie = createSessionCookie('session-123', TEST_SECRET, 3600); + const result = verifySessionCookie(cookie, 'wrong-secret'); + expect(result).toBeNull(); + }); + + it('should reject malformed cookies', () => { + expect(verifySessionCookie('', TEST_SECRET)).toBeNull(); + expect(verifySessionCookie('no-dots', TEST_SECRET)).toBeNull(); + expect(verifySessionCookie('too.many.dots', TEST_SECRET)).toBeNull(); + expect(verifySessionCookie('invalid.base64!', TEST_SECRET)).toBeNull(); + }); +}); + +describe('Proxy Cookie', () => { + it('should create and verify (same as session cookie format)', () => { + const cookie = createProxyCookie('session-456', TEST_SECRET, 86400); + const result = verifyProxyCookie(cookie, TEST_SECRET); + expect(result).toBe('session-456'); + }); +}); + +describe('Auth Ticket', () => { + it('should create and verify an auth ticket', () => { + const ticket = createAuthTicket( + 'session-789', 'did:plc:abc123', 'user.bsky.social', + 'https://search.arcnode.xyz', TEST_SECRET, + ); + const result = verifyAuthTicket(ticket, TEST_SECRET); + expect(result).not.toBeNull(); + expect(result!.sid).toBe('session-789'); + expect(result!.did).toBe('did:plc:abc123'); + expect(result!.handle).toBe('user.bsky.social'); + expect(result!.origin).toBe('https://search.arcnode.xyz'); + }); + + it('should reject an expired ticket', () => { + vi.useFakeTimers(); + const ticket = createAuthTicket( + 'session-789', 'did:plc:abc123', 'user.bsky.social', + 'https://search.arcnode.xyz', TEST_SECRET, + ); + // Advance past 60s expiry + vi.advanceTimersByTime(61 * 1000); + const result = verifyAuthTicket(ticket, TEST_SECRET); + expect(result).toBeNull(); + vi.useRealTimers(); + }); + + it('should reject a ticket with wrong origin', () => { + const ticket = createAuthTicket( + 'session-789', 'did:plc:abc123', 'user.bsky.social', + 'https://search.arcnode.xyz', TEST_SECRET, + ); + const result = verifyAuthTicket(ticket, TEST_SECRET, 'https://evil.example.com'); + expect(result).toBeNull(); + }); + + it('should accept a ticket with matching expected origin', () => { + const ticket = createAuthTicket( + 'session-789', 'did:plc:abc123', 'user.bsky.social', + 'https://search.arcnode.xyz', TEST_SECRET, + ); + const result = verifyAuthTicket(ticket, TEST_SECRET, 'https://search.arcnode.xyz'); + expect(result).not.toBeNull(); + expect(result!.sid).toBe('session-789'); + }); + + it('should reject a tampered ticket', () => { + const ticket = createAuthTicket( + 'session-789', 'did:plc:abc123', 'user.bsky.social', + 'https://search.arcnode.xyz', TEST_SECRET, + ); + const tampered = ticket.slice(0, -1) + 'x'; + const result = verifyAuthTicket(tampered, TEST_SECRET); + expect(result).toBeNull(); + }); +}); + +describe('parseCookies', () => { + it('should parse a standard cookie header', () => { + const result = parseCookies('foo=bar; baz=qux'); + expect(result).toEqual({ foo: 'bar', baz: 'qux' }); + }); + + it('should handle cookies with = in the value', () => { + const result = parseCookies('token=abc=def=ghi'); + expect(result).toEqual({ token: 'abc=def=ghi' }); + }); + + it('should return empty object for undefined', () => { + expect(parseCookies(undefined)).toEqual({}); + }); + + it('should return empty object for empty string', () => { + expect(parseCookies('')).toEqual({}); + }); + + it('should trim whitespace', () => { + const result = parseCookies(' foo = bar ; baz = qux '); + expect(result).toEqual({ foo: 'bar', baz: 'qux' }); + }); +}); + +describe('isAllowedRedirect', () => { + const allowed = ['https://search.arcnode.xyz', 'https://element.arcnode.xyz']; + + it('should allow a URL with an allowed origin', () => { + expect(isAllowedRedirect('https://search.arcnode.xyz/some/path', allowed)).toBe(true); + expect(isAllowedRedirect('https://element.arcnode.xyz/', allowed)).toBe(true); + }); + + it('should reject a URL with a disallowed origin', () => { + expect(isAllowedRedirect('https://evil.example.com/search', allowed)).toBe(false); + }); + + it('should reject an invalid URL', () => { + expect(isAllowedRedirect('not-a-url', allowed)).toBe(false); + }); + + it('should reject a URL with different port', () => { + expect(isAllowedRedirect('https://search.arcnode.xyz:8443/path', allowed)).toBe(false); + }); + + it('should reject a URL with different scheme', () => { + expect(isAllowedRedirect('http://search.arcnode.xyz/path', allowed)).toBe(false); + }); +}); + +describe('extractOrigin', () => { + it('should extract origin from a full URL', () => { + expect(extractOrigin('https://search.arcnode.xyz/some/path?q=test')).toBe('https://search.arcnode.xyz'); + }); + + it('should return null for invalid URL', () => { + expect(extractOrigin('not-a-url')).toBeNull(); + }); +}); + +describe('Cookie names', () => { + it('should export expected cookie names', () => { + expect(SESSION_COOKIE_NAME).toBe('_atauth_session'); + expect(PROXY_COOKIE_NAME).toBe('_atauth_proxy'); + }); +}); diff --git a/gateway/src/utils/proxy-auth.ts b/gateway/src/utils/proxy-auth.ts new file mode 100644 index 0000000..7fb640b --- /dev/null +++ b/gateway/src/utils/proxy-auth.ts @@ -0,0 +1,162 @@ +/** + * Forward-Auth Proxy Utilities + * + * HMAC-signed cookie and ticket creation/verification for the + * forward-auth SSO gateway. Follows the same pattern as utils/hmac.ts. + */ + +import crypto from 'crypto'; +import type { ProxySessionCookiePayload, ProxyTicketPayload } from '../types/proxy.js'; + +const ALGORITHM = 'sha256'; + +export const SESSION_COOKIE_NAME = '_atauth_session'; +export const PROXY_COOKIE_NAME = '_atauth_proxy'; + +// ===== Cookie Utilities ===== + +/** + * Create an HMAC-signed session cookie value. + * Payload contains only the session ID (small, opaque). + */ +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 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 session cookie. + * Returns the session ID if valid, null otherwise. + */ +export function verifySessionCookie(cookie: string, secret: string): string | null { + const payload = verifyHmacToken(cookie, secret); + return payload?.sid ?? null; +} + +// ===== Proxy Cookie Utilities ===== + +/** + * Create an HMAC-signed proxy cookie (set on the protected service domain). + * Same format as session cookie but with independent TTL. + */ +export function createProxyCookie(sessionId: string, secret: string, ttlSeconds: number): string { + return createSessionCookie(sessionId, secret, ttlSeconds); +} + +/** + * Verify an HMAC-signed proxy cookie. + */ +export function verifyProxyCookie(cookie: string, secret: string): string | null { + return verifySessionCookie(cookie, secret); +} + +// ===== Auth Ticket Utilities ===== + +/** + * Create a signed auth ticket for the redirect-back flow. + * Short-lived (60s), embedded in the redirect URL. + */ +export function createAuthTicket( + sessionId: string, + did: string, + handle: string, + targetOrigin: string, + secret: string, +): string { + const now = Math.floor(Date.now() / 1000); + const payload: ProxyTicketPayload = { + sid: sessionId, + did, + handle, + origin: targetOrigin, + iat: now, + exp: now + 60, // 60 seconds + }; + const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signature = crypto.createHmac(ALGORITHM, secret).update(payloadBase64).digest('base64url'); + return `${payloadBase64}.${signature}`; +} + +/** + * Verify a signed auth ticket. + * Returns the full payload if valid, null otherwise. + */ +export function verifyAuthTicket( + ticket: string, + secret: string, + expectedOrigin?: string, +): ProxyTicketPayload | null { + const payload = verifyHmacToken(ticket, secret); + if (!payload) return null; + if (expectedOrigin && payload.origin !== expectedOrigin) return null; + return payload; +} + +// ===== Helpers ===== + +/** + * Generic HMAC token verification with constant-time comparison. + */ +function verifyHmacToken(token: string, secret: string): T | null { + const parts = token.split('.'); + if (parts.length !== 2) return null; + + const [payloadBase64, providedSignature] = parts; + const expectedSignature = crypto.createHmac(ALGORITHM, secret).update(payloadBase64).digest('base64url'); + + const providedBuf = Buffer.from(providedSignature); + const expectedBuf = Buffer.from(expectedSignature); + + if (providedBuf.length !== expectedBuf.length) return null; + if (!crypto.timingSafeEqual(providedBuf, expectedBuf)) return null; + + try { + const payload = JSON.parse(Buffer.from(payloadBase64, 'base64url').toString('utf8')) as T; + if (payload.exp < Math.floor(Date.now() / 1000)) return null; + return payload; + } catch { + return null; + } +} + +/** + * Parse a Cookie header string into key-value pairs. + */ +export function parseCookies(cookieHeader: string | undefined): Record { + if (!cookieHeader) return {}; + const cookies: Record = {}; + for (const pair of cookieHeader.split(';')) { + const idx = pair.indexOf('='); + if (idx === -1) continue; + const key = pair.substring(0, idx).trim(); + const value = pair.substring(idx + 1).trim(); + if (key) cookies[key] = value; + } + return cookies; +} + +/** + * Validate that a redirect URL belongs to an allowed origin. + */ +export function isAllowedRedirect(url: string, allowedOrigins: string[]): boolean { + try { + const parsed = new URL(url); + return allowedOrigins.includes(parsed.origin); + } catch { + return false; + } +} + +/** + * Extract origin from a URL string. + */ +export function extractOrigin(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} -- knotmirror 0.0.1