Isolate authentication cookies between local apps #46

Merged
eleith merged 1 commits from fix/isolate-auth-cookies into main 2026-11-10 20:19:13 +00:00
5 changed files with 59 additions and 4 deletions

No files matched your search

@@ -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.
@@ -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));
+3
View File
@@ -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,
+11 -4
View File
@@ -23,11 +23,12 @@ export function request(
body?: unknown,
headers: Record<string, string> = {}
) {
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('; ');
+4
View File
@@ -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.