feat: configure authentication in YAML #52

Merged
eleith merged 1 commits from feat/config-first-auth into main 2026-10-26 22:15:30 +00:00
58 changed files with 1106 additions and 566 deletions

No files matched your search

+8
View File
@@ -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.
+1 -1
View File
@@ -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"]
+8 -8
View File
@@ -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
+15 -23
View File
@@ -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
+73 -11
View File
@@ -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();
+1 -3
View File
@@ -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) {
+3 -3
View File
@@ -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, '<svg'])
for (const value of [username, password, server.path, '<svg'])
expect(payload).not.toContain(value);
await page.getByRole('button', { name: 'Sign out' }).click();
expect(
(await page.request.get(`${server.url}/bookmarks/photos/logo`, { maxRedirects: 0 })).status()
).toBe(401);
for (const value of [username, password, server.secret!, server.path])
for (const value of [username, password, server.path])
expect(server.output()).not.toContain(value);
} finally {
expect(await server.stop()).toEqual({ code: 0, signal: null });
+2 -4
View File
@@ -10,7 +10,7 @@ const configuration = `links:
`;
test('Open uses a new tab by default', async ({ page, context }) => {
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();
+1 -4
View File
@@ -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;
}
+5 -18
View File
@@ -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!,
'<svg'
]) {
for (const value of [username, password, server.path, fixture.publicDir, '<svg']) {
expect(payload).not.toContain(value);
}
} finally {
@@ -215,9 +208,7 @@ test('open mode serves only exact contained image references, with MIME and body
`;
const server = await startServer({
configuration: configuration('') + extra,
publicDir: fixture.publicDir,
secret: null,
origin: null
publicDir: fixture.publicDir
});
try {
await server.ready();
@@ -352,9 +343,7 @@ test('missing and atomically replaced bytes recover without changing the prepare
await rm(join(fixture.publicDir, 'photo.svg'));
const server = await startServer({
configuration: configuration(''),
publicDir: fixture.publicDir,
secret: null,
origin: null
publicDir: fixture.publicDir
});
try {
await server.ready();
@@ -379,9 +368,7 @@ test('a missing public directory is valid at startup and recovers when its refer
await rm(fixture.publicDir, { recursive: true });
const server = await startServer({
configuration: configuration(''),
publicDir: fixture.publicDir,
secret: null,
origin: null
publicDir: fixture.publicDir
});
try {
await server.ready();
+4 -6
View File
@@ -27,7 +27,7 @@ links:
let server: TestServer;
test.beforeAll(async () => {
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();
+3 -4
View File
@@ -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 });
+1 -2
View File
@@ -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();
+4 -10
View File
@@ -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();
+1 -1
View File
@@ -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();
});
-1
View File
@@ -47,7 +47,6 @@ function expectPrivate(text: string, app: TestServer) {
otherSource,
faviconSource,
app.path,
app.secret!,
publicDir,
logo,
favicon
+4 -4
View File
@@ -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 {
+2
View File
@@ -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)}
+22 -16
View File
@@ -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<number> {
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 = {}) {
+1 -1
View File
@@ -29,7 +29,7 @@ test.beforeAll(async () => {
join(publicDir, 'private-theme-logo.svg'),
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path fill="#369" d="M0 0h48v48H0z"/></svg>'
);
server = await startServer({ configuration, publicDir, secret: null, origin: null });
server = await startServer({ configuration, publicDir });
await server.ready();
});
+1 -1
View File
@@ -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 .",
+1 -2
View File
@@ -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))
}
}
+9 -3
View File
@@ -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<typeof import('./lib/server/boot.js').bootApp>(),
report: vi.fn<typeof import('./lib/server/logger.js').reportStartupFailure>(),
load: vi.fn<typeof import('@directory/config').loadConfigFile>(),
@@ -22,6 +23,7 @@ const mocks = vi.hoisted(() => ({
prepare: vi.fn<typeof import('./lib/server/config/snapshot.js').prepareSnapshot>()
}));
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');
});
+3 -2
View File
@@ -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.
+5 -3
View File
@@ -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(
`<style>:root{--color-primary:light-dark(${light},${dark});--color-on-primary:light-dark(${onLight},${onDark})}</style>`
);
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);
}
+40 -26
View File
@@ -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<typeof import('node:crypto')>();
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({
+5 -8
View File
@@ -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<SvelteKitAuthConfig['logger']> = {
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({
@@ -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([
@@ -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.'
);
});
});
-54
View File
@@ -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);
}
+89 -103
View File
@@ -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');
+116 -29
View File
@@ -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<typeof import('./config/load-directo
);
const realConfig = await vi.importActual<typeof import('@directory/config')>('@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<Directory>();
@@ -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' });
+15 -6
View File
@@ -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<Application> {
const options = env === undefined ? {} : { env };
export async function bootApp(
env: ConfigEnvironment = process.env,
frameworkEnv: ConfigEnvironment = process.env
): Promise<Application> {
const options = { env };
let path: string;
let publicDir: string;
let directory: Directory;
@@ -25,10 +27,17 @@ export async function bootApp(env?: ConfigEnvironment): Promise<Application> {
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' });
}
@@ -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',
@@ -25,8 +25,12 @@ vi.mock('./snapshot.js', async (importOriginal) => ({
const realSnapshot = await vi.importActual<typeof import('./snapshot.js')>('./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();
@@ -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,
@@ -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();
@@ -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<void> {
// The process event reaches both cached hooks and replaced server modules.
export async function startDevelopment(
server: ServerState,
env?: ConfigEnvironment
): Promise<void> {
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;
@@ -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];
@@ -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<void> {
const application = await bootApp();
export async function startProduction(server: ServerState, env?: ConfigEnvironment): Promise<void> {
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;
+15 -41
View File
@@ -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([
+1 -21
View File
@@ -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})`);
@@ -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: '/' }] }
});
});
});
@@ -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<void> {
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 = '';
}
@@ -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 () => {
@@ -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',
@@ -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');
}
);
+5 -4
View File
@@ -9,7 +9,11 @@ function event(site: Record<string, string> = {}) {
{
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);
});
+13
View File
@@ -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);
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "build/bootstrap",
"types": ["node"],
"noEmitOnError": true
},
"include": ["src/start.ts"]
}
+24 -14
View File
@@ -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');
}
}
}
]
]
};
});
+216
View File
@@ -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<DirectoryAuth['origin']>().toEqualTypeOf<string>();
expectTypeOf<DirectoryAuth['secret']>().toEqualTypeOf<string | undefined>();
});
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}` }
]);
}
}
}
}
);
});
+36 -3
View File
@@ -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": {} }
}
+31 -3
View File
@@ -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);
+3 -1
View File
@@ -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',
+39 -7
View File
@@ -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(`
+21 -3
View File
@@ -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<DirectoryConfiguration['auth']> = {
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);
});
+43 -2
View File
@@ -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,