Generate a session secret when none is configured #53
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@hledger-reports/web': patch
|
||||
---
|
||||
|
||||
Use a random session secret at startup when none is configured. Set `AUTH_SECRET` to stay signed in across restarts.
|
||||
@@ -14,7 +14,7 @@ accounts:
|
||||
url:
|
||||
app: '${APP_URL:-http://127.0.0.1:3000}'
|
||||
auth:
|
||||
secret: '${AUTH_SECRET}'
|
||||
# Optional: set AUTH_SECRET to keep sessions across restarts.
|
||||
credentials:
|
||||
username: household
|
||||
password: '${AUTH_PASSWORD}'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import type { Config } from '@hledger-reports/config';
|
||||
import type { BetterAuthOptions } from 'better-auth/minimal';
|
||||
import { createAuth, getSession } from './index.js';
|
||||
import {
|
||||
cookies,
|
||||
@@ -92,17 +94,102 @@ test('restart preserves the original seven-day expiry; tampering fails and reads
|
||||
expect(await getSession(auth, sessionHeaders(cookie))).toBeNull();
|
||||
});
|
||||
|
||||
test('configured secret wins over SDK environment keys; rotation invalidates existing sessions', async () => {
|
||||
test.each([undefined, ''])(
|
||||
'omitted secret with AUTH_SECRET=%j lasts for one auth instance only',
|
||||
async (environmentSecret) => {
|
||||
vi.stubEnv('AUTH_SECRET', environmentSecret);
|
||||
vi.stubEnv('BETTER_AUTH_SECRET', 'environment-secret-that-must-not-control-this-app');
|
||||
vi.stubEnv('BETTER_AUTH_SECRETS', '9:environment-key-that-must-not-control-this-app');
|
||||
const config = { ...credentialsConfig, auth: { credentials } };
|
||||
const auth = createAuth(config);
|
||||
const cookie = cookies(await request(auth, '/sign-in/credentials', credentials));
|
||||
const initial = await getSession(auth, sessionHeaders(cookie));
|
||||
expect(initial).not.toBeNull();
|
||||
vi.setSystemTime(new Date('2026-10-30T12:00:00Z'));
|
||||
expect(await getSession(auth, sessionHeaders(cookie))).toEqual(initial);
|
||||
expect(await getSession(createAuth(config), sessionHeaders(cookie))).toBeNull();
|
||||
expect(await getSession(auth, sessionHeaders(cookie))).toEqual(initial);
|
||||
}
|
||||
);
|
||||
|
||||
test('AUTH_SECRET preserves sessions across auth instances', async () => {
|
||||
const config = { ...credentialsConfig, auth: { credentials } };
|
||||
const environmentSecret = ` ${secret} `;
|
||||
vi.stubEnv('AUTH_SECRET', environmentSecret);
|
||||
vi.stubEnv('BETTER_AUTH_SECRET', 'environment-secret-that-must-not-control-this-app');
|
||||
vi.stubEnv('BETTER_AUTH_SECRETS', '9:environment-key-that-must-not-control-this-app');
|
||||
const cookie = cookies(
|
||||
await request(createAuth(credentialsConfig), '/sign-in/credentials', credentials)
|
||||
);
|
||||
const auth = createAuth(config);
|
||||
expect(auth.options.secret).toBe(environmentSecret);
|
||||
const cookie = cookies(await request(auth, '/sign-in/credentials', credentials));
|
||||
expect(await getSession(auth, sessionHeaders(cookie))).not.toBeNull();
|
||||
vi.stubEnv('BETTER_AUTH_SECRETS', '10:another-environment-key-that-must-not-control-this-app');
|
||||
expect(await getSession(createAuth(credentialsConfig), sessionHeaders(cookie))).not.toBeNull();
|
||||
expect(await getSession(createAuth(config), sessionHeaders(cookie))).toMatchObject({
|
||||
user: { name: credentials.username }
|
||||
});
|
||||
});
|
||||
|
||||
test('configured secret wins over invalid AUTH_SECRET and SDK keys; rotation invalidates sessions', async () => {
|
||||
vi.stubEnv('AUTH_SECRET', 'short');
|
||||
vi.stubEnv('BETTER_AUTH_SECRET', 'environment-secret-that-must-not-control-this-app');
|
||||
vi.stubEnv('BETTER_AUTH_SECRETS', '9:environment-key-that-must-not-control-this-app');
|
||||
const config = { ...credentialsConfig, auth: { secret: ` ${secret} `, credentials } };
|
||||
const auth = createAuth(config);
|
||||
expect(auth.options.secret).toBe(config.auth.secret);
|
||||
const cookie = cookies(await request(auth, '/sign-in/credentials', credentials));
|
||||
vi.stubEnv('BETTER_AUTH_SECRETS', '10:another-environment-key-that-must-not-control-this-app');
|
||||
expect(await getSession(createAuth(config), sessionHeaders(cookie))).not.toBeNull();
|
||||
const rotated = createAuth({
|
||||
...credentialsConfig,
|
||||
auth: { secret: 'rotated-test-secret-q9V3m1R8s2H6d5K7', credentials }
|
||||
});
|
||||
expect(await getSession(rotated, sessionHeaders(cookie))).toBeNull();
|
||||
});
|
||||
|
||||
test('rejects invalid supplied keys without leaking them or falling back', () => {
|
||||
const privateKey = 'private-key-do-not-log';
|
||||
const withoutSecret = { ...credentialsConfig, auth: { credentials } };
|
||||
const rejectsWithoutLeaking = (config: Config, rejected: string) => {
|
||||
let failure: unknown;
|
||||
try {
|
||||
createAuth(config);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(Error);
|
||||
expect(failure).not.toHaveProperty('cause');
|
||||
const message = (failure as Error).message;
|
||||
expect(message).toMatch(/^Authentication secret/);
|
||||
if (rejected.length > 0) expect(message).not.toContain(rejected);
|
||||
};
|
||||
for (const invalid of [privateKey, ' '.repeat(32)]) {
|
||||
vi.stubEnv('AUTH_SECRET', invalid);
|
||||
rejectsWithoutLeaking(withoutSecret, invalid);
|
||||
}
|
||||
vi.stubEnv('AUTH_SECRET', secret);
|
||||
for (const invalid of ['', privateKey, ' '.repeat(32)]) {
|
||||
rejectsWithoutLeaking(
|
||||
{ ...credentialsConfig, auth: { secret: invalid, credentials } },
|
||||
invalid
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('application logging discards private SDK diagnostic arguments', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
const auth = createAuth(credentialsConfig);
|
||||
await auth.$context;
|
||||
const logger: NonNullable<BetterAuthOptions['logger']> = auth.options.logger;
|
||||
if (!logger || !logger.log) throw new Error('Expected the application auth logger.');
|
||||
warn.mockClear();
|
||||
error.mockClear();
|
||||
logger.log('warn', 'private SDK diagnostic', { secret: 'fictional-private-session-key' });
|
||||
logger.log('error', 'fictional-private-session-key', { diagnostic: '/private/fixture' });
|
||||
expect(warn).toHaveBeenCalledExactlyOnceWith('authentication.warning');
|
||||
expect(error).toHaveBeenCalledExactlyOnceWith('authentication.error');
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
error.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Config } from '@hledger-reports/config';
|
||||
import { betterAuth } from 'better-auth/minimal';
|
||||
import { authProviders } from './providers.js';
|
||||
import { resolveAuthSecret } from './secret.js';
|
||||
|
||||
export { signIn, signOut } from './actions.js';
|
||||
export { callback } from './callback.js';
|
||||
@@ -8,12 +9,13 @@ export { callback } from './callback.js';
|
||||
const WEEK_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
export function createAuth(config: Config) {
|
||||
const secret = resolveAuthSecret(config.auth.secret);
|
||||
// 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.
|
||||
secrets: [{ version: 1, value: config.auth.secret }],
|
||||
secret,
|
||||
// The resolved key is authoritative, including over the SDK's environment keyring.
|
||||
secrets: [{ version: 1, value: secret }],
|
||||
baseURL: config.url.app.replace(/\/+$/, ''),
|
||||
basePath: '/auth',
|
||||
telemetry: { enabled: false },
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
export function resolveAuthSecret(
|
||||
configured?: string,
|
||||
env: Readonly<Record<string, string | undefined>> = process.env
|
||||
): string {
|
||||
const secret = configured ?? (env.AUTH_SECRET || undefined);
|
||||
if (secret === undefined) return randomBytes(32).toString('base64');
|
||||
if (secret.length < 32 || secret.trim().length === 0) {
|
||||
throw new Error('Authentication secret must be at least 32 characters and not blank');
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
@@ -9,7 +9,8 @@ Use the [configuration example](../apps/web/config/hledger-reports.example.yaml)
|
||||
Choose exactly one strategy: credentials or OIDC.
|
||||
|
||||
- Set `url.app` to the exact browser origin. `localhost` and `127.0.0.1` are different origins.
|
||||
- Supply a strong password and a stable random `AUTH_SECRET` of at least 32 characters through private environment or secret tooling.
|
||||
- Use a strong password for credentials sign-in.
|
||||
- The session key uses `auth.secret`, then a nonempty `AUTH_SECRET`, otherwise a random key generated at startup. Set a stable random secret of at least 32 characters to stay signed in across restarts.
|
||||
- Keep secrets out of commits, command arguments and logs. Restart services after configuration changes.
|
||||
|
||||
For OIDC, set the issuer, client ID and client secret. Register
|
||||
@@ -29,9 +30,9 @@ 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.
|
||||
port, preventing accidental collisions between local apps and instances.
|
||||
|
||||
Sessions survive restart with the same secret. Changing a password or provider
|
||||
Without a supplied secret, restarting web signs users out. Sessions survive restart
|
||||
with the same configured or environment 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.
|
||||
@@ -38,7 +38,6 @@
|
||||
"required": ["credentials"],
|
||||
"properties": {
|
||||
"secret": {
|
||||
"default": "${AUTH_SECRET}",
|
||||
"anyOf": [
|
||||
{
|
||||
"minLength": 32,
|
||||
@@ -72,7 +71,6 @@
|
||||
"required": ["oidc"],
|
||||
"properties": {
|
||||
"secret": {
|
||||
"default": "${AUTH_SECRET}",
|
||||
"anyOf": [
|
||||
{
|
||||
"minLength": 32,
|
||||
|
||||
@@ -26,22 +26,57 @@ it('requires exactly one complete auth strategy', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults an omitted secret to AUTH_SECRET and rejects missing or short secrets', () => {
|
||||
const input = credentialsInput();
|
||||
expect(
|
||||
validateConfig(input, { env: { AUTH_SECRET: testSecret, AUTH_PASSWORD: 'synthetic-password' } })
|
||||
.auth
|
||||
).toEqual(rawConfig().auth);
|
||||
expect(input.auth).not.toHaveProperty('secret');
|
||||
it('keeps omitted secrets absent regardless of ambient AUTH_SECRET without changing input', () => {
|
||||
const oidc = {
|
||||
issuer: 'https://identity.example.test/tenant/',
|
||||
client_id: 'reports',
|
||||
client_secret: 'synthetic-client-secret'
|
||||
};
|
||||
for (const input of [credentialsInput(), rawConfig({ auth: { oidc } })]) {
|
||||
const original = structuredClone(input);
|
||||
const env = { AUTH_PASSWORD: 'synthetic-password' };
|
||||
const expected = validateConfig(input, { env });
|
||||
expect(expected.auth).not.toHaveProperty('secret');
|
||||
for (const secret of ['', 'short', testSecret]) {
|
||||
expect(validateConfig(input, { env: { ...env, AUTH_SECRET: secret } })).toEqual(expected);
|
||||
}
|
||||
expect(validateConfig(input, { env })).toEqual(expected);
|
||||
expect(input).toEqual(original);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not read environment values when no references are authored', () => {
|
||||
const env = new Proxy(
|
||||
{},
|
||||
{
|
||||
getOwnPropertyDescriptor() {
|
||||
throw new Error('Environment must not be read');
|
||||
}
|
||||
}
|
||||
);
|
||||
const auth = { credentials: { username: 'shared-user', password: 'synthetic-password' } };
|
||||
expect(validateConfig(rawConfig({ auth }), { env }).auth).toEqual(auth);
|
||||
});
|
||||
|
||||
it('requires explicit secret environment references to exist', () => {
|
||||
const input = rawConfig({ auth: { ...rawConfig().auth, secret: '${SESSION}' } });
|
||||
expect(() => validateConfig(input, { env: {} })).toThrowError(
|
||||
expect.objectContaining({
|
||||
code: 'CONFIG_MISSING_ENVIRONMENT',
|
||||
issues: [{ path: '/auth/secret', message: 'A required environment variable is missing.' }]
|
||||
})
|
||||
);
|
||||
for (const secret of ['short', ' '.repeat(32)]) {
|
||||
});
|
||||
|
||||
it('rejects explicitly supplied short or blank secrets before and after interpolation', () => {
|
||||
for (const secret of ['', 'x'.repeat(31), ' '.repeat(32)]) {
|
||||
expect(() =>
|
||||
validateConfig(input, { env: { AUTH_SECRET: secret, AUTH_PASSWORD: 'password' } })
|
||||
validateConfig(rawConfig({ auth: { ...rawConfig().auth, secret } }), { env: {} })
|
||||
).toThrow(ConfigError);
|
||||
expect(() =>
|
||||
validateConfig(rawConfig({ auth: { ...rawConfig().auth, secret: '${SESSION}' } }), {
|
||||
env: { SESSION: secret }
|
||||
})
|
||||
).toThrow(ConfigError);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ConfigError, invalidInput } from './errors.js';
|
||||
import { checkSchema, readInput } from './input.js';
|
||||
import { interpolateString } from './interpolate.js';
|
||||
import {
|
||||
authSecretDefault,
|
||||
configSchema,
|
||||
credentialsAuthSchema,
|
||||
oidcAuthSchema,
|
||||
@@ -55,10 +54,13 @@ function resolveAuth(
|
||||
raw: ConfigInput['auth'],
|
||||
env: Readonly<Record<string, string | undefined>>
|
||||
): Config['auth'] {
|
||||
const secret = interpolateString(raw.secret ?? authSecretDefault, env, '/auth/secret');
|
||||
const secretFields: { secret?: string } = {};
|
||||
if (raw.secret !== undefined) {
|
||||
secretFields.secret = interpolateString(raw.secret, env, '/auth/secret');
|
||||
}
|
||||
if ('credentials' in raw) {
|
||||
const auth = {
|
||||
secret,
|
||||
...secretFields,
|
||||
credentials: {
|
||||
username: interpolateString(raw.credentials.username, env, '/auth/credentials/username'),
|
||||
password: interpolateString(raw.credentials.password, env, '/auth/credentials/password')
|
||||
@@ -68,7 +70,7 @@ function resolveAuth(
|
||||
return auth;
|
||||
}
|
||||
const auth = {
|
||||
secret,
|
||||
...secretFields,
|
||||
oidc: {
|
||||
issuer: interpolateString(raw.oidc.issuer, env, '/auth/oidc/issuer'),
|
||||
client_id: interpolateString(raw.oidc.client_id, env, '/auth/oidc/client_id'),
|
||||
|
||||
@@ -22,10 +22,7 @@ const issuerSchema = Type.String({
|
||||
const environmentSchema = Type.String({ pattern: environmentTokenPattern });
|
||||
const rawAppSchema = Type.Union([appSchema, environmentSchema]);
|
||||
const rawIssuerSchema = Type.Union([issuerSchema, environmentSchema]);
|
||||
export const authSecretDefault = '${AUTH_SECRET}';
|
||||
const rawSecretSchema = Type.Union([secretSchema, environmentSchema], {
|
||||
default: authSecretDefault
|
||||
});
|
||||
const rawSecretSchema = Type.Union([secretSchema, environmentSchema]);
|
||||
|
||||
const credentialsSchema = Type.Object(
|
||||
{ username: identitySchema, password: passwordSchema },
|
||||
@@ -40,11 +37,11 @@ const rawOidcSchema = Type.Object(
|
||||
{ additionalProperties: false }
|
||||
);
|
||||
export const credentialsAuthSchema = Type.Object(
|
||||
{ secret: secretSchema, credentials: credentialsSchema },
|
||||
{ secret: Type.Optional(secretSchema), credentials: credentialsSchema },
|
||||
{ additionalProperties: false }
|
||||
);
|
||||
export const oidcAuthSchema = Type.Object(
|
||||
{ secret: secretSchema, oidc: oidcSchema },
|
||||
{ secret: Type.Optional(secretSchema), oidc: oidcSchema },
|
||||
{ additionalProperties: false }
|
||||
);
|
||||
const rawAuthSchema = Type.Union([
|
||||
|
||||
Reference in new issue
Block a user