AtAuth
at main 194 lines 6.5 kB view raw
1/** 2 * Forward-Auth Proxy Utilities 3 * 4 * HMAC-signed cookie and ticket creation/verification for the 5 * forward-auth SSO gateway. Follows the same pattern as utils/hmac.ts. 6 */ 7 8import crypto from 'crypto'; 9import type { ProxySessionCookiePayload, ProxyTicketPayload } from '../types/proxy.js'; 10 11const ALGORITHM = 'sha256'; 12 13export const SESSION_COOKIE_NAME = '_atauth_session'; 14export const PROXY_COOKIE_NAME = '_atauth_proxy'; 15export const ADMIN_COOKIE_NAME = '_atauth_admin'; 16 17// ===== Cookie Utilities ===== 18 19/** 20 * Create an HMAC-signed session cookie value (for ATAuth domain). 21 * Includes typ:'session' to prevent cookie confusion with proxy cookies. 22 */ 23export function createSessionCookie(sessionId: string, secret: string, ttlSeconds: number): string { 24 const now = Math.floor(Date.now() / 1000); 25 const payload: ProxySessionCookiePayload = { typ: 'session', sid: sessionId, iat: now, exp: now + ttlSeconds }; 26 const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); 27 const signature = crypto.createHmac(ALGORITHM, secret).update(payloadBase64).digest('base64url'); 28 return `${payloadBase64}.${signature}`; 29} 30 31/** 32 * Verify an HMAC-signed session cookie. 33 * Rejects cookies with wrong type to prevent cookie confusion attacks. 34 */ 35export function verifySessionCookie(cookie: string, secret: string): string | null { 36 const payload = verifyHmacToken<ProxySessionCookiePayload>(cookie, secret); 37 if (!payload || payload.typ !== 'session') return null; 38 return payload.sid; 39} 40 41// ===== Proxy Cookie Utilities ===== 42 43/** 44 * Create an HMAC-signed proxy cookie (set on the protected service domain). 45 * Includes typ:'proxy' to prevent cookie confusion with session cookies. 46 */ 47export function createProxyCookie(sessionId: string, secret: string, ttlSeconds: number): string { 48 const now = Math.floor(Date.now() / 1000); 49 const payload: ProxySessionCookiePayload = { typ: 'proxy', sid: sessionId, iat: now, exp: now + ttlSeconds }; 50 const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); 51 const signature = crypto.createHmac(ALGORITHM, secret).update(payloadBase64).digest('base64url'); 52 return `${payloadBase64}.${signature}`; 53} 54 55/** 56 * Verify an HMAC-signed proxy cookie. 57 * Rejects cookies with wrong type to prevent cookie confusion attacks. 58 */ 59export function verifyProxyCookie(cookie: string, secret: string): string | null { 60 const payload = verifyHmacToken<ProxySessionCookiePayload>(cookie, secret); 61 if (!payload || payload.typ !== 'proxy') return null; 62 return payload.sid; 63} 64 65// ===== Admin Cookie Utilities ===== 66 67/** 68 * Create an HMAC-signed admin session cookie (24h TTL). 69 * Proves the bearer successfully authenticated with the admin token. 70 */ 71export function createAdminCookie(secret: string, ttlSeconds: number): string { 72 const now = Math.floor(Date.now() / 1000); 73 const payload: ProxySessionCookiePayload = { typ: 'admin', sid: 'admin', iat: now, exp: now + ttlSeconds }; 74 const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); 75 const signature = crypto.createHmac(ALGORITHM, secret).update(payloadBase64).digest('base64url'); 76 return `${payloadBase64}.${signature}`; 77} 78 79/** 80 * Verify an HMAC-signed admin session cookie. 81 * Returns true if valid, false otherwise. 82 */ 83export function verifyAdminCookie(cookie: string, secret: string): boolean { 84 const payload = verifyHmacToken<ProxySessionCookiePayload>(cookie, secret); 85 return payload !== null && payload.typ === 'admin'; 86} 87 88// ===== Auth Ticket Utilities ===== 89 90/** 91 * Create a signed auth ticket for the redirect-back flow. 92 * Short-lived (60s), embedded in the redirect URL. 93 */ 94export function createAuthTicket( 95 sessionId: string, 96 did: string, 97 handle: string, 98 targetOrigin: string, 99 secret: string, 100): string { 101 const now = Math.floor(Date.now() / 1000); 102 const payload: ProxyTicketPayload = { 103 sid: sessionId, 104 did, 105 handle, 106 origin: targetOrigin, 107 iat: now, 108 exp: now + 60, // 60 seconds 109 }; 110 const payloadBase64 = Buffer.from(JSON.stringify(payload)).toString('base64url'); 111 const signature = crypto.createHmac(ALGORITHM, secret).update(payloadBase64).digest('base64url'); 112 return `${payloadBase64}.${signature}`; 113} 114 115/** 116 * Verify a signed auth ticket. 117 * Returns the full payload if valid, null otherwise. 118 */ 119export function verifyAuthTicket( 120 ticket: string, 121 secret: string, 122 expectedOrigin?: string, 123): ProxyTicketPayload | null { 124 const payload = verifyHmacToken<ProxyTicketPayload>(ticket, secret); 125 if (!payload) return null; 126 if (expectedOrigin && payload.origin !== expectedOrigin) return null; 127 return payload; 128} 129 130// ===== Helpers ===== 131 132/** 133 * Generic HMAC token verification with constant-time comparison. 134 */ 135function verifyHmacToken<T extends { exp: number }>(token: string, secret: string): T | null { 136 const parts = token.split('.'); 137 if (parts.length !== 2) return null; 138 139 const [payloadBase64, providedSignature] = parts; 140 const expectedSignature = crypto.createHmac(ALGORITHM, secret).update(payloadBase64).digest('base64url'); 141 142 const providedBuf = Buffer.from(providedSignature); 143 const expectedBuf = Buffer.from(expectedSignature); 144 145 if (providedBuf.length !== expectedBuf.length) return null; 146 if (!crypto.timingSafeEqual(providedBuf, expectedBuf)) return null; 147 148 try { 149 const payload = JSON.parse(Buffer.from(payloadBase64, 'base64url').toString('utf8')) as T; 150 if (payload.exp < Math.floor(Date.now() / 1000)) return null; 151 return payload; 152 } catch { 153 return null; 154 } 155} 156 157/** 158 * Parse a Cookie header string into key-value pairs. 159 */ 160export function parseCookies(cookieHeader: string | undefined): Record<string, string> { 161 if (!cookieHeader) return {}; 162 const cookies: Record<string, string> = {}; 163 for (const pair of cookieHeader.split(';')) { 164 const idx = pair.indexOf('='); 165 if (idx === -1) continue; 166 const key = pair.substring(0, idx).trim(); 167 const value = pair.substring(idx + 1).trim(); 168 if (key) cookies[key] = value; 169 } 170 return cookies; 171} 172 173/** 174 * Validate that a redirect URL belongs to an allowed origin. 175 */ 176export function isAllowedRedirect(url: string, allowedOrigins: string[]): boolean { 177 try { 178 const parsed = new URL(url); 179 return allowedOrigins.includes(parsed.origin); 180 } catch { 181 return false; 182 } 183} 184 185/** 186 * Extract origin from a URL string. 187 */ 188export function extractOrigin(url: string): string | null { 189 try { 190 return new URL(url).origin; 191 } catch { 192 return null; 193 } 194}