feat: configure authentication in YAML #52
No files matched your search
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ function expectPrivate(text: string, app: TestServer) {
|
||||
otherSource,
|
||||
faviconSource,
|
||||
app.path,
|
||||
app.secret!,
|
||||
publicDir,
|
||||
logo,
|
||||
favicon
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 .",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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');
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "build/bootstrap",
|
||||
"types": ["node"],
|
||||
"noEmitOnError": true
|
||||
},
|
||||
"include": ["src/start.ts"]
|
||||
}
|
||||
+24
-14
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
};
|
||||
});
|
||||
@@ -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}` }
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -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": {} }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user