Isolate authentication cookies between local apps #46
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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('; ');
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user