diff --git a/.changeset/configuration-first-auth.md b/.changeset/configuration-first-auth.md new file mode 100644 index 0000000..23ff3b2 --- /dev/null +++ b/.changeset/configuration-first-auth.md @@ -0,0 +1,8 @@ +--- +'@directory/web': minor +'@directory/cli': minor +--- + +Configure authentication entirely in YAML: `auth.origin` is required when authentication is enabled, and `auth.secret` is optional. Directory generates a session secret at startup by default; set an explicit secret to keep sessions across restarts. Ordinary configuration reloads preserve sessions. + +Docker Compose no longer requires `.env`, `AUTH_SECRET`, `ORIGIN`, or `AUTH_URL`. Existing authenticated deployments must move their origin into YAML and explicitly reference or set a secret if they need persistent sessions. diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index b001567..c2b25cf 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -58,4 +58,4 @@ COPY --from=pruner /pruned/package.json /app/package.json RUN mkdir -p /app/config /app/public USER node EXPOSE 3000 -CMD ["node", "/app/build/index.js"] +CMD ["node", "/app/build/bootstrap/start.js"] diff --git a/apps/web/docker-compose.dev.yml b/apps/web/docker-compose.dev.yml index 2965e1a..dc1ee3f 100644 --- a/apps/web/docker-compose.dev.yml +++ b/apps/web/docker-compose.dev.yml @@ -10,14 +10,14 @@ services: LOCAL_UID: ${LOCAL_UID:-1000} LOCAL_GID: ${LOCAL_GID:-1000} user: '${LOCAL_UID:-1000}:${LOCAL_GID:-1000}' - env_file: .env + # Optional values for ${VAR} references in directory.yaml. + env_file: + - path: .env + required: false environment: NODE_ENV: development - AUTH_SECRET: ${AUTH_SECRET:-} CONFIG_PATH: /m/apps/web/config/directory.yaml DIRECTORY_PUBLIC_DIR: /m/apps/web/public - ORIGIN: ${ORIGIN:-http://localhost:5173} - AUTH_URL: ${ORIGIN:-http://localhost:5173} ports: - '127.0.0.1:5173:5173' volumes: @@ -51,14 +51,14 @@ services: profiles: - tools user: '${LOCAL_UID:-1000}:${LOCAL_GID:-1000}' - env_file: .env + # Optional values for ${VAR} references in directory.yaml. + env_file: + - path: .env + required: false environment: NODE_ENV: development - AUTH_SECRET: ${AUTH_SECRET:-} CONFIG_PATH: /m/apps/web/config/directory.yaml DIRECTORY_PUBLIC_DIR: /m/apps/web/public - ORIGIN: ${ORIGIN:-http://localhost:5173} - AUTH_URL: ${ORIGIN:-http://localhost:5173} volumes: - ./config:/m/apps/web/config:ro read_only: true diff --git a/apps/web/docker-compose.yml b/apps/web/docker-compose.yml index 1c2283e..96f3bf1 100644 --- a/apps/web/docker-compose.yml +++ b/apps/web/docker-compose.yml @@ -1,23 +1,20 @@ services: directory-app: - image: ${DIRECTORY_IMAGE:-git.eleith.com/eleith.private/directory:latest} + # Replace latest with a published version or digest to pin updates. + image: ghcr.io/eleith/directory:latest platform: linux/amd64 build: context: ../.. dockerfile: apps/web/Dockerfile target: prod - env_file: .env - environment: - NODE_ENV: production - AUTH_SECRET: ${AUTH_SECRET:-} - HOST: 0.0.0.0 - PORT: '3000' - CONFIG_PATH: /app/config/directory.yaml - DIRECTORY_PUBLIC_DIR: /app/public - ORIGIN: ${ORIGIN:-http://localhost:3000} - AUTH_URL: ${ORIGIN:-http://localhost:3000} + # Optional values for ${VAR} references in directory.yaml. + env_file: + - path: .env + required: false ports: - - '${DIRECTORY_BIND:-127.0.0.1}:${DIRECTORY_PORT:-3000}:3000' + # Localhost only. Use '3000:3000' for LAN access; restrict it with your firewall. + # Change the left port to use a different host port, e.g. '127.0.0.1:8080:3000'. + - '127.0.0.1:3000:3000' volumes: - ./config:/app/config:ro - ./public:/app/public:ro @@ -41,7 +38,8 @@ services: retries: 3 directory-cli: - image: ${DIRECTORY_CLI_IMAGE:-git.eleith.com/eleith.private/directory-cli:latest} + # Keep this version matched to the web image if you pin it. + image: ghcr.io/eleith/directory-cli:latest platform: linux/amd64 build: context: ../.. @@ -49,16 +47,10 @@ services: target: prod profiles: - tools - env_file: .env - environment: - NODE_ENV: production - AUTH_SECRET: ${AUTH_SECRET:-} - HOST: 0.0.0.0 - PORT: '3000' - CONFIG_PATH: /app/config/directory.yaml - DIRECTORY_PUBLIC_DIR: /app/public - ORIGIN: ${ORIGIN:-http://localhost:3000} - AUTH_URL: ${ORIGIN:-http://localhost:3000} + # Optional values for ${VAR} references in directory.yaml. + env_file: + - path: .env + required: false volumes: - ./config:/app/config:ro read_only: true diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index 7237e30..2720ed5 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -17,13 +17,28 @@ import { expiredSession, readSession } from './support/session.js'; test.setTimeout(60_000); +const hostileLegacyEnvironment = { + ORIGIN: 'https://legacy-origin.example.test', + AUTH_URL: 'https://legacy-auth.example.test', + AUTH_SECRET: 'PRIVATE_C6_IGNORED_LEGACY_SECRET_0123456789', + AUTH_TRUST_HOST: 'true', + HOST_HEADER: 'x-forwarded-host', + PROTOCOL_HEADER: 'x-forwarded-proto', + PORT_HEADER: 'x-forwarded-port' +}; + function expectPublic(text: string, extra: string[] = []) { for (const value of [...privateSentinels, ...extra]) expect(text).not.toContain(value); } function expectSafeLogs(server: TestServer, extra: string[] = []) { expect(server.outputTruncated()).toBe(false); - expectPublic(server.output(), [server.path, ...(server.secret ? [server.secret] : []), ...extra]); + expectPublic(server.output(), [ + server.path, + ...(server.secret ? [server.secret] : []), + hostileLegacyEnvironment.AUTH_SECRET, + ...extra + ]); } function expectLocalLocation(response: Response, server: TestServer): URL { @@ -240,7 +255,12 @@ test('native routes reject unsupported verbs instead of serving public or privat test('native SSR credentials and named root signout work without JavaScript', async ({ browser }) => { - const server = await startServer({ configuration: configuration() }); + const secret = randomSecret(); + const server = await startServer({ + configuration: configuration(), + secret, + env: hostileLegacyEnvironment + }); const context = await browser.newContext({ javaScriptEnabled: false }); try { await server.ready(); @@ -267,7 +287,7 @@ test('native SSR credentials and named root signout work without JavaScript', as expect(cookie!.sameSite).toBe('Lax'); expect(cookie!.expires - Date.now() / 1000).toBeGreaterThan(7 * 86_400 - 120); expect(cookie!.expires - Date.now() / 1000).toBeLessThanOrEqual(7 * 86_400 + 10); - const jwt = await readSession(server.secret!, cookie!.name, cookie!.value); + const jwt = await readSession(secret, cookie!.name, cookie!.value); expect(jwt!.exp - jwt!.iat).toBe(7 * 86_400); const data = await context.request.get(`${server.url}/__data.json?x-sveltekit-invalidated=11`); @@ -276,6 +296,8 @@ test('native SSR credentials and named root signout work without JavaScript', as expect(payload).toContain(privateDescription); expect(payload).not.toContain(username); expect(payload).not.toContain(password); + expect(payload).not.toContain(secret); + expect(await page.content()).not.toContain(secret); expect(payload).not.toContain(cookie!.value); expect(payload).not.toContain('roles'); await page.goto(`${server.url}/signin?callbackUrl=${encodeURIComponent('/?returned=yes')}`); @@ -353,8 +375,13 @@ test('SDK rejects wrong, missing and non-string credentials and does not forward } }); -test('return paths and untrusted proxy headers cannot mint foreign redirects', async ({ page }) => { - const server = await startServer({ configuration: configuration() }); +test('YAML origin overrides legacy env, return paths and untrusted proxy headers', async ({ + page +}) => { + const server = await startServer({ + configuration: configuration(), + env: hostileLegacyEnvironment + }); try { await server.ready(); for (const callback of [ @@ -479,7 +506,8 @@ test('raw auth APIs stay blocked and same-origin checks cover every POST content test('HTTPS origin sets Secure SDK cookies even when the trusted upstream transport is HTTP', async () => { const server = await startServer({ configuration: configuration(), - origin: 'https://directory.example.test' + origin: 'https://directory.example.test', + env: hostileLegacyEnvironment }); try { await server.ready(); @@ -502,6 +530,38 @@ test('HTTPS origin sets Secure SDK cookies even when the trusted upstream transp } }); +test('generated sessions expire on restart even when a legacy AUTH_SECRET is set', async () => { + const options = { configuration: configuration(), env: hostileLegacyEnvironment }; + let server = await startServer(options); + try { + await server.ready(); + const cookie = sessionCookie(await postCredentials(server)); + const authenticated = await server.fetch('/__data.json', { + headers: { cookie: cookie.pair } + }); + expect(authenticated.status).toBe(200); + expect(await authenticated.text()).toContain(privateDescription); + expectSafeLogs(server, [cookie.value]); + const { port } = server; + expect(await server.stop()).toEqual({ code: 0, signal: null }); + + server = await startServer({ ...options, port }); + await server.ready(); + await expectUnauthorized( + await server.fetch('/__data.json', { headers: { cookie: cookie.pair } }) + ); + const freshCookie = sessionCookie(await postCredentials(server)); + const freshSession = await server.fetch('/__data.json', { + headers: { cookie: freshCookie.pair } + }); + expect(freshSession.status).toBe(200); + expect(await freshSession.text()).toContain(privateDescription); + expectSafeLogs(server, [cookie.value, freshCookie.value]); + } finally { + expect(await server.stop()).toEqual({ code: 0, signal: null }); + } +}); + test('tamper, expiry and secret rotation deny sessions while same-secret restart preserves real JWTs', async () => { const secret = randomSecret(); let server = await startServer({ configuration: configuration(), secret }); @@ -582,6 +642,7 @@ test('auth edits reject the entire reload; restoring auth allows display updates .poll( async () => { const response = await server.fetch('/__data.json', { headers: { cookie: cookie.pair } }); + expect(response.status).toBe(200); return response.text(); }, { timeout: 10_000, intervals: [100, 250, 500] } @@ -600,9 +661,7 @@ test('omitting auth needs no AUTH_SECRET, ORIGIN or AUTH_URL and exposes no sign browser }) => { const server = await startServer({ - configuration: configuration(''), - secret: null, - origin: null + configuration: configuration('') }); const context = await browser.newContext({ javaScriptEnabled: false }); try { @@ -624,11 +683,14 @@ test('omitting auth needs no AUTH_SECRET, ORIGIN or AUTH_URL and exposes no sign } }); -test('real OIDC discovery and browser roundtrip use PKCE, state and nonce and the same Directory session', async ({ +test('real OIDC discovery and browser roundtrip use YAML origin, PKCE, state and nonce despite legacy env', async ({ browser }) => { const issuer = await startIssuer(); - const server = await startServer({ configuration: configuration(oidcAuth(issuer.origin)) }); + const server = await startServer({ + configuration: configuration(oidcAuth(issuer.origin)), + env: hostileLegacyEnvironment + }); try { issuer.allowCallback(server.origin); await server.ready(); diff --git a/apps/web/e2e/bookmark-logo-fallback.e2e.ts b/apps/web/e2e/bookmark-logo-fallback.e2e.ts index 1c150bd..d8b4c3a 100644 --- a/apps/web/e2e/bookmark-logo-fallback.e2e.ts +++ b/apps/web/e2e/bookmark-logo-fallback.e2e.ts @@ -64,9 +64,7 @@ test.beforeEach(async () => { await writeFile(join(publicDir, 'other.svg'), 'not an image'); server = await startServer({ configuration: configuration(), - publicDir, - secret: null, - origin: null + publicDir }); await server.ready(); } catch (error) { diff --git a/apps/web/e2e/bookmark-logos.e2e.ts b/apps/web/e2e/bookmark-logos.e2e.ts index 7d0911c..16b7a58 100644 --- a/apps/web/e2e/bookmark-logos.e2e.ts +++ b/apps/web/e2e/bookmark-logos.e2e.ts @@ -19,7 +19,7 @@ ${auth}`; test('bookmark logos render on cards and details without JavaScript and follow accepted snapshots', async ({ browser }) => { - const server = await startServer({ configuration: configuration(), secret: null, origin: null }); + const server = await startServer({ configuration: configuration() }); const context = await browser.newContext({ javaScriptEnabled: false }); try { await server.ready(); @@ -87,13 +87,13 @@ test('bookmark logo GET and HEAD require a real session and never become public const data = await page.request.get(`${server.url}/__data.json`); const payload = await data.text(); expect(payload).toContain('/bookmarks/photos/logo'); - for (const value of [username, password, server.secret!, server.path, ' { - const server = await startServer({ configuration, secret: null, origin: null }); + const server = await startServer({ configuration }); try { await server.ready(); await context.route('https://service.example.test/**', (route) => @@ -48,9 +48,7 @@ test('Open uses a new tab by default', async ({ page, context }) => { test('Open uses the current tab when configured', async ({ page, context }) => { const server = await startServer({ - configuration: `site:\n open_links_in_new_tab: false\n${configuration}`, - secret: null, - origin: null + configuration: `site:\n open_links_in_new_tab: false\n${configuration}` }); try { await server.ready(); diff --git a/apps/web/e2e/config-update.e2e.ts b/apps/web/e2e/config-update.e2e.ts index 0452e8d..1729935 100644 --- a/apps/web/e2e/config-update.e2e.ts +++ b/apps/web/e2e/config-update.e2e.ts @@ -38,7 +38,7 @@ test.beforeEach(async () => { test.afterEach(async () => { expect(await server.stop()).toEqual({ code: 0, signal: null }); expect(server.outputTruncated()).toBe(false); - for (const value of [...privateSentinels, logoPath, server.path, server.secret!]) { + for (const value of [...privateSentinels, logoPath, server.path]) { expect(server.output()).not.toContain(value); } }); @@ -70,9 +70,6 @@ async function readVersion( const text = await response.text(); const value: unknown = JSON.parse(text); expect(value).toEqual({ snapshotId: expect.stringMatching(uuid) }); - for (const sentinel of [...privateSentinels, siteTitle, updatedTitle, logoPath, server.secret!]) { - expect(text).not.toContain(sentinel); - } return (value as { snapshotId: string }).snapshotId; } diff --git a/apps/web/e2e/configured-images.e2e.ts b/apps/web/e2e/configured-images.e2e.ts index fe83433..2ab2fd1 100644 --- a/apps/web/e2e/configured-images.e2e.ts +++ b/apps/web/e2e/configured-images.e2e.ts @@ -91,7 +91,7 @@ async function stop(server: TestServer, directory: string) { expect(await server.stop()).toEqual({ code: 0, signal: null }); const output = server.output(); expect(server.outputTruncated()).toBe(false); - for (const value of [...privateValues, directory, server.path, server.secret!]) { + for (const value of [...privateValues, directory, server.path]) { if (value) expect(output).not.toContain(value); } } finally { @@ -172,14 +172,7 @@ test('mounted files and generated bookmark logos require a real session, includi const data = await server.fetch('/__data.json', { headers: { cookie } }); const payload = await data.text(); expect(payload).toContain('/public/photo.svg'); - for (const value of [ - username, - password, - server.path, - fixture.publicDir, - server.secret!, - ' { - server = await startServer({ configuration, secret: null, origin: null }); + server = await startServer({ configuration }); await server.ready(); }); test.afterAll(async () => { @@ -355,7 +355,7 @@ for (const width of [360, 1280]) { test('local details keep the loaded snapshot until real native navigation accepts a replacement', async ({ page }) => { - const app = await startServer({ configuration, secret: null, origin: null }); + const app = await startServer({ configuration }); try { await app.ready(); await page.goto(app.url); @@ -386,7 +386,7 @@ test('local details keep the loaded snapshot until real native navigation accept test('a native snapshot replacement supersedes an already active tile without reopening stale details', async ({ page }) => { - const app = await startServer({ configuration, secret: null, origin: null }); + const app = await startServer({ configuration }); try { await app.ready(); await page.goto(app.url); @@ -414,9 +414,7 @@ test('dialog scroll lock and close preserve the grid scroll position', async ({ configuration: configuration.replace( 'title: Dialog fixture', `title: Dialog fixture\n footer: ${JSON.stringify('Footer line.\n'.repeat(80))}` - ), - secret: null, - origin: null + ) }); try { await app.ready(); diff --git a/apps/web/e2e/directory.e2e.ts b/apps/web/e2e/directory.e2e.ts index 7395584..dd59513 100644 --- a/apps/web/e2e/directory.e2e.ts +++ b/apps/web/e2e/directory.e2e.ts @@ -33,7 +33,7 @@ ${auth}`; } test('groups, bookmark titles and plain details work without JavaScript', async ({ browser }) => { - const server = await startServer({ configuration: configuration(), secret: null, origin: null }); + const server = await startServer({ configuration: configuration() }); const context = await browser.newContext({ javaScriptEnabled: false }); try { await server.ready(); @@ -112,8 +112,7 @@ test('actual bookmark HTML and Kit data remain private until sign-in', async ({ expect(data.status()).toBe(200); const payload = await data.text(); expect(payload).toContain('photos.example.test'); - for (const value of [username, password, server.secret!, server.path]) - expect(payload).not.toContain(value); + for (const value of [username, password, server.path]) expect(payload).not.toContain(value); await page.getByRole('button', { name: 'Sign out' }).click(); await expect(page).toHaveURL(/\/signin/); expect( @@ -130,7 +129,7 @@ test('flat and empty directories, invalid selections and phone-width text stay u const flat = configuration() .replaceAll(' group: Media\n', '') .replaceAll(' group: Behind the scenes\n', ''); - const server = await startServer({ configuration: flat, secret: null, origin: null }); + const server = await startServer({ configuration: flat }); try { await server.ready(); await page.setViewportSize({ width: 360, height: 900 }); diff --git a/apps/web/e2e/error-recovery.e2e.ts b/apps/web/e2e/error-recovery.e2e.ts index 5967fe6..0624e80 100644 --- a/apps/web/e2e/error-recovery.e2e.ts +++ b/apps/web/e2e/error-recovery.e2e.ts @@ -100,8 +100,7 @@ test('recovery preserves authentication and does not replace private API denials expect(response?.status()).toBe(404); await expect(page.getByRole('heading', { name: 'Page not found', exact: true })).toBeVisible(); const html = await page.content(); - for (const value of [...privateSentinels, server.secret!, server.path]) - expect(html).not.toContain(value); + for (const value of [...privateSentinels, server.path]) expect(html).not.toContain(value); await page.getByRole('link', { name: 'Back to home', exact: true }).click(); await expect(page).toHaveURL(`${server.url}/`); await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible(); diff --git a/apps/web/e2e/favicon.e2e.ts b/apps/web/e2e/favicon.e2e.ts index aa9b357..31ffb5a 100644 --- a/apps/web/e2e/favicon.e2e.ts +++ b/apps/web/e2e/favicon.e2e.ts @@ -34,6 +34,7 @@ function configuration(source?: string, auth = true, selectedPassword = password let authentication = ''; if (auth) { authentication = `auth: + origin: '\${TEST_ORIGIN}' credentials: username: ${username} password: ${selectedPassword} @@ -164,12 +165,7 @@ async function stop(server: TestServer, directory: string) { try { expect(await server.stop()).toEqual({ code: 0, signal: null }); expect(server.outputTruncated()).toBe(false); - expectNoPrivateData(server.output(), [ - directory, - server.path, - ...(server.secret ? [server.secret] : []), - svg - ]); + expectNoPrivateData(server.output(), [directory, server.path, svg]); } finally { await rm(directory, { recursive: true, force: true }); } @@ -204,7 +200,7 @@ for (const [source, bytes, contentType] of [ const payload = await data.text(); expect(payload).toContain('/favicon'); expect(payload).toContain(title); - expectNoPrivateData(payload, [fixture.publicDir, server.path, server.secret!]); + expectNoPrivateData(payload, [fixture.publicDir, server.path]); const cookie = await signIn(server); for (const session of ['', 'authjs.session-token=invalid-favicon-cookie', cookie]) { @@ -320,9 +316,7 @@ test('no-auth mode remains open without changing alias selection', async () => { const fixture = await fixtures(); const server = await startServer({ configuration: configuration(sourceA, false), - publicDir: fixture.publicDir, - secret: null, - origin: null + publicDir: fixture.publicDir }); try { await server.ready(); diff --git a/apps/web/e2e/search.e2e.ts b/apps/web/e2e/search.e2e.ts index a5cfc0f..f4d827c 100644 --- a/apps/web/e2e/search.e2e.ts +++ b/apps/web/e2e/search.e2e.ts @@ -24,7 +24,7 @@ links: let server: TestServer; test.beforeAll(async () => { - server = await startServer({ configuration, secret: null, origin: null }); + server = await startServer({ configuration }); await server.ready(); }); diff --git a/apps/web/e2e/signin.e2e.ts b/apps/web/e2e/signin.e2e.ts index 04f697b..eaf4c05 100644 --- a/apps/web/e2e/signin.e2e.ts +++ b/apps/web/e2e/signin.e2e.ts @@ -47,7 +47,6 @@ function expectPrivate(text: string, app: TestServer) { otherSource, faviconSource, app.path, - app.secret!, publicDir, logo, favicon diff --git a/apps/web/e2e/site-header-logo.e2e.ts b/apps/web/e2e/site-header-logo.e2e.ts index eb47e83..127d295 100644 --- a/apps/web/e2e/site-header-logo.e2e.ts +++ b/apps/web/e2e/site-header-logo.e2e.ts @@ -24,6 +24,7 @@ function configuration(source?: string, auth = false) { const logo = source === undefined ? '' : ` logo_path: ${source}\n`; const authentication = auth ? `auth: + origin: '\${TEST_ORIGIN}' credentials: username: ${username} password: ${password} @@ -43,8 +44,7 @@ ${authentication}`; async function boot(source?: string, auth = false) { server = await startServer({ configuration: configuration(source, auth), - publicDir, - ...(auth ? {} : { secret: null, origin: null }) + publicDir }); await server.ready(); return server; @@ -97,8 +97,8 @@ test.afterEach(async () => { expect(await server.stop()).toEqual({ code: 0, signal: null }); expect(server.outputTruncated()).toBe(false); expectNoPrivateData(server.output()); - for (const value of [directory, server.path, server.secret].filter(Boolean)) { - expect(server.output()).not.toContain(value!); + for (const value of [directory, server.path]) { + expect(server.output()).not.toContain(value); } } } finally { diff --git a/apps/web/e2e/support/configuration.ts b/apps/web/e2e/support/configuration.ts index 99787e7..944d3e0 100644 --- a/apps/web/e2e/support/configuration.ts +++ b/apps/web/e2e/support/configuration.ts @@ -18,6 +18,7 @@ export const privateSentinels = [ ]; export const credentialsAuth = `auth: + origin: '\${TEST_ORIGIN}' credentials: username: ${JSON.stringify(username)} password: ${JSON.stringify(password)} @@ -25,6 +26,7 @@ export const credentialsAuth = `auth: export function oidcAuth(issuer: string): string { return `auth: + origin: '\${TEST_ORIGIN}' oidc: issuer: ${JSON.stringify(issuer)} client_id: ${JSON.stringify(clientId)} diff --git a/apps/web/e2e/support/server.ts b/apps/web/e2e/support/server.ts index fb8713b..d622e48 100644 --- a/apps/web/e2e/support/server.ts +++ b/apps/web/e2e/support/server.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; -const serverModule = new URL('../../build/index.js', import.meta.url).href; +const serverModule = new URL('../../build/bootstrap/start.js', import.meta.url).href; const outputLimit = 64 * 1024; export function randomSecret(): string { @@ -30,24 +30,37 @@ async function reservePort(): Promise { interface ServerOptions { configuration: string; publicDir?: string; - secret?: string | null; - origin?: string | null; + /** Explicit YAML secret; omitted by default so the runtime generates its own. */ + secret?: string; + /** Value for the fixture's ${TEST_ORIGIN}; defaults to the allocated localhost URL. */ + origin?: string; port?: number; + /** Deliberately injected process env, e.g. hostile legacy auth settings. */ + env?: NodeJS.ProcessEnv; } -/** A real adapter-node process; never inherits operator config or authentication env. */ +/** A real bootstrap/adapter process; inherits no operator configuration or environment. */ export async function startServer(options: ServerOptions) { const temporary = await mkdtemp(join(tmpdir(), 'directory-auth-e2e-')); const path = join(temporary, 'directory.yaml'); + // Fixture YAML keeps auth at the top level; apply the same secret on reload. + const renderConfiguration = (configuration: string) => { + if (options.secret === undefined) return configuration; + if (!/^auth:\n/m.test(configuration)) throw new Error('A test secret requires an auth block'); + return configuration.replace(/^auth:\n/m, "auth:\n secret: '${TEST_AUTH_SECRET}'\n"); + }; try { - await writeFile(path, options.configuration); + await writeFile(path, renderConfiguration(options.configuration)); const publicDir = options.publicDir ?? join(temporary, 'public'); if (options.publicDir === undefined) await mkdir(publicDir); const port = options.port ?? (await reservePort()); const url = `http://localhost:${port}`; - const origin = options.origin === undefined ? url : options.origin; - const secret = options.secret === undefined ? randomSecret() : options.secret; + const origin = options.origin ?? url; + const secret = options.secret; const environment: NodeJS.ProcessEnv = { + ...options.env, + TEST_ORIGIN: origin, + TEST_AUTH_SECRET: secret, HOST: '127.0.0.1', PORT: String(port), CONFIG_PATH: path, @@ -55,13 +68,6 @@ export async function startServer(options: ServerOptions) { NODE_ENV: 'production', SHUTDOWN_TIMEOUT: '1' }; - if (origin !== null) { - environment.ORIGIN = origin; - environment.AUTH_URL = origin; - } - if (secret !== null) { - environment.AUTH_SECRET = secret; - } const script = ` import { once } from 'node:events'; const { server } = await import(${JSON.stringify(serverModule)}); @@ -123,7 +129,7 @@ process.send({ ready: server.server.address().port }); } return { url, - origin: origin ?? url, + origin, port, secret, path, @@ -137,7 +143,7 @@ process.send({ ready: server.server.address().port }); if (!listening) throw new Error(`Adapter failed to become ready: ${output}`); }, async replace(configuration: string) { - await writeFile(`${path}.replacement`, configuration); + await writeFile(`${path}.replacement`, renderConfiguration(configuration)); await rename(`${path}.replacement`, path); }, fetch(pathname: string, init: RequestInit = {}) { diff --git a/apps/web/e2e/theme-toggle.e2e.ts b/apps/web/e2e/theme-toggle.e2e.ts index f427ed0..f9789c2 100644 --- a/apps/web/e2e/theme-toggle.e2e.ts +++ b/apps/web/e2e/theme-toggle.e2e.ts @@ -29,7 +29,7 @@ test.beforeAll(async () => { join(publicDir, 'private-theme-logo.svg'), '' ); - server = await startServer({ configuration, publicDir, secret: null, origin: null }); + server = await startServer({ configuration, publicDir }); await server.ready(); }); diff --git a/apps/web/package.json b/apps/web/package.json index 410d529..7e5eeb1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ ], "scripts": { "dev": "vite dev --host 127.0.0.1", - "build": "vite build", + "build": "vite build && tsc -p tsconfig.bootstrap.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "lint": "prettier --ignore-path ../../.prettierignore --check . && eslint .", "format": "prettier --ignore-path ../../.prettierignore --write .", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 492cd5c..de51b79 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -11,13 +11,12 @@ export default defineConfig({ use: { baseURL: origin, trace: 'retain-on-failure' }, projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], webServer: { - command: 'node build/index.js', + command: 'node build/bootstrap/start.js', url: origin, reuseExistingServer: false, env: { HOST: '127.0.0.1', PORT: '4183', - ORIGIN: origin, CONFIG_PATH: fileURLToPath(new URL('./e2e/fixtures/directory.yaml', import.meta.url)) } } diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index e1dd061..2bfc427 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -15,6 +15,7 @@ const adapterEvents: NodeJS.EventEmitter = process; const mocks = vi.hoisted(() => ({ building: false, dev: false, + env: { ORIGIN: 'https://directory.example.com', AUTH_URL: 'https://directory.example.com' }, boot: vi.fn(), report: vi.fn(), load: vi.fn(), @@ -22,6 +23,7 @@ const mocks = vi.hoisted(() => ({ prepare: vi.fn() })); +vi.mock('$env/dynamic/private', () => ({ env: mocks.env })); vi.mock('$app/environment', () => ({ get building() { return mocks.building; @@ -72,7 +74,11 @@ function snapshot(title: string, light = '#13579b', dark = '#c4d6e8') { { site: { title, description: `${title} description`, favicon_path: '/public/site.svg' }, appearance: { primary_light_color: light, primary_dark_color: dark }, - auth: { credentials: { username: 'family', password: sentinel } }, + auth: { + origin: 'https://directory.example.com', + secret: sentinel, + credentials: { username: 'family', password: sentinel } + }, links: { photos: { title: 'Photos', @@ -166,7 +172,7 @@ describe('server hooks', () => { expect(ready.directory.currentSnapshot).not.toHaveBeenCalled(); pending.resolve(ready); await startup; - expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(); + expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, mocks.env); expect((await hooks.handle({ event: event(), resolve })).status).toBe(200); adapterEvents.emit('sveltekit:shutdown'); expect((await hooks.handle({ event: event(), resolve })).status).toBe(503); @@ -179,7 +185,7 @@ describe('server hooks', () => { await hooks.init(); expect(development).toHaveBeenCalledOnce(); expect(production).not.toHaveBeenCalled(); - expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(); + expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, mocks.env); process.emit('directory:dev-stop'); }); diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index ed9441a..22f906f 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,4 +1,5 @@ import { building, dev } from '$app/environment'; +import { env } from '$env/dynamic/private'; import type { Handle, ServerInit } from '@sveltejs/kit'; import { sequence } from '@sveltejs/kit/hooks'; import { themeStyleTag } from './lib/server/appearance.js'; @@ -12,8 +13,8 @@ const server: ServerState = { application: undefined }; export const init: ServerInit = async () => { if (building) return; try { - if (dev) await startDevelopment(server); - else await startProduction(server); + if (dev) await startDevelopment(server, env); + else await startProduction(server, env); } catch (error) { reportStartupFailure(error); // eslint-disable-next-line preserve-caught-error -- Raw startup errors may contain configuration secrets. diff --git a/apps/web/src/lib/server/appearance.test.ts b/apps/web/src/lib/server/appearance.test.ts index 0ed6715..bb02f9e 100644 --- a/apps/web/src/lib/server/appearance.test.ts +++ b/apps/web/src/lib/server/appearance.test.ts @@ -13,7 +13,11 @@ function snapshot(light: string, dark: string) { logo_path: '/public/PRIVATE_LOGO.svg' }, appearance: { primary_light_color: light, primary_dark_color: dark }, - auth: { credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' } }, + auth: { + origin: 'https://private-auth.example.test', + secret: 'PRIVATE_SESSION_SECRET_AT_LEAST_32_CHARACTERS', + credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' } + }, links: { private: { title: 'PRIVATE_LINK', url: 'https://private.example.test' } } }, { env: {} } @@ -36,8 +40,6 @@ describe('server-owned theme style', () => { expect(style).toBe( `` ); - expect(style).not.toContain('PRIVATE_'); - expect(style).not.toContain('private.example.test'); expect(Object.isFrozen(captured.display.appearance)).toBe(true); expect(JSON.stringify(captured)).toBe(before); } diff --git a/apps/web/src/lib/server/auth/index.test.ts b/apps/web/src/lib/server/auth/index.test.ts index 59f446a..79a5adc 100644 --- a/apps/web/src/lib/server/auth/index.test.ts +++ b/apps/web/src/lib/server/auth/index.test.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { isHttpError, isRedirect, redirect, type Handle, type RequestEvent } from '@sveltejs/kit'; import type { Session, SvelteKitAuthConfig } from '@auth/sveltekit'; @@ -18,6 +19,11 @@ const mocks = vi.hoisted(() => ({ logWarn: vi.fn() })); +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, randomBytes: vi.fn(actual.randomBytes) }; +}); + vi.mock('@auth/sveltekit', () => ({ SvelteKitAuth: mocks.constructor })); @@ -26,7 +32,7 @@ vi.mock('../logger.js', () => ({ logger: { error: mocks.logError, warn: mocks.lo vi.mock('@sveltejs/kit/hooks', () => ({ sequence: mocks.sequence })); const origin = 'https://directory.example'; -const env = { ORIGIN: origin, AUTH_URL: origin, AUTH_SECRET: 'x'.repeat(32) }; +const secret = ' x'.repeat(32) + ' '; const credentials = { username: 'family', password: 'private-password' }; const signedIn: Session = { user: { name: 'Directory' }, expires: '2030-01-01T00:00:00.000Z' }; const snapshot = prepareSnapshot( @@ -79,7 +85,7 @@ function eventFor( } function authenticate() { - return createAuthentication({ credentials }, env); + return createAuthentication({ origin, secret, credentials }); } async function responseFor(event: RequestEvent) { @@ -230,8 +236,10 @@ describe('boot factory', () => { it('constructs one SDK instance and seven-day native JWT pipeline per application', async () => { const authentication = authenticate(); expect(authentication.strategy).toBe('credentials'); + expect(randomBytes).not.toHaveBeenCalled(); + expect(JSON.stringify(authentication)).not.toContain(secret); expect(authJsConfig()).toMatchObject({ - secret: env.AUTH_SECRET, + secret, trustHost: true, basePath: '/auth', redirectProxyUrl: '', @@ -245,9 +253,17 @@ describe('boot factory', () => { expect(mocks.sequence).toHaveBeenCalledTimes(1); }); - it('does not construct an SDK on invalid enabled settings', () => { - expect(() => createAuthentication({ credentials }, {})).toThrow(); - expect(mocks.constructor).not.toHaveBeenCalled(); + it('passes fresh cryptographic bytes to each SDK instance without changing config', () => { + const config = Object.freeze({ origin, credentials }); + createAuthentication(config); + createAuthentication(config); + expect(vi.mocked(randomBytes).mock.calls).toEqual([[32], [32]]); + const secrets = mocks.constructor.mock.calls.map(([settings]) => settings.secret); + const generated = vi + .mocked(randomBytes) + .mock.results.map(({ value }) => (value as Buffer).toString('base64')); + expect(secrets).toEqual(generated); + expect(config).not.toHaveProperty('secret'); }); it('pins redirect callbacks and does not emit raw SDK diagnostics', async () => { @@ -392,16 +408,15 @@ describe('whole-directory gate', () => { }); it('permits only the configured OIDC GET callback, including cross-site navigation', async () => { - const authentication = createAuthentication( - { - oidc: { - issuer: 'https://issuer.example', - client_id: 'client', - client_secret: 'secret' - } - }, - env - ); + const authentication = createAuthentication({ + origin, + secret, + oidc: { + issuer: 'https://issuer.example', + client_id: 'client', + client_secret: 'secret' + } + }); const callback = eventFor('/auth/callback/oidc?state=test', { headers: { origin: 'https://issuer.example' } }); @@ -600,16 +615,15 @@ describe('native action wrappers', () => { ); it('selects configured OIDC without forwarding credential or authorization overrides', async () => { - const authentication = createAuthentication( - { - oidc: { - issuer: 'https://issuer.example', - client_id: 'client', - client_secret: 'secret' - } - }, - env - ); + const authentication = createAuthentication({ + origin, + secret, + oidc: { + issuer: 'https://issuer.example', + client_id: 'client', + client_secret: 'secret' + } + }); await authentication.signIn( actionEvent( new URLSearchParams({ diff --git a/apps/web/src/lib/server/auth/index.ts b/apps/web/src/lib/server/auth/index.ts index b16ed87..80bbd4e 100644 --- a/apps/web/src/lib/server/auth/index.ts +++ b/apps/web/src/lib/server/auth/index.ts @@ -1,13 +1,13 @@ +import { randomBytes } from 'node:crypto'; import { SvelteKitAuth, type SvelteKitAuthConfig } from '@auth/sveltekit'; import { sequence } from '@sveltejs/kit/hooks'; import type { Handle, RequestEvent } from '@sveltejs/kit'; -import type { ConfigEnvironment, DirectoryAuth } from '@directory/config/types'; +import type { DirectoryAuth } from '@directory/config/types'; import { logger } from '../logger.js'; import { protectAuthRequest, requireSignIn } from './access.js'; import { signInAction, signOutAction } from './form-actions.js'; import { buildProviders } from './providers.js'; import { localRedirect } from './redirect.js'; -import { requireAuthSettings } from './settings.js'; export { localRedirect } from './redirect.js'; @@ -25,18 +25,15 @@ const authJsLogger: NonNullable = { logger.error('Auth.js failed (authjs_error).'); }, warn(code) { - // SvelteKit always sets /auth; core warns on every call when AUTH_URL is also set. if (code === 'env-url-basepath-redundant') return; logger.warn('Auth.js warning (authjs_warning).'); }, debug() {} }; -export function createAuthentication( - config: DirectoryAuth, - env: ConfigEnvironment = process.env -): Authentication { - const { secret, origin } = requireAuthSettings(env); +export function createAuthentication(config: DirectoryAuth): Authentication { + const { origin } = config; + const secret = config.secret ?? randomBytes(32).toString('base64'); const strategy = config.credentials ? 'credentials' : 'oidc'; const policy = { origin, strategy } as const; const authJs = SvelteKitAuth({ diff --git a/apps/web/src/lib/server/auth/providers.test.ts b/apps/web/src/lib/server/auth/providers.test.ts index 2ebbcbf..b160c17 100644 --- a/apps/web/src/lib/server/auth/providers.test.ts +++ b/apps/web/src/lib/server/auth/providers.test.ts @@ -48,7 +48,10 @@ describe('credentials', () => { it('uses the native Credentials provider and captures configuration', async () => { const configured = { ...credentials }; - const provider = buildProviders({ credentials: configured })[0]; + const provider = buildProviders({ + origin: 'https://directory.example', + credentials: configured + })[0]; expect(provider).toMatchObject({ id: 'credentials', type: 'credentials' }); if (!provider || typeof provider === 'function' || provider.type !== 'credentials') { throw new Error('Expected native credentials provider'); @@ -67,6 +70,7 @@ describe('credentials', () => { it('explicitly requires OIDC PKCE, state and nonce without adding schema fields', () => { expect( buildProviders({ + origin: 'https://directory.example', oidc: { issuer: 'https://issuer.example', client_id: 'client', client_secret: 'secret' } }) ).toEqual([ diff --git a/apps/web/src/lib/server/auth/settings.test.ts b/apps/web/src/lib/server/auth/settings.test.ts deleted file mode 100644 index 22f6e98..0000000 --- a/apps/web/src/lib/server/auth/settings.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { AuthSettingsError, requireAuthSettings } from './settings.js'; - -const origin = 'https://directory.example'; -const env = { AUTH_SECRET: 'a'.repeat(32), ORIGIN: origin, AUTH_URL: origin }; - -describe('enabled authentication settings', () => { - it.each([ - 'https://directory.example', - 'https://directory.example:8443', - 'https://127.0.0.1:1234', - 'https://[::1]:1234', - 'http://localhost:1234' - ])('accepts matching canonical Kit and SDK origins: %s', (origin) => { - expect(requireAuthSettings({ ...env, ORIGIN: origin, AUTH_URL: origin })).toEqual({ - secret: env.AUTH_SECRET, - origin - }); - expect( - requireAuthSettings({ ...env, ORIGIN: origin, AUTH_URL: origin, NEXTAUTH_URL: origin }) - ).toEqual({ secret: env.AUTH_SECRET, origin }); - }); - - it.each([ - [{}, 'AUTH_SECRET'], - [{ ...env, AUTH_SECRET: undefined }, 'AUTH_SECRET'], - [{ ...env, AUTH_SECRET: 'x'.repeat(31) }, 'AUTH_SECRET'], - [{ ...env, ORIGIN: undefined }, 'ORIGIN'], - [{ ...env, AUTH_URL: undefined }, 'AUTH_URL'], - [{ ...env, AUTH_URL: 'https://evil.example' }, 'AUTH_URL'], - [{ ...env, NEXTAUTH_URL: 'https://evil.example' }, 'NEXTAUTH_URL'], - [{ ...env, NEXTAUTH_URL: 'http://directory.example' }, 'NEXTAUTH_URL'], - [{ ...env, NEXTAUTH_URL: '' }, 'NEXTAUTH_URL'], - [{ ...env, AUTH_REDIRECT_PROXY_URL: 'https://proxy.example/auth' }, 'AUTH_REDIRECT_PROXY_URL'] - ] as const)('identifies only the failing setting: %j', (input, setting) => { - expect(() => requireAuthSettings(input)).toThrow('Authentication configuration invalid.'); - try { - requireAuthSettings(input); - expect.unreachable(); - } catch (error) { - expect(error).toBeInstanceOf(AuthSettingsError); - expect(error).toHaveProperty('setting', setting); - expect(error).not.toHaveProperty('cause'); - } - }); - - it('continues to accept an empty redirect proxy setting', () => { - expect(requireAuthSettings({ ...env, AUTH_REDIRECT_PROXY_URL: '' })).toEqual({ - secret: env.AUTH_SECRET, - origin - }); - }); - - it.each([ - `${origin}/`, - 'https://user:secret@directory.example', - 'https://directory.example/path', - 'https://directory.example/auth', - 'https://directory.example/?secret=1', - 'https://directory.example/#secret', - 'https://directory.example/#', - 'https://directory.example/?', - 'https://directory.example?secret=sentinel', - 'https://@directory.example', - 'https://directory.example/path/..', - 'HTTPS://DIRECTORY.example:443', - ' https://directory.example', - 'https://directory.example\\', - 'file:///tmp/private', - 'invalid secret sentinel', - 'http://127.0.0.1:1234', - 'http://127.1:1234', - 'http://[::1]:1234', - 'http://directory.example', - 'http://localhost.localdomain:1234', - 'http://localhost.:1234' - ])('rejects noncanonical or insecure origins in either setting: %s', (value) => { - for (const name of ['ORIGIN', 'AUTH_URL', 'NEXTAUTH_URL']) { - expect(() => requireAuthSettings({ ...env, [name]: value })).toThrow( - 'Authentication configuration invalid.' - ); - expect(() => requireAuthSettings({ ...env, [name]: value })).toThrow( - expect.objectContaining({ setting: name }) - ); - } - expect(() => requireAuthSettings({ ...env, ORIGIN: value, AUTH_URL: value })).toThrow( - 'Authentication configuration invalid.' - ); - }); -}); diff --git a/apps/web/src/lib/server/auth/settings.ts b/apps/web/src/lib/server/auth/settings.ts deleted file mode 100644 index a4be7ca..0000000 --- a/apps/web/src/lib/server/auth/settings.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ConfigEnvironment } from '@directory/config/types'; - -export type AuthSetting = - | 'AUTH_SECRET' - | 'ORIGIN' - | 'AUTH_URL' - | 'NEXTAUTH_URL' - | 'AUTH_REDIRECT_PROXY_URL'; - -export class AuthSettingsError extends Error { - readonly setting: AuthSetting; - - constructor(setting: AuthSetting) { - super('Authentication configuration invalid.'); - this.setting = setting; - } -} - -export interface AuthSettings { - readonly secret: string; - readonly origin: string; -} - -export function requireAuthSettings(env: ConfigEnvironment): AuthSettings { - const secret = env.AUTH_SECRET; - if (!secret || secret.length < 32) throw new AuthSettingsError('AUTH_SECRET'); - const origin = fixedOrigin(env.ORIGIN, 'ORIGIN'); - if (fixedOrigin(env.AUTH_URL, 'AUTH_URL') !== origin) { - throw new AuthSettingsError('AUTH_URL'); - } - if (env.NEXTAUTH_URL !== undefined && fixedOrigin(env.NEXTAUTH_URL, 'NEXTAUTH_URL') !== origin) { - throw new AuthSettingsError('NEXTAUTH_URL'); - } - if (env.AUTH_REDIRECT_PROXY_URL) throw new AuthSettingsError('AUTH_REDIRECT_PROXY_URL'); - return { secret, origin }; -} - -function fixedOrigin(value: string | undefined, setting: AuthSetting): string { - const url = parseUrl(value, setting); - if (value !== url.origin || (url.protocol === 'http:' && url.hostname !== 'localhost')) { - throw new AuthSettingsError(setting); - } - return url.origin; -} - -function parseUrl(value: string | undefined, setting: AuthSetting): URL { - try { - const url = new URL(value ?? ''); - if (url.protocol === 'http:' || url.protocol === 'https:') return url; - } catch { - // Configuration values must not appear in startup errors. - } - throw new AuthSettingsError(setting); -} diff --git a/apps/web/src/lib/server/boot.process.test.ts b/apps/web/src/lib/server/boot.process.test.ts index 27317fd..6f2fd7c 100644 --- a/apps/web/src/lib/server/boot.process.test.ts +++ b/apps/web/src/lib/server/boot.process.test.ts @@ -4,16 +4,17 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, onTestFinished, vi } from 'vitest'; -const serverModule = new URL('../../../build/index.js', import.meta.url).href; +const serverModule = new URL('../../../build/bootstrap/start.js', import.meta.url).href; const sentinel = 'PRIVATE_CONFIG_CREDENTIAL_LINK'; const bookmarkTitle = 'Visible household bookmark'; -const credentialsConfiguration = `links: {}\nauth:\n credentials:\n username: ${sentinel}\n password: ${sentinel}`; -const secretHint = - 'Invalid authentication configuration: AUTH_SECRET is required when authentication is enabled and must contain at least 32 characters.'; -const originHint = - 'Invalid authentication configuration: ORIGIN must be a canonical HTTPS root origin or HTTP localhost origin, with no trailing slash, path, query or fragment.'; -const authUrlHint = - 'Invalid authentication configuration: AUTH_URL must be a canonical root origin matching ORIGIN exactly.'; +const credentialsConfiguration = `links: {}\nauth:\n origin: http://localhost\n credentials:\n username: ${sentinel}\n password: ${sentinel}`; +const hostileEnvironment = { + ORIGIN: `https://foreign.example.com/${sentinel}`, + AUTH_URL: `malformed-${sentinel}`, + AUTH_SECRET: 'short', + NEXTAUTH_URL: `https://foreign.example.com/${sentinel}`, + AUTH_REDIRECT_PROXY_URL: `https://proxy.example.com/${sentinel}` +}; function source(title: string): string { return ` @@ -43,8 +44,6 @@ process.send(server.server.address().port); env: { HOST: '127.0.0.1', PORT: '0', - ORIGIN: 'http://localhost', - AUTH_URL: 'http://localhost', CONFIG_PATH: path, NODE_ENV: 'production', SHUTDOWN_TIMEOUT: '1', @@ -123,95 +122,12 @@ describe('production configuration startup', () => { contents: `links:\n photos:\n title: Photos\n url: '\${${sentinel}}'` }, { - name: 'credentials auth missing AUTH_SECRET', - code: 'auth_failed', - hint: secretHint, - contents: credentialsConfiguration - }, - { - name: 'enabled auth without ORIGIN', - code: 'auth_failed', - hint: originHint, - contents: credentialsConfiguration, - environment: { AUTH_SECRET: sentinel.repeat(2), ORIGIN: undefined } - }, - { - name: 'enabled auth without AUTH_URL', - code: 'auth_failed', - hint: authUrlHint, - contents: credentialsConfiguration, - environment: { AUTH_SECRET: sentinel.repeat(2), AUTH_URL: undefined } - }, - { - name: 'enabled auth over an HTTP IP address', - code: 'auth_failed', - hint: originHint, - contents: credentialsConfiguration, - environment: { - AUTH_SECRET: sentinel.repeat(2), - ORIGIN: 'http://127.0.0.1', - AUTH_URL: 'http://127.0.0.1' - } - }, - { - name: 'enabled auth with a non-root ORIGIN', - code: 'auth_failed', - hint: originHint, - contents: credentialsConfiguration, - environment: { AUTH_SECRET: sentinel.repeat(2), ORIGIN: `http://localhost/${sentinel}` } - }, - { - name: 'enabled auth with a trailing-slash ORIGIN', - code: 'auth_failed', - hint: originHint, - contents: credentialsConfiguration, - environment: { AUTH_SECRET: sentinel.repeat(2), ORIGIN: 'http://localhost/' } - }, - { - name: 'enabled auth with a mismatched AUTH_URL', - code: 'auth_failed', - hint: authUrlHint, - contents: credentialsConfiguration, - environment: { - AUTH_SECRET: sentinel.repeat(2), - AUTH_URL: `https://foreign.example.com/${sentinel}` - } - }, - { - name: 'OIDC auth missing AUTH_SECRET', - code: 'auth_failed', - hint: secretHint, - contents: `links: {}\nauth:\n oidc:\n issuer: https://identity.example.com\n client_id: ${sentinel}\n client_secret: ${sentinel}` - }, - { - name: 'enabled auth with a short AUTH_SECRET', - code: 'auth_failed', - hint: secretHint, - contents: credentialsConfiguration, - environment: { AUTH_SECRET: sentinel } - }, - { - name: 'enabled auth with a mismatched NEXTAUTH_URL', - code: 'auth_failed', - hint: 'Invalid authentication configuration: NEXTAUTH_URL must match ORIGIN exactly or be unset.', - contents: credentialsConfiguration, - environment: { - AUTH_SECRET: sentinel.repeat(2), - NEXTAUTH_URL: `https://foreign.example.com/${sentinel}` - } - }, - { - name: 'enabled auth with an unsupported redirect proxy', - code: 'auth_failed', - hint: 'Invalid authentication configuration: AUTH_REDIRECT_PROXY_URL is unsupported and must be unset.', - contents: credentialsConfiguration, - environment: { - AUTH_SECRET: sentinel.repeat(2), - AUTH_REDIRECT_PROXY_URL: `https://proxy.example.com/${sentinel}` - } + name: 'credentials auth missing YAML origin', + code: 'load_failed', + contents: credentialsConfiguration.replace(' origin: http://localhost\n', '') } - ])('rejects $name before opening a socket', async ({ contents, code, environment, hint }) => { - const server = await launchServer(contents, environment); + ])('rejects $name before opening a socket', async ({ contents, code }) => { + const server = await launchServer(contents, hostileEnvironment); const result = await server.closed; expect(result.signal).toBeNull(); expect(result.code).not.toBe(0); @@ -226,9 +142,6 @@ describe('production configuration startup', () => { message: `Application startup failed (${code}).` }) ); - if (hint !== undefined) { - expect(records).toContainEqual(expect.objectContaining({ level: 'ERROR', message: hint })); - } for (const record of records) { expect(record).not.toHaveProperty('error'); expect(record).not.toHaveProperty('cause'); @@ -238,7 +151,7 @@ describe('production configuration startup', () => { expect(server.output()).not.toContain('Listening on'); expect(server.output()).not.toContain('foreign.example.com'); expect(server.output()).not.toContain('proxy.example.com'); - for (const value of Object.values(environment ?? {})) { + for (const value of Object.values(hostileEnvironment)) { if (value !== undefined) expect(server.output()).not.toContain(value); } expect(server.output()).not.toContain(sentinel); @@ -246,6 +159,79 @@ describe('production configuration startup', () => { }); }); +it.each([ + { + name: 'without legacy environment or a YAML secret', + environment: {}, + contents: credentialsConfiguration + }, + { + name: 'ignoring conflicting legacy environment', + environment: hostileEnvironment, + contents: credentialsConfiguration + }, + { + name: 'with explicit legacy-named environment references', + environment: { ORIGIN: 'http://localhost', AUTH_SECRET: sentinel }, + contents: credentialsConfiguration + .replace('origin: http://localhost', 'origin: ${ORIGIN}') + .replace(`password: ${sentinel}`, 'password: ${AUTH_SECRET}') + } +])('starts YAML credentials auth $name', async ({ environment, contents }) => { + const server = await launchServer(contents, environment); + const address = await server.ready(); + await expectHealthy(address); + const gated = await fetch(address, { + redirect: 'manual', + headers: { accept: 'text/html', connection: 'close' } + }); + expect(gated.status).toBe(303); + const target = new URL(gated.headers.get('location')!, 'http://localhost'); + expect(target.origin).toBe('http://localhost'); + expect(target.pathname).toBe('/signin'); + const response = await fetch(`${address}/signin`, { + method: 'POST', + redirect: 'manual', + headers: { origin: 'http://localhost', accept: 'text/html', connection: 'close' }, + body: new URLSearchParams({ username: sentinel, password: sentinel, redirectTo: '/' }) + }); + expect([302, 303]).toContain(response.status); + expect(new URL(response.headers.get('location')!, 'http://localhost').href).toBe( + 'http://localhost/' + ); + const cookie = response.headers.getSetCookie().find((value) => value.includes('session-token=')); + expect(cookie).toBeDefined(); + const authenticated = await fetch(address, { + redirect: 'manual', + headers: { cookie: cookie!.split(';')[0]!, connection: 'close' } + }); + expect(authenticated.status).toBe(200); + expect(await authenticated.text()).not.toContain(sentinel); + expect(server.output()).not.toContain(sentinel); + expect(server.output()).not.toContain(server.path); + expect(server.output()).not.toContain('auth_failed'); + expect(await server.stop()).toEqual({ code: 0, signal: null }); +}); + +it('keeps application environment references and fallbacks stable across bootstrap and reloads', async () => { + const fallbackTitle = '${DIRECTORY_TITLE} ${MISSING_TITLE:-Fallback}'; + const title = 'Configured Fallback'; + const server = await launchServer(source(fallbackTitle), { + ...hostileEnvironment, + DIRECTORY_TITLE: 'Configured' + }); + const address = await server.ready(); + await expectPage(address, title); + await writeFile(server.path, source(`${fallbackTitle} reloaded`)); + await vi.waitFor(() => expectPage(address, `${title} reloaded`), { timeout: 5000 }); + await expectHealthy(address); + expect(server.output()).not.toContain('load_failed'); + expect(server.output()).not.toContain('restart_required'); + expect(server.output()).not.toContain(sentinel); + expect(server.output()).not.toContain(server.path); + expect(await server.stop()).toEqual({ code: 0, signal: null }); +}); + it('polls atomic saves, retains last good across invalid/deleted files, and shuts down cleanly', async () => { const server = await launchServer(source('first')); const origin = await server.ready(); @@ -275,7 +261,7 @@ it('polls atomic saves, retains last good across invalid/deleted files, and shut await vi.waitFor(() => expectPage(origin, 'third'), { timeout: 5000, interval: 50 }); await writeFile( server.path, - `${source('rejected')}\nauth:\n credentials:\n username: ${sentinel}\n password: ${sentinel}` + `${source('rejected')}\nauth:\n origin: http://localhost\n credentials:\n username: ${sentinel}\n password: ${sentinel}` ); await vi.waitFor(() => expect(server.output()).toContain('restart_required'), { timeout: 5000 }); await expectPage(origin, 'third'); diff --git a/apps/web/src/lib/server/boot.test.ts b/apps/web/src/lib/server/boot.test.ts index b211b84..03c0e79 100644 --- a/apps/web/src/lib/server/boot.test.ts +++ b/apps/web/src/lib/server/boot.test.ts @@ -2,9 +2,8 @@ import { inspect } from 'node:util'; import { resolve } from 'node:path'; import { ConfigError, validateConfig } from '@directory/config'; import type { DirectoryConfiguration } from '@directory/config/types'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Authentication } from './auth/index.js'; -import { AuthSettingsError } from './auth/settings.js'; import { bootApp } from './boot.js'; import type { Directory } from './config/load-directory.js'; import { DirectoryError } from './directory-error.js'; @@ -39,11 +38,17 @@ const realDirectory = await vi.importActual('@directory/config'); const sentinel = 'PRIVATE-environment-password-filename.yaml'; +const origin = 'https://directory.example.com'; +const frameworkEnv = { ORIGIN: origin, AUTH_URL: origin }; const authCases = [ - { strategy: 'credentials', auth: { credentials: { username: sentinel, password: sentinel } } }, + { + strategy: 'credentials', + auth: { origin, credentials: { username: sentinel, password: sentinel } } + }, { strategy: 'oidc', auth: { + origin, oidc: { issuer: 'https://identity.example.com', client_id: sentinel, @@ -89,6 +94,8 @@ function expectRedacted(error: unknown): void { beforeEach(() => { vi.clearAllMocks(); + vi.stubEnv('ORIGIN', undefined); + vi.stubEnv('AUTH_URL', undefined); mocks.auth.mockReset(); mocks.path.mockReset().mockReturnValue(`/config/${sentinel}`); mocks.publicDir.mockReset().mockReturnValue('/mounted/public'); @@ -97,6 +104,8 @@ beforeEach(() => { mocks.watch.mockReset().mockReturnValue(mocks.stopWatching); }); +afterEach(() => vi.unstubAllEnvs()); + describe('bootApp', () => { it('waits for valid startup, pins one path for loading and watching, then closes the save gap', async () => { const pending = Promise.withResolvers(); @@ -163,8 +172,15 @@ describe('bootApp', () => { it('passes the same default options to both path resolvers', async () => { const app = await bootApp(); - expect(mocks.path).toHaveBeenCalledExactlyOnceWith({}); - expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, {}); + expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: process.env }); + expect(mocks.directory).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, { + env: process.env, + report: reportDirectoryIssue + }); + expect(mocks.directory.mock.calls[0]![1]!.env).toBe(process.env); + expect(mocks.publicDir).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, { + env: process.env + }); app.stop(); }); @@ -238,6 +254,7 @@ describe('bootApp', () => { it.each(authCases)( 'creates $strategy authentication once before watching, retaining it across the post-watch reload', async ({ strategy, auth }) => { + vi.stubEnv('ORIGIN', origin); const config = configuration(auth); const replacement = validateConfig( { ...config, site: { ...config.site, title: 'Replacement' } }, @@ -259,8 +276,8 @@ describe('bootApp', () => { expect(mocks.watch).not.toHaveBeenCalled(); return ready; }); - const app = await bootApp(env); - expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(expect.any(Object), env); + const app = await bootApp(env, frameworkEnv); + expect(mocks.auth).toHaveBeenCalledExactlyOnceWith(expect.any(Object)); expect(mocks.auth.mock.invocationCallOrder[0]).toBeLessThan( mocks.watch.mock.invocationCallOrder[0]! ); @@ -275,46 +292,116 @@ describe('bootApp', () => { } ); - it('preserves a known auth settings error for reporting after stopping the directory', async () => { - const directory = loadedDirectory(configuration(authCases[0]!.auth)); - const failure = new AuthSettingsError('AUTH_SECRET'); + it.each([ + { name: 'auth enabled after preparation', auth: authCases[0]!.auth, env: {} }, + { name: 'auth disabled after preparation', auth: undefined, env: frameworkEnv }, + { + name: 'origin changed after preparation', + auth: authCases[0]!.auth, + env: { + ORIGIN: `https://${sentinel}.example.com`, + AUTH_URL: `https://${sentinel}.example.com` + } + }, + { + name: 'Kit private ORIGIN mismatch', + auth: authCases[0]!.auth, + env: { ORIGIN: sentinel, AUTH_URL: origin } + }, + { + name: 'Kit private AUTH_URL mismatch', + auth: authCases[0]!.auth, + env: { ORIGIN: origin, AUTH_URL: sentinel } + } + ])('rejects $name before authentication, watching or reloading', async ({ auth, env }) => { + vi.stubEnv('ORIGIN', auth?.origin); + const directory = loadedDirectory(configuration(auth)); mocks.directory.mockResolvedValueOnce(directory); - mocks.auth.mockImplementationOnce(() => { - throw failure; - }); + const error: unknown = await bootApp({}, env).catch((error: unknown) => error); + expect(error).toBeInstanceOf(DirectoryError); + expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' }); + expectRedacted(error); + expect(mocks.auth).not.toHaveBeenCalled(); + expect(directory.stop).toHaveBeenCalledOnce(); + expect(directory.reload).not.toHaveBeenCalled(); + expect(mocks.watch).not.toHaveBeenCalled(); const output = vi.spyOn(logger, 'error').mockReturnValue(undefined); try { - const error: unknown = await bootApp({}).catch((error: unknown) => error); - expect(error).toBe(failure); - expectRedacted(error); - expect(directory.stop).toHaveBeenCalledOnce(); - expect(directory.reload).not.toHaveBeenCalled(); - expect(mocks.watch).not.toHaveBeenCalled(); reportStartupFailure(error); - expect(output.mock.calls).toEqual([ - ['Application startup failed (auth_failed).'], - [ - 'Invalid authentication configuration: AUTH_SECRET is required when authentication is enabled and must contain at least 32 characters.' - ] - ]); + expect(output).toHaveBeenCalledExactlyOnceWith('Application startup failed (auth_failed).'); } finally { output.mockRestore(); } }); + it('accepts empty framework origins when authentication is absent', async () => { + const app = await bootApp({}, { ORIGIN: '', AUTH_URL: '' }); + expect(app.authentication).toBeUndefined(); + expect(mocks.auth).not.toHaveBeenCalled(); + app.stop(); + }); + + it('passes explicit source env to the loader separately from prepared process and Kit private origins', async () => { + const source = { ORIGIN: sentinel, AUTH_URL: sentinel, PASSWORD: sentinel }; + vi.stubEnv('ORIGIN', origin); + vi.stubEnv('AUTH_URL', 'https://different.example.com'); + const directory = loadedDirectory(configuration(authCases[0]!.auth)); + mocks.directory.mockResolvedValueOnce(directory); + const ready = authentication('credentials'); + mocks.auth.mockReturnValueOnce(ready); + const app = await bootApp(source, frameworkEnv); + expect(mocks.directory).toHaveBeenCalledExactlyOnceWith(`/config/${sentinel}`, { + env: source, + report: reportDirectoryIssue + }); + expect(mocks.path).toHaveBeenCalledExactlyOnceWith({ env: source }); + expect(mocks.auth).toHaveBeenCalledExactlyOnceWith( + directory.currentSnapshot().configuration.auth + ); + expect(app.authentication).toBe(ready); + app.stop(); + }); + + it('rejects a process origin mismatch even when Kit private env matches YAML', async () => { + vi.stubEnv('ORIGIN', `https://${sentinel}.example.com`); + const directory = loadedDirectory(configuration(authCases[0]!.auth)); + mocks.directory.mockResolvedValueOnce(directory); + const error: unknown = await bootApp({}, frameworkEnv).catch((error: unknown) => error); + expect(error).toBeInstanceOf(DirectoryError); + expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' }); + expectRedacted(error); + expect(directory.stop).toHaveBeenCalledOnce(); + expect(directory.reload).not.toHaveBeenCalled(); + expect(mocks.auth).not.toHaveBeenCalled(); + expect(mocks.watch).not.toHaveBeenCalled(); + }); + + it('checks process.env by default rather than source interpolation origins', async () => { + // Keep the adapter origin valid so only the default SDK environment can reject this boot. + vi.stubEnv('ORIGIN', origin); + const directory = loadedDirectory(configuration(authCases[0]!.auth)); + mocks.directory.mockResolvedValueOnce(directory); + const error: unknown = await bootApp(frameworkEnv).catch((error: unknown) => error); + expect(error).toBeInstanceOf(DirectoryError); + expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' }); + expectRedacted(error); + expect(directory.stop).toHaveBeenCalledOnce(); + expect(mocks.auth).not.toHaveBeenCalled(); + expect(mocks.watch).not.toHaveBeenCalled(); + }); + it.each(authCases)( 'redacts unknown $strategy provider initialization failures before watching or reloading', async ({ auth }) => { + vi.stubEnv('ORIGIN', origin); const directory = loadedDirectory(configuration(auth)); mocks.directory.mockResolvedValueOnce(directory); mocks.auth.mockImplementationOnce(() => { throw new Error(sentinel, { cause: sentinel }); }); - const env = { ORIGIN: 'https://directory.example.com' }; - const error: unknown = await bootApp(env).catch((error: unknown) => error); + const error: unknown = await bootApp({}, frameworkEnv).catch((error: unknown) => error); expect(mocks.auth).toHaveBeenCalledExactlyOnceWith( - directory.currentSnapshot.mock.results[0]?.value.configuration.auth, - env + directory.currentSnapshot.mock.results[0]?.value.configuration.auth ); expect(error).toBeInstanceOf(DirectoryError); expect((error as DirectoryError).issue).toEqual({ code: 'auth_failed' }); diff --git a/apps/web/src/lib/server/boot.ts b/apps/web/src/lib/server/boot.ts index cf602c9..d227d9d 100644 --- a/apps/web/src/lib/server/boot.ts +++ b/apps/web/src/lib/server/boot.ts @@ -1,15 +1,17 @@ import { resolveConfigPath, resolvePublicDir } from '@directory/config'; import type { ConfigEnvironment } from '@directory/config/types'; import { createAuthentication, type Authentication } from './auth/index.js'; -import { AuthSettingsError } from './auth/settings.js'; import { loadDirectory, type Directory } from './config/load-directory.js'; import { DirectoryError } from './directory-error.js'; import type { Application } from './lifecycle/application.js'; import { watchConfig } from './config/watch.js'; import { reportDirectoryIssue } from './logger.js'; -export async function bootApp(env?: ConfigEnvironment): Promise { - const options = env === undefined ? {} : { env }; +export async function bootApp( + env: ConfigEnvironment = process.env, + frameworkEnv: ConfigEnvironment = process.env +): Promise { + const options = { env }; let path: string; let publicDir: string; let directory: Directory; @@ -25,10 +27,17 @@ export async function bootApp(env?: ConfigEnvironment): Promise { let authentication: Authentication | undefined; try { const auth = directory.currentSnapshot().configuration.auth; - if (auth !== undefined) authentication = createAuthentication(auth, env); - } catch (error) { + // Reject edits between URL preparation and Kit startup, including enabling/disabling auth. + if ( + auth?.origin !== (process.env.ORIGIN || undefined) || + auth?.origin !== (frameworkEnv.ORIGIN || undefined) || + auth?.origin !== (frameworkEnv.AUTH_URL || undefined) + ) { + throw new DirectoryError({ code: 'auth_failed' }); + } + if (auth !== undefined) authentication = createAuthentication(auth); + } catch { directory.stop(); - if (error instanceof AuthSettingsError) throw error; throw new DirectoryError({ code: 'auth_failed' }); } diff --git a/apps/web/src/lib/server/config/directory-content.test.ts b/apps/web/src/lib/server/config/directory-content.test.ts index 7633c50..12c63fc 100644 --- a/apps/web/src/lib/server/config/directory-content.test.ts +++ b/apps/web/src/lib/server/config/directory-content.test.ts @@ -228,8 +228,12 @@ describe('prepareDirectoryContent', () => { }); it.each([ - { credentials: { username: 'private-user', password: '${PRIVATE_VALUE}' } }, { + origin: 'https://directory.example.com', + credentials: { username: 'private-user', password: '${PRIVATE_VALUE}' } + }, + { + origin: 'https://directory.example.com', oidc: { issuer: 'https://identity.example.com', client_id: 'private-client', diff --git a/apps/web/src/lib/server/config/load-directory.test.ts b/apps/web/src/lib/server/config/load-directory.test.ts index 8c8a7dd..fa97a4c 100644 --- a/apps/web/src/lib/server/config/load-directory.test.ts +++ b/apps/web/src/lib/server/config/load-directory.test.ts @@ -25,8 +25,12 @@ vi.mock('./snapshot.js', async (importOriginal) => ({ const realSnapshot = await vi.importActual('./snapshot.js'); const sentinel = 'PRIVATE-password-env-filename.yml'; -const credentials = { credentials: { username: 'family', password: sentinel } }; +const credentials = { + origin: 'https://directory.example.com', + credentials: { username: 'family', password: sentinel } +}; const oidc = { + origin: 'https://directory.example.com', oidc: { issuer: 'https://identity.example.com', client_id: 'directory', @@ -181,6 +185,7 @@ site: title: \${DIRECTORY_TITLE} logo_path: /public/boot.svg auth: + origin: https://directory.example.com credentials: username: family password: \${DIRECTORY_PASSWORD} @@ -205,6 +210,7 @@ links: const boot = directory.currentSnapshot(); expect(boot.configuration.site.title).toBe('Filesystem boot'); expect(boot.configuration.auth?.credentials?.password).toBe(sentinel); + expect(boot.configuration.auth).not.toHaveProperty('secret'); expect(boot.display.site).toEqual({ title: 'Filesystem boot', description: '', @@ -243,6 +249,8 @@ links: const recovered = directory.currentSnapshot(); expect(recovered).not.toBe(boot); expect(recovered.configuration.site.title).toBe('Filesystem recovered'); + expect(recovered.configuration.auth).toEqual(boot.configuration.auth); + expect(recovered.configuration.auth).not.toHaveProperty('secret'); expect(recovered.display.site.title).toBe('Filesystem recovered'); expect(recovered.assets.referencedPaths).toEqual(['/public/recovered.svg']); expect(recovered.display.snapshotId).not.toBe(boot.display.snapshotId); @@ -510,6 +518,50 @@ describe('owned snapshot reloads', () => { }); describe('boot authentication', () => { + it.each([ + { + change: 'origin change', + bootAuth: credentials, + nextAuth: { ...credentials, origin: 'https://other.example.com' } + }, + { + change: 'secret rotation', + bootAuth: { ...oidc, secret: `${sentinel}-session` }, + nextAuth: { ...oidc, secret: `${sentinel}-replacement` } + } + ])( + 'requires restart for $change and retains the entire last-good snapshot', + async ({ bootAuth, nextAuth }) => { + const report = vi.fn(); + mocks.load.mockResolvedValueOnce(configuration('boot', bootAuth)); + const directory = await loadDirectory(undefined, { report }); + mocks.load.mockResolvedValueOnce(configuration('accepted', bootAuth)); + await directory.reload(); + const accepted = directory.currentSnapshot(); + const captured = structuredClone(accepted); + + mocks.load.mockResolvedValueOnce(configuration('rejected', nextAuth)); + await directory.reload(); + expect(report).toHaveBeenCalledExactlyOnceWith({ code: 'restart_required' }); + expectRedacted(report.mock.calls); + expect(directory.currentSnapshot()).toBe(accepted); + expect(directory.currentSnapshot()).toEqual(captured); + + mocks.load.mockResolvedValueOnce(configuration('restored', bootAuth)); + await directory.reload(); + const restored = directory.currentSnapshot(); + expect(restored).not.toBe(accepted); + expect(restored.configuration.auth).toEqual(bootAuth); + expect(restored.display.site.title).toBe('restored'); + expect(restored.display.groups[0]?.links[0]?.title).toBe('restored photos'); + expect(restored.assets.referencedPaths).toEqual([ + '/public/restored.svg', + '/public/restored-photos.svg' + ]); + expect(report).toHaveBeenCalledTimes(1); + } + ); + it('rejects enabling auth on an unauthenticated boot, then accepts restored absent auth', async () => { const report = vi.fn(); const directory = await loadDirectory(undefined, { report }); @@ -530,7 +582,7 @@ describe('boot authentication', () => { const directory = await loadDirectory(undefined, { report }); const boot = directory.currentSnapshot(); mocks.load.mockResolvedValueOnce( - configuration('changed', { oidc: { ...oidc.oidc, client_secret: 'changed' } }) + configuration('changed', { ...oidc, oidc: { ...oidc.oidc, client_secret: 'changed' } }) ); await directory.reload(); expect(directory.currentSnapshot()).toBe(boot); @@ -549,7 +601,8 @@ describe('boot authentication', () => { client_secret: sentinel, client_id: 'directory', issuer: 'https://identity.example.com' - } + }, + origin: oidc.origin }; mocks.load.mockResolvedValueOnce(restored); await directory.reload(); diff --git a/apps/web/src/lib/server/config/snapshot.test.ts b/apps/web/src/lib/server/config/snapshot.test.ts index 70c13d6..0b88693 100644 --- a/apps/web/src/lib/server/config/snapshot.test.ts +++ b/apps/web/src/lib/server/config/snapshot.test.ts @@ -9,7 +9,11 @@ function configuration() { return validateConfig( { site: { title: 'Home', logo_path: '/public/home.svg', favicon_path: '/public/site-icon.png' }, - auth: { credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' } }, + auth: { + origin: 'https://directory.example.com', + secret: 'PRIVATE_SESSION_SECRET_AT_LEAST_32_CHARACTERS', + credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' } + }, links: { photos: { ...service, diff --git a/apps/web/src/lib/server/lifecycle/development.test.ts b/apps/web/src/lib/server/lifecycle/development.test.ts index 92d7d4f..5a7631d 100644 --- a/apps/web/src/lib/server/lifecycle/development.test.ts +++ b/apps/web/src/lib/server/lifecycle/development.test.ts @@ -52,7 +52,7 @@ describe('development lifecycle', () => { mocks.boot.mockResolvedValueOnce(ready); const off = vi.spyOn(process, 'off'); await startDevelopment(server); - expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(); + expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, undefined); expect(server.application).toBe(ready); expect(listeners.mock.calls.filter(([name]) => name === 'sveltekit:shutdown')).toEqual([]); const shutdown = devStopListener(); diff --git a/apps/web/src/lib/server/lifecycle/development.ts b/apps/web/src/lib/server/lifecycle/development.ts index 4737b64..51b520f 100644 --- a/apps/web/src/lib/server/lifecycle/development.ts +++ b/apps/web/src/lib/server/lifecycle/development.ts @@ -1,3 +1,4 @@ +import type { ConfigEnvironment } from '@directory/config/types'; import { bootApp } from '../boot.js'; import type { Application, ServerState } from './application.js'; @@ -7,15 +8,17 @@ interface DevelopmentAttempt { stopped: boolean; } -export async function startDevelopment(server: ServerState): Promise { - // The process event reaches both cached hooks and replaced server modules. +export async function startDevelopment( + server: ServerState, + env?: ConfigEnvironment +): Promise { process.emit('directory:dev-stop'); const attempt: DevelopmentAttempt = { server, application: undefined, stopped: false }; const shutdown = () => stopDevelopment(attempt, shutdown); process.once('directory:dev-stop', shutdown); let application: Application; try { - application = await bootApp(); + application = await bootApp(undefined, env); } catch (error) { stopDevelopment(attempt, shutdown); throw error; diff --git a/apps/web/src/lib/server/lifecycle/production.test.ts b/apps/web/src/lib/server/lifecycle/production.test.ts index 712bff7..b630df9 100644 --- a/apps/web/src/lib/server/lifecycle/production.test.ts +++ b/apps/web/src/lib/server/lifecycle/production.test.ts @@ -48,7 +48,7 @@ describe('production lifecycle', () => { const ready = application('Production'); mocks.boot.mockResolvedValueOnce(ready); await startProduction(server); - expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(); + expect(mocks.boot).toHaveBeenCalledExactlyOnceWith(undefined, undefined); expect(server.application).toBe(ready); expect(listeners.mock.calls.filter(([name]) => name === 'directory:dev-stop')).toEqual([]); const shutdown = listeners.mock.calls.find(([name]) => name === 'sveltekit:shutdown')![1]; diff --git a/apps/web/src/lib/server/lifecycle/production.ts b/apps/web/src/lib/server/lifecycle/production.ts index 32b679e..f1dc9d8 100644 --- a/apps/web/src/lib/server/lifecycle/production.ts +++ b/apps/web/src/lib/server/lifecycle/production.ts @@ -1,8 +1,9 @@ +import type { ConfigEnvironment } from '@directory/config/types'; import { bootApp } from '../boot.js'; import type { Application, ServerState } from './application.js'; -export async function startProduction(server: ServerState): Promise { - const application = await bootApp(); +export async function startProduction(server: ServerState, env?: ConfigEnvironment): Promise { + const application = await bootApp(undefined, env); // adapter-node emits shutdown after listening, which waits for Kit init. process.once('sveltekit:shutdown', () => stopProduction(server, application)); server.application = application; diff --git a/apps/web/src/lib/server/logger.test.ts b/apps/web/src/lib/server/logger.test.ts index e7b91e2..a5e1a0a 100644 --- a/apps/web/src/lib/server/logger.test.ts +++ b/apps/web/src/lib/server/logger.test.ts @@ -1,6 +1,5 @@ import { ConfigError } from '@directory/config'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { AuthSettingsError } from './auth/settings.js'; import { DirectoryError } from './directory-error.js'; import { logger, reportDirectoryIssue, reportStartupFailure } from './logger.js'; @@ -38,47 +37,22 @@ describe('safe server reporting', () => { expect(output).toHaveBeenCalledExactlyOnceWith('Application startup failed (prepare_failed).'); }); - it.each([ - [ - 'AUTH_SECRET', - 'Invalid authentication configuration: AUTH_SECRET is required when authentication is enabled and must contain at least 32 characters.' - ], - [ - 'ORIGIN', - 'Invalid authentication configuration: ORIGIN must be a canonical HTTPS root origin or HTTP localhost origin, with no trailing slash, path, query or fragment.' - ], - [ - 'AUTH_URL', - 'Invalid authentication configuration: AUTH_URL must be a canonical root origin matching ORIGIN exactly.' - ], - [ - 'NEXTAUTH_URL', - 'Invalid authentication configuration: NEXTAUTH_URL must match ORIGIN exactly or be unset.' - ], - [ - 'AUTH_REDIRECT_PROXY_URL', - 'Invalid authentication configuration: AUTH_REDIRECT_PROXY_URL is unsupported and must be unset.' - ] - ] as const)( - 'reports only a fixed hint for %s, ignoring even mutated error details', - (setting, hint) => { - const output = vi.spyOn(logger, 'error').mockReturnValue(undefined); - const error = new AuthSettingsError(setting); - error.message = sentinel; - error.stack = sentinel; - error.cause = new Error(sentinel); - reportStartupFailure(error); - expect(output.mock.calls).toEqual([['Application startup failed (auth_failed).'], [hint]]); - expect(JSON.stringify(output.mock.calls)).not.toContain(sentinel); - } - ); - - it('does not interpolate an unrecognized setting even on a typed error', () => { + it('reports auth schema issue paths without configuration values or legacy hints', () => { const output = vi.spyOn(logger, 'error').mockReturnValue(undefined); - const error = new AuthSettingsError('AUTH_SECRET'); - Object.defineProperty(error, 'setting', { value: sentinel }); - reportStartupFailure(error); - expect(output).toHaveBeenCalledExactlyOnceWith('Application startup failed (auth_failed).'); + reportStartupFailure( + new DirectoryError({ + code: 'load_failed', + issues: new ConfigError([ + { code: 'invalid_value', path: '/auth/origin' }, + { code: 'invalid_value', path: '/auth/secret' } + ]).issues.map((detail) => ({ ...detail, message: sentinel })) + }) + ); + expect(output.mock.calls).toEqual([ + ['Application startup failed (load_failed).'], + [' /auth/origin (invalid_value)'], + [' /auth/secret (invalid_value)'] + ]); }); it.each([ diff --git a/apps/web/src/lib/server/logger.ts b/apps/web/src/lib/server/logger.ts index 1e52c19..da49232 100644 --- a/apps/web/src/lib/server/logger.ts +++ b/apps/web/src/lib/server/logger.ts @@ -1,5 +1,4 @@ import { Logger } from 'tslog'; -import { AuthSettingsError, type AuthSetting } from './auth/settings.js'; import { DirectoryError, type DirectoryIssue } from './directory-error.js'; let logType: 'pretty' | 'json' = 'pretty'; @@ -17,11 +16,7 @@ export function reportDirectoryIssue(issue: DirectoryIssue): void { } export function reportStartupFailure(error: unknown): void { - if (error instanceof AuthSettingsError) { - logger.error('Application startup failed (auth_failed).'); - const hint = authSettingsHint(error.setting); - if (hint !== undefined) logger.error(hint); - } else if (error instanceof DirectoryError) { + if (error instanceof DirectoryError) { logger.error(`Application startup failed (${error.issue.code}).`); reportIssueDetails(error.issue); } else { @@ -29,21 +24,6 @@ export function reportStartupFailure(error: unknown): void { } } -function authSettingsHint(setting: AuthSetting): string | undefined { - switch (setting) { - case 'AUTH_SECRET': - return 'Invalid authentication configuration: AUTH_SECRET is required when authentication is enabled and must contain at least 32 characters.'; - case 'ORIGIN': - return 'Invalid authentication configuration: ORIGIN must be a canonical HTTPS root origin or HTTP localhost origin, with no trailing slash, path, query or fragment.'; - case 'AUTH_URL': - return 'Invalid authentication configuration: AUTH_URL must be a canonical root origin matching ORIGIN exactly.'; - case 'NEXTAUTH_URL': - return 'Invalid authentication configuration: NEXTAUTH_URL must match ORIGIN exactly or be unset.'; - case 'AUTH_REDIRECT_PROXY_URL': - return 'Invalid authentication configuration: AUTH_REDIRECT_PROXY_URL is unsupported and must be unset.'; - } -} - function reportIssueDetails(issue: DirectoryIssue): void { for (const detail of issue.issues ?? []) { logger.error(` ${detail.path} (${detail.code})`); diff --git a/apps/web/src/lib/server/runtime-environment.test.ts b/apps/web/src/lib/server/runtime-environment.test.ts new file mode 100644 index 0000000..1559c2b --- /dev/null +++ b/apps/web/src/lib/server/runtime-environment.test.ts @@ -0,0 +1,85 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DirectoryError } from './directory-error.js'; +import { prepareRuntimeEnvironment } from './runtime-environment.js'; + +const origin = 'https://directory.example.com'; +const sentinel = 'PRIVATE-runtime-setting'; +let directory: string; +let path: string; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'directory-runtime-')); + path = join(directory, 'directory.yaml'); +}); + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +function credentials(extra = ''): string { + return `links: {} +auth: + origin: '${origin}' + credentials: + username: family + password: test-password +${extra}`; +} + +describe('framework environment preparation', () => { + it('uses the YAML origin instead of legacy framework settings', async () => { + await writeFile(path, credentials()); + const env = { + CONFIG_PATH: path, + ORIGIN: sentinel, + AUTH_URL: sentinel, + NEXTAUTH_URL: sentinel, + AUTH_REDIRECT_PROXY_URL: sentinel, + AUTH_SECRET: sentinel + }; + await prepareRuntimeEnvironment(env); + expect(env.ORIGIN).toBe(origin); + expect(env.AUTH_URL).toBe(origin); + expect(env.NEXTAUTH_URL).toBe(''); + expect(env.AUTH_REDIRECT_PROXY_URL).toBe(''); + expect(env.AUTH_SECRET).toBe(sentinel); + }); + + it('removes inherited framework URLs for an open directory', async () => { + await writeFile(path, 'links: {}'); + const env = { + CONFIG_PATH: path, + ORIGIN: sentinel, + AUTH_URL: sentinel, + NEXTAUTH_URL: sentinel, + AUTH_REDIRECT_PROXY_URL: sentinel + }; + await prepareRuntimeEnvironment(env); + expect(env).not.toHaveProperty('ORIGIN'); + expect(env).not.toHaveProperty('AUTH_URL'); + expect(env.NEXTAUTH_URL).toBe(''); + expect(env.AUTH_REDIRECT_PROXY_URL).toBe(''); + }); + + it('fails safely before changing the environment for invalid configuration', async () => { + await writeFile(path, credentials(` secret: '${sentinel}'\n`)); + const env = { CONFIG_PATH: path, ORIGIN: sentinel, AUTH_URL: sentinel }; + const before = { ...env }; + const error: unknown = await prepareRuntimeEnvironment(env).catch((error: unknown) => error); + expect(error).toBeInstanceOf(DirectoryError); + expect((error as DirectoryError).issue.code).toBe('load_failed'); + expect(String(error)).not.toContain(sentinel); + expect(JSON.stringify(error)).not.toContain(sentinel); + expect(JSON.stringify(error)).not.toContain(path); + expect(env).toEqual(before); + }); + + it('redacts unreadable config paths', async () => { + await expect(prepareRuntimeEnvironment({ CONFIG_PATH: path })).rejects.toMatchObject({ + issue: { code: 'load_failed', issues: [{ code: 'file_unreadable', path: '/' }] } + }); + }); +}); diff --git a/apps/web/src/lib/server/runtime-environment.ts b/apps/web/src/lib/server/runtime-environment.ts new file mode 100644 index 0000000..ea9a51b --- /dev/null +++ b/apps/web/src/lib/server/runtime-environment.ts @@ -0,0 +1,26 @@ +import { ConfigError, loadConfigFile } from '@directory/config'; +import { DirectoryError } from './directory-error.js'; + +export async function prepareRuntimeEnvironment( + env: NodeJS.ProcessEnv = process.env +): Promise { + let origin: string | undefined; + try { + origin = (await loadConfigFile(undefined, { env })).auth?.origin; + } catch (error) { + throw new DirectoryError({ + code: 'load_failed', + ...(error instanceof ConfigError ? { issues: error.issues } : {}) + }); + } + + if (origin === undefined) { + delete env.ORIGIN; + delete env.AUTH_URL; + } else { + env.ORIGIN = origin; + env.AUTH_URL = origin; + } + env.NEXTAUTH_URL = ''; + env.AUTH_REDIRECT_PROXY_URL = ''; +} diff --git a/apps/web/src/routes/(protected)/(endpoints)/config/version/server.test.ts b/apps/web/src/routes/(protected)/(endpoints)/config/version/server.test.ts index 68a33a6..22a747d 100644 --- a/apps/web/src/routes/(protected)/(endpoints)/config/version/server.test.ts +++ b/apps/web/src/routes/(protected)/(endpoints)/config/version/server.test.ts @@ -10,7 +10,11 @@ function snapshot(title: string) { validateConfig( { site: { title, description: sentinel }, - auth: { credentials: { username: sentinel, password: sentinel } }, + auth: { + origin: 'https://private-auth.example.test', + secret: `${sentinel}-session-secret`, + credentials: { username: sentinel, password: sentinel } + }, links: { photos: { title: sentinel, url: 'https://private.example.test', tags: [sentinel] } } @@ -33,9 +37,6 @@ describe('accepted configuration version endpoint', () => { expect(response.headers.get('content-type')).toContain('application/json'); const text = await response.text(); expect(JSON.parse(text)).toEqual({ snapshotId: captured.display.snapshotId }); - expect(text).not.toContain(sentinel); - expect(text).not.toContain('private.example.test'); - expect(text).not.toContain('Family'); }); it('keeps an older request capture independent of a later snapshot', async () => { diff --git a/apps/web/src/routes/(protected)/page.test.ts b/apps/web/src/routes/(protected)/page.test.ts index b75b455..30ab729 100644 --- a/apps/web/src/routes/(protected)/page.test.ts +++ b/apps/web/src/routes/(protected)/page.test.ts @@ -33,6 +33,8 @@ function eventFor(query = '', withAuth = false) { }; if (withAuth) { configuration.auth = { + origin: 'https://private-auth.example.test', + secret: 'PRIVATE_SESSION_SECRET_AT_LEAST_32_CHARACTERS', credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' } }; } @@ -120,6 +122,7 @@ describe('directory page data', () => { expect(serialized).toContain('https://photos.example.com'); expect(serialized).toContain('Family albums'); expect(serialized).not.toContain('PRIVATE_'); + expect(serialized).not.toContain('private-auth.example.test'); expect(serialized).not.toContain('2099-01-01'); for (const field of [ 'configuration', diff --git a/apps/web/src/routes/(public)/signin/page.test.ts b/apps/web/src/routes/(public)/signin/page.test.ts index a75c72b..e4ae29a 100644 --- a/apps/web/src/routes/(public)/signin/page.test.ts +++ b/apps/web/src/routes/(public)/signin/page.test.ts @@ -16,7 +16,11 @@ function eventFor(query = '', strategy: Authentication['strategy'] = 'credential description: 'PRIVATE_DESCRIPTION', logo_path: '/public/private.svg' }, - auth: { credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' } }, + auth: { + origin: 'https://private-auth.example.test', + secret: 'PRIVATE_SESSION_SECRET_AT_LEAST_32_CHARACTERS', + credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' } + }, links: { photos: { title: 'PRIVATE_LINK', url: 'https://photos.example.com' } } }, { env: {} } @@ -55,8 +59,6 @@ describe('sign-in page', () => { callbackUrl: '/?link=photos', error: null }); - expect(JSON.stringify(data)).not.toContain('PRIVATE_'); - expect(JSON.stringify(data)).not.toContain('private.svg'); } ); diff --git a/apps/web/src/routes/layout.test.ts b/apps/web/src/routes/layout.test.ts index ee2904a..401b2e4 100644 --- a/apps/web/src/routes/layout.test.ts +++ b/apps/web/src/routes/layout.test.ts @@ -9,7 +9,11 @@ function event(site: Record = {}) { { site: { title: 'PRIVATE_LAYOUT_TITLE', description: 'PRIVATE_DESCRIPTION', ...site }, appearance: { primary_light_color: '#13579b', primary_dark_color: '#c4d6e8' }, - auth: { credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' } }, + auth: { + origin: 'https://private-auth.example.test', + secret: 'PRIVATE_SESSION_SECRET_AT_LEAST_32_CHARACTERS', + credentials: { username: 'PRIVATE_USERNAME', password: 'PRIVATE_PASSWORD' } + }, links: { private: { title: 'PRIVATE_LINK', url: 'https://private.example.test' } } }, { env: {} } @@ -32,9 +36,6 @@ describe('public branding layout data', () => { const before = JSON.stringify(captured.locals); const data = load(captured); expect(data).toEqual({ faviconPath: '/favicon.svg', logoPath: '/favicon.svg' }); - expect(JSON.stringify(data)).not.toContain('PRIVATE_'); - expect(JSON.stringify(data)).not.toContain('private.example.test'); - expect(JSON.stringify(data)).not.toContain('2099-01-01'); expect(JSON.stringify(captured.locals)).toBe(before); }); diff --git a/apps/web/src/start.ts b/apps/web/src/start.ts new file mode 100644 index 0000000..69c7fa5 --- /dev/null +++ b/apps/web/src/start.ts @@ -0,0 +1,13 @@ +import { prepareRuntimeEnvironment } from './lib/server/runtime-environment.js'; +import { reportStartupFailure } from './lib/server/logger.js'; + +try { + await prepareRuntimeEnvironment(); +} catch (error) { + reportStartupFailure(error); + process.exit(1); +} + +// adapter-node captures ORIGIN during import, before its Kit init hook runs. +const entry = '../index.js'; +export const { server } = await import(entry); diff --git a/apps/web/tsconfig.bootstrap.json b/apps/web/tsconfig.bootstrap.json new file mode 100644 index 0000000..d577478 --- /dev/null +++ b/apps/web/tsconfig.bootstrap.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build/bootstrap", + "types": ["node"], + "noEmitOnError": true + }, + "include": ["src/start.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 7a4fad4..8a619b3 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,19 +1,29 @@ import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; +import { prepareRuntimeEnvironment } from './src/lib/server/runtime-environment.js'; import Icons from 'unplugin-icons/vite'; -export default defineConfig({ - optimizeDeps: { include: ['fuse.js'] }, - plugins: [ - sveltekit(), - Icons({ compiler: 'svelte' }), - // Kit's SSR loader does not run import.meta.hot.dispose. - { - name: 'directory-dev-cleanup', - apply: 'serve', - closeBundle() { - process.emit('directory:dev-stop'); +export default defineConfig(async ({ command, mode, isPreview }) => { + if (command === 'serve' && !isPreview) { + Object.assign(process.env, loadEnv(mode, process.cwd(), '')); + await prepareRuntimeEnvironment(); + // Empty overrides stop Vite from restoring legacy URL values from dotenv files. + process.env.ORIGIN ??= ''; + process.env.AUTH_URL ??= ''; + } + return { + optimizeDeps: { include: ['fuse.js'] }, + plugins: [ + sveltekit(), + Icons({ compiler: 'svelte' }), + // Kit's SSR loader does not run import.meta.hot.dispose. + { + name: 'directory-dev-cleanup', + apply: 'serve', + closeBundle() { + process.emit('directory:dev-stop'); + } } - } - ] + ] + }; }); diff --git a/packages/config/src/auth.test.ts b/packages/config/src/auth.test.ts new file mode 100644 index 0000000..af6d2a9 --- /dev/null +++ b/packages/config/src/auth.test.ts @@ -0,0 +1,216 @@ +import { inspect } from 'node:util'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { ConfigError } from './errors.js'; +import { validateConfig } from './load.js'; +import type { DirectoryAuth } from './types.js'; + +const origin = 'https://directory.example.com'; +const secret = 'test-only-secret-0123456789abcdef'; +const credentials = { username: 'family', password: 'fixture' }; +const oidc = { issuer: 'https://id.example.com', client_id: 'directory', client_secret: 'fixture' }; +const strategies = [ + { name: 'credentials', strategy: { credentials } }, + { name: 'OIDC', strategy: { oidc } } +]; + +const acceptedOrigins = [ + origin, + 'https://directory.example.com:8443', + 'https://localhost', + 'https://127.0.0.1', + 'https://[::1]', + 'https://[2001:db8::1]:8443', + 'https://xn--bcher-kva.example', + 'https://directory.example.com.', + 'http://localhost', + 'http://localhost:3000', + 'http://localhost:443', + 'http://localhost:0', + 'http://localhost:65535' +]; +const rejectedOrigins = [ + '', + ' ', + 'null', + 'directory.example.com', + '//directory.example.com', + 'ftp://directory.example.com', + 'javascript:alert(1)', + 'https://', + 'https://[broken', + 'http://directory.example.com', + 'http://127.0.0.1:3000', + 'http://[::1]:3000', + 'http://localhost.example.com', + 'http://sub.localhost', + 'http://localhost.', + 'https://user:password@directory.example.com', + 'https://@directory.example.com', + 'https://directory.example.com/', + 'https://directory.example.com/path', + 'https://directory.example.com/.', + 'https://directory.example.com/..', + 'https://directory.example.com?', + 'https://directory.example.com?query', + 'https://directory.example.com#', + 'https://directory.example.com#fragment', + 'https://directory.example.com\\path', + 'https:\\directory.example.com', + 'https:directory.example.com', + 'https:///directory.example.com', + 'HTTPS://directory.example.com', + 'https://DIRECTORY.example.com', + 'http://LOCALHOST:3000', + 'https://bücher.example', + 'https://%64irectory.example.com', + 'https://directory.example.com:443', + 'http://localhost:80', + 'http://localhost:03000', + 'http://localhost:', + 'http://localhost:65536', + 'https://directory.example.com:008443', + 'https://127.1', + 'https://0x7f000001', + 'https://[0:0:0:0:0:0:0:1]', + 'https://[2001:DB8::1]', + ' https://directory.example.com', + 'https://directory.example.com ', + 'https://directory.example.com\n', + 'https://direc\ttory.example.com', + 'https://directory.example.com\u0000' +]; + +// These fields share validators across strategies; exercise URL attack classes once. +describe('canonical auth origins', () => { + it.each(acceptedOrigins)('accepts %j', (origin) => { + expect(validateConfig({ links: {}, auth: { origin, credentials } }).auth?.origin).toBe(origin); + }); + + it.each([...rejectedOrigins, null, false, 3000, [], {}])( + 'rejects rather than normalizing %j', + (origin) => { + expect(() => validateConfig({ links: {}, auth: { origin, credentials } })).toThrow( + ConfigError + ); + } + ); +}); + +describe('optional auth secrets', () => { + it.each(['x'.repeat(32), ' padded-secret-'.repeat(20)])( + 'preserves a supplied secret verbatim', + (secret) => { + expect( + validateConfig({ links: {}, auth: { origin, secret, credentials } }).auth?.secret + ).toBe(secret); + } + ); + + it.each([null, false, 32, [], {}, '', 'x'.repeat(31), ' '.repeat(32)])( + 'rejects invalid explicit secret %j', + (secret) => { + expect(() => validateConfig({ links: {}, auth: { origin, secret, credentials } })).toThrow( + ConfigError + ); + } + ); +}); + +describe('auth strategy boundary', () => { + it('retains required origin and optional secret types', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('does not enable auth from environment settings alone', () => { + expect( + validateConfig({ links: {} }, { env: { ORIGIN: origin, AUTH_SECRET: secret } }) + ).not.toHaveProperty('auth'); + }); + + it.each(strategies)( + 'requires an explicit origin for $name and never generates a secret', + ({ strategy }) => { + const env = { ORIGIN: origin, AUTH_SECRET: secret }; + expect(() => validateConfig({ links: {}, auth: strategy }, { env })).toThrow(ConfigError); + const raw = { links: {}, auth: { origin, ...strategy } }; + const before = structuredClone(raw); + expect(validateConfig(raw, { env }).auth).toEqual(raw.auth); + expect(validateConfig(raw, { env }).auth).not.toHaveProperty('secret'); + expect(raw).toEqual(before); + expect(validateConfig({ links: {}, auth: { origin, secret, ...strategy } }).auth).toEqual({ + origin, + secret, + ...strategy + }); + } + ); + + it('applies the shared origin and secret constraints to OIDC too', () => { + expect(() => + validateConfig({ links: {}, auth: { origin: 'http://directory.example.com', oidc } }) + ).toThrow(ConfigError); + expect(() => + validateConfig({ links: {}, auth: { origin, secret: 'x'.repeat(31), oidc } }) + ).toThrow(ConfigError); + }); +}); + +describe('auth interpolation and safe errors', () => { + it('expands origin and secret before validation without changing authored input', () => { + const raw = { + links: {}, + auth: { origin: '${PUBLIC_ORIGIN:-http://localhost:3000}', secret: '${SECRET}', credentials } + }; + const before = structuredClone(raw); + expect(validateConfig(raw, { env: { PUBLIC_ORIGIN: origin, SECRET: secret } }).auth).toEqual({ + origin, + secret, + credentials + }); + expect(validateConfig(raw, { env: { SECRET: secret } }).auth).toEqual({ + origin: 'http://localhost:3000', + secret, + credentials + }); + expect(raw).toEqual(before); + }); + + it.each(['origin', 'secret'] as const)( + 'redacts invalid literal, expanded and missing %s', + (field) => { + const sentinel = 'SENTINEL-NEVER-LOG-THIS'; + for (const { value, env } of [ + { value: sentinel, env: {} }, + { value: '${PRIVATE_VALUE}', env: { PRIVATE_VALUE: sentinel } }, + { value: '${PRIVATE_VALUE}', env: {} } + ]) { + try { + validateConfig( + { links: {}, auth: { origin, secret, credentials, [field]: value } }, + { env } + ); + expect.fail('Expected invalid auth'); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + for (const text of [ + String(error), + inspect(error), + JSON.stringify(error), + (error as Error).stack + ]) { + expect(text).not.toContain(sentinel); + expect(text).not.toContain('PRIVATE_VALUE'); + expect(text).not.toContain(secret); + } + expect(error).not.toHaveProperty('cause'); + if (value.startsWith('${') && !env.PRIVATE_VALUE) { + expect((error as ConfigError).issues).toMatchObject([ + { code: 'missing_environment', path: `/auth/${field}` } + ]); + } + } + } + } + ); +}); diff --git a/packages/config/src/config.schema.json b/packages/config/src/config.schema.json index 7557e7c..6b0de59 100644 --- a/packages/config/src/config.schema.json +++ b/packages/config/src/config.schema.json @@ -33,6 +33,32 @@ } ] }, + "AuthOrigin": { + "description": "The canonical public origin of Directory. HTTPS is required except for http://localhost[:port]. No credentials, path, trailing slash, query, fragment, or noncanonical URL spelling.", + "type": "string", + "anyOf": [ + { + "type": "string", + "format": "uri", + "pattern": "^(?:https://(?:[^\\s/\\\\?#@:\\[\\]A-Z%]+|\\[[0-9a-f:.]+\\])(?::(?!443(?![\\s\\S]))[0-9]+)?|http://localhost(?::(?!80(?![\\s\\S]))[0-9]+)?)(?![\\s\\S])" + }, + { + "type": "string", + "pattern": "(?:^|[^$])(?:\\$\\$)*\\$\\{[A-Z_][A-Z0-9_]*(?::-[^}]*)?\\}" + } + ] + }, + "AuthSecret": { + "description": "An optional user-generated random session secret of at least 32 characters. Omit to let the server generate an ephemeral secret at startup; the config loader does not generate one.", + "type": "string", + "anyOf": [ + { "type": "string", "pattern": "\\S", "minLength": 32 }, + { + "type": "string", + "pattern": "(?:^|[^$])(?:\\$\\$)*\\$\\{[A-Z_][A-Z0-9_]*(?::-[^}]*)?\\}" + } + ] + }, "LocalImagePath": { "description": "A decoded image path under /public/. Use SVG, PNG, JPEG, GIF or WebP; no dot segments, percent escapes, backslashes, queries or fragments.", "type": "string", @@ -232,15 +258,22 @@ "additionalProperties": false, "title": "OIDC", "type": "object", - "required": ["oidc"], - "properties": { "oidc": { "$ref": "#/$defs/OidcAuth" }, "credentials": { "not": {} } } + "required": ["origin", "oidc"], + "properties": { + "origin": { "$ref": "#/$defs/AuthOrigin" }, + "secret": { "$ref": "#/$defs/AuthSecret" }, + "oidc": { "$ref": "#/$defs/OidcAuth" }, + "credentials": { "not": {} } + } }, { "additionalProperties": false, "title": "Shared username and password", "type": "object", - "required": ["credentials"], + "required": ["origin", "credentials"], "properties": { + "origin": { "$ref": "#/$defs/AuthOrigin" }, + "secret": { "$ref": "#/$defs/AuthSecret" }, "credentials": { "$ref": "#/$defs/CredentialsAuth" }, "oidc": { "not": {} } } diff --git a/packages/config/src/editor-schema.test.ts b/packages/config/src/editor-schema.test.ts index daf1ae3..b03bb92 100644 --- a/packages/config/src/editor-schema.test.ts +++ b/packages/config/src/editor-schema.test.ts @@ -7,6 +7,7 @@ import { configurationReferences, HexColorSchema, HttpUrlSchema, + AuthOriginSchema, LocalImagePathSchema, DirectoryConfigurationSchema } from './schema.js'; @@ -61,12 +62,34 @@ describe('default-aware editor schema', () => { 'client_secret' ]); const authBranches = definition(editor, 'Auth').anyOf as ObjectSchema[]; - expect(authBranches.map((branch) => branch.required)).toEqual([['oidc'], ['credentials']]); + expect(authBranches.map((branch) => branch.required)).toEqual([ + ['origin', 'oidc'], + ['origin', 'credentials'] + ]); expect(authBranches.every((branch) => branch.additionalProperties === false)).toBe(true); expect(property(editor, 'links').additionalProperties).toBe(false); expect(object((definition(editor, 'HttpUrl').anyOf as unknown[])[0]).format).toBe('uri'); }); + it('keeps auth origin required and secret optional, constrained and without defaults', () => { + const editor = createEditorSchema(); + for (const branch of definition(editor, 'Auth').anyOf as ObjectSchema[]) { + expect(property(branch, 'origin')).toEqual({ $ref: '#/$defs/AuthOrigin' }); + expect(property(branch, 'secret')).toEqual({ $ref: '#/$defs/AuthSecret' }); + expect(branch.required).not.toContain('secret'); + } + const origin = definition(editor, 'AuthOrigin'); + const secret = definition(editor, 'AuthSecret'); + expect(secret).not.toHaveProperty('default'); + expect((secret.anyOf as ObjectSchema[])[0]).toEqual({ + type: 'string', + minLength: 32, + pattern: '\\S' + }); + expect(interpolationPattern(origin).test('${PUBLIC_ORIGIN}')).toBe(true); + expect(interpolationPattern(secret).test('${SECRET:-fallback}')).toBe(true); + }); + it('keeps site image defaults on references without broadening bookmark image choices', () => { const editor = createEditorSchema(); for (const field of ['logo_path', 'favicon_path']) { @@ -180,7 +203,12 @@ describe('default-aware editor schema', () => { it('separates literal constraints from the environment-token branch', () => { const editor = createEditorSchema(); - for (const canonical of [HttpUrlSchema, LocalImagePathSchema, HexColorSchema]) { + for (const canonical of [ + HttpUrlSchema, + AuthOriginSchema, + LocalImagePathSchema, + HexColorSchema + ]) { const adapted = definition(editor, canonical.$id!); const literal: ObjectSchema = { type: 'string', pattern: canonical.pattern }; if (canonical.format) literal.format = 'uri'; @@ -324,7 +352,7 @@ describe('default-aware editor schema', () => { }); it('leaves canonical validation strict for placeholders and expanded values', () => { - for (const schema of [HttpUrlSchema, LocalImagePathSchema, HexColorSchema]) { + for (const schema of [HttpUrlSchema, AuthOriginSchema, LocalImagePathSchema, HexColorSchema]) { expect(Value.Check(schema, '${VALUE}')).toBe(false); expect(Value.Check(schema, false)).toBe(false); expect(Value.Check(schema, 1)).toBe(false); diff --git a/packages/config/src/editor-schema.ts b/packages/config/src/editor-schema.ts index 2bfcbaa..92e7a2a 100644 --- a/packages/config/src/editor-schema.ts +++ b/packages/config/src/editor-schema.ts @@ -112,7 +112,9 @@ function adaptSchemaForEditor( resolveReference(node.$ref, context); node.$ref = context.referenceTargets.get(node.$ref) ?? node.$ref; } - if (node.format === 'directory-http-url') node.format = 'uri'; + if (node.format === 'directory-http-url' || node.format === 'directory-auth-origin') { + node.format = 'uri'; + } removeDefaultedPropertiesFromRequired(node, context); for (const key of [ 'properties', diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index 0f995a5..cafd268 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -11,6 +11,7 @@ import { loadConfigFile, validateConfig } from './load.js'; import type { ConfigEnvironment } from './types.js'; const link = { title: 'Photos', url: 'https://photos.example.com' }; +const origin = 'https://directory.example.com'; const sentinel = 'SENTINEL-never-log-this'; let directory: string; @@ -274,18 +275,25 @@ describe('default, interpolate, then validate', () => { it('loads either complete auth strategy and never fills missing credentials', () => { const credentials = validateConfig( - { links: {}, auth: { credentials: { username: 'family', password: '${PASSWORD}' } } }, + { links: {}, auth: { origin, credentials: { username: 'family', password: '${PASSWORD}' } } }, { env: { PASSWORD: sentinel } } ); - expect(credentials.auth).toEqual({ credentials: { username: 'family', password: sentinel } }); + expect(credentials.auth).toEqual({ + origin, + credentials: { username: 'family', password: sentinel } + }); const oidc = validateConfig( { links: {}, - auth: { oidc: { issuer: '${ISSUER}', client_id: 'directory', client_secret: '${SECRET}' } } + auth: { + origin, + oidc: { issuer: '${ISSUER}', client_id: 'directory', client_secret: '${SECRET}' } + } }, { env: { ISSUER: 'https://identity.example.com', SECRET: sentinel } } ); expect(oidc.auth).toEqual({ + origin, oidc: { issuer: 'https://identity.example.com', client_id: 'directory', @@ -319,15 +327,19 @@ describe('default, interpolate, then validate', () => { { links: {}, site: { favicon_path: false } }, { links: {}, auth: {} }, { links: {}, auth: null }, - { links: {}, auth: { credentials: {} } }, + { links: {}, auth: { origin, credentials: {} } }, { links: {}, - auth: { oidc: { issuer: 'https://identity.example.com', client_id: 'directory' } } + auth: { origin, oidc: { issuer: 'https://identity.example.com', client_id: 'directory' } } + }, + { + links: {}, + auth: { origin, credentials: { username: 'family', password: 'test' }, oidc: null } }, - { links: {}, auth: { credentials: { username: 'family', password: 'test' }, oidc: null } }, { links: {}, auth: { + origin, credentials: { username: 'family', password: 'test' }, oidc: { issuer: 'https://identity.example.com', @@ -368,7 +380,7 @@ describe('default, interpolate, then validate', () => { expect(invalid.issues[0]?.path).toBe('/links/*/url'); const badAuth = configurationError(() => validateConfig( - { links: {}, auth: { credentials: { username: sentinel, password: {} } } }, + { links: {}, auth: { origin, credentials: { username: sentinel, password: {} } } }, { env: {} } ) ); @@ -428,6 +440,26 @@ describe('YAML file loader', () => { expect(config).not.toHaveProperty('auth'); }); + it('loads interpolated auth settings from YAML', async () => { + const secret = 'test-only-secret-0123456789abcdef'; + const config = await loadYaml( + `links: {} +auth: + origin: '\${PUBLIC_ORIGIN}' + secret: '\${SESSION_SECRET}' + credentials: + username: family + password: '\${PASSWORD}' +`, + { PUBLIC_ORIGIN: origin, SESSION_SECRET: secret, PASSWORD: sentinel } + ); + expect(config.auth).toEqual({ + origin, + secret, + credentials: { username: 'family', password: sentinel } + }); + }); + it('loads an empty directory, flat links, multiline descriptions and native booleans', async () => { expect((await loadYaml('links: {}')).links).toEqual({}); const config = await loadYaml(` diff --git a/packages/config/src/schema.test.ts b/packages/config/src/schema.test.ts index a752b25..03ed5e4 100644 --- a/packages/config/src/schema.test.ts +++ b/packages/config/src/schema.test.ts @@ -15,6 +15,7 @@ import { import type { DirectoryConfiguration, DirectoryLink, DirectorySite } from './types.js'; const link = { title: 'Photos', url: 'https://photos.example.com' }; +const origin = 'https://directory.example.com'; const raw = { links: { photos: link } }; const linkDefaults = { tags: [], requires_auth: false, internal_only: false, admin_only: false }; @@ -102,6 +103,7 @@ describe('configuration defaults and inferred types', () => { const missingTitle: DirectoryLink = { url: link.url, ...linkDefaults }; // @ts-expect-error both auth strategies are forbidden statically too const both: NonNullable = { + origin, credentials: { username: 'family', password: 'fixture' }, oidc: { issuer: 'https://id.example.com', client_id: 'directory', client_secret: 'fixture' } }; @@ -143,18 +145,31 @@ describe('strict configuration boundary', () => { null, [], '', + { secret: 'x'.repeat(32) }, + { credentials: null }, + { oidc: null }, { credentials: {} }, { credentials: { username: 'family' } }, { credentials: { username: 'family', password: '' } }, { oidc: { issuer: 'https://id.example.com', client_id: 'directory' } }, { oidc: { issuer: 'javascript:alert(1)', client_id: 'directory', client_secret: 'fixture' } }, { credentials: { username: 'family', password: 'fixture' }, oidc: null }, + { + oidc: { + issuer: 'https://id.example.com', + client_id: 'directory', + client_secret: 'fixture' + }, + credentials: null + }, { credentials: { username: 'family', password: 'fixture' }, oidc: { issuer: 'https://id.example.com', client_id: 'directory', client_secret: 'fixture' } }, { unknown: {} } - ].map((auth) => ({ auth })) + ].map((auth) => ({ + auth: auth && !Array.isArray(auth) && typeof auth === 'object' ? { origin, ...auth } : auth + })) )('rejects invalid or ambiguous auth: $auth', ({ auth }) => { expect(accepts({ ...raw, auth })).toBe(false); }); @@ -163,7 +178,7 @@ describe('strict configuration boundary', () => { { credentials: { username: 'family', password: 'fixture' } }, { oidc: { issuer: 'https://id.example.com', client_id: 'directory', client_secret: 'fixture' } } ])('accepts exactly one complete auth strategy', (auth) => { - expect(accepts({ ...raw, auth })).toBe(true); + expect(accepts({ ...raw, auth: { origin, ...auth } })).toBe(true); }); it.each([ @@ -229,7 +244,10 @@ describe('strict configuration boundary', () => { { ...raw, site: { categories: {} } }, { ...raw, site: { group_by: 'section' } }, { ...raw, appearance: { background_color: '#fff' } }, - { ...raw, auth: { credentials: { username: 'family', password: 'fixture', extra: true } } } + { + ...raw, + auth: { origin, credentials: { username: 'family', password: 'fixture', extra: true } } + } ])('rejects unknown structural fields and invalid explicit values', (value) => { expect(accepts(value)).toBe(false); }); diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 8dcd84a..c895549 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -22,6 +22,18 @@ FormatRegistry.Set('directory-http-url', (value) => { } }); +FormatRegistry.Set('directory-auth-origin', (value) => { + try { + const url = new URL(value); + return ( + (url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost')) && + value === url.origin + ); + } catch { + return false; + } +}); + const nonEmptyText = { minLength: 1, pattern: '\\S' }; // Unlike $, this also rejects a final newline in identifiers, colors and paths. const end = '(?![\\s\\S])'; @@ -35,6 +47,23 @@ export const HttpUrlSchema = Type.String({ description: 'An absolute HTTP or HTTPS URL. Relative and executable URLs are not accepted.' }); +export const AuthOriginSchema = Type.String({ + $id: 'AuthOrigin', + format: 'directory-auth-origin', + // Editors cannot run the URL canonicalization check, but can enforce the origin shape. + pattern: `^(?:https://(?:[^\\s/\\\\?#@:\\[\\]A-Z%]+|\\[[0-9a-f:.]+\\])(?::(?!443${end})[0-9]+)?|http://localhost(?::(?!80${end})[0-9]+)?)${end}`, + description: + 'The canonical public origin of Directory. HTTPS is required except for http://localhost[:port]. No credentials, path, trailing slash, query, fragment, or noncanonical URL spelling.' +}); + +export const AuthSecretSchema = Type.String({ + $id: 'AuthSecret', + ...nonEmptyText, + minLength: 32, + description: + 'An optional user-generated random session secret of at least 32 characters. Omit to let the server generate an ephemeral secret at startup; the config loader does not generate one.' +}); + export const LocalImagePathSchema = Type.String({ $id: 'LocalImagePath', pattern: `^/public/(?:${imageSegment}/)*${imageSegment}\\.(?:[sS][vV][gG]|[pP][nN][gG]|[jJ][pP][eE]?[gG]|[gG][iI][fF]|[wW][eE][bB][pP])${end}`, @@ -128,11 +157,21 @@ export const CredentialsAuthSchema = Type.Object( export const AuthSchema = Type.Union( [ Type.Object( - { oidc: ref(OidcAuthSchema), credentials: Type.Optional(Type.Never()) }, + { + origin: ref(AuthOriginSchema), + secret: Type.Optional(ref(AuthSecretSchema)), + oidc: ref(OidcAuthSchema), + credentials: Type.Optional(Type.Never()) + }, { additionalProperties: false, title: 'OIDC' } ), Type.Object( - { credentials: ref(CredentialsAuthSchema), oidc: Type.Optional(Type.Never()) }, + { + origin: ref(AuthOriginSchema), + secret: Type.Optional(ref(AuthSecretSchema)), + credentials: ref(CredentialsAuthSchema), + oidc: Type.Optional(Type.Never()) + }, { additionalProperties: false, title: 'Shared username and password' } ) ], @@ -155,6 +194,8 @@ export const DirectoryConfigurationSchema = Type.Object( export const configurationReferences: TSchema[] = [ HttpUrlSchema, + AuthOriginSchema, + AuthSecretSchema, LocalImagePathSchema, SiteImagePathSchema, HexColorSchema,