From 671a2127383ba8d1a2119284833d21482d470e8a Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 15 Feb 2026 21:26:21 -0500 Subject: [PATCH 1/5] Encrypt session and backup session cookies using AES-GCM --- src/CookieManager.ts | 56 ++++++++++++ src/StartupAPIEnv.ts | 1 + src/auth/index.ts | 14 ++- src/index.ts | 156 ++++++++++++++++++++++++--------- test/account_switching.spec.ts | 5 +- test/admin.spec.ts | 47 ++++++---- test/integration.spec.ts | 12 ++- 7 files changed, 225 insertions(+), 66 deletions(-) create mode 100644 src/CookieManager.ts diff --git a/src/CookieManager.ts b/src/CookieManager.ts new file mode 100644 index 0000000..989ed7a --- /dev/null +++ b/src/CookieManager.ts @@ -0,0 +1,56 @@ +export class CookieManager { + private keyPromise: Promise | null = null; + + constructor(private secret: string) {} + + private async getKey(): Promise { + if (this.keyPromise) return this.keyPromise; + + this.keyPromise = (async () => { + const encoder = new TextEncoder(); + const secretData = encoder.encode(this.secret); + const hash = await crypto.subtle.digest('SHA-256', secretData); + return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); + })(); + + return this.keyPromise; + } + + async encrypt(value: string): Promise { + const key = await this.getKey(); + const encoder = new TextEncoder(); + const data = encoder.encode(value); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); + + const combined = new Uint8Array(iv.length + ciphertext.byteLength); + combined.set(iv); + combined.set(new Uint8Array(ciphertext), iv.length); + + return btoa(String.fromCharCode(...combined)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + } + + async decrypt(encrypted: string): Promise { + try { + const key = await this.getKey(); + const base64 = encrypted.replace(/-/g, '+').replace(/_/g, '/'); + const combined = new Uint8Array( + atob(base64) + .split('') + .map((c) => c.charCodeAt(0)), + ); + + const iv = combined.slice(0, 12); + const ciphertext = combined.slice(12); + + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + return new TextDecoder().decode(decrypted); + } catch (e) { + console.error('Failed to decrypt cookie:', e); + return null; + } + } +} diff --git a/src/StartupAPIEnv.ts b/src/StartupAPIEnv.ts index db518d3..f6126e6 100644 --- a/src/StartupAPIEnv.ts +++ b/src/StartupAPIEnv.ts @@ -7,5 +7,6 @@ export type StartupAPIEnv = { TWITCH_CLIENT_ID: string; TWITCH_CLIENT_SECRET: string; ADMIN_IDS: string; + COOKIE_SECRET: string; SYSTEM: DurableObjectNamespace; } & Env; diff --git a/src/auth/index.ts b/src/auth/index.ts index 8e24e43..11a3673 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -3,8 +3,15 @@ import type { StartupAPIEnv } from '../StartupAPIEnv'; import { GoogleProvider } from './GoogleProvider'; import { TwitchProvider } from './TwitchProvider'; import { OAuthProvider } from './OAuthProvider'; - -export async function handleAuth(request: Request, env: StartupAPIEnv, url: URL, usersPath: string): Promise { +import { CookieManager } from '../CookieManager'; + +export async function handleAuth( + request: Request, + env: StartupAPIEnv, + url: URL, + usersPath: string, + cookieManager: CookieManager, +): Promise { const path = url.pathname; const authPath = usersPath + 'auth'; @@ -152,8 +159,9 @@ export async function handleAuth(request: Request, env: StartupAPIEnv, url: URL, // Set cookie and redirect home const doId = id.toString(); + const encryptedSession = await cookieManager.encrypt(`${session.sessionId}:${doId}`); const headers = new Headers(); - headers.set('Set-Cookie', `session_id=${session.sessionId}:${doId}; Path=/; HttpOnly; Secure; SameSite=Lax`); + headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`); headers.set('Location', '/'); return new Response(null, { status: 302, headers }); diff --git a/src/index.ts b/src/index.ts index c15ab38..a0300f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { injectPowerStrip } from './PowerStrip'; import { UserDO } from './UserDO'; import { AccountDO } from './AccountDO'; import { SystemDO } from './SystemDO'; +import { CookieManager } from './CookieManager'; const DEFAULT_USERS_PATH = '/users/'; @@ -29,18 +30,19 @@ export default { const url = new URL(request.url); const usersPath = env.USERS_PATH || DEFAULT_USERS_PATH; + const cookieManager = new CookieManager(env.COOKIE_SECRET || 'dev-secret'); // Handle OAuth Routes if (url.pathname.startsWith(usersPath + 'auth/')) { - return handleAuth(request, env, url, usersPath); + return handleAuth(request, env, url, usersPath, cookieManager); } if (url.pathname === usersPath + 'me/avatar') { - return handleMeImage(request, env, 'avatar'); + return handleMeImage(request, env, 'avatar', cookieManager); } if (url.pathname === usersPath + 'me/provider-icon') { - return handleMeImage(request, env, 'provider-icon'); + return handleMeImage(request, env, 'provider-icon', cookieManager); } // Handle API Routes @@ -48,41 +50,47 @@ export default { const apiPath = url.pathname.replace(usersPath + 'api/', '/'); if (apiPath === '/me') { - return handleMe(request, env); + return handleMe(request, env, cookieManager); } if (apiPath === '/stop-impersonation' && request.method === 'POST') { const cookieHeader = request.headers.get('Cookie'); const cookies = parseCookies(cookieHeader || ''); - const backupSession = cookies['backup_session_id']; + const backupSessionEncrypted = cookies['backup_session_id']; - if (!backupSession) { + if (!backupSessionEncrypted) { return new Response('No impersonation session found', { status: 400 }); } + const backupSession = await cookieManager.decrypt(backupSessionEncrypted); + if (!backupSession) { + return new Response('Invalid backup session', { status: 400 }); + } + const headers = new Headers(); - headers.set('Set-Cookie', `session_id=${backupSession}; Path=/; HttpOnly; Secure; SameSite=Lax`); + const newSessionIdEncrypted = await cookieManager.encrypt(backupSession); + headers.set('Set-Cookie', `session_id=${newSessionIdEncrypted}; Path=/; HttpOnly; Secure; SameSite=Lax`); headers.append('Set-Cookie', `backup_session_id=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`); return Response.json({ success: true }, { headers }); } if (apiPath === '/me/accounts') { - return handleMyAccounts(request, env); + return handleMyAccounts(request, env, cookieManager); } if (apiPath === '/me/accounts/switch' && request.method === 'POST') { - return handleSwitchAccount(request, env); + return handleSwitchAccount(request, env, cookieManager); } } if (url.pathname === usersPath + 'logout') { - return handleLogout(request, env, usersPath); + return handleLogout(request, env, usersPath, cookieManager); } // Admin Routes if (url.pathname.startsWith(usersPath + 'admin/')) { - return handleAdmin(request, env, usersPath); + return handleAdmin(request, env, usersPath, cookieManager); } // Intercept requests to usersPath and serve them from the public/users directory. @@ -121,8 +129,13 @@ export default { }, } satisfies ExportedHandler; -async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: string): Promise { - const user = await getUserFromSession(request, env); +async function handleAdmin( + request: Request, + env: StartupAPIEnv, + usersPath: string, + cookieManager: CookieManager, +): Promise { + const user = await getUserFromSession(request, env, cookieManager); if (!user || !isAdmin(user, env)) { return new Response('Forbidden', { status: 403 }); } @@ -155,7 +168,7 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri // Get current session to backup const cookieHeader = request.headers.get('Cookie'); const cookies = parseCookies(cookieHeader || ''); - const currentSession = cookies['session_id']; + const currentSessionEncrypted = cookies['session_id']; // Create a session for the target user const targetUserStub = env.USER.get(env.USER.idFromString(userId)); @@ -163,10 +176,17 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri const { sessionId } = (await sessionRes.json()) as any; const doId = userId; + const sessionValue = `${sessionId}:${doId}`; + const encryptedSession = await cookieManager.encrypt(sessionValue); + const headers = new Headers(); - headers.set('Set-Cookie', `session_id=${sessionId}:${doId}; Path=/; HttpOnly; Secure; SameSite=Lax`); - if (currentSession) { - headers.append('Set-Cookie', `backup_session_id=${currentSession}; Path=/; HttpOnly; Secure; SameSite=Lax`); + headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`); + if (currentSessionEncrypted) { + const decryptedCurrentSession = await cookieManager.decrypt(currentSessionEncrypted); + if (decryptedCurrentSession) { + const encryptedBackup = await cookieManager.encrypt(decryptedCurrentSession); + headers.append('Set-Cookie', `backup_session_id=${encryptedBackup}; Path=/; HttpOnly; Secure; SameSite=Lax`); + } } return Response.json({ success: true }, { headers }); @@ -176,13 +196,20 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri return new Response('Not Found', { status: 404 }); } -async function getUserFromSession(request: Request, env: StartupAPIEnv): Promise { +async function getUserFromSession( + request: Request, + env: StartupAPIEnv, + cookieManager: CookieManager, +): Promise { const cookieHeader = request.headers.get('Cookie'); if (!cookieHeader) return null; const cookies = parseCookies(cookieHeader); - const sessionCookie = cookies['session_id']; + const sessionCookieEncrypted = cookies['session_id']; + + if (!sessionCookieEncrypted) return null; + const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); if (!sessionCookie || !sessionCookie.includes(':')) return null; const [sessionId, doId] = sessionCookie.split(':'); @@ -221,13 +248,22 @@ function isAdmin(user: any, env: StartupAPIEnv): boolean { ); } -async function handleMe(request: Request, env: StartupAPIEnv): Promise { +async function handleMe( + request: Request, + env: StartupAPIEnv, + cookieManager: CookieManager, +): Promise { const cookieHeader = request.headers.get('Cookie'); if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); const cookies = parseCookies(cookieHeader); - const sessionCookie = cookies['session_id']; + const sessionCookieEncrypted = cookies['session_id']; + + if (!sessionCookieEncrypted) { + return new Response('Unauthorized', { status: 401 }); + } + const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); if (!sessionCookie || !sessionCookie.includes(':')) { return new Response('Unauthorized', { status: 401 }); } @@ -270,13 +306,23 @@ async function handleMe(request: Request, env: StartupAPIEnv): Promise } } -async function handleMeImage(request: Request, env: StartupAPIEnv, type: string): Promise { +async function handleMeImage( + request: Request, + env: StartupAPIEnv, + type: string, + cookieManager: CookieManager, +): Promise { const cookieHeader = request.headers.get('Cookie'); if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); const cookies = parseCookies(cookieHeader); - const sessionCookie = cookies['session_id']; + const sessionCookieEncrypted = cookies['session_id']; + if (!sessionCookieEncrypted) { + return new Response('Unauthorized', { status: 401 }); + } + + const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); if (!sessionCookie || !sessionCookie.includes(':')) { return new Response('Unauthorized', { status: 401 }); } @@ -292,24 +338,32 @@ async function handleMeImage(request: Request, env: StartupAPIEnv, type: string) } } -async function handleLogout(request: Request, env: StartupAPIEnv, usersPath: string): Promise { +async function handleLogout( + request: Request, + env: StartupAPIEnv, + usersPath: string, + cookieManager: CookieManager, +): Promise { const cookieHeader = request.headers.get('Cookie'); if (cookieHeader) { const cookies = parseCookies(cookieHeader); - const sessionCookie = cookies['session_id']; - - if (sessionCookie && sessionCookie.includes(':')) { - const [sessionId, doId] = sessionCookie.split(':'); - try { - const id = env.USER.idFromString(doId); - const stub = env.USER.get(id); - await stub.fetch('http://do/sessions', { - method: 'DELETE', - body: JSON.stringify({ sessionId }), - }); - } catch (e) { - console.error('Error deleting session:', e); - // Continue to clear cookie even if DO call fails + const sessionCookieEncrypted = cookies['session_id']; + + if (sessionCookieEncrypted) { + const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); + if (sessionCookie && sessionCookie.includes(':')) { + const [sessionId, doId] = sessionCookie.split(':'); + try { + const id = env.USER.idFromString(doId); + const stub = env.USER.get(id); + await stub.fetch('http://do/sessions', { + method: 'DELETE', + body: JSON.stringify({ sessionId }), + }); + } catch (e) { + console.error('Error deleting session:', e); + // Continue to clear cookie even if DO call fails + } } } } @@ -331,13 +385,22 @@ function parseCookies(cookieHeader: string): Record { ); } -async function handleMyAccounts(request: Request, env: StartupAPIEnv): Promise { +async function handleMyAccounts( + request: Request, + env: StartupAPIEnv, + cookieManager: CookieManager, +): Promise { const cookieHeader = request.headers.get('Cookie'); if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); const cookies = parseCookies(cookieHeader); - const sessionCookie = cookies['session_id']; + const sessionCookieEncrypted = cookies['session_id']; + + if (!sessionCookieEncrypted) { + return new Response('Unauthorized', { status: 401 }); + } + const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); if (!sessionCookie || !sessionCookie.includes(':')) { return new Response('Unauthorized', { status: 401 }); } @@ -380,13 +443,22 @@ async function handleMyAccounts(request: Request, env: StartupAPIEnv): Promise { +async function handleSwitchAccount( + request: Request, + env: StartupAPIEnv, + cookieManager: CookieManager, +): Promise { const cookieHeader = request.headers.get('Cookie'); if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); const cookies = parseCookies(cookieHeader); - const sessionCookie = cookies['session_id']; + const sessionCookieEncrypted = cookies['session_id']; + + if (!sessionCookieEncrypted) { + return new Response('Unauthorized', { status: 401 }); + } + const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); if (!sessionCookie || !sessionCookie.includes(':')) { return new Response('Unauthorized', { status: 401 }); } diff --git a/test/account_switching.spec.ts b/test/account_switching.spec.ts index 1d698ff..44c657f 100644 --- a/test/account_switching.spec.ts +++ b/test/account_switching.spec.ts @@ -1,7 +1,10 @@ import { env, SELF } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; +import { CookieManager } from '../src/CookieManager'; describe('Account Switching Integration', () => { + const cookieManager = new CookieManager(env.COOKIE_SECRET || 'dev-secret'); + it('should list accounts and switch between them', async () => { // 1. Setup User and Session const userId = env.USER.newUniqueId(); @@ -10,7 +13,7 @@ describe('Account Switching Integration', () => { const sessionRes = await userStub.fetch('http://do/sessions', { method: 'POST' }); const { sessionId } = (await sessionRes.json()) as any; - const cookieHeader = `session_id=${sessionId}:${userIdStr}`; + const cookieHeader = `session_id=${await cookieManager.encrypt(`${sessionId}:${userIdStr}`)}`; // 2. Setup Accounts // Account 1 (Personal) diff --git a/test/admin.spec.ts b/test/admin.spec.ts index 6846c0d..0909c3a 100644 --- a/test/admin.spec.ts +++ b/test/admin.spec.ts @@ -1,7 +1,10 @@ import { env, SELF } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; +import { CookieManager } from '../src/CookieManager'; describe('Admin Administration', () => { + const cookieManager = new CookieManager(env.COOKIE_SECRET || 'dev-secret'); + it('should deny access to non-admin users', async () => { // 1. Create a normal user const userId = env.USER.newUniqueId(); @@ -22,7 +25,7 @@ describe('Admin Administration', () => { }), }); - const cookieHeader = `session_id=${sessionId}:${userIdStr}`; + const cookieHeader = `session_id=${await cookieManager.encrypt(`${sessionId}:${userIdStr}`)}`; // 2. Try to access admin route const res = await SELF.fetch('http://example.com/users/admin/api/users', { @@ -52,7 +55,7 @@ describe('Admin Administration', () => { }), }); - const cookieHeader = `session_id=${sessionId}:${userIdStr}`; + const cookieHeader = `session_id=${await cookieManager.encrypt(`${sessionId}:${userIdStr}`)}`; // 2. Access admin route const res = await SELF.fetch('http://example.com/users/admin/api/users', { @@ -84,7 +87,7 @@ describe('Admin Administration', () => { }), }); - const cookieHeader = `session_id=${sessionId}:${userIdStr}`; + const cookieHeader = `session_id=${await cookieManager.encrypt(`${sessionId}:${userIdStr}`)}`; // 2. Access admin dashboard const res = await SELF.fetch('http://example.com/users/admin/', { @@ -142,7 +145,7 @@ describe('Admin Administration', () => { const sessionRes = await userStub.fetch('http://do/sessions', { method: 'POST' }); const { sessionId } = (await sessionRes.json()) as any; - const cookieHeader = `session_id=${sessionId}:${userIdStr}`; + const cookieHeader = `session_id=${await cookieManager.encrypt(`${sessionId}:${userIdStr}`)}`; // 2. Create a new account const accountName = 'New Admin Account'; @@ -187,7 +190,7 @@ describe('Admin Administration', () => { const sessionRes = await adminStub.fetch('http://do/sessions', { method: 'POST' }); const { sessionId } = (await sessionRes.json()) as any; - const cookieHeader = `session_id=${sessionId}:${adminIdStr}`; + const cookieHeader = `session_id=${await cookieManager.encrypt(`${sessionId}:${adminIdStr}`)}`; // 2. Create a target user who will be the owner const ownerId = env.USER.newUniqueId(); @@ -240,7 +243,7 @@ describe('Admin Administration', () => { const sessionRes = await adminStub.fetch('http://do/sessions', { method: 'POST' }); const { sessionId } = (await sessionRes.json()) as any; - const cookieHeader = `session_id=${sessionId}:${adminIdStr}`; + const cookieHeader = `session_id=${await cookieManager.encrypt(`${sessionId}:${adminIdStr}`)}`; // 2. Create an account const createRes = await SELF.fetch('http://example.com/users/admin/api/accounts', { @@ -298,7 +301,7 @@ describe('Admin Administration', () => { const sessionRes = await adminStub.fetch('http://do/sessions', { method: 'POST' }); const { sessionId } = (await sessionRes.json()) as any; - const cookieHeader = `session_id=${sessionId}:${adminIdStr}`; + const cookieHeader = `session_id=${await cookieManager.encrypt(`${sessionId}:${adminIdStr}`)}`; // 2. Create an account const createRes = await SELF.fetch('http://example.com/users/admin/api/accounts', { @@ -361,7 +364,7 @@ describe('Admin Administration', () => { const sessionRes = await adminStub.fetch('http://do/sessions', { method: 'POST' }); const { sessionId } = (await sessionRes.json()) as any; - const cookieHeader = `session_id=${sessionId}:${adminIdStr}`; + const cookieHeader = `session_id=${await cookieManager.encrypt(`${sessionId}:${adminIdStr}`)}`; // 2. Create an account const createRes = await SELF.fetch('http://example.com/users/admin/api/accounts', { @@ -403,7 +406,7 @@ describe('Admin Administration', () => { const sessionRes = await adminStub.fetch('http://do/sessions', { method: 'POST' }); const { sessionId } = (await sessionRes.json()) as any; - const cookieHeader = `session_id=${sessionId}:${adminIdStr}`; + const cookieHeader = `session_id=${await cookieManager.encrypt(`${sessionId}:${adminIdStr}`)}`; // 2. Create a user to delete const userId = env.USER.newUniqueId(); @@ -446,7 +449,7 @@ describe('Admin Administration', () => { const sessionRes = await adminStub.fetch('http://do/sessions', { method: 'POST' }); const { sessionId } = (await sessionRes.json()) as any; - const cookieHeader = `session_id=${sessionId}:${adminIdStr}`; + const cookieHeader = `session_id=${await cookieManager.encrypt(`${sessionId}:${adminIdStr}`)}`; // 2. Create an account with an owner const ownerId = env.USER.newUniqueId(); @@ -487,7 +490,8 @@ describe('Admin Administration', () => { const sessionRes = await adminStub.fetch('http://do/sessions', { method: 'POST' }); const { sessionId: adminSessionId } = (await sessionRes.json()) as any; - const adminCookie = `session_id=${adminSessionId}:${adminIdStr}`; + const encryptedAdminSession = await cookieManager.encrypt(`${adminSessionId}:${adminIdStr}`); + const adminCookie = `session_id=${encryptedAdminSession}`; // 2. Impersonate another user const targetUserId = env.USER.newUniqueId().toString(); @@ -501,13 +505,19 @@ describe('Admin Administration', () => { }); const setCookie = impRes.headers.get('Set-Cookie'); - expect(setCookie).toContain('backup_session_id=' + adminSessionId); + expect(setCookie).toContain('backup_session_id='); // Get the new session cookie const cookies = impRes.headers.getSetCookie(); const impCookie = cookies.find((c) => c.startsWith('session_id=')); - const backupCookie = cookies.find((c) => c.startsWith('backup_session_id=')); - const combinedCookie = `${impCookie}; ${backupCookie}`; + const backupCookieStr = cookies.find((c) => c.startsWith('backup_session_id=')); + const backupCookieValue = backupCookieStr?.split(';')[0].split('=')[1]; + + // Verify backup session contains the original session info + const decryptedBackup = await cookieManager.decrypt(backupCookieValue!); + expect(decryptedBackup).toBe(`${adminSessionId}:${adminIdStr}`); + + const combinedCookie = `${impCookie}; ${backupCookieStr}`; // 3. Stop impersonation const stopRes = await SELF.fetch('http://example.com/users/api/stop-impersonation', { @@ -517,8 +527,11 @@ describe('Admin Administration', () => { expect(stopRes.status).toBe(200); const stopSetCookie = stopRes.headers.getSetCookie(); - const restoredSession = stopSetCookie.find((c) => c.startsWith('session_id=' + adminSessionId)); - expect(restoredSession).toBeDefined(); + const restoredSessionCookie = stopSetCookie.find((c) => c.startsWith('session_id=')); + const restoredSessionValue = restoredSessionCookie?.split(';')[0].split('=')[1]; + const decryptedRestored = await cookieManager.decrypt(restoredSessionValue!); + expect(decryptedRestored).toBe(`${adminSessionId}:${adminIdStr}`); + const deletedBackup = stopSetCookie.find((c) => c.startsWith('backup_session_id=;')); expect(deletedBackup).toBeDefined(); }); @@ -531,7 +544,7 @@ describe('Admin Administration', () => { const userStub = env.USER.get(adminId); const sessionRes = await userStub.fetch('http://do/sessions', { method: 'POST' }); const { sessionId } = (await sessionRes.json()) as any; - const cookieHeader = `session_id=${sessionId}:${adminIdStr}`; + const cookieHeader = `session_id=${await cookieManager.encrypt(`${sessionId}:${adminIdStr}`)}`; // 2. Try to impersonate themselves const res = await SELF.fetch('http://example.com/users/admin/api/impersonate', { diff --git a/test/integration.spec.ts b/test/integration.spec.ts index 1b4462c..702cbf2 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -1,7 +1,10 @@ import { env, SELF } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; +import { CookieManager } from '../src/CookieManager'; describe('Integration Tests', () => { + const cookieManager = new CookieManager(env.COOKIE_SECRET || 'dev-secret'); + it('should return 401 for /api/me without cookie', async () => { const res = await SELF.fetch('http://example.com/users/api/me'); expect(res.status).toBe(401); @@ -29,9 +32,10 @@ describe('Integration Tests', () => { // 2. Fetch /api/me with the cookie const doId = id.toString(); + const encryptedCookie = await cookieManager.encrypt(`${sessionId}:${doId}`); const res = await SELF.fetch('http://example.com/users/api/me', { headers: { - Cookie: `session_id=${sessionId}:${doId}`, + Cookie: `session_id=${encryptedCookie}`, }, }); @@ -60,9 +64,10 @@ describe('Integration Tests', () => { // Fetch image via worker const doId = id.toString(); + const encryptedCookie = await cookieManager.encrypt(`${sessionId}:${doId}`); const res = await SELF.fetch('http://example.com/users/me/avatar', { headers: { - Cookie: `session_id=${sessionId}:${doId}`, + Cookie: `session_id=${encryptedCookie}`, }, }); @@ -83,9 +88,10 @@ describe('Integration Tests', () => { const { sessionId } = (await sessionRes.json()) as any; // 2. Call /logout with the cookie + const encryptedCookie = await cookieManager.encrypt(`${sessionId}:${doId}`); const logoutRes = await SELF.fetch('http://example.com/users/logout', { headers: { - Cookie: `session_id=${sessionId}:${doId}`, + Cookie: `session_id=${encryptedCookie}`, }, redirect: 'manual', // Don't follow the redirect to / }); From c3a21b2559563009a49c7e46f8abc2eb27a6ba36 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 15 Feb 2026 21:30:03 -0500 Subject: [PATCH 2/5] Strictly require COOKIE_SECRET in actual code and use wrangler.test.jsonc for tests --- src/index.ts | 6 +++++- vitest.config.mts | 2 +- wrangler.test.jsonc | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 wrangler.test.jsonc diff --git a/src/index.ts b/src/index.ts index a0300f3..de330f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,11 @@ export default { const url = new URL(request.url); const usersPath = env.USERS_PATH || DEFAULT_USERS_PATH; - const cookieManager = new CookieManager(env.COOKIE_SECRET || 'dev-secret'); + + if (!env.COOKIE_SECRET) { + throw new Error('COOKIE_SECRET environment variable is required'); + } + const cookieManager = new CookieManager(env.COOKIE_SECRET); // Handle OAuth Routes if (url.pathname.startsWith(usersPath + 'auth/')) { diff --git a/vitest.config.mts b/vitest.config.mts index e475b73..3babd01 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -5,7 +5,7 @@ export default defineWorkersConfig({ poolOptions: { workers: { isolatedStorage: false, - wrangler: { configPath: './wrangler.jsonc' }, + wrangler: { configPath: './wrangler.test.jsonc' }, }, }, }, diff --git a/wrangler.test.jsonc b/wrangler.test.jsonc new file mode 100644 index 0000000..e8ad3d2 --- /dev/null +++ b/wrangler.test.jsonc @@ -0,0 +1,35 @@ +{ + "name": "startup-api-test", + "main": "src/index.ts", + "compatibility_date": "2025-09-27", + "compatibility_flags": ["global_fetch_strictly_public"], + "assets": { + "directory": "./public", + "run_worker_first": true, + "binding": "ASSETS" + }, + "durable_objects": { + "bindings": [ + { "name": "USER", "class_name": "UserDO" }, + { "name": "ACCOUNT", "class_name": "AccountDO" }, + { "name": "SYSTEM", "class_name": "SystemDO" } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["UserDO"] + }, + { + "tag": "v2", + "new_sqlite_classes": ["AccountDO"] + }, + { + "tag": "v3", + "new_sqlite_classes": ["SystemDO"] + } + ], + "vars": { + "COOKIE_SECRET": "dev-secret" + } +} From f104e785b666257e1984859883889e8e542acbf8 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 15 Feb 2026 21:43:41 -0500 Subject: [PATCH 3/5] Require SESSION_SECRET and add documentation --- public/index.html | 12 +++++++++++- src/StartupAPIEnv.ts | 2 +- src/index.ts | 11 ++++++----- test/account_switching.spec.ts | 2 +- test/admin.spec.ts | 4 ++-- test/integration.spec.ts | 2 +- worker-configuration.d.ts | 8 +++----- wrangler.test.jsonc | 18 ++++++++++-------- 8 files changed, 35 insertions(+), 24 deletions(-) diff --git a/public/index.html b/public/index.html index 678dff9..bd35151 100644 --- a/public/index.html +++ b/public/index.html @@ -334,13 +334,23 @@

Welcome to Startup API

ORIGIN_URL - REQUIRED + REQUIRED

The base URL of the application you want to proxy, e.g. your app URL.

Until you set this variable the worker serves this help page.

ORIGIN_URL = "https://your-app-origin.com"
+
+
+ SESSION_SECRET + REQUIRED +
+

A long, random string used to encrypt session cookies. Keep this secret!

+

Until you set this variable the worker serves this help page.

+
SESSION_SECRET = "your-long-random-secret-string"
+
+
USERS_PATH diff --git a/src/StartupAPIEnv.ts b/src/StartupAPIEnv.ts index f6126e6..7132072 100644 --- a/src/StartupAPIEnv.ts +++ b/src/StartupAPIEnv.ts @@ -7,6 +7,6 @@ export type StartupAPIEnv = { TWITCH_CLIENT_ID: string; TWITCH_CLIENT_SECRET: string; ADMIN_IDS: string; - COOKIE_SECRET: string; + SESSION_SECRET: string; SYSTEM: DurableObjectNamespace; } & Env; diff --git a/src/index.ts b/src/index.ts index de330f2..ac5c719 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,13 +28,14 @@ export default { return env.ASSETS.fetch(request); } + if (!env.ORIGIN_URL || !env.SESSION_SECRET) { + return env.ASSETS.fetch(request); + } + const url = new URL(request.url); const usersPath = env.USERS_PATH || DEFAULT_USERS_PATH; - if (!env.COOKIE_SECRET) { - throw new Error('COOKIE_SECRET environment variable is required'); - } - const cookieManager = new CookieManager(env.COOKIE_SECRET); + const cookieManager = new CookieManager(env.SESSION_SECRET); // Handle OAuth Routes if (url.pathname.startsWith(usersPath + 'auth/')) { @@ -243,7 +244,7 @@ async function getUserFromSession( function isAdmin(user: any, env: StartupAPIEnv): boolean { if (!env.ADMIN_IDS) return false; - const adminIds = env.ADMIN_IDS.split(',').map((e) => e.trim()); + const adminIds = env.ADMIN_IDS.split(',').map((e) => e.trim()).filter(Boolean); return ( adminIds.includes(user.id) || (user.email && adminIds.includes(user.email)) || diff --git a/test/account_switching.spec.ts b/test/account_switching.spec.ts index 44c657f..b8aa5e1 100644 --- a/test/account_switching.spec.ts +++ b/test/account_switching.spec.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import { CookieManager } from '../src/CookieManager'; describe('Account Switching Integration', () => { - const cookieManager = new CookieManager(env.COOKIE_SECRET || 'dev-secret'); + const cookieManager = new CookieManager(env.SESSION_SECRET); it('should list accounts and switch between them', async () => { // 1. Setup User and Session diff --git a/test/admin.spec.ts b/test/admin.spec.ts index 0909c3a..d2a1232 100644 --- a/test/admin.spec.ts +++ b/test/admin.spec.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import { CookieManager } from '../src/CookieManager'; describe('Admin Administration', () => { - const cookieManager = new CookieManager(env.COOKIE_SECRET || 'dev-secret'); + const cookieManager = new CookieManager(env.SESSION_SECRET); it('should deny access to non-admin users', async () => { // 1. Create a normal user @@ -512,7 +512,7 @@ describe('Admin Administration', () => { const impCookie = cookies.find((c) => c.startsWith('session_id=')); const backupCookieStr = cookies.find((c) => c.startsWith('backup_session_id=')); const backupCookieValue = backupCookieStr?.split(';')[0].split('=')[1]; - + // Verify backup session contains the original session info const decryptedBackup = await cookieManager.decrypt(backupCookieValue!); expect(decryptedBackup).toBe(`${adminSessionId}:${adminIdStr}`); diff --git a/test/integration.spec.ts b/test/integration.spec.ts index 702cbf2..2af2b14 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import { CookieManager } from '../src/CookieManager'; describe('Integration Tests', () => { - const cookieManager = new CookieManager(env.COOKIE_SECRET || 'dev-secret'); + const cookieManager = new CookieManager(env.SESSION_SECRET); it('should return 401 for /api/me without cookie', async () => { const res = await SELF.fetch('http://example.com/users/api/me'); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 9174ffc..6490b82 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: d59011049af78e8d423b1468c1c881d8) +// Generated by Wrangler by running `wrangler types` (hash: 4287b82825f47dc4dcadc27cf339d02a) // Runtime types generated with workerd@1.20260120.0 2025-09-27 global_fetch_strictly_public declare namespace Cloudflare { interface GlobalProps { @@ -8,11 +8,10 @@ declare namespace Cloudflare { } interface PreviewEnv { ASSETS: Fetcher; + SESSION_SECRET: string; ADMIN_IDS: string; ORIGIN_URL: string; AUTH_ORIGIN: string; - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; TWITCH_CLIENT_ID: string; TWITCH_CLIENT_SECRET: string; USER: DurableObjectNamespace; @@ -20,11 +19,10 @@ declare namespace Cloudflare { SYSTEM: DurableObjectNamespace; } interface Env { + SESSION_SECRET: string; ADMIN_IDS: string; ORIGIN_URL: string; AUTH_ORIGIN: string; - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; TWITCH_CLIENT_ID: string; TWITCH_CLIENT_SECRET: string; ASSETS: Fetcher; diff --git a/wrangler.test.jsonc b/wrangler.test.jsonc index e8ad3d2..66531f9 100644 --- a/wrangler.test.jsonc +++ b/wrangler.test.jsonc @@ -6,30 +6,32 @@ "assets": { "directory": "./public", "run_worker_first": true, - "binding": "ASSETS" + "binding": "ASSETS", }, "durable_objects": { "bindings": [ { "name": "USER", "class_name": "UserDO" }, { "name": "ACCOUNT", "class_name": "AccountDO" }, - { "name": "SYSTEM", "class_name": "SystemDO" } - ] + { "name": "SYSTEM", "class_name": "SystemDO" }, + ], }, "migrations": [ { "tag": "v1", - "new_sqlite_classes": ["UserDO"] + "new_sqlite_classes": ["UserDO"], }, { "tag": "v2", - "new_sqlite_classes": ["AccountDO"] + "new_sqlite_classes": ["AccountDO"], }, { "tag": "v3", - "new_sqlite_classes": ["SystemDO"] - } + "new_sqlite_classes": ["SystemDO"], + }, ], "vars": { - "COOKIE_SECRET": "dev-secret" + "SESSION_SECRET": "dev-secret", + "ORIGIN_URL": "http://example.com", + "ADMIN_IDS": "35dc566be4615393c7a738bf294b1f2372bcdfc6ec567c455cc5a5b50f059a51,ce375132e4c7d64ed3b6bea7af2b518cdb8c72cbe1cfdc003652a187c355b3f8,admin@example.com" } } From bf4682cf83b5d7f9274527c4e5146ab15d94eff7 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 15 Feb 2026 21:45:53 -0500 Subject: [PATCH 4/5] Rename COOKIE_SECRET to SESSION_SECRET and update documentation layout --- public/index.html | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/public/index.html b/public/index.html index bd35151..8997726 100644 --- a/public/index.html +++ b/public/index.html @@ -324,33 +324,34 @@

Welcome to Startup API

-
Environment Variables
+
Required Configuration

- Configure your installation by setting these variables in your - wrangler.jsonc or Cloudflare Dashboard: + These variables must be set in your wrangler.jsonc or Cloudflare Dashboard for the worker to function. + Until these are configured, the worker serves this documentation page.

ORIGIN_URL - REQUIRED

The base URL of the application you want to proxy, e.g. your app URL.

-

Until you set this variable the worker serves this help page.

ORIGIN_URL = "https://your-app-origin.com"
SESSION_SECRET - REQUIRED

A long, random string used to encrypt session cookies. Keep this secret!

-

Until you set this variable the worker serves this help page.

SESSION_SECRET = "your-long-random-secret-string"
+
+
+
+
Optional Configuration
+
USERS_PATH From 9298582aa10e66777e1fd5cf33d07caa636e32a5 Mon Sep 17 00:00:00 2001 From: Sergey Chernyshev Date: Sun, 15 Feb 2026 21:54:50 -0500 Subject: [PATCH 5/5] Hard-code admin ID for testing --- wrangler.test.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wrangler.test.jsonc b/wrangler.test.jsonc index 66531f9..bcf4272 100644 --- a/wrangler.test.jsonc +++ b/wrangler.test.jsonc @@ -32,6 +32,6 @@ "vars": { "SESSION_SECRET": "dev-secret", "ORIGIN_URL": "http://example.com", - "ADMIN_IDS": "35dc566be4615393c7a738bf294b1f2372bcdfc6ec567c455cc5a5b50f059a51,ce375132e4c7d64ed3b6bea7af2b518cdb8c72cbe1cfdc003652a187c355b3f8,admin@example.com" - } + "ADMIN_IDS": "35dc566be4615393c7a738bf294b1f2372bcdfc6ec567c455cc5a5b50f059a51", + }, }