From 06405c5a8baadae58e3431e6f592e6e22ebbeaee Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 10 Nov 2026 12:12:02 -0800 Subject: [PATCH] fix(auth): isolate cookies by app and public port --- .changeset/isolate-authentication-cookies.md | 5 +++ apps/web/src/lib/server/auth/index.test.ts | 36 ++++++++++++++++++++ apps/web/src/lib/server/auth/index.ts | 3 ++ apps/web/src/lib/server/auth/test-support.ts | 15 +++++--- docs/authentication.md | 4 +++ 5 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 .changeset/isolate-authentication-cookies.md diff --git a/.changeset/isolate-authentication-cookies.md b/.changeset/isolate-authentication-cookies.md new file mode 100644 index 0000000..a5ebcb4 --- /dev/null +++ b/.changeset/isolate-authentication-cookies.md @@ -0,0 +1,5 @@ +--- +'@hledger-reports/web': patch +--- + +Use an hledger-specific cookie prefix with the configured public port so local apps and instances do not overwrite each other's sessions. Sign in again after updating; other apps' cookies and financial data remain untouched. diff --git a/apps/web/src/lib/server/auth/index.test.ts b/apps/web/src/lib/server/auth/index.test.ts index 3cd20e9..60ce521 100644 --- a/apps/web/src/lib/server/auth/index.test.ts +++ b/apps/web/src/lib/server/auth/index.test.ts @@ -5,6 +5,7 @@ import { credentials, credentialsConfig, request, + secret, sessionHeaders } from './test-support.js'; @@ -38,6 +39,41 @@ test('configured credentials create only an encrypted session, not a password ac expect(cookies(invalid)).toBe(''); }); +test('local instances retain separate sessions when signing in and out on the same host', async () => { + const firstCredentials = { ...credentials, username: 'first-instance' }; + const secondCredentials = { ...credentials, username: 'second-instance' }; + const firstConfig = { + ...credentialsConfig, + url: { app: 'http://localhost:5173' }, + auth: { secret, credentials: firstCredentials } + }; + const secondConfig = { + ...credentialsConfig, + url: { app: 'http://localhost:5174' }, + auth: { secret, credentials: secondCredentials } + }; + const first = createAuth(firstConfig); + const second = createAuth(secondConfig); + const firstLogin = await request(first, '/sign-in/credentials', firstCredentials); + const secondLogin = await request(second, '/sign-in/credentials', secondCredentials); + expect(firstLogin.status).toBe(200); + expect(secondLogin.status).toBe(200); + const sharedCookies = cookies(firstLogin, secondLogin); + expect(await getSession(createAuth(firstConfig), sessionHeaders(sharedCookies))).toMatchObject({ + user: { name: firstCredentials.username } + }); + expect(await getSession(second, sessionHeaders(sharedCookies))).toMatchObject({ + user: { name: secondCredentials.username } + }); + const logout = await request(first, '/sign-out', {}, { cookie: sharedCookies }); + expect(logout.status).toBe(200); + const remainingCookies = cookies(firstLogin, secondLogin, logout); + expect(await getSession(first, sessionHeaders(remainingCookies))).toBeNull(); + expect(await getSession(second, sessionHeaders(remainingCookies))).toMatchObject({ + user: { name: secondCredentials.username } + }); +}); + test('restart preserves the original seven-day expiry; tampering fails and reads do not refresh', async () => { const auth = createAuth(credentialsConfig); const cookie = cookies(await request(auth, '/sign-in/credentials', credentials)); diff --git a/apps/web/src/lib/server/auth/index.ts b/apps/web/src/lib/server/auth/index.ts index bbe254f..f891446 100644 --- a/apps/web/src/lib/server/auth/index.ts +++ b/apps/web/src/lib/server/auth/index.ts @@ -8,6 +8,8 @@ export { callback } from './callback.js'; const WEEK_SECONDS = 7 * 24 * 60 * 60; export function createAuth(config: Config) { + // Cookie scope ignores ports, so distinguish local instances by their public port. + const publicPort = new URL(config.url.app).port || 'default'; return betterAuth({ secret: config.auth.secret, // Explicit key configuration also prevents BETTER_AUTH_SECRETS overriding YAML. @@ -32,6 +34,7 @@ export function createAuth(config: Config) { accountLinking: { enabled: false } }, advanced: { + cookiePrefix: `hledger-${publicPort}`, disableOriginCheck: false, disableCSRFCheck: false, trustedProxyHeaders: false, diff --git a/apps/web/src/lib/server/auth/test-support.ts b/apps/web/src/lib/server/auth/test-support.ts index 0b917d6..11b88d4 100644 --- a/apps/web/src/lib/server/auth/test-support.ts +++ b/apps/web/src/lib/server/auth/test-support.ts @@ -23,11 +23,12 @@ export function request( body?: unknown, headers: Record = {} ) { + const requestOrigin = new URL(auth.options.baseURL).origin; return auth.handler( - new Request(new URL(`/auth${path}`, origin), { + new Request(new URL(`/auth${path}`, requestOrigin), { method: body === undefined ? 'GET' : 'POST', headers: { - origin, + origin: requestOrigin, 'content-type': 'application/json', 'x-hledger-client-ip': `192.0.2.${++client}`, ...headers @@ -37,8 +38,14 @@ export function request( ); } -export function cookies(response: Response) { - return [...parseSetCookieHeader(response.headers.get('set-cookie') ?? '')] +export function cookies(response: Response, ...updates: Response[]) { + const cookiesByName = parseSetCookieHeader(response.headers.get('set-cookie') ?? ''); + for (const update of updates) { + for (const [name, attributes] of parseSetCookieHeader(update.headers.get('set-cookie') ?? '')) { + cookiesByName.set(name, attributes); + } + } + return [...cookiesByName] .filter(([, attributes]) => attributes.value) .map(([name, attributes]) => `${name}=${encodeURIComponent(attributes.value)}`) .join('; '); diff --git a/docs/authentication.md b/docs/authentication.md index b0f3802..554b15d 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -28,6 +28,10 @@ development for direct local HTTP access. Do not expose the upstream publicly. ## Sessions +Authentication cookies use an `hledger-` prefix scoped to the configured public +port, preventing accidental collisions between local apps and instances. Sign in +again after this update; old shared cookies are not reused or cleared. + Sessions survive restart with the same secret. Changing a password or provider assignment does not immediately revoke existing sessions. Rotate the auth secret and restart web to sign everyone out. Local sign-out cannot revoke copied cookies. -- 2.54.0