AtAuth
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}