Migrate to stateless authentication #70
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@directory/web': patch
|
||||
---
|
||||
|
||||
Replace sign-in with stateless authentication and fixed seven-day sessions without renewal. Existing sessions require signing in again. OIDC identities must supply an email.
|
||||
+77
-606
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Browser, type Page } from '@playwright/test';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import {
|
||||
clientId,
|
||||
clientSecret,
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
siteTitle,
|
||||
username
|
||||
} from './support/configuration.js';
|
||||
import { startIssuer, type TestIssuer } from './support/oidc-issuer.js';
|
||||
import { startIssuer } from './support/oidc-issuer.js';
|
||||
import { randomSecret, startServer, type TestServer } from './support/server.js';
|
||||
import { expiredSession, readSession } from './support/session.js';
|
||||
import { responseCookieHeader } from './support/session.js';
|
||||
|
||||
test.setTimeout(60_000);
|
||||
|
||||
@@ -61,76 +61,24 @@ async function expectUnauthorized(response: Response) {
|
||||
|
||||
async function postCredentials(
|
||||
server: TestServer,
|
||||
fields: Record<string, string> = { username, password, redirectTo: '/' },
|
||||
headers: Record<string, string> = {}
|
||||
fields: Record<string, string> = { username, password, redirectTo: '/' }
|
||||
) {
|
||||
return server.fetch('/signin', {
|
||||
method: 'POST',
|
||||
headers: { accept: 'text/html', origin: server.origin, ...headers },
|
||||
headers: { accept: 'text/html', origin: server.origin },
|
||||
body: new URLSearchParams(fields)
|
||||
});
|
||||
}
|
||||
|
||||
function sessionCookie(response: Response) {
|
||||
const header = response.headers.getSetCookie().find((value) => /session-token=/.test(value));
|
||||
expect(header, 'Successful SDK sign-in must issue a session cookie').toBeDefined();
|
||||
const pair = header!.split(';')[0]!;
|
||||
const separator = pair.indexOf('=');
|
||||
return {
|
||||
header: header!,
|
||||
pair,
|
||||
name: pair.slice(0, separator),
|
||||
value: pair.slice(separator + 1)
|
||||
};
|
||||
}
|
||||
|
||||
async function signIn(page: Page, server: TestServer) {
|
||||
await page.goto(`${server.url}/signin`);
|
||||
await page.getByLabel('Username').fill(username);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: siteTitle, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function oidcRoundTrip(browser: Browser, server: TestServer, succeeds: boolean) {
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${server.url}/signin`);
|
||||
await page.getByRole('button', { name: 'Sign in with single sign-on' }).click();
|
||||
if (succeeds) {
|
||||
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: siteTitle, exact: true })).toBeVisible();
|
||||
} else {
|
||||
await expect(page).toHaveURL(
|
||||
(url) => url.origin === server.url && url.pathname === '/signin'
|
||||
);
|
||||
await expect(page.getByRole('alert')).toContainText('Unable to sign in');
|
||||
const response = await context.request.get(`${server.url}/__data.json`, { maxRedirects: 0 });
|
||||
expect(response.status()).toBe(401);
|
||||
expectPublic(await response.text());
|
||||
}
|
||||
return await context.cookies();
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
function expectIssuerHealthy(issuer: TestIssuer) {
|
||||
expect(issuer.violations).toEqual([]);
|
||||
expect(issuer.handlerErrors).toEqual([]);
|
||||
}
|
||||
|
||||
async function stopOidc(server: TestServer, issuer: TestIssuer) {
|
||||
try {
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
} finally {
|
||||
await issuer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
test('enabled authentication gates HTML, actual Kit data, HEAD and unknown routes', async () => {
|
||||
test('enabled authentication gates HTML, Kit data, HEAD and generated images without leaking private data', async () => {
|
||||
const server = await startServer({ configuration: configuration() });
|
||||
try {
|
||||
await server.ready();
|
||||
@@ -145,9 +93,7 @@ test('enabled authentication gates HTML, actual Kit data, HEAD and unknown route
|
||||
'/',
|
||||
'/__data.json?x-sveltekit-invalidated=11',
|
||||
'/unknown',
|
||||
'/healthz/extra',
|
||||
'/signin/extra',
|
||||
'/favicon-missing.svg'
|
||||
'/bookmarks/household/logo'
|
||||
]) {
|
||||
await expectUnauthorized(
|
||||
await server.fetch(pathname, { headers: { accept: 'application/json' } })
|
||||
@@ -156,105 +102,26 @@ test('enabled authentication gates HTML, actual Kit data, HEAD and unknown route
|
||||
await expectUnauthorized(
|
||||
await server.fetch('/', { method: 'HEAD', headers: { accept: 'text/html' } })
|
||||
);
|
||||
await expectUnauthorized(
|
||||
await server.fetch('/', {
|
||||
method: 'POST',
|
||||
headers: { origin: server.origin },
|
||||
body: 'private request'
|
||||
})
|
||||
);
|
||||
|
||||
const signin = await server.fetch('/signin');
|
||||
expect(signin.status).toBe(200);
|
||||
expect(signin.headers.get('cache-control')).toContain('no-store');
|
||||
expect(signin.headers.get('referrer-policy')).toBeTruthy();
|
||||
const html = await signin.text();
|
||||
expect(html).toContain(siteTitle);
|
||||
expectPublic(html);
|
||||
const signinData = await server.fetch('/signin/__data.json?x-sveltekit-invalidated=11');
|
||||
expect(signinData.status).toBe(200);
|
||||
expect(signinData.headers.get('content-type')).toContain('application/json');
|
||||
const data = await signinData.text();
|
||||
expect(data).toContain(siteTitle);
|
||||
expectPublic(data);
|
||||
expect(data).not.toContain('session');
|
||||
expect(data).not.toContain('authentication');
|
||||
|
||||
for (const pathname of ['/signin', '/signin/__data.json?x-sveltekit-invalidated=11']) {
|
||||
const signin = await server.fetch(pathname);
|
||||
expect(signin.status).toBe(200);
|
||||
expect(signin.headers.get('cache-control')).toContain('no-store');
|
||||
expectPublic(await signin.text());
|
||||
}
|
||||
const health = await server.fetch('/healthz');
|
||||
expect(health.status).toBe(200);
|
||||
expect(await health.json()).toEqual({ status: 'ok' });
|
||||
const headHealth = await server.fetch('/healthz', { method: 'HEAD' });
|
||||
expect(headHealth.status).toBe(200);
|
||||
expect(await headHealth.text()).toBe('');
|
||||
const scriptPath = html.match(/_app\/immutable\/[^"'\s]+\.js/)?.[0];
|
||||
expect(scriptPath).toBeDefined();
|
||||
for (const method of ['GET', 'HEAD']) {
|
||||
for (const [path, contentType] of [
|
||||
['/favicon.png', 'image/png'],
|
||||
[`/${scriptPath!}`, 'javascript']
|
||||
] as const) {
|
||||
const asset = await server.fetch(path, { method });
|
||||
expect(asset.status).toBe(200);
|
||||
expect(asset.headers.get('content-type')).toContain(contentType);
|
||||
const body = await asset.text();
|
||||
if (method === 'HEAD') expect(body).toBe('');
|
||||
else expect(body.length).toBeGreaterThan(0);
|
||||
expectPublic(body);
|
||||
}
|
||||
}
|
||||
for (const path of ['/_app/immutable/missing.js', '/public/private.svg']) {
|
||||
const missing = await server.fetch(path);
|
||||
expect(missing.status).toBe(404);
|
||||
expectPublic(await missing.text());
|
||||
}
|
||||
const favicon = await server.fetch('/favicon.png');
|
||||
expect(favicon.status).toBe(200);
|
||||
expect(favicon.headers.get('content-type')).toContain('image/png');
|
||||
expectSafeLogs(server);
|
||||
} finally {
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
}
|
||||
});
|
||||
|
||||
test('native routes reject unsupported verbs instead of serving public or private content', async () => {
|
||||
const server = await startServer({ configuration: configuration() });
|
||||
try {
|
||||
await server.ready();
|
||||
for (const path of ['/signin', '/healthz']) {
|
||||
for (const method of ['PUT', 'PATCH', 'DELETE']) {
|
||||
const response = await server.fetch(path, {
|
||||
method,
|
||||
headers: { origin: server.origin }
|
||||
});
|
||||
expect(response.status).toBe(405);
|
||||
expectPublic(await response.text());
|
||||
}
|
||||
}
|
||||
const healthPost = await server.fetch('/healthz', {
|
||||
method: 'POST',
|
||||
headers: { origin: server.origin }
|
||||
});
|
||||
expect(healthPost.status).toBe(405);
|
||||
expectPublic(await healthPost.text());
|
||||
|
||||
const cookie = sessionCookie(await postCredentials(server)).pair;
|
||||
for (const path of ['/', '/public/private.svg', '/bookmarks/photos/logo']) {
|
||||
for (const method of ['PUT', 'PATCH', 'DELETE']) {
|
||||
for (const session of ['', cookie]) {
|
||||
const response = await server.fetch(path, {
|
||||
method,
|
||||
headers: { origin: server.origin, cookie: session }
|
||||
});
|
||||
const expectedStatus = session || path.startsWith('/public/') ? 405 : 401;
|
||||
expect(response.status).toBe(expectedStatus);
|
||||
expectPublic(await response.text());
|
||||
}
|
||||
}
|
||||
}
|
||||
expectSafeLogs(server);
|
||||
} finally {
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
}
|
||||
});
|
||||
|
||||
test('native SSR credentials and named root signout work without JavaScript', async ({
|
||||
test('native credentials reject a wrong password, sign in and sign out without JavaScript', async ({
|
||||
browser
|
||||
}) => {
|
||||
const secret = randomSecret();
|
||||
@@ -272,36 +139,24 @@ test('native SSR credentials and named root signout work without JavaScript', as
|
||||
await page.getByLabel('Username').fill(username);
|
||||
await page.getByLabel('Password').fill('wrong-password');
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await expect(page.getByRole('alert')).toHaveText(
|
||||
/Invalid username or password|Unable to sign in/
|
||||
);
|
||||
await expect(page.getByRole('alert')).toBeVisible();
|
||||
expectPublic(await page.getByRole('alert').innerText());
|
||||
const rejected = await context.request.get(`${server.url}/__data.json`, { maxRedirects: 0 });
|
||||
expect(rejected.status()).toBe(401);
|
||||
expectPublic(await rejected.text());
|
||||
|
||||
await signIn(page, server);
|
||||
await expect(page.getByText(siteDescription)).toBeVisible();
|
||||
const form = page.locator('form', { has: page.getByRole('button', { name: 'Sign out' }) });
|
||||
await expect(form).toHaveAttribute('method', 'POST');
|
||||
await expect(form).toHaveAttribute('action', '?/signout');
|
||||
|
||||
const cookie = (await context.cookies()).find((value) => /session-token$/.test(value.name));
|
||||
expect(cookie).toBeDefined();
|
||||
expect(cookie!.httpOnly).toBe(true);
|
||||
expect(cookie!.secure).toBe(false);
|
||||
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(secret, cookie!.name, cookie!.value);
|
||||
expect(jwt!.exp - jwt!.iat).toBe(7 * 86_400);
|
||||
|
||||
await expect(page.getByText('PRIVATE_C6_SERVICE', { exact: true })).toBeVisible();
|
||||
const cookies = await context.cookies(server.url);
|
||||
expect(cookies.length).toBeGreaterThan(0);
|
||||
const data = await context.request.get(`${server.url}/__data.json?x-sveltekit-invalidated=11`);
|
||||
expect(data.status()).toBe(200);
|
||||
const payload = await data.text();
|
||||
expect(payload).toContain(siteDescription);
|
||||
expect(payload).not.toContain(username);
|
||||
expect(payload).not.toContain(password);
|
||||
expect(payload).not.toContain(secret);
|
||||
for (const value of [username, password, secret, ...cookies.map((cookie) => cookie.value)]) {
|
||||
expect(payload).not.toContain(value);
|
||||
}
|
||||
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')}`);
|
||||
await expect(page).toHaveURL(`${server.url}/?returned=yes`);
|
||||
|
||||
@@ -310,314 +165,24 @@ test('native SSR credentials and named root signout work without JavaScript', as
|
||||
const denied = await context.request.get(`${server.url}/__data.json`, { maxRedirects: 0 });
|
||||
expect(denied.status()).toBe(401);
|
||||
expectPublic(await denied.text());
|
||||
expect((await context.cookies()).filter((value) => /session-token$/.test(value.name))).toEqual(
|
||||
[]
|
||||
expectSafeLogs(
|
||||
server,
|
||||
cookies.map((cookie) => cookie.value)
|
||||
);
|
||||
expectSafeLogs(server);
|
||||
} finally {
|
||||
await context.close();
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
}
|
||||
});
|
||||
|
||||
test('SDK rejects wrong, missing and non-string credentials and does not forward arbitrary action fields', async () => {
|
||||
test('auth and site.url edits reject the entire snapshot; display reloads preserve the running session and provider', async () => {
|
||||
const server = await startServer({ configuration: configuration() });
|
||||
try {
|
||||
await server.ready();
|
||||
for (const fields of [
|
||||
{ username: 'wrong-user', password },
|
||||
{ username, password: 'wrong-password' },
|
||||
{ username },
|
||||
{ password },
|
||||
{}
|
||||
]) {
|
||||
const response = await postCredentials(server, fields);
|
||||
expect(response.status).toBe(303);
|
||||
expect(expectLocalLocation(response, server).pathname).toBe('/signin');
|
||||
expect(response.headers.getSetCookie().some((cookie) => /session-token=/.test(cookie))).toBe(
|
||||
false
|
||||
);
|
||||
expectPublic(await response.text());
|
||||
}
|
||||
const malformed = new FormData();
|
||||
malformed.set('username', new Blob(['not-a-string']), 'username.txt');
|
||||
malformed.set('password', password);
|
||||
const response = await server.fetch('/signin', {
|
||||
method: 'POST',
|
||||
headers: { accept: 'text/html', origin: server.origin },
|
||||
body: malformed
|
||||
});
|
||||
expect(response.status).toBe(303);
|
||||
expect(expectLocalLocation(response, server).pathname).toBe('/signin');
|
||||
await expectUnauthorized(await server.fetch('/__data.json'));
|
||||
|
||||
const allowed = await postCredentials(server, {
|
||||
username,
|
||||
password,
|
||||
redirectTo: '/?family=all',
|
||||
providerId: 'oidc',
|
||||
redirect: 'false',
|
||||
callbackUrl: 'https://foreign.example.test/PRIVATE_C6_EVIL',
|
||||
'authorizationParams-redirect_uri': 'https://foreign.example.test/PRIVATE_C6_EVIL',
|
||||
'authorizationParams-client_id': 'foreign-client'
|
||||
});
|
||||
expect([302, 303]).toContain(allowed.status);
|
||||
expect(expectLocalLocation(allowed, server).pathname).toBe('/');
|
||||
expect(expectLocalLocation(allowed, server).search).toBe('?family=all');
|
||||
const cookie = sessionCookie(allowed);
|
||||
const root = await server.fetch('/', { headers: { cookie: cookie.pair } });
|
||||
expect(root.status).toBe(200);
|
||||
const refreshed = sessionCookie(root);
|
||||
expect(refreshed.name).toBe(cookie.name);
|
||||
expect(refreshed.header).not.toMatch(/; Secure(?:;|$)/i);
|
||||
expect(await root.text()).toContain(siteDescription);
|
||||
expectSafeLogs(server, ['PRIVATE_C6_EVIL']);
|
||||
} finally {
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
}
|
||||
});
|
||||
|
||||
test('site.url 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 [
|
||||
'https://foreign.example.test/PRIVATE_C6_EVIL',
|
||||
'//foreign.example.test/PRIVATE_C6_EVIL',
|
||||
'/\\foreign.example.test/PRIVATE_C6_EVIL',
|
||||
'\\\\foreign.example.test/PRIVATE_C6_EVIL',
|
||||
'%2f%2fforeign.example.test/PRIVATE_C6_EVIL',
|
||||
'%5c%5cforeign.example.test/PRIVATE_C6_EVIL'
|
||||
]) {
|
||||
await page.goto(`${server.url}/signin?${new URLSearchParams({ callbackUrl: callback })}`);
|
||||
expectPublic(await page.content());
|
||||
const returnPath = await page.locator('input[name="redirectTo"]').inputValue();
|
||||
expect(new URL(returnPath, server.origin).origin).toBe(server.origin);
|
||||
const response = await postCredentials(server, { username, password, redirectTo: callback });
|
||||
expect([302, 303]).toContain(response.status);
|
||||
expectLocalLocation(response, server);
|
||||
const cookie = sessionCookie(response);
|
||||
const signout = await server.fetch('/?/signout', {
|
||||
method: 'POST',
|
||||
headers: { accept: 'text/html', origin: server.origin, cookie: cookie.pair },
|
||||
body: new URLSearchParams({ redirectTo: callback })
|
||||
});
|
||||
expect([302, 303]).toContain(signout.status);
|
||||
expectLocalLocation(signout, server);
|
||||
}
|
||||
const hostileHeaders = {
|
||||
host: 'foreign.example.test',
|
||||
'x-forwarded-host': 'foreign.example.test',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-port': '443',
|
||||
forwarded: 'host=foreign.example.test;proto=https',
|
||||
'x-auth-return-redirect': '1'
|
||||
};
|
||||
const response = await postCredentials(
|
||||
server,
|
||||
{ username, password, redirectTo: '/' },
|
||||
hostileHeaders
|
||||
);
|
||||
expect([302, 303]).toContain(response.status);
|
||||
expectLocalLocation(response, server);
|
||||
for (const cookie of response.headers.getSetCookie())
|
||||
expect(cookie).not.toContain('foreign.example.test');
|
||||
const badOrigin = await postCredentials(
|
||||
server,
|
||||
{ username, password },
|
||||
{ origin: 'https://foreign.example.test' }
|
||||
);
|
||||
expect(badOrigin.status).toBe(403);
|
||||
expectPublic(await badOrigin.text());
|
||||
expectSafeLogs(server, ['PRIVATE_C6_EVIL', 'foreign.example.test']);
|
||||
} finally {
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
}
|
||||
});
|
||||
|
||||
test('raw auth APIs stay blocked and same-origin checks cover every POST content type', async () => {
|
||||
const server = await startServer({ configuration: configuration() });
|
||||
try {
|
||||
await server.ready();
|
||||
const cookie = sessionCookie(await postCredentials(server));
|
||||
for (const pathname of [
|
||||
'/auth/session',
|
||||
'/auth/providers',
|
||||
'/auth/signout',
|
||||
'/auth/callback/credentials',
|
||||
'/auth/callback/oidc'
|
||||
]) {
|
||||
for (const method of ['GET', 'POST']) {
|
||||
const response = await server.fetch(pathname, {
|
||||
method,
|
||||
headers: {
|
||||
origin: server.origin,
|
||||
cookie: cookie.pair,
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
...(method === 'POST' ? { body: JSON.stringify({ username, password }) } : {})
|
||||
});
|
||||
expect([401, 404]).toContain(response.status);
|
||||
expectPublic(await response.text());
|
||||
}
|
||||
}
|
||||
for (const contentType of [
|
||||
'application/x-www-form-urlencoded',
|
||||
'text/plain',
|
||||
'application/json',
|
||||
'multipart/form-data; boundary=c6'
|
||||
]) {
|
||||
for (const pathname of [
|
||||
'/signin',
|
||||
'/?/signout',
|
||||
'/auth/signout',
|
||||
'/auth/session',
|
||||
'/auth/callback/credentials'
|
||||
]) {
|
||||
const response = await server.fetch(pathname, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'https://foreign.example.test',
|
||||
cookie: cookie.pair,
|
||||
'content-type': contentType
|
||||
},
|
||||
body: 'username=foreign&password=foreign'
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expectPublic(await response.text());
|
||||
}
|
||||
}
|
||||
const missingOrigin = await server.fetch('/signin', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ username, password })
|
||||
});
|
||||
expect(missingOrigin.status).toBe(403);
|
||||
const stillSignedIn = await server.fetch('/__data.json', { headers: { cookie: cookie.pair } });
|
||||
expect(stillSignedIn.status).toBe(200);
|
||||
expectSafeLogs(server);
|
||||
} finally {
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
}
|
||||
});
|
||||
|
||||
test('HTTPS site.url sets Secure SDK cookies even when the trusted upstream transport is HTTP', async () => {
|
||||
const server = await startServer({
|
||||
configuration: configuration(),
|
||||
origin: 'https://directory.example.test',
|
||||
env: hostileLegacyEnvironment
|
||||
});
|
||||
try {
|
||||
await server.ready();
|
||||
const response = await postCredentials(server);
|
||||
expect([302, 303]).toContain(response.status);
|
||||
expectLocalLocation(response, server);
|
||||
const cookie = sessionCookie(response);
|
||||
expect(cookie.name).toBe('__Secure-authjs.session-token');
|
||||
expect(cookie.header).toMatch(/; Secure(?:;|$)/i);
|
||||
expect(cookie.header).toMatch(/; HttpOnly(?:;|$)/i);
|
||||
expect(cookie.header).toMatch(/; SameSite=Lax(?:;|$)/i);
|
||||
const root = await server.fetch('/__data.json', { headers: { cookie: cookie.pair } });
|
||||
expect(root.status).toBe(200);
|
||||
const refreshed = sessionCookie(root);
|
||||
expect(refreshed.name).toBe(cookie.name);
|
||||
expect(refreshed.header).toMatch(/; Secure(?:;|$)/i);
|
||||
expectSafeLogs(server);
|
||||
} finally {
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
}
|
||||
});
|
||||
|
||||
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(siteDescription);
|
||||
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(siteDescription);
|
||||
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 });
|
||||
try {
|
||||
await server.ready();
|
||||
const cookie = sessionCookie(await postCredentials(server));
|
||||
const parts = cookie.value.split('.');
|
||||
parts[3] = `${parts[3]![0] === 'a' ? 'b' : 'a'}${parts[3]!.slice(1)}`;
|
||||
await expectUnauthorized(
|
||||
await server.fetch('/__data.json', {
|
||||
headers: { cookie: `${cookie.name}=${parts.join('.')}` }
|
||||
})
|
||||
);
|
||||
const expired = await expiredSession(secret, cookie.name);
|
||||
await expectUnauthorized(
|
||||
await server.fetch('/__data.json', {
|
||||
headers: { cookie: `${cookie.name}=${expired}` }
|
||||
})
|
||||
);
|
||||
expectSafeLogs(server, [cookie.value, expired]);
|
||||
const { port, origin } = server;
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
|
||||
server = await startServer({ configuration: configuration(), secret, port, origin });
|
||||
await server.ready();
|
||||
const preserved = await server.fetch('/__data.json', { headers: { cookie: cookie.pair } });
|
||||
expect(preserved.status).toBe(200);
|
||||
expect(await preserved.text()).toContain(siteDescription);
|
||||
expectSafeLogs(server, [cookie.value]);
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
|
||||
server = await startServer({
|
||||
configuration: configuration(),
|
||||
secret: randomSecret(),
|
||||
port,
|
||||
origin
|
||||
});
|
||||
await server.ready();
|
||||
await expectUnauthorized(
|
||||
await server.fetch('/__data.json', { headers: { cookie: cookie.pair } })
|
||||
);
|
||||
expectSafeLogs(server, [cookie.value]);
|
||||
} finally {
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
}
|
||||
});
|
||||
|
||||
test('auth and site.url edits reject the entire snapshot; restoring auth allows display updates and old providers keep working', async () => {
|
||||
const server = await startServer({ configuration: configuration() });
|
||||
try {
|
||||
await server.ready();
|
||||
const cookie = sessionCookie(await postCredentials(server));
|
||||
const cookie = responseCookieHeader(await postCredentials(server));
|
||||
expect(cookie).not.toBe('');
|
||||
const readSnapshot = async () => {
|
||||
const response = await server.fetch('/config/version', { headers: { cookie: cookie.pair } });
|
||||
const response = await server.fetch('/config/version', { headers: { cookie } });
|
||||
expect(response.status).toBe(200);
|
||||
return response.json();
|
||||
};
|
||||
@@ -645,7 +210,7 @@ test('auth and site.url edits reject the entire snapshot; restoring auth allows
|
||||
const html = await signin.text();
|
||||
expect(html).toContain(siteTitle);
|
||||
expect(html).not.toContain('Rejected title');
|
||||
const root = await server.fetch('/', { headers: { cookie: cookie.pair } });
|
||||
const root = await server.fetch('/', { headers: { cookie } });
|
||||
expect(root.status).toBe(200);
|
||||
const rootHtml = await root.text();
|
||||
expect(rootHtml).toContain(siteTitle);
|
||||
@@ -653,9 +218,12 @@ test('auth and site.url edits reject the entire snapshot; restoring auth allows
|
||||
expect(rootHtml).not.toContain('rejected.example.test');
|
||||
}
|
||||
const oldProvider = await postCredentials(server);
|
||||
expect([302, 303]).toContain(oldProvider.status);
|
||||
expect(oldProvider.status).toBe(303);
|
||||
expect(expectLocalLocation(oldProvider, server).pathname).toBe('/');
|
||||
sessionCookie(oldProvider);
|
||||
const freshCookie = responseCookieHeader(oldProvider);
|
||||
expect((await server.fetch('/__data.json', { headers: { cookie: freshCookie } })).status).toBe(
|
||||
200
|
||||
);
|
||||
const rejectedProvider = await postCredentials(server, {
|
||||
username,
|
||||
password: 'PRIVATE_C6_REJECTED_PASSWORD'
|
||||
@@ -666,35 +234,29 @@ test('auth and site.url edits reject the entire snapshot; restoring auth allows
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const response = await server.fetch('/__data.json', { headers: { cookie: cookie.pair } });
|
||||
const response = await server.fetch('/__data.json', { headers: { cookie } });
|
||||
expect(response.status).toBe(200);
|
||||
return response.text();
|
||||
},
|
||||
{ timeout: 10_000, intervals: [100, 250, 500] }
|
||||
)
|
||||
.toContain('Restored title');
|
||||
const root = await server.fetch('/', { headers: { cookie: cookie.pair } });
|
||||
expect(root.status).toBe(200);
|
||||
expect(await root.text()).toContain('Restored title');
|
||||
expectSafeLogs(server, ['PRIVATE_C6_REJECTED_PASSWORD']);
|
||||
} finally {
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
}
|
||||
});
|
||||
|
||||
test('omitting auth needs no operator AUTH_SECRET, ORIGIN or AUTH_URL and exposes no signout control', async ({
|
||||
test('omitting auth keeps native pages and Kit data open without operator auth environment', async ({
|
||||
browser
|
||||
}) => {
|
||||
const server = await startServer({
|
||||
configuration: configuration('')
|
||||
});
|
||||
const server = await startServer({ configuration: configuration('') });
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
try {
|
||||
await server.ready();
|
||||
const page = await context.newPage();
|
||||
await page.goto(server.url);
|
||||
await expect(page.getByRole('heading', { name: siteTitle, exact: true })).toBeVisible();
|
||||
await expect(page.getByText(siteDescription)).toBeVisible();
|
||||
await expect(page.getByText('PRIVATE_C6_SERVICE', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Sign out' })).toHaveCount(0);
|
||||
await page.goto(`${server.url}/signin`);
|
||||
await expect(page).toHaveURL(`${server.url}/`);
|
||||
@@ -708,7 +270,7 @@ test('omitting auth needs no operator AUTH_SECRET, ORIGIN or AUTH_URL and expose
|
||||
}
|
||||
});
|
||||
|
||||
test('real OIDC discovery and browser roundtrip use site.url, PKCE, state and nonce despite legacy env', async ({
|
||||
test('native OIDC login, GET callback and local logout work without JavaScript at the runtime origin', async ({
|
||||
browser
|
||||
}) => {
|
||||
const issuer = await startIssuer();
|
||||
@@ -716,150 +278,59 @@ test('real OIDC discovery and browser roundtrip use site.url, PKCE, state and no
|
||||
configuration: configuration(oidcAuth(issuer.origin)),
|
||||
env: hostileLegacyEnvironment
|
||||
});
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
try {
|
||||
issuer.allowCallback(server.origin);
|
||||
await server.ready();
|
||||
const publicData = await server.fetch('/signin/__data.json');
|
||||
expect(publicData.status).toBe(200);
|
||||
expectPublic(await publicData.text(), [issuer.origin]);
|
||||
const kickoff = await postCredentials(
|
||||
server,
|
||||
{
|
||||
providerId: 'credentials',
|
||||
redirectTo: '/',
|
||||
'authorizationParams-redirect_uri': 'https://foreign.example.test/PRIVATE_C6_EVIL',
|
||||
'authorizationParams-client_id': 'foreign-client',
|
||||
'authorizationParams-state': 'foreign-state',
|
||||
'authorizationParams-nonce': 'foreign-nonce',
|
||||
'authorizationParams-code_challenge': 'foreign-challenge'
|
||||
},
|
||||
{
|
||||
host: 'foreign.example.test',
|
||||
'x-forwarded-host': 'foreign.example.test',
|
||||
'x-forwarded-proto': 'https',
|
||||
'x-forwarded-port': '443',
|
||||
forwarded: 'host=foreign.example.test;proto=https',
|
||||
'x-auth-return-redirect': '1'
|
||||
}
|
||||
);
|
||||
expect([302, 303]).toContain(kickoff.status);
|
||||
const authorizationUrl = new URL(kickoff.headers.get('location')!);
|
||||
expect(authorizationUrl.origin).toBe(issuer.origin);
|
||||
expect(authorizationUrl.searchParams.get('redirect_uri')).toBe(
|
||||
`${server.origin}/auth/callback/oidc`
|
||||
);
|
||||
expect(authorizationUrl.searchParams.get('client_id')).not.toBe('foreign-client');
|
||||
expect(authorizationUrl.searchParams.get('state')).not.toBe('foreign-state');
|
||||
expect(authorizationUrl.searchParams.get('nonce')).not.toBe('foreign-nonce');
|
||||
expect(authorizationUrl.searchParams.get('code_challenge')).not.toBe('foreign-challenge');
|
||||
expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
const cookies = await oidcRoundTrip(browser, server, true);
|
||||
expect(issuer.discoveryRequests()).toBeGreaterThanOrEqual(2);
|
||||
expect(issuer.authorizations).toHaveLength(1);
|
||||
expect(issuer.grants).toHaveLength(1);
|
||||
expect(issuer.authorizations[0]!.used).toBe(true);
|
||||
const cookie = cookies.find((value) => /session-token$/.test(value.name));
|
||||
expect(cookie).toBeDefined();
|
||||
const data = await server.fetch('/__data.json', {
|
||||
headers: { cookie: `${cookie!.name}=${cookie!.value}` }
|
||||
});
|
||||
expect(data.status).toBe(200);
|
||||
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${server.url}/signin?callbackUrl=${encodeURIComponent('/?returned=oidc')}`);
|
||||
await page.getByRole('button', { name: 'Sign in with single sign-on' }).click();
|
||||
await expect(page).toHaveURL(`${server.url}/?returned=oidc`);
|
||||
await expect(page.getByRole('button', { name: 'Sign out' })).toBeVisible();
|
||||
await expect(page.getByText('PRIVATE_C6_SERVICE', { exact: true })).toBeVisible();
|
||||
|
||||
const cookies = await context.cookies(server.url);
|
||||
expect(cookies.length).toBeGreaterThan(0);
|
||||
const data = await context.request.get(`${server.url}/__data.json`);
|
||||
expect(data.status()).toBe(200);
|
||||
const payload = await data.text();
|
||||
expect(payload).toContain(siteTitle);
|
||||
expect(payload).toContain(siteDescription);
|
||||
expect(payload).not.toContain('PRIVATE_C6_IDENTITY');
|
||||
expect(payload).not.toContain('roles');
|
||||
for (const value of [
|
||||
'PRIVATE_C6_IDENTITY',
|
||||
'private-c6@example.test',
|
||||
username,
|
||||
password,
|
||||
clientId,
|
||||
clientSecret,
|
||||
issuer.origin,
|
||||
...issuer.tokens
|
||||
...issuer.tokens,
|
||||
...cookies.map((cookie) => cookie.value)
|
||||
]) {
|
||||
expect(payload).not.toContain(value);
|
||||
}
|
||||
const replay = await server.fetch(
|
||||
`/auth/callback/oidc?${new URLSearchParams({
|
||||
code: issuer.authorizations[0]!.code,
|
||||
state: issuer.authorizations[0]!.state
|
||||
})}`
|
||||
);
|
||||
expect([302, 303]).toContain(replay.status);
|
||||
expectLocalLocation(replay, server);
|
||||
await expectUnauthorized(await server.fetch('/__data.json'));
|
||||
expect(issuer.grants).toHaveLength(1);
|
||||
expectIssuerHealthy(issuer);
|
||||
|
||||
await page.getByRole('button', { name: 'Sign out' }).click();
|
||||
await expect(page).toHaveURL((url) => url.origin === server.url && url.pathname === '/signin');
|
||||
const denied = await context.request.get(`${server.url}/__data.json`, { maxRedirects: 0 });
|
||||
expect(denied.status()).toBe(401);
|
||||
expectPublic(await denied.text());
|
||||
expect(issuer.violations).toEqual([]);
|
||||
expect(issuer.handlerErrors).toEqual([]);
|
||||
expectSafeLogs(server, [
|
||||
issuer.origin,
|
||||
...issuer.tokens,
|
||||
cookie!.value,
|
||||
'PRIVATE_C6_EVIL',
|
||||
'foreign.example.test'
|
||||
...cookies.map((cookie) => cookie.value)
|
||||
]);
|
||||
} finally {
|
||||
await stopOidc(server, issuer);
|
||||
}
|
||||
});
|
||||
|
||||
test('OIDC rejects bad state before token exchange and rejects nonce, issuer, audience and invalid tokens', async ({
|
||||
browser
|
||||
}) => {
|
||||
const issuer = await startIssuer();
|
||||
const server = await startServer({ configuration: configuration(oidcAuth(issuer.origin)) });
|
||||
try {
|
||||
issuer.allowCallback(server.origin);
|
||||
await server.ready();
|
||||
issuer.scenario('bad-state');
|
||||
await oidcRoundTrip(browser, server, false);
|
||||
expect(issuer.authorizations).toHaveLength(1);
|
||||
expect(issuer.tokenRequests()).toBe(0);
|
||||
for (const scenario of [
|
||||
'bad-nonce',
|
||||
'bad-issuer',
|
||||
'bad-audience',
|
||||
'malformed-token',
|
||||
'expired-token'
|
||||
] as const) {
|
||||
issuer.scenario(scenario);
|
||||
const previousGrants = issuer.grants.length;
|
||||
await oidcRoundTrip(browser, server, false);
|
||||
expect(issuer.grants.length, scenario).toBe(previousGrants + 1);
|
||||
try {
|
||||
await context.close();
|
||||
expect(await server.stop()).toEqual({ code: 0, signal: null });
|
||||
} finally {
|
||||
await issuer.stop();
|
||||
}
|
||||
expectIssuerHealthy(issuer);
|
||||
expectSafeLogs(server, [issuer.origin, ...issuer.tokens]);
|
||||
} finally {
|
||||
await stopOidc(server, issuer);
|
||||
}
|
||||
});
|
||||
|
||||
test('OIDC discovery and token outages fail closed and recovery does not weaken the checks', async ({
|
||||
browser
|
||||
}) => {
|
||||
const issuer = await startIssuer();
|
||||
const server = await startServer({ configuration: configuration(oidcAuth(issuer.origin)) });
|
||||
try {
|
||||
issuer.allowCallback(server.origin);
|
||||
await server.ready();
|
||||
issuer.scenario('discovery-outage');
|
||||
await oidcRoundTrip(browser, server, false);
|
||||
expect(issuer.discoveryRequests()).toBeGreaterThan(0);
|
||||
expect(issuer.authorizations).toHaveLength(0);
|
||||
issuer.scenario('discovery-issuer');
|
||||
await oidcRoundTrip(browser, server, false);
|
||||
expect(issuer.authorizations).toHaveLength(0);
|
||||
issuer.scenario('token-outage');
|
||||
await oidcRoundTrip(browser, server, false);
|
||||
expect(issuer.grants).toHaveLength(1);
|
||||
issuer.scenario('normal');
|
||||
// Verify the pinned SDK keeps S256 when discovery omits its capability.
|
||||
issuer.advertiseS256(false);
|
||||
await oidcRoundTrip(browser, server, true);
|
||||
expect(issuer.authorizations).toHaveLength(2);
|
||||
expect(issuer.grants).toHaveLength(2);
|
||||
expectIssuerHealthy(issuer);
|
||||
expectSafeLogs(server, [issuer.origin, ...issuer.tokens, clientSecret]);
|
||||
} finally {
|
||||
await stopOidc(server, issuer);
|
||||
}
|
||||
});
|
||||
@@ -113,9 +113,6 @@ test('version is private and opaque; only accepted changes advance the real watc
|
||||
}
|
||||
const page = await context.newPage();
|
||||
await signIn(page, false);
|
||||
expect((await context.cookies()).some((cookie) => /session-token$/.test(cookie.name))).toBe(
|
||||
true
|
||||
);
|
||||
const original = await readVersion(context.request);
|
||||
expect(await readVersion(context.request, 'text/html')).toBe(original);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { credentialsAuth, password, username } from './support/configuration.js';
|
||||
import { startServer, type TestServer } from './support/server.js';
|
||||
import { responseCookieHeader } from './support/session.js';
|
||||
|
||||
const siteDescription = 'Useful services with familiar images.';
|
||||
const privateLogo = `<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
@@ -81,9 +82,9 @@ async function signIn(server: TestServer) {
|
||||
body: new URLSearchParams({ username, password, redirectTo: '/' })
|
||||
});
|
||||
expect([302, 303]).toContain(response.status);
|
||||
const cookie = response.headers.getSetCookie().find((value) => /session-token=/.test(value));
|
||||
expect(cookie).toBeDefined();
|
||||
return cookie!.split(';')[0]!;
|
||||
const cookie = responseCookieHeader(response);
|
||||
expect(cookie).not.toBe('');
|
||||
return cookie;
|
||||
}
|
||||
|
||||
function expectPrivateHeaders(response: Response) {
|
||||
@@ -118,45 +119,43 @@ test('mounted files are public while generated bookmark logos require a session,
|
||||
try {
|
||||
await server.ready();
|
||||
for (const method of ['GET', 'HEAD']) {
|
||||
for (const cookie of ['', 'authjs.session-token=invalid-session']) {
|
||||
for (const [path, contentType, bytes] of [
|
||||
['/public/photo.svg', 'image/svg+xml', Buffer.from(privateLogo)],
|
||||
['/public/photo.png', 'image/png', png],
|
||||
['/public/brand%20logo.svg', 'image/svg+xml', Buffer.from(privateBrand)],
|
||||
['/%70ublic/photo.svg', 'image/svg+xml', Buffer.from(privateLogo)],
|
||||
['/public/photo%2Esvg', 'image/svg+xml', Buffer.from(privateLogo)],
|
||||
['/public/unreferenced.svg', 'image/svg+xml', Buffer.from(privateLogo)],
|
||||
['/public/images/bookmarks/test-ling.svg', 'image/svg+xml', Buffer.from(privateLogo)]
|
||||
] as const) {
|
||||
const image = await server.fetch(path, {
|
||||
method,
|
||||
headers: { accept: 'text/html', cookie }
|
||||
});
|
||||
expect(image.status).toBe(200);
|
||||
expectPrivateHeaders(image);
|
||||
expect(image.headers.get('content-type')).toBe(contentType);
|
||||
expect(image.headers.get('content-length')).toBe(String(bytes.length));
|
||||
expect(Buffer.from(await image.arrayBuffer())).toEqual(
|
||||
method === 'HEAD' ? Buffer.alloc(0) : bytes
|
||||
);
|
||||
}
|
||||
for (const path of [
|
||||
'/bookmarks/photos/logo',
|
||||
'/bookmarks/missing/logo',
|
||||
'/%62ookmarks/photos/logo',
|
||||
'/bookmarks/%70hotos/logo',
|
||||
'/bookmarks/photos/%6cogo'
|
||||
]) {
|
||||
const denied = await server.fetch(path, {
|
||||
method,
|
||||
headers: { accept: 'text/html', cookie }
|
||||
});
|
||||
expect(denied.status).toBe(401);
|
||||
expect(denied.headers.has('location')).toBe(false);
|
||||
expectPrivateHeaders(denied);
|
||||
if (method === 'HEAD') expect(await denied.text()).toBe('');
|
||||
else await expectNoPrivateData(denied);
|
||||
}
|
||||
for (const [path, contentType, bytes] of [
|
||||
['/public/photo.svg', 'image/svg+xml', Buffer.from(privateLogo)],
|
||||
['/public/photo.png', 'image/png', png],
|
||||
['/public/brand%20logo.svg', 'image/svg+xml', Buffer.from(privateBrand)],
|
||||
['/%70ublic/photo.svg', 'image/svg+xml', Buffer.from(privateLogo)],
|
||||
['/public/photo%2Esvg', 'image/svg+xml', Buffer.from(privateLogo)],
|
||||
['/public/unreferenced.svg', 'image/svg+xml', Buffer.from(privateLogo)],
|
||||
['/public/images/bookmarks/test-ling.svg', 'image/svg+xml', Buffer.from(privateLogo)]
|
||||
] as const) {
|
||||
const image = await server.fetch(path, {
|
||||
method,
|
||||
headers: { accept: 'text/html' }
|
||||
});
|
||||
expect(image.status).toBe(200);
|
||||
expectPrivateHeaders(image);
|
||||
expect(image.headers.get('content-type')).toBe(contentType);
|
||||
expect(image.headers.get('content-length')).toBe(String(bytes.length));
|
||||
expect(Buffer.from(await image.arrayBuffer())).toEqual(
|
||||
method === 'HEAD' ? Buffer.alloc(0) : bytes
|
||||
);
|
||||
}
|
||||
for (const path of [
|
||||
'/bookmarks/photos/logo',
|
||||
'/bookmarks/missing/logo',
|
||||
'/%62ookmarks/photos/logo',
|
||||
'/bookmarks/%70hotos/logo',
|
||||
'/bookmarks/photos/%6cogo'
|
||||
]) {
|
||||
const denied = await server.fetch(path, {
|
||||
method,
|
||||
headers: { accept: 'text/html' }
|
||||
});
|
||||
expect(denied.status).toBe(401);
|
||||
expect(denied.headers.has('location')).toBe(false);
|
||||
expectPrivateHeaders(denied);
|
||||
if (method === 'HEAD') expect(await denied.text()).toBe('');
|
||||
else await expectNoPrivateData(denied);
|
||||
}
|
||||
}
|
||||
for (const path of ['/healthz', '/favicon.png', '/signin']) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { startServer, type TestServer } from './support/server.js';
|
||||
import { responseCookieHeader } from './support/session.js';
|
||||
|
||||
const title = 'Favicon fixture';
|
||||
const siteDescription = 'Useful services with familiar browser icons.';
|
||||
@@ -127,9 +128,9 @@ async function signIn(server: TestServer) {
|
||||
body: new URLSearchParams({ username, password, redirectTo: '/' })
|
||||
});
|
||||
expect([302, 303]).toContain(response.status);
|
||||
const cookie = response.headers.getSetCookie().find((value) => /session-token=/.test(value));
|
||||
expect(cookie).toBeDefined();
|
||||
return cookie!.split(';')[0]!;
|
||||
const cookie = responseCookieHeader(response);
|
||||
expect(cookie).not.toBe('');
|
||||
return cookie;
|
||||
}
|
||||
|
||||
async function stop(server: TestServer, directory: string) {
|
||||
@@ -174,7 +175,7 @@ for (const [source, bytes, contentType] of [
|
||||
expectNoPrivateData(payload, [fixture.publicDir, server.path]);
|
||||
|
||||
const cookie = await signIn(server);
|
||||
for (const session of ['', 'authjs.session-token=invalid-favicon-cookie', cookie]) {
|
||||
for (const session of ['', cookie]) {
|
||||
await expectImage(server, bytes, contentType, session);
|
||||
}
|
||||
for (const query of [
|
||||
|
||||
@@ -127,7 +127,11 @@ export async function startIssuer() {
|
||||
const params = await body(request);
|
||||
const authorization = authorizations.find((entry) => entry.code === params.get('code'));
|
||||
const verifier = params.get('code_verifier') ?? '';
|
||||
const basic = `Basic ${Buffer.from(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`).toString('base64')}`;
|
||||
// OAuth Basic credentials use form encoding (including ! and spaces), not URI encoding.
|
||||
const encodedCredentials = [clientId, clientSecret]
|
||||
.map((value) => new URLSearchParams({ value }).toString().slice('value='.length))
|
||||
.join(':');
|
||||
const basic = `Basic ${Buffer.from(encodedCredentials).toString('base64')}`;
|
||||
if (
|
||||
request.headers.authorization !== basic ||
|
||||
params.get('grant_type') !== 'authorization_code' ||
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { applySetCookies } from 'better-auth/cookies';
|
||||
|
||||
// Resolve core through the SDK that owns it, rather than add a test dependency
|
||||
// or reproduce Auth.js's encrypted JWT format in this fixture.
|
||||
const requireSdk = createRequire(import.meta.resolve('@auth/sveltekit'));
|
||||
const jwtUrl = pathToFileURL(requireSdk.resolve('@auth/core/jwt')).href;
|
||||
interface JwtModule {
|
||||
encode(options: {
|
||||
secret: string;
|
||||
salt: string;
|
||||
token: Record<string, unknown>;
|
||||
maxAge: number;
|
||||
}): Promise<string>;
|
||||
decode(options: {
|
||||
secret: string;
|
||||
salt: string;
|
||||
token: string;
|
||||
}): Promise<{ iat: number; exp: number } | null>;
|
||||
}
|
||||
|
||||
export async function readSession(secret: string, name: string, token: string) {
|
||||
const jwt = (await import(jwtUrl)) as JwtModule;
|
||||
return jwt.decode({ secret, salt: name, token });
|
||||
}
|
||||
|
||||
export async function expiredSession(secret: string, name: string): Promise<string> {
|
||||
const jwt = (await import(jwtUrl)) as JwtModule;
|
||||
return jwt.encode({ secret, salt: name, token: { sub: 'c6-expired-session' }, maxAge: -3_600 });
|
||||
/** Carry the complete native login cookie jar, including any chunked cookies. */
|
||||
export function responseCookieHeader(response: Response): string {
|
||||
const headers = new Headers();
|
||||
applySetCookies(headers, response.headers.getSetCookie());
|
||||
return headers.get('cookie') ?? '';
|
||||
}
|
||||
@@ -21,12 +21,12 @@
|
||||
"@directory/config": "workspace:*",
|
||||
"@takumi-rs/core": "2.18.2",
|
||||
"@takumi-rs/helpers": "2.18.2",
|
||||
"better-auth": "1.7.14",
|
||||
"bits-ui": "catalog:",
|
||||
"fuse.js": "catalog:",
|
||||
"tslog": "5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@auth/sveltekit": "1.11.3",
|
||||
"@iconify-json/ph": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"@sveltejs/adapter-node": "catalog:",
|
||||
|
||||
Vendored
+3
-3
@@ -1,6 +1,5 @@
|
||||
import type { Session } from '@auth/sveltekit';
|
||||
import type { DirectoryContent } from './lib/display/types.js';
|
||||
import type { Authentication } from './lib/server/auth/index.js';
|
||||
import type { Authentication, DirectorySession } from './lib/server/auth/index.js';
|
||||
import type { PreparedSnapshot } from './lib/server/directory/snapshot.js';
|
||||
|
||||
declare global {
|
||||
@@ -24,7 +23,8 @@ declare global {
|
||||
snapshot: PreparedSnapshot;
|
||||
publicDir: string;
|
||||
authentication: Authentication | undefined;
|
||||
session: Session | null;
|
||||
session: DirectorySession | null;
|
||||
auth(): Promise<DirectorySession | null>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,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' },
|
||||
env: { ORIGIN: '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>(),
|
||||
@@ -326,7 +326,7 @@ describe('server hooks', () => {
|
||||
}
|
||||
return bookmarkLogo(event as Parameters<typeof bookmarkLogo>[0]);
|
||||
});
|
||||
for (const cookie of ['', 'authjs.session-token=invalid-session']) {
|
||||
for (const cookie of ['', 'unrelated-cookie=opaque']) {
|
||||
for (const [path, routeId, params] of [
|
||||
['/public/photo.svg', '/(public)/public/[...file]', { file: 'photo.svg' }],
|
||||
['/public/site.svg', '/(public)/public/[...file]', { file: 'site.svg' }],
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import type { Handle, RequestEvent } from '@sveltejs/kit';
|
||||
import { protectAuthRequest, requireSignIn, type AccessPolicy } from './access.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
vi.mock('../logger.js', () => ({ logger: { error: vi.fn() } }));
|
||||
|
||||
const origin = 'https://directory.example';
|
||||
const policy: AccessPolicy = { origin, strategy: 'credentials' };
|
||||
const session = { user: { name: 'Directory' }, expires: '2030-01-01T00:00:00.000Z' };
|
||||
type Resolve = Parameters<Handle>[0]['resolve'];
|
||||
type RequestOptions = {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
routeId?: string | null;
|
||||
data?: boolean;
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
function requestFor(path = '/', options: RequestOptions = {}) {
|
||||
const url = new URL(path, origin);
|
||||
const routeId = options.routeId === undefined ? '/(protected)' : options.routeId;
|
||||
const event = {
|
||||
url,
|
||||
request: new Request(url, {
|
||||
method: options.method ?? 'GET',
|
||||
headers: { accept: 'text/html', ...options.headers }
|
||||
}),
|
||||
route: { id: routeId },
|
||||
isDataRequest: options.data ?? false,
|
||||
locals: { auth: vi.fn<App.Locals['auth']>().mockResolvedValue(null), session: null }
|
||||
} as unknown as RequestEvent;
|
||||
const resolve = vi.fn<Resolve>().mockResolvedValue(new Response('private-content'));
|
||||
return { event, resolve };
|
||||
}
|
||||
|
||||
it.each([
|
||||
['/(public)', 200],
|
||||
['/(public)/public/[...file]', 200],
|
||||
['/(publicity)/future', 303],
|
||||
['/(protected)', 303],
|
||||
['/(protected)/(endpoints)', 401],
|
||||
['/(protected)/(endpoints)/bookmarks/[id]/logo', 401],
|
||||
['/(protected)/(endpoints-other)/future', 303],
|
||||
[null, 303]
|
||||
] as const)('uses matched route-group boundaries for %s', async (routeId, status) => {
|
||||
const input = requestFor('/future', { routeId });
|
||||
const response = await requireSignIn(input, policy);
|
||||
expect(response.status).toBe(status);
|
||||
if (status === 200) expect(input.event.locals.auth).not.toHaveBeenCalled();
|
||||
else expect(input.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each<RequestOptions>([
|
||||
{ method: 'GET', data: true },
|
||||
{ method: 'GET', headers: { accept: 'application/json' } },
|
||||
{ method: 'GET', headers: { accept: 'text/html;q=0' } },
|
||||
{ method: 'HEAD' },
|
||||
{ method: 'POST', headers: { origin } }
|
||||
])('denies private non-navigation requests: %j', async (options) => {
|
||||
const input = requestFor('/private', options);
|
||||
const response = await requireSignIn(input, policy);
|
||||
expect(response.status).toBe(401);
|
||||
expect(input.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redirects signed-out HTML navigation back to its local destination', async () => {
|
||||
const response = await requireSignIn(requestFor('/private?q=one'), policy);
|
||||
const location = new URL(response.headers.get('location')!, origin);
|
||||
expect(response.status).toBe(303);
|
||||
expect(location.origin).toBe(origin);
|
||||
expect(location.pathname).toBe('/signin');
|
||||
expect(location.searchParams.get('callbackUrl')).toBe('/private?q=one');
|
||||
});
|
||||
|
||||
it('allows an authenticated request and captures its session for the private UI', async () => {
|
||||
const input = requestFor('/bookmarks/photos/logo', {
|
||||
routeId: '/(protected)/(endpoints)/bookmarks/[id]/logo'
|
||||
});
|
||||
vi.mocked(input.event.locals.auth).mockResolvedValue(session);
|
||||
expect((await requireSignIn(input, policy)).status).toBe(200);
|
||||
expect(input.event.locals.session).toBe(session);
|
||||
});
|
||||
|
||||
it('fails closed on a session-reader failure without forwarding its raw cause', async () => {
|
||||
const input = requestFor('/');
|
||||
const sentinel = 'private-session-token';
|
||||
vi.mocked(input.event.locals.auth).mockRejectedValue(new Error(sentinel));
|
||||
const response = await requireSignIn(input, policy);
|
||||
expect(response.status).toBe(401);
|
||||
expect(input.resolve).not.toHaveBeenCalled();
|
||||
expect(await response.text()).not.toContain(sentinel);
|
||||
expect(JSON.stringify(vi.mocked(logger.error).mock.calls)).not.toContain(sentinel);
|
||||
});
|
||||
|
||||
it('rejects either inconsistent request origin before resolving a route', async () => {
|
||||
for (const replaceRequest of [false, true]) {
|
||||
const input = requestFor('/');
|
||||
const foreign = new URL('https://another.example/');
|
||||
if (replaceRequest) input.event.request = new Request(foreign);
|
||||
else input.event.url = foreign;
|
||||
expect((await protectAuthRequest(input, policy)).status).toBe(403);
|
||||
expect(input.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([undefined, 'null', 'https://another.example'])('rejects POST origin %s', async (value) => {
|
||||
const input = requestFor('/signin', {
|
||||
method: 'POST',
|
||||
headers: value === undefined ? {} : { origin: value }
|
||||
});
|
||||
expect((await protectAuthRequest(input, policy)).status).toBe(403);
|
||||
expect(input.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/auth/sign-in/credentials',
|
||||
'/auth/sign-in/social',
|
||||
'/auth/get-session',
|
||||
'/auth/sign-out'
|
||||
])('keeps native auth endpoints private to the form wrappers: %s', async (path) => {
|
||||
const input = requestFor(path, { method: 'POST', headers: { origin } });
|
||||
expect((await protectAuthRequest(input, policy)).status).toBe(404);
|
||||
expect(input.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('permits only a configured OIDC GET callback, not data requests or POSTs', async () => {
|
||||
for (const [strategy, method, data, status] of [
|
||||
['oidc', 'GET', false, 200],
|
||||
['credentials', 'GET', false, 404],
|
||||
['oidc', 'GET', true, 404],
|
||||
['oidc', 'POST', false, 404]
|
||||
] as const) {
|
||||
const input = requestFor('/auth/callback/oidc', {
|
||||
method,
|
||||
data,
|
||||
headers: { origin: method === 'GET' ? 'https://identity.example' : origin }
|
||||
});
|
||||
expect((await protectAuthRequest(input, { origin, strategy })).status).toBe(status);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['credentials', 'private-library-error'])('sanitizes sign-in errors: %s', async (code) => {
|
||||
const input = requestFor('/signin');
|
||||
input.resolve.mockResolvedValue(
|
||||
Response.redirect(`${origin}/signin?callbackUrl=%2Fsafe&error=${code}&token=private-token`, 302)
|
||||
);
|
||||
const response = await protectAuthRequest(input, policy);
|
||||
const location = new URL(response.headers.get('location')!, origin);
|
||||
expect(location.searchParams.get('callbackUrl')).toBe('/safe');
|
||||
expect(location.searchParams.get('error')).toBe(
|
||||
code === 'credentials' ? 'credentials' : 'signin'
|
||||
);
|
||||
expect(location.searchParams.has('token')).toBe(false);
|
||||
expect(response.headers.get('cache-control')).toBe('private, no-store');
|
||||
});
|
||||
|
||||
it('preserves all response cookies when applying private caching', async () => {
|
||||
const input = requestFor('/signin');
|
||||
const headers = new Headers();
|
||||
headers.append('set-cookie', 'one=opaque; Path=/; HttpOnly');
|
||||
headers.append('set-cookie', 'two=opaque; Path=/; HttpOnly');
|
||||
input.resolve.mockResolvedValue(new Response(null, { headers }));
|
||||
const response = await protectAuthRequest(input, policy);
|
||||
expect(response.headers.getSetCookie()).toEqual(headers.getSetCookie());
|
||||
expect(response.headers.get('cache-control')).toBe('private, no-store');
|
||||
});
|
||||
|
||||
it('fails closed when a downstream auth request throws, without exposing its cause', async () => {
|
||||
const input = requestFor('/signin');
|
||||
const sentinel = 'private-auth-token';
|
||||
input.resolve.mockRejectedValue(new Error(sentinel));
|
||||
const response = await protectAuthRequest(input, policy);
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.text()).not.toContain(sentinel);
|
||||
expect(JSON.stringify(vi.mocked(logger.error).mock.calls)).not.toContain(sentinel);
|
||||
});
|
||||
@@ -39,11 +39,16 @@ export async function protectAuthRequest(
|
||||
const headers = new Headers(response.headers);
|
||||
const location = headers.get('location');
|
||||
if (location && isSignInError(location, policy.origin)) {
|
||||
const callbackUrl = localRedirect(
|
||||
new URL(location, policy.origin).searchParams.get('callbackUrl'),
|
||||
policy.origin
|
||||
);
|
||||
headers.set('location', signInLocation(callbackUrl, true));
|
||||
const destination = new URL(location, policy.origin);
|
||||
const callbackUrl = localRedirect(destination.searchParams.get('callbackUrl'), policy.origin);
|
||||
let error = 'signin';
|
||||
if (
|
||||
policy.strategy === 'credentials' &&
|
||||
destination.searchParams.get('error') === 'credentials'
|
||||
) {
|
||||
error = 'credentials';
|
||||
}
|
||||
headers.set('location', `${signInLocation(callbackUrl)}&error=${error}`);
|
||||
}
|
||||
headers.set('cache-control', 'private, no-store');
|
||||
return new Response(response.body, {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { isRedirect, type RequestEvent } from '@sveltejs/kit';
|
||||
import { signInAction } from './actions.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
vi.mock('../logger.js', () => ({ logger: { error: vi.fn() } }));
|
||||
|
||||
it('keeps unexpected action failures out of logs and redirects', async () => {
|
||||
const origin = 'https://directory.example';
|
||||
const sentinel = 'private-token-from-native-error';
|
||||
const request = new Request(`${origin}/signin`, {
|
||||
method: 'POST',
|
||||
headers: { origin },
|
||||
body: new URLSearchParams({ redirectTo: '/safe' })
|
||||
});
|
||||
const event = {
|
||||
request,
|
||||
url: new URL(request.url),
|
||||
getClientAddress: () => '192.0.2.1'
|
||||
} as RequestEvent;
|
||||
const auth = { handler: vi.fn().mockRejectedValue(new Error(sentinel)) };
|
||||
const failure: unknown = await signInAction(event, auth, origin, 'credentials').catch(
|
||||
(error: unknown) => error
|
||||
);
|
||||
|
||||
expect(isRedirect(failure)).toBe(true);
|
||||
if (!isRedirect(failure)) throw new Error('Expected a safe action redirect.');
|
||||
const location = new URL(failure.location, origin);
|
||||
expect(location.origin).toBe(origin);
|
||||
expect(location.pathname).toBe('/signin');
|
||||
expect(location.searchParams.get('callbackUrl')).toBe('/safe');
|
||||
expect(location.searchParams.has('error')).toBe(true);
|
||||
expect(failure.location).not.toContain(sentinel);
|
||||
expect(JSON.stringify(vi.mocked(logger.error).mock.calls)).not.toContain(sentinel);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { isRedirect, redirect, type RequestEvent } from '@sveltejs/kit';
|
||||
import { parseSetCookieHeader, toCookieOptions } from 'better-auth/cookies';
|
||||
import { logger } from '../logger.js';
|
||||
import type { Auth } from './index.js';
|
||||
import { localRedirect, signInLocation } from './redirect.js';
|
||||
import { authenticationHeaders, requireActionOrigin } from './request.js';
|
||||
|
||||
export async function signInAction(
|
||||
event: RequestEvent,
|
||||
auth: Pick<Auth, 'handler'>,
|
||||
origin: string,
|
||||
strategy: 'credentials' | 'oidc'
|
||||
): Promise<void> {
|
||||
requireActionOrigin(event, origin);
|
||||
let callbackUrl = '/';
|
||||
try {
|
||||
const submitted = await event.request.clone().formData();
|
||||
callbackUrl = localRedirect(stringField(submitted, 'redirectTo'), origin);
|
||||
let response: Response;
|
||||
if (strategy === 'credentials') {
|
||||
response = await submitAuth(event, auth, origin, '/sign-in/credentials', {
|
||||
username: stringField(submitted, 'username') ?? '',
|
||||
password: stringField(submitted, 'password') ?? ''
|
||||
});
|
||||
} else {
|
||||
response = await submitAuth(event, auth, origin, '/sign-in/social', {
|
||||
provider: 'oidc',
|
||||
callbackURL: callbackUrl,
|
||||
errorCallbackURL: signInLocation(callbackUrl, true)
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error =
|
||||
strategy === 'credentials' && response.status === 401 ? 'credentials' : 'signin';
|
||||
redirect(303, `${signInLocation(callbackUrl)}&error=${error}`);
|
||||
}
|
||||
if (strategy === 'oidc') {
|
||||
// Only the native provider response can choose an external destination.
|
||||
const destination = new URL(response.headers.get('location') ?? '');
|
||||
if (!['https:', 'http:'].includes(destination.protocol)) {
|
||||
throw new Error('Invalid authentication destination.');
|
||||
}
|
||||
redirect(303, destination.href);
|
||||
}
|
||||
redirect(303, callbackUrl);
|
||||
} catch (failure) {
|
||||
handleActionFailure(failure, callbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOutAction(
|
||||
event: RequestEvent,
|
||||
auth: Pick<Auth, 'handler'>,
|
||||
origin: string
|
||||
): Promise<void> {
|
||||
requireActionOrigin(event, origin);
|
||||
let callbackUrl = '/';
|
||||
try {
|
||||
const submitted = await event.request.clone().formData();
|
||||
callbackUrl = localRedirect(stringField(submitted, 'redirectTo'), origin);
|
||||
const response = await submitAuth(event, auth, origin, '/sign-out', {});
|
||||
if (!response.ok) redirect(303, signInLocation(callbackUrl, true));
|
||||
redirect(303, callbackUrl);
|
||||
} catch (failure) {
|
||||
handleActionFailure(failure, callbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAuth(
|
||||
event: RequestEvent,
|
||||
auth: Pick<Auth, 'handler'>,
|
||||
origin: string,
|
||||
path: string,
|
||||
body: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const headers = authenticationHeaders(event);
|
||||
headers.set('content-type', 'application/json');
|
||||
headers.delete('content-length');
|
||||
// Use the HTTP handler, not auth.api: native throttling lives in the router.
|
||||
const response = await auth.handler(
|
||||
new Request(new URL(`/auth${path}`, origin), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
);
|
||||
// The same native cookie helpers used by sveltekitCookies, without bypassing the router.
|
||||
for (const [name, attributes] of parseSetCookieHeader(response.headers.get('set-cookie') ?? '')) {
|
||||
event.cookies.set(name, attributes.value, {
|
||||
...toCookieOptions(attributes),
|
||||
path: attributes.path || '/'
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function handleActionFailure(failure: unknown, callbackUrl: string): never {
|
||||
if (isRedirect(failure)) throw failure;
|
||||
logger.error('Authentication action failed (auth_action_failed).');
|
||||
redirect(303, signInLocation(callbackUrl, true));
|
||||
}
|
||||
|
||||
function stringField(form: FormData, name: string): string | null {
|
||||
const values = form.getAll(name);
|
||||
if (values.length !== 1) return null;
|
||||
const value = values[0];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { verifyCredentials } from './credentials.js';
|
||||
|
||||
const credentials = { username: 'family', password: 'correct café ☕' };
|
||||
|
||||
describe('configured shared credentials', () => {
|
||||
it('authorizes the configured shared credentials', () => {
|
||||
expect(verifyCredentials(credentials, credentials)).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
{},
|
||||
{ username: 'someone-else', password: credentials.password },
|
||||
{ username: credentials.username, password: 'incorrect' },
|
||||
{ username: ['family'], password: credentials.password },
|
||||
{ username: credentials.username, password: { toString: () => credentials.password } },
|
||||
{ username: 'family ', password: credentials.password },
|
||||
{ username: credentials.username, password: `${credentials.password}\u0000` }
|
||||
])('rejects wrong or malformed credentials: %j', (submitted) => {
|
||||
expect(verifyCredentials(credentials, submitted)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import type { DirectoryCredentialsAuth } from '@directory/config/types';
|
||||
|
||||
export function verifyCredentials(
|
||||
configured: DirectoryCredentialsAuth,
|
||||
submitted: unknown
|
||||
): { id: string; name: string } | null {
|
||||
if (typeof submitted !== 'object' || submitted === null) return null;
|
||||
const fields = submitted as Record<string, unknown>;
|
||||
const username = typeof fields.username === 'string' ? fields.username : '';
|
||||
const password = typeof fields.password === 'string' ? fields.password : '';
|
||||
// Fixed-size digests allow both comparisons even when input lengths differ.
|
||||
const usernameMatches = secretEquals(username, configured.username);
|
||||
const passwordMatches = secretEquals(password, configured.password);
|
||||
return usernameMatches && passwordMatches ? { id: 'directory', name: 'Directory' } : null;
|
||||
}
|
||||
|
||||
function secretEquals(left: string, right: string): boolean {
|
||||
return timingSafeEqual(digest(left), digest(right));
|
||||
}
|
||||
|
||||
function digest(value: string): Buffer {
|
||||
return createHash('sha256').update(value).digest();
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { isRedirect, redirect, type RequestEvent } from '@sveltejs/kit';
|
||||
import type { SvelteKitAuth } from '@auth/sveltekit';
|
||||
import { logger } from '../logger.js';
|
||||
import { isSignInError, localRedirect, signInLocation } from './redirect.js';
|
||||
import { requireActionOrigin } from './request.js';
|
||||
|
||||
type AuthJsHandlers = ReturnType<typeof SvelteKitAuth>;
|
||||
|
||||
export async function signInAction(
|
||||
event: RequestEvent,
|
||||
origin: string,
|
||||
strategy: 'credentials' | 'oidc',
|
||||
authJs: AuthJsHandlers
|
||||
): Promise<void> {
|
||||
requireActionOrigin(event, origin);
|
||||
let callbackUrl = '/';
|
||||
try {
|
||||
const submitted = await event.request.clone().formData();
|
||||
callbackUrl = localRedirect(stringField(submitted, 'redirectTo'), origin);
|
||||
const body = new URLSearchParams({ redirectTo: callbackUrl, providerId: strategy });
|
||||
if (strategy === 'credentials') {
|
||||
body.set('username', stringField(submitted, 'username') ?? '');
|
||||
body.set('password', stringField(submitted, 'password') ?? '');
|
||||
}
|
||||
await authJs.signIn(prepareFormEvent(event, body));
|
||||
} catch (failure) {
|
||||
handleActionFailure(failure, origin, callbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOutAction(
|
||||
event: RequestEvent,
|
||||
origin: string,
|
||||
authJs: AuthJsHandlers
|
||||
): Promise<void> {
|
||||
requireActionOrigin(event, origin);
|
||||
let callbackUrl = '/';
|
||||
try {
|
||||
const submitted = await event.request.clone().formData();
|
||||
callbackUrl = localRedirect(stringField(submitted, 'redirectTo'), origin);
|
||||
const body = new URLSearchParams({ redirectTo: callbackUrl });
|
||||
await authJs.signOut(prepareFormEvent(event, body));
|
||||
} catch (failure) {
|
||||
handleActionFailure(failure, origin, callbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function prepareFormEvent(event: RequestEvent, body: URLSearchParams): RequestEvent {
|
||||
const headers = new Headers(event.request.headers);
|
||||
headers.set('content-type', 'application/x-www-form-urlencoded');
|
||||
headers.delete('content-length');
|
||||
return {
|
||||
...event,
|
||||
request: new Request(event.request.url, { method: 'POST', headers, body })
|
||||
};
|
||||
}
|
||||
|
||||
function handleActionFailure(failure: unknown, origin: string, callbackUrl: string): never {
|
||||
if (isRedirect(failure)) {
|
||||
if (isSignInError(failure.location, origin)) redirect(303, signInLocation(callbackUrl, true));
|
||||
throw failure;
|
||||
}
|
||||
logger.error('Authentication action failed (auth_action_failed).');
|
||||
redirect(303, signInLocation(callbackUrl, true));
|
||||
}
|
||||
|
||||
function stringField(form: FormData, name: string): string | null {
|
||||
const values = form.getAll(name);
|
||||
if (values.length !== 1) return null;
|
||||
const value = values[0];
|
||||
if (typeof value !== 'string') return null;
|
||||
return value;
|
||||
}
|
||||
@@ -1,693 +1,210 @@
|
||||
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';
|
||||
import { validateConfig } from '@directory/config';
|
||||
import { prepareSnapshot } from '../directory/snapshot.js';
|
||||
import { createAuthentication } from './index.js';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { isRedirect, type RequestEvent } from '@sveltejs/kit';
|
||||
import { setRequestCookie } from 'better-auth/cookies';
|
||||
import type { DirectoryAuth } from '@directory/config/types';
|
||||
import { signInAction, signOutAction } from './actions.js';
|
||||
import { createAuth, createSessionReader, type Auth } from './index.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
constructor: vi.fn(),
|
||||
sequence: vi.fn(),
|
||||
handle: vi.fn(),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
session: vi.fn(),
|
||||
resolve: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn()
|
||||
vi.hoisted(() => vi.stubEnv('NODE_ENV', 'production'));
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: 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
|
||||
}));
|
||||
vi.mock('../logger.js', () => ({ logger: { error: mocks.logError, warn: mocks.logWarn } }));
|
||||
// Native sequence needs Kit's request store; real HTTP tests exercise that integration.
|
||||
vi.mock('@sveltejs/kit/hooks', () => ({ sequence: mocks.sequence }));
|
||||
|
||||
const origin = 'https://directory.example';
|
||||
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(
|
||||
validateConfig(
|
||||
{
|
||||
site: { favicon_path: '/public/site.svg', logo_path: '/public/site-logo.svg' },
|
||||
links: {
|
||||
photos: {
|
||||
title: 'Photos',
|
||||
url: 'https://photos.example',
|
||||
logo_path: '/public/logos/photos.svg'
|
||||
}
|
||||
}
|
||||
},
|
||||
{ env: {} }
|
||||
)
|
||||
);
|
||||
const secret = 'configured-session-secret-with-at-least-32-characters';
|
||||
const credentials = { username: 'family', password: 'correct café ☕' };
|
||||
const config = { secret, credentials };
|
||||
let cookieHeaders: Headers;
|
||||
let client = 0;
|
||||
let address: string;
|
||||
|
||||
function eventFor(
|
||||
path = '/',
|
||||
options: {
|
||||
method?: string;
|
||||
headers?: HeadersInit;
|
||||
body?: BodyInit;
|
||||
data?: boolean;
|
||||
routeId?: RequestEvent['route']['id'];
|
||||
} = {}
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2030-01-01T12:00:00Z'));
|
||||
cookieHeaders = new Headers();
|
||||
address = `192.0.2.${++client}`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function authentication(settings: DirectoryAuth = config): Auth {
|
||||
const auth = createAuth(settings, origin);
|
||||
if (!auth) throw new Error('Expected enabled authentication.');
|
||||
return auth;
|
||||
}
|
||||
|
||||
function formEvent(
|
||||
path: string,
|
||||
fields: Record<string, string> | URLSearchParams,
|
||||
browserOrigin = origin
|
||||
): RequestEvent {
|
||||
const method = options.method ?? 'GET';
|
||||
const headers = new Headers({ accept: 'text/html', ...options.headers });
|
||||
const headers = new Headers(cookieHeaders);
|
||||
headers.set('origin', browserOrigin);
|
||||
const request = new Request(new URL(path, origin), {
|
||||
method,
|
||||
method: 'POST',
|
||||
headers,
|
||||
...(options.body === undefined ? {} : { body: options.body })
|
||||
body: new URLSearchParams(fields)
|
||||
});
|
||||
const pathname = new URL(request.url).pathname;
|
||||
let routeId: RequestEvent['route']['id'] = null;
|
||||
if (pathname === '/' || pathname === '/__data.json') routeId = '/(protected)';
|
||||
if (pathname === '/signin') routeId = '/(public)/signin';
|
||||
if (pathname === '/healthz') routeId = '/(public)/healthz';
|
||||
if (options.routeId !== undefined) routeId = options.routeId;
|
||||
return {
|
||||
url: new URL(request.url),
|
||||
request,
|
||||
locals: { snapshot, publicDir: '/mounted/public' },
|
||||
route: { id: routeId },
|
||||
isDataRequest: options.data ?? false,
|
||||
cookies: { set: vi.fn(), get: vi.fn() }
|
||||
url: new URL(request.url),
|
||||
getClientAddress: () => address,
|
||||
cookies: {
|
||||
set(name: string, value: string) {
|
||||
// Model only Kit forwarding cookies into the next browser request.
|
||||
setRequestCookie(cookieHeaders, name, value);
|
||||
}
|
||||
}
|
||||
} as unknown as RequestEvent;
|
||||
}
|
||||
|
||||
function authenticate() {
|
||||
return createAuthentication({ origin, secret, credentials });
|
||||
}
|
||||
|
||||
async function responseFor(event: RequestEvent) {
|
||||
return authenticate().handle({ event, resolve: mocks.resolve });
|
||||
}
|
||||
|
||||
function authJsConfig(): SvelteKitAuthConfig {
|
||||
return mocks.constructor.mock.calls[0]?.[0] as SvelteKitAuthConfig;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.sequence.mockImplementation((...handlers: Handle[]): Handle => {
|
||||
return ({ event, resolve }) => {
|
||||
const run = (index: number, current: RequestEvent): ReturnType<Handle> => {
|
||||
const handler = handlers[index];
|
||||
return handler
|
||||
? handler({ event: current, resolve: (next) => Promise.resolve(run(index + 1, next)) })
|
||||
: resolve(current);
|
||||
};
|
||||
return run(0, event);
|
||||
};
|
||||
});
|
||||
mocks.constructor.mockReturnValue({
|
||||
handle: mocks.handle,
|
||||
signIn: mocks.signIn,
|
||||
signOut: mocks.signOut
|
||||
});
|
||||
mocks.handle.mockImplementation(async ({ event, resolve }) => {
|
||||
event.locals.auth = mocks.session;
|
||||
if (event.url.pathname === '/auth/callback/oidc') {
|
||||
return new Response(null, { status: 302, headers: { location: '/' } });
|
||||
}
|
||||
return resolve(event);
|
||||
});
|
||||
mocks.session.mockResolvedValue(null);
|
||||
mocks.resolve.mockResolvedValue(new Response('private-page'));
|
||||
});
|
||||
|
||||
describe('bookmark logo API access', () => {
|
||||
it.each(['GET', 'HEAD'])('requires a session for %s even with HTML Accept', async (method) => {
|
||||
for (const path of ['/bookmarks/photos/logo', '/bookmarks/missing/logo']) {
|
||||
const response = await responseFor(
|
||||
eventFor(path, { method, routeId: '/(protected)/(endpoints)/bookmarks/[id]/logo' })
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get('cache-control')).toBe('private, no-store');
|
||||
}
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'uses the matched logo API route for encoded %s aliases',
|
||||
async (method) => {
|
||||
for (const path of [
|
||||
'/%62ookmarks/photos/logo',
|
||||
'/bookmarks/%70hotos/logo',
|
||||
'/bookmarks/photos/%6cogo'
|
||||
]) {
|
||||
const event = eventFor(path, { method });
|
||||
event.route.id = '/(protected)/(endpoints)/bookmarks/[id]/logo';
|
||||
expect((await responseFor(event)).status).toBe(401);
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('lets an authenticated request reach the prepared SVG route', async () => {
|
||||
mocks.session.mockResolvedValueOnce(signedIn);
|
||||
const event = eventFor('/bookmarks/photos/logo', {
|
||||
routeId: '/(protected)/(endpoints)/bookmarks/[id]/logo'
|
||||
});
|
||||
expect((await responseFor(event)).status).toBe(200);
|
||||
expect(mocks.resolve).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('public mounted image access', () => {
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'lets configured and unreferenced %s images reach the public route without reading a session',
|
||||
async (method) => {
|
||||
for (const path of [
|
||||
'/public/site.svg',
|
||||
'/public/site-logo.svg',
|
||||
'/public/logos/photos.svg',
|
||||
'/public/unreferenced.svg',
|
||||
'/%70ublic/site.svg',
|
||||
'/public/site%2Esvg'
|
||||
]) {
|
||||
const event = eventFor(path, { method, routeId: '/(public)/public/[...file]' });
|
||||
const response = await responseFor(event);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('cache-control')).toBe('private, no-store');
|
||||
expect(mocks.resolve).toHaveBeenLastCalledWith(event);
|
||||
}
|
||||
expect(mocks.session).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
trustHost: true,
|
||||
basePath: '/auth',
|
||||
redirectProxyUrl: '',
|
||||
session: { strategy: 'jwt', maxAge: 604800 },
|
||||
pages: { signIn: '/signin', error: '/signin' }
|
||||
});
|
||||
for (let index = 0; index < 2; index++) {
|
||||
await authentication.handle({ event: eventFor('/signin'), resolve: mocks.resolve });
|
||||
}
|
||||
expect(mocks.constructor).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.sequence).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
authenticate();
|
||||
const callback = authJsConfig().callbacks?.redirect;
|
||||
expect(await callback?.({ url: 'https://evil.example/token', baseUrl: origin })).toBe(
|
||||
`${origin}/`
|
||||
);
|
||||
expect(await callback?.({ url: '/safe?a=1', baseUrl: 'https://evil.example' })).toBe(
|
||||
`${origin}/safe?a=1`
|
||||
);
|
||||
const authJsLogger = authJsConfig().logger;
|
||||
authJsLogger?.error?.(new Error('secret/token/issuer sentinel'));
|
||||
authJsLogger?.warn?.('env-url-basepath-redundant');
|
||||
expect(mocks.logWarn).not.toHaveBeenCalled();
|
||||
authJsLogger?.warn?.('env-url-basepath-mismatch');
|
||||
authJsLogger?.debug?.('sensitive debug', { token: 'raw-token' });
|
||||
expect(mocks.logError.mock.calls).toEqual([['Auth.js failed (authjs_error).']]);
|
||||
expect(mocks.logWarn.mock.calls).toEqual([['Auth.js warning (authjs_warning).']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whole-directory gate', () => {
|
||||
it.each([
|
||||
['/(public)/future', 200],
|
||||
['/(public)', 200],
|
||||
['/(publicity)/future', 303],
|
||||
['/(public)-other/future', 303],
|
||||
['/(protected)', 303],
|
||||
['/(protected)/future', 303],
|
||||
['/(protected)/(endpoints)/future', 401],
|
||||
['/(protected)/(endpoints)', 401],
|
||||
['/(protected)/(endpoints-other)/future', 303],
|
||||
['/(other)/future', 303],
|
||||
[null, 303]
|
||||
] as const)('uses route-group boundaries, not URL ownership: %s', async (routeId, status) => {
|
||||
const response = await responseFor(
|
||||
eventFor('/future', { routeId: routeId as RequestEvent['route']['id'] })
|
||||
);
|
||||
expect(response.status).toBe(status);
|
||||
if (status === 200) {
|
||||
expect(mocks.session).not.toHaveBeenCalled();
|
||||
expect(mocks.resolve).toHaveBeenCalledOnce();
|
||||
} else {
|
||||
expect(mocks.session).toHaveBeenCalledOnce();
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['/signin', '/healthz', '/favicon.png', '/_app/immutable/missing.js'])(
|
||||
'does not make unresolved URLs public: %s',
|
||||
async (path) => {
|
||||
const response = await responseFor(
|
||||
eventFor(path, {
|
||||
routeId: null,
|
||||
headers: { accept: 'application/json' }
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['/', '/signin?callbackUrl=%2F'],
|
||||
['/private?tag=1', '/signin?callbackUrl=%2Fprivate%3Ftag%3D1'],
|
||||
['/bookmarks', '/signin?callbackUrl=%2Fbookmarks'],
|
||||
['/bookmarks/photos', '/signin?callbackUrl=%2Fbookmarks%2Fphotos'],
|
||||
['/bookmarks/photos/other', '/signin?callbackUrl=%2Fbookmarks%2Fphotos%2Fother']
|
||||
])(
|
||||
'redirects only unauthenticated HTML navigation with a local callback: %s',
|
||||
async (path, location) => {
|
||||
const response = await responseFor(eventFor(path));
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('location')).toBe(location);
|
||||
expect(await response.text()).not.toContain('private-page');
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['/', { data: true }],
|
||||
['/private', { headers: { accept: 'application/json' } }],
|
||||
['/private', { headers: { accept: 'text/html;q=0' } }],
|
||||
['/private', { headers: { accept: '*/*' } }],
|
||||
['/private', { method: 'HEAD' }],
|
||||
['/private', { method: 'POST', headers: { origin } }],
|
||||
['/private', { method: 'OPTIONS' }],
|
||||
['/public/private.svg', { headers: { accept: 'image/svg+xml' } }],
|
||||
['/unknown', { headers: { accept: '' } }]
|
||||
])('denies private non-navigation requests: %s %j', async (path, options) => {
|
||||
const response = await responseFor(eventFor(path, options));
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.text()).toBe('Access denied');
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['text/html;q=0.000', 401],
|
||||
['text/html; q=0.0 ; charset=utf-8', 401],
|
||||
['text/html;q=0.5', 303],
|
||||
['text/plain, text/html; charset=utf-8', 303],
|
||||
['text/html;q=0, text/html;q=0.8', 303],
|
||||
['text/html;q=1, application/json;q=0', 401]
|
||||
])('preserves navigation Accept policy for %s', async (accept, status) => {
|
||||
const response = await responseFor(eventFor('/private', { headers: { accept } }));
|
||||
expect(response.status).toBe(status);
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/signin', 'GET', false],
|
||||
['/signin', 'GET', true],
|
||||
['/signin', 'POST', false],
|
||||
['/healthz', 'GET', false],
|
||||
['/healthz', 'HEAD', false]
|
||||
])('lets public routes handle their requests: %s %s', async (path, method, data) => {
|
||||
const response = await responseFor(eventFor(path, { method, data, headers: { origin } }));
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.resolve).toHaveBeenCalledOnce();
|
||||
expect(mocks.session).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/auth',
|
||||
'/auth/',
|
||||
'/auth/session',
|
||||
'/auth/providers',
|
||||
'/auth/csrf',
|
||||
'/auth/signout',
|
||||
'/auth/callback/credentials',
|
||||
'/auth/signin/oidc',
|
||||
'/auth/error'
|
||||
])('blocks unused raw SDK route %s even with a session', async (path) => {
|
||||
mocks.session.mockResolvedValue(signedIn);
|
||||
for (const method of ['GET', 'POST']) {
|
||||
const response = await responseFor(eventFor(path, { method, headers: { origin } }));
|
||||
expect(response.status).toBe(404);
|
||||
}
|
||||
expect(mocks.handle).not.toHaveBeenCalled();
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('permits only the configured OIDC GET callback, including cross-site navigation', async () => {
|
||||
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' }
|
||||
});
|
||||
expect((await authentication.handle({ event: callback, resolve: mocks.resolve })).status).toBe(
|
||||
302
|
||||
);
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
expect((await responseFor(callback)).status).toBe(404);
|
||||
const post = eventFor('/auth/callback/oidc', { method: 'POST', headers: { origin } });
|
||||
expect((await authentication.handle({ event: post, resolve: mocks.resolve })).status).toBe(404);
|
||||
const data = eventFor('/auth/callback/oidc', { data: true });
|
||||
expect((await authentication.handle({ event: data, resolve: mocks.resolve })).status).toBe(404);
|
||||
});
|
||||
|
||||
it('caches the native SDK session for the root UI without exposing it in data', async () => {
|
||||
mocks.session.mockResolvedValue(signedIn);
|
||||
const event = eventFor('/');
|
||||
expect((await responseFor(event)).status).toBe(200);
|
||||
expect(event.locals.session).toBe(signedIn);
|
||||
expect(mocks.session).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not authorize a session without a user', async () => {
|
||||
mocks.session.mockResolvedValue({ expires: '2099-01-01T00:00:00.000Z' });
|
||||
const response = await responseFor(eventFor('/__data.json', { data: true }));
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies protected-route session errors without logging causes', async () => {
|
||||
mocks.session.mockRejectedValue(new Error('token sentinel'));
|
||||
const response = await responseFor(eventFor('/'));
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
expect(mocks.logError).toHaveBeenCalledWith(
|
||||
'Authentication session failed (auth_session_failed).'
|
||||
);
|
||||
});
|
||||
|
||||
it('adds private caching and safe locations to immutable SDK redirects', async () => {
|
||||
mocks.handle.mockResolvedValue(
|
||||
Response.redirect(`${origin}/signin?error=RawSDKCode&code=secret`, 302)
|
||||
);
|
||||
const response = await responseFor(eventFor('/signin'));
|
||||
expect(response.headers.get('cache-control')).toBe('private, no-store');
|
||||
expect(response.headers.get('location')).toBe('/signin?callbackUrl=%2F&error=signin');
|
||||
});
|
||||
|
||||
it('preserves multiple native SDK cookie headers when applying private caching', async () => {
|
||||
const headers = new Headers({ location: '/' });
|
||||
headers.append('set-cookie', 'one=opaque; Path=/; HttpOnly');
|
||||
headers.append('set-cookie', 'two=opaque; Path=/; HttpOnly');
|
||||
mocks.handle.mockResolvedValue(new Response(null, { status: 302, headers }));
|
||||
const response = await responseFor(eventFor('/signin'));
|
||||
expect(response.headers.getSetCookie()).toEqual(headers.getSetCookie());
|
||||
});
|
||||
|
||||
it('preserves a safe callback while normalizing generic action-error redirects', async () => {
|
||||
mocks.handle.mockImplementation(() =>
|
||||
redirect(303, '/signin?callbackUrl=%2Fsafe&error=signin')
|
||||
);
|
||||
const response = await responseFor(eventFor('/signin'));
|
||||
expect(response.headers.get('location')).toBe('/signin?callbackUrl=%2Fsafe&error=signin');
|
||||
});
|
||||
|
||||
it('denies unexpected SDK exceptions without returning private content or raw errors', async () => {
|
||||
mocks.handle.mockRejectedValue(new Error('raw token sentinel'));
|
||||
const response = await responseFor(eventFor('/signin'));
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.text()).toBe('Access denied');
|
||||
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trusted origin boundary', () => {
|
||||
it('rejects inconsistently constructed event and request origins before calling SDK', async () => {
|
||||
const event = eventFor('/');
|
||||
event.url = new URL('https://evil.example/');
|
||||
expect((await responseFor(event)).status).toBe(403);
|
||||
const other = eventFor('https://evil.example/');
|
||||
other.url = new URL(origin);
|
||||
expect((await responseFor(other)).status).toBe(403);
|
||||
expect(mocks.handle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['application/x-www-form-urlencoded', 'application/json', 'text/plain'])(
|
||||
'requires exact Origin for POST regardless of %s',
|
||||
async (contentType) => {
|
||||
for (const badOrigin of [undefined, 'null', 'https://evil.example', `${origin}/`]) {
|
||||
const response = await responseFor(
|
||||
eventFor('/signin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': contentType,
|
||||
...(badOrigin === undefined ? {} : { origin: badOrigin })
|
||||
},
|
||||
body: 'username=family'
|
||||
})
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
}
|
||||
expect(mocks.handle).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('passes the original Kit event and attacker headers directly to the native SDK', async () => {
|
||||
const event = eventFor('/signin', {
|
||||
headers: {
|
||||
host: 'evil.example',
|
||||
'x-forwarded-host': 'evil.example',
|
||||
'x-forwarded-proto': 'http',
|
||||
'x-forwarded-port': '80',
|
||||
forwarded: 'host=evil.example;proto=http',
|
||||
'x-auth-return-redirect': '1'
|
||||
}
|
||||
});
|
||||
const originalRequest = event.request;
|
||||
const originalHeaders = [...event.request.headers];
|
||||
await responseFor(event);
|
||||
const nativeEvent = mocks.handle.mock.calls[0]?.[0].event as RequestEvent;
|
||||
expect(nativeEvent).toBe(event);
|
||||
expect(nativeEvent.request).toBe(originalRequest);
|
||||
expect([...nativeEvent.request.headers]).toEqual(originalHeaders);
|
||||
expect(nativeEvent.url).toBe(event.url);
|
||||
expect(nativeEvent.cookies).toBe(event.cookies);
|
||||
expect(nativeEvent.locals).toBe(event.locals);
|
||||
expect(mocks.resolve.mock.calls[0]?.[0]).toBe(event);
|
||||
expect(event.cookies.set).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('native action wrappers', () => {
|
||||
function actionEvent(body: BodyInit, contentType = 'application/x-www-form-urlencoded') {
|
||||
return eventFor('/signin', {
|
||||
method: 'POST',
|
||||
headers: { origin, 'content-type': contentType },
|
||||
body
|
||||
});
|
||||
async function destination(action: Promise<void>): Promise<URL> {
|
||||
try {
|
||||
await action;
|
||||
} catch (result) {
|
||||
if (!isRedirect(result)) throw result;
|
||||
expect(result.status).toBe(303);
|
||||
return new URL(result.location, origin);
|
||||
}
|
||||
throw new Error('Expected an action redirect.');
|
||||
}
|
||||
|
||||
it('selects configured credentials and allowlists the entire SDK action body', async () => {
|
||||
const body = new URLSearchParams({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
redirectTo: '/safe?q=1',
|
||||
providerId: 'evil',
|
||||
redirect: 'false',
|
||||
'authorizationParams-prompt': 'evil',
|
||||
token: 'secret'
|
||||
});
|
||||
const event = actionEvent(body);
|
||||
await authenticate().signIn(event);
|
||||
const normalized = mocks.signIn.mock.calls[0]?.[0] as RequestEvent;
|
||||
expect(Object.fromEntries(await normalized.request.formData())).toEqual({
|
||||
providerId: 'credentials',
|
||||
...credentials,
|
||||
redirectTo: '/safe?q=1'
|
||||
});
|
||||
expect(normalized.cookies).toBe(event.cookies);
|
||||
expect(await event.request.text()).toBe(body.toString());
|
||||
});
|
||||
function signIn(auth: Auth, fields = credentials) {
|
||||
return signInAction(formEvent('/signin', fields), auth, origin, 'credentials');
|
||||
}
|
||||
|
||||
it.each(['signIn', 'signOut'] as const)(
|
||||
'copies only the allowlisted %s body, preserving native Kit context and headers',
|
||||
async (kind) => {
|
||||
const event = eventFor('/signin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin,
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
'content-length': '16',
|
||||
host: 'evil.example',
|
||||
'x-forwarded-host': 'evil.example',
|
||||
'x-forwarded-proto': 'http',
|
||||
'x-forwarded-port': '80',
|
||||
forwarded: 'host=evil.example;proto=http',
|
||||
'x-auth-return-redirect': '1'
|
||||
},
|
||||
body: 'redirectTo=/safe'
|
||||
});
|
||||
const originalHeaders = [...event.request.headers];
|
||||
await authenticate()[kind](event);
|
||||
const formEvent = mocks[kind].mock.calls[0]?.[0] as RequestEvent;
|
||||
expect(formEvent).not.toBe(event);
|
||||
expect(formEvent.request).not.toBe(event.request);
|
||||
expect(formEvent.request.url).toBe(event.request.url);
|
||||
expect(formEvent.url).toBe(event.url);
|
||||
expect(formEvent.cookies).toBe(event.cookies);
|
||||
expect(formEvent.locals).toBe(event.locals);
|
||||
const expectedHeaders = new Headers(event.request.headers);
|
||||
expectedHeaders.set('content-type', 'application/x-www-form-urlencoded');
|
||||
expectedHeaders.delete('content-length');
|
||||
expect([...formEvent.request.headers]).toEqual([...expectedHeaders]);
|
||||
expect([...event.request.headers]).toEqual(originalHeaders);
|
||||
}
|
||||
);
|
||||
|
||||
it('selects configured OIDC without forwarding credential or authorization overrides', async () => {
|
||||
const authentication = createAuthentication({
|
||||
it('keeps native form login/logout local and propagates their session cookies', async () => {
|
||||
const auth = authentication();
|
||||
const login = await destination(
|
||||
signInAction(
|
||||
formEvent('/signin', {
|
||||
...credentials,
|
||||
redirectTo: 'https://another.example/private',
|
||||
providerId: 'unconfigured-provider'
|
||||
}),
|
||||
auth,
|
||||
origin,
|
||||
secret,
|
||||
oidc: {
|
||||
issuer: 'https://issuer.example',
|
||||
client_id: 'client',
|
||||
client_secret: 'secret'
|
||||
}
|
||||
});
|
||||
await authentication.signIn(
|
||||
actionEvent(
|
||||
new URLSearchParams({
|
||||
...credentials,
|
||||
redirectTo: '//evil.example',
|
||||
providerId: 'credentials'
|
||||
})
|
||||
)
|
||||
);
|
||||
const normalized = mocks.signIn.mock.calls[0]?.[0] as RequestEvent;
|
||||
expect(Object.fromEntries(await normalized.request.formData())).toEqual({
|
||||
providerId: 'oidc',
|
||||
redirectTo: '/'
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes sign-out return paths and drops every other field', async () => {
|
||||
await authenticate().signOut(
|
||||
actionEvent(
|
||||
new URLSearchParams({
|
||||
redirectTo: 'https://evil.example/token',
|
||||
redirect: 'false',
|
||||
'authorizationParams-state': 'secret'
|
||||
})
|
||||
)
|
||||
);
|
||||
const normalized = mocks.signOut.mock.calls[0]?.[0] as RequestEvent;
|
||||
expect(Object.fromEntries(await normalized.request.formData())).toEqual({ redirectTo: '/' });
|
||||
});
|
||||
|
||||
it('rejects duplicate fields rather than selecting an attacker-controlled occurrence', async () => {
|
||||
await authenticate().signIn(
|
||||
actionEvent(
|
||||
'username=family&username=evil&password=one&password=two&redirectTo=/one&redirectTo=/two'
|
||||
)
|
||||
);
|
||||
const normalized = mocks.signIn.mock.calls[0]?.[0] as RequestEvent;
|
||||
expect(Object.fromEntries(await normalized.request.formData())).toEqual({
|
||||
providerId: 'credentials',
|
||||
username: '',
|
||||
password: '',
|
||||
redirectTo: '/'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not stringify uploaded credential fields or accept an uploaded return path', async () => {
|
||||
const body = new FormData();
|
||||
body.set('username', new File(['family'], 'username.txt'));
|
||||
body.set('password', new File(['private-password'], 'password.txt'));
|
||||
body.set('redirectTo', new File(['/safe'], 'return.txt'));
|
||||
await authenticate().signIn(eventFor('/signin', { method: 'POST', headers: { origin }, body }));
|
||||
const normalized = mocks.signIn.mock.calls[0]?.[0] as RequestEvent;
|
||||
expect(Object.fromEntries(await normalized.request.formData())).toEqual({
|
||||
providerId: 'credentials',
|
||||
username: '',
|
||||
password: '',
|
||||
redirectTo: '/'
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves legitimate native Kit redirects and cookie context', async () => {
|
||||
mocks.signIn.mockImplementation(async (event: RequestEvent) => {
|
||||
event.cookies.set('native-session', 'opaque', { path: '/' });
|
||||
redirect(302, 'https://issuer.example/authorize?state=opaque');
|
||||
});
|
||||
const event = actionEvent('redirectTo=/safe');
|
||||
const result = await authenticate()
|
||||
.signIn(event)
|
||||
.catch((error: unknown) => error);
|
||||
expect(isRedirect(result) && result.location).toBe(
|
||||
'https://issuer.example/authorize?state=opaque'
|
||||
);
|
||||
expect(event.cookies.set).toHaveBeenCalledWith('native-session', 'opaque', { path: '/' });
|
||||
});
|
||||
|
||||
it.each(['signIn', 'signOut'] as const)(
|
||||
'maps %s SDK failures to a generic safe redirect',
|
||||
async (kind) => {
|
||||
mocks[kind].mockRejectedValue(new Error('raw token / issuer URL sentinel'));
|
||||
const result = await authenticate()
|
||||
[kind](actionEvent('redirectTo=%2Fsafe'))
|
||||
.catch((error: unknown) => error);
|
||||
expect(isRedirect(result) && result.location).toBe(
|
||||
'/signin?callbackUrl=%2Fsafe&error=signin'
|
||||
);
|
||||
expect(mocks.logError.mock.calls).toEqual([
|
||||
['Authentication action failed (auth_action_failed).']
|
||||
]);
|
||||
}
|
||||
'credentials'
|
||||
)
|
||||
);
|
||||
expect(login.origin).toBe(origin);
|
||||
expect(login.pathname).toBe('/');
|
||||
expect(await createSessionReader(auth, cookieHeaders)()).not.toBeNull();
|
||||
|
||||
it('sanitizes SDK error redirects while preserving safe form callback', async () => {
|
||||
mocks.signIn.mockImplementation(() =>
|
||||
redirect(302, `${origin}/signin?error=CredentialsSignin&code=raw`)
|
||||
);
|
||||
const result = await authenticate()
|
||||
.signIn(actionEvent('redirectTo=%2Fsafe'))
|
||||
.catch((error: unknown) => error);
|
||||
expect(isRedirect(result) && result.location).toBe('/signin?callbackUrl=%2Fsafe&error=signin');
|
||||
const logout = await destination(
|
||||
signOutAction(formEvent('/?/signout', { redirectTo: '/signin' }), auth, origin)
|
||||
);
|
||||
expect(logout.pathname).toBe('/signin');
|
||||
expect(await createSessionReader(auth, cookieHeaders)()).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects wrong credentials without creating a session', async () => {
|
||||
const auth = authentication();
|
||||
const location = await destination(signIn(auth, { ...credentials, password: 'incorrect' }));
|
||||
expect(location.pathname).toBe('/signin');
|
||||
expect(location.searchParams.get('error')).toBe('credentials');
|
||||
expect(await createSessionReader(auth, cookieHeaders)()).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects duplicate credential fields instead of choosing an occurrence', async () => {
|
||||
const auth = authentication();
|
||||
const fields = new URLSearchParams(credentials);
|
||||
fields.append('username', credentials.username);
|
||||
const location = await destination(
|
||||
signInAction(formEvent('/signin', fields), auth, origin, 'credentials')
|
||||
);
|
||||
expect(location.pathname).toBe('/signin');
|
||||
expect(await createSessionReader(auth, cookieHeaders)()).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a cross-origin native form before it can sign in', async () => {
|
||||
const auth = authentication();
|
||||
await expect(
|
||||
signInAction(
|
||||
formEvent('/signin', credentials, 'https://another.example'),
|
||||
auth,
|
||||
origin,
|
||||
'credentials'
|
||||
)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
expect(await createSessionReader(auth, cookieHeaders)()).toBeNull();
|
||||
});
|
||||
|
||||
it('expires an active session at the original seven-day deadline without renewal', async () => {
|
||||
const auth = authentication();
|
||||
await destination(signIn(auth));
|
||||
const initial = await createSessionReader(auth, cookieHeaders)();
|
||||
expect(initial?.expires).toBe('2030-01-08T12:00:00.000Z');
|
||||
|
||||
vi.setSystemTime(new Date('2030-01-08T11:00:00Z'));
|
||||
expect((await createSessionReader(auth, cookieHeaders)())?.expires).toBe(initial?.expires);
|
||||
vi.setSystemTime(new Date('2030-01-08T12:00:01Z'));
|
||||
expect(await createSessionReader(auth, cookieHeaders)()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps YAML keys authoritative across restarts and invalidates sessions on rotation', async () => {
|
||||
vi.stubEnv('BETTER_AUTH_SECRETS', '99:ambient-session-secret-that-must-not-override-yaml');
|
||||
await destination(signIn(authentication()));
|
||||
|
||||
vi.stubEnv('BETTER_AUTH_SECRETS', '99:another-ambient-secret-that-must-not-override-yaml');
|
||||
expect(await createSessionReader(authentication(), cookieHeaders)()).not.toBeNull();
|
||||
const rotated = authentication({
|
||||
...config,
|
||||
secret: 'replacement-session-secret-with-at-least-32-characters'
|
||||
});
|
||||
expect(await createSessionReader(rotated, cookieHeaders)()).toBeNull();
|
||||
});
|
||||
|
||||
it('generates a process-local key when omitted, without inheriting ambient auth secrets', async () => {
|
||||
vi.stubEnv('BETTER_AUTH_SECRET', secret);
|
||||
vi.stubEnv('AUTH_SECRET', secret);
|
||||
const auth = authentication({ credentials });
|
||||
await destination(signIn(auth));
|
||||
expect(await createSessionReader(auth, cookieHeaders)()).not.toBeNull();
|
||||
expect(await createSessionReader(authentication({ credentials }), cookieHeaders)()).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves public mode independent of authentication configuration', async () => {
|
||||
vi.stubEnv('BETTER_AUTH_SECRET', 'invalid');
|
||||
expect(createAuth(undefined, '')).toBeUndefined();
|
||||
expect(await createSessionReader(undefined, new Headers())()).toBeNull();
|
||||
});
|
||||
|
||||
it('handles malformed bodies generically and validates direct action invocation origins', async () => {
|
||||
let result = await authenticate()
|
||||
.signIn(actionEvent('{"token":"sentinel"}', 'application/json'))
|
||||
.catch((error: unknown) => error);
|
||||
expect(isRedirect(result) && result.location).toBe('/signin?callbackUrl=%2F&error=signin');
|
||||
result = await authenticate()
|
||||
.signOut(eventFor('/', { method: 'POST' }))
|
||||
.catch((error: unknown) => error);
|
||||
expect(isHttpError(result) && result.status).toBe(403);
|
||||
expect(mocks.signIn).not.toHaveBeenCalled();
|
||||
expect(mocks.signOut).not.toHaveBeenCalled();
|
||||
it('starts OIDC through the configured provider and existing callback without a browser SDK', async () => {
|
||||
const issuer = 'https://identity.example';
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
Response.json({
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/authorize`,
|
||||
token_endpoint: `${issuer}/token`,
|
||||
userinfo_endpoint: `${issuer}/userinfo`,
|
||||
jwks_uri: `${issuer}/jwks`,
|
||||
id_token_signing_alg_values_supported: ['RS256']
|
||||
})
|
||||
)
|
||||
);
|
||||
const auth = authentication({
|
||||
secret,
|
||||
oidc: { issuer, client_id: 'directory', client_secret: 'configured-client-secret' }
|
||||
});
|
||||
const location = await destination(
|
||||
signInAction(
|
||||
formEvent('/signin', { redirectTo: '/', provider: 'unconfigured-provider' }),
|
||||
auth,
|
||||
origin,
|
||||
'oidc'
|
||||
)
|
||||
);
|
||||
expect(location.origin).toBe(issuer);
|
||||
expect(location.pathname).toBe('/authorize');
|
||||
expect(location.searchParams.get('redirect_uri')).toBe(`${origin}/auth/callback/oidc`);
|
||||
// Callback completion remains covered by the existing local-issuer browser flow at cutover.
|
||||
expect(cookieHeaders.has('cookie')).toBe(true);
|
||||
});
|
||||
@@ -1,16 +1,24 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { SvelteKitAuth, type SvelteKitAuthConfig } from '@auth/sveltekit';
|
||||
import { betterAuth, type BetterAuthOptions } from 'better-auth/minimal';
|
||||
import { isAuthPath } from 'better-auth/svelte-kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import type { Handle, RequestEvent } from '@sveltejs/kit';
|
||||
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 { signInAction, signOutAction } from './actions.js';
|
||||
import { buildAuthOptions } from './providers.js';
|
||||
import { authenticationHeaders, CLIENT_ADDRESS_HEADER } from './request.js';
|
||||
|
||||
export { localRedirect } from './redirect.js';
|
||||
|
||||
const WEEK_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
export interface DirectorySession {
|
||||
user: { name: string };
|
||||
expires: string;
|
||||
}
|
||||
|
||||
export interface Authentication {
|
||||
readonly strategy: 'credentials' | 'oidc';
|
||||
readonly handle: Handle;
|
||||
@@ -18,46 +26,77 @@ export interface Authentication {
|
||||
signOut(event: RequestEvent): Promise<void>;
|
||||
}
|
||||
|
||||
const WEEK_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
const authJsLogger: NonNullable<SvelteKitAuthConfig['logger']> = {
|
||||
error() {
|
||||
logger.error('Auth.js failed (authjs_error).');
|
||||
},
|
||||
warn(code) {
|
||||
if (code === 'env-url-basepath-redundant') return;
|
||||
logger.warn('Auth.js warning (authjs_warning).');
|
||||
},
|
||||
debug() {}
|
||||
};
|
||||
|
||||
export function createAuthentication(config: DirectoryAuth & { origin: string }): Authentication {
|
||||
const { origin } = config;
|
||||
const secret = config.secret ?? randomBytes(32).toString('base64');
|
||||
const auth = createAuth(config, origin)!;
|
||||
const strategy = config.credentials ? 'credentials' : 'oidc';
|
||||
const policy = { origin, strategy } as const;
|
||||
const authJs = SvelteKitAuth({
|
||||
secret,
|
||||
trustHost: true,
|
||||
basePath: '/auth',
|
||||
redirectProxyUrl: '',
|
||||
session: { strategy: 'jwt', maxAge: WEEK_SECONDS },
|
||||
providers: buildProviders(config),
|
||||
pages: { signIn: '/signin', error: '/signin' },
|
||||
callbacks: {
|
||||
redirect: ({ url }) => origin + localRedirect(url, origin)
|
||||
},
|
||||
logger: authJsLogger
|
||||
});
|
||||
const handle = sequence(
|
||||
(input) => protectAuthRequest(input, policy),
|
||||
authJs.handle,
|
||||
(input) => requireSignIn(input, policy)
|
||||
);
|
||||
const nativeHandle: Handle = ({ event, resolve }) => {
|
||||
event.locals.auth = createSessionReader(auth, event.request.headers);
|
||||
if (!isAuthPath(event.request.url, auth.options)) return resolve(event);
|
||||
// protectAuthRequest has already checked both request and Kit origins.
|
||||
return auth.handler(new Request(event.request, { headers: authenticationHeaders(event) }));
|
||||
};
|
||||
return {
|
||||
strategy,
|
||||
handle,
|
||||
signIn: (event) => signInAction(event, origin, strategy, authJs),
|
||||
signOut: (event) => signOutAction(event, origin, authJs)
|
||||
handle: sequence(
|
||||
(input) => protectAuthRequest(input, policy),
|
||||
nativeHandle,
|
||||
(input) => requireSignIn(input, policy)
|
||||
),
|
||||
signIn: (event) => signInAction(event, auth, origin, strategy),
|
||||
signOut: (event) => signOutAction(event, auth, origin)
|
||||
};
|
||||
}
|
||||
|
||||
export function createAuth(config: DirectoryAuth | undefined, origin: string) {
|
||||
if (!config) return undefined;
|
||||
const secret = config.secret ?? randomBytes(32).toString('base64');
|
||||
if (secret.length < 32) {
|
||||
throw new Error('Authentication secret must contain at least 32 characters.');
|
||||
}
|
||||
|
||||
return betterAuth<BetterAuthOptions & { baseURL: string }>({
|
||||
secret,
|
||||
// Native environment keyrings must not override YAML or defeat secret rotation.
|
||||
secrets: [{ version: 1, value: secret }],
|
||||
baseURL: origin,
|
||||
basePath: '/auth',
|
||||
trustedOrigins: [origin],
|
||||
telemetry: { enabled: false },
|
||||
session: {
|
||||
expiresIn: WEEK_SECONDS,
|
||||
disableSessionRefresh: true,
|
||||
cookieCache: { refreshCache: false }
|
||||
},
|
||||
account: {
|
||||
storeAccountCookie: false,
|
||||
accountLinking: { enabled: false }
|
||||
},
|
||||
advanced: {
|
||||
disableOriginCheck: false,
|
||||
ipAddress: { ipAddressHeaders: [CLIENT_ADDRESS_HEADER] }
|
||||
},
|
||||
...buildAuthOptions(config, origin),
|
||||
logger: {
|
||||
log: (level) => logger[level]('Authentication event (better_auth).')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export type Auth = NonNullable<ReturnType<typeof createAuth>>;
|
||||
|
||||
export function createSessionReader(auth: Auth | undefined, headers: Headers) {
|
||||
async function readSession(): Promise<DirectorySession | null> {
|
||||
if (!auth) return null;
|
||||
const data = await auth.api.getSession({ headers });
|
||||
if (!data) return null;
|
||||
return {
|
||||
user: { name: data.user.name },
|
||||
expires: data.session.expiresAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
let session: Promise<DirectorySession | null> | undefined;
|
||||
return () => (session ??= readSession());
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { CredentialsConfig } from '@auth/sveltekit/providers/credentials';
|
||||
import { buildProviders, verifyCredentials } from './providers.js';
|
||||
|
||||
vi.mock('node:crypto', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:crypto')>();
|
||||
return { ...actual, timingSafeEqual: vi.fn(actual.timingSafeEqual) };
|
||||
});
|
||||
|
||||
const credentials = { username: 'family', password: 'family-secret' };
|
||||
|
||||
describe('credentials', () => {
|
||||
it('returns only a fixed common identity', () => {
|
||||
expect(verifyCredentials(credentials, credentials)).toEqual({
|
||||
id: 'directory',
|
||||
name: 'Directory'
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
{},
|
||||
{ username: 'wrong', password: credentials.password },
|
||||
{ username: credentials.username, password: 'wrong' },
|
||||
{ username: ['family'], password: credentials.password },
|
||||
{ username: credentials.username, password: { toString: () => credentials.password } },
|
||||
{ username: 'family ', password: credentials.password },
|
||||
{ username: credentials.username, password: 'family-secret\u0000' }
|
||||
])('denies wrong/malformed input: %j', (submitted) => {
|
||||
expect(verifyCredentials(credentials, submitted)).toBeNull();
|
||||
});
|
||||
|
||||
it('always compares both fixed-size digests, even on a wrong username', () => {
|
||||
const compare = vi.mocked(timingSafeEqual);
|
||||
compare.mockClear();
|
||||
try {
|
||||
expect(verifyCredentials(credentials, { username: 'no', password: 'no' })).toBeNull();
|
||||
expect(compare).toHaveBeenCalledTimes(2);
|
||||
for (const [left, right] of compare.mock.calls) {
|
||||
expect(left.byteLength).toBe(32);
|
||||
expect(right.byteLength).toBe(32);
|
||||
}
|
||||
} finally {
|
||||
compare.mockClear();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the native Credentials provider and captures configuration', async () => {
|
||||
const configured = { ...credentials };
|
||||
const provider = buildProviders({
|
||||
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');
|
||||
}
|
||||
configured.password = 'changed';
|
||||
const options = provider.options as Partial<CredentialsConfig> | undefined;
|
||||
expect(
|
||||
await options?.authorize?.(credentials, new Request('https://directory.example'))
|
||||
).toEqual({
|
||||
id: 'directory',
|
||||
name: 'Directory'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('explicitly requires OIDC PKCE, state and nonce without adding schema fields', () => {
|
||||
expect(
|
||||
buildProviders({
|
||||
oidc: { issuer: 'https://issuer.example', client_id: 'client', client_secret: 'secret' }
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
id: 'oidc',
|
||||
name: 'SSO',
|
||||
type: 'oidc',
|
||||
issuer: 'https://issuer.example',
|
||||
clientId: 'client',
|
||||
clientSecret: 'secret',
|
||||
checks: ['pkce', 'state', 'nonce']
|
||||
}
|
||||
]);
|
||||
});
|
||||
@@ -1,53 +1,74 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import Credentials from '@auth/sveltekit/providers/credentials';
|
||||
import type { SvelteKitAuthConfig } from '@auth/sveltekit';
|
||||
import type { DirectoryAuth } from '@directory/config/types';
|
||||
import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth';
|
||||
import { APIError, createAuthEndpoint, formCsrfMiddleware } from 'better-auth/api';
|
||||
import { setSessionCookie } from 'better-auth/cookies';
|
||||
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
|
||||
import type { DirectoryAuth, DirectoryCredentialsAuth } from '@directory/config/types';
|
||||
import { verifyCredentials } from './credentials.js';
|
||||
|
||||
type SharedCredentials = NonNullable<DirectoryAuth['credentials']>;
|
||||
|
||||
export function buildProviders(config: DirectoryAuth): SvelteKitAuthConfig['providers'] {
|
||||
export function buildAuthOptions(
|
||||
config: DirectoryAuth,
|
||||
origin: string
|
||||
): Pick<BetterAuthOptions, 'plugins' | 'onAPIError'> {
|
||||
if (config.credentials) {
|
||||
const credentials = { ...config.credentials };
|
||||
return [
|
||||
Credentials({
|
||||
name: 'Password',
|
||||
credentials: {
|
||||
username: { label: 'Username', type: 'text' },
|
||||
password: { label: 'Password', type: 'password' }
|
||||
},
|
||||
authorize: (submitted) => verifyCredentials(credentials, submitted)
|
||||
})
|
||||
];
|
||||
return { plugins: [credentialsProvider({ ...config.credentials })] };
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 'oidc',
|
||||
name: 'SSO',
|
||||
type: 'oidc',
|
||||
issuer: config.oidc.issuer,
|
||||
clientId: config.oidc.client_id,
|
||||
clientSecret: config.oidc.client_secret,
|
||||
checks: ['pkce', 'state', 'nonce']
|
||||
const oidc = config.oidc;
|
||||
return {
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: 'oidc',
|
||||
name: 'SSO',
|
||||
discoveryUrl: `${oidc.issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`,
|
||||
clientId: oidc.client_id,
|
||||
clientSecret: oidc.client_secret,
|
||||
tokenEndpointAuth: { method: 'client_secret_basic' },
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
pkce: true,
|
||||
requireIdTokenVerification: true,
|
||||
disableProviderLogout: true
|
||||
}
|
||||
]
|
||||
})
|
||||
],
|
||||
onAPIError: { errorURL: new URL('/signin', origin).href }
|
||||
};
|
||||
}
|
||||
|
||||
function credentialsProvider(credentials: DirectoryCredentialsAuth): BetterAuthPlugin {
|
||||
const email = 'directory@directory.local';
|
||||
return {
|
||||
id: 'directory-credentials',
|
||||
endpoints: {
|
||||
signInCredentials: createAuthEndpoint(
|
||||
'/sign-in/credentials',
|
||||
{ method: 'POST', use: [formCsrfMiddleware] },
|
||||
async (ctx) => {
|
||||
const identity = verifyCredentials(credentials, ctx.body);
|
||||
if (!identity) {
|
||||
throw new APIError('UNAUTHORIZED', {
|
||||
code: 'CredentialsSignin',
|
||||
message: 'Invalid username or password'
|
||||
});
|
||||
}
|
||||
|
||||
// Only the common identity enters the ephemeral adapter, never the password.
|
||||
const adapter = ctx.context.internalAdapter;
|
||||
const existing = await adapter.findUserByEmail(email);
|
||||
let user = existing?.user;
|
||||
if (!user) {
|
||||
user = await adapter.createUser(
|
||||
{ name: identity.name, email, emailVerified: true },
|
||||
{ method: 'directory-credentials' }
|
||||
);
|
||||
}
|
||||
const session = await adapter.createSession(user.id);
|
||||
if (!session) throw new APIError('INTERNAL_SERVER_ERROR');
|
||||
await setSessionCookie(ctx, { session, user });
|
||||
return ctx.json({ success: true });
|
||||
}
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export function verifyCredentials(
|
||||
configured: SharedCredentials,
|
||||
submitted: Partial<Record<string, unknown>> | undefined
|
||||
): { id: string; name: string } | null {
|
||||
const username = typeof submitted?.username === 'string' ? submitted.username : '';
|
||||
const password = typeof submitted?.password === 'string' ? submitted.password : '';
|
||||
// Fixed-size digests allow both comparisons even when input lengths differ.
|
||||
const usernameMatches = secretEquals(username, configured.username);
|
||||
const passwordMatches = secretEquals(password, configured.password);
|
||||
return usernameMatches && passwordMatches ? { id: 'directory', name: 'Directory' } : null;
|
||||
}
|
||||
|
||||
function secretEquals(left: string, right: string): boolean {
|
||||
return timingSafeEqual(digest(left), digest(right));
|
||||
}
|
||||
|
||||
function digest(value: string): Buffer {
|
||||
return createHash('sha256').update(value).digest();
|
||||
};
|
||||
}
|
||||
@@ -18,11 +18,7 @@ export function localRedirect(
|
||||
export function isSignInError(location: string, origin: string): boolean {
|
||||
try {
|
||||
const url = new URL(location, origin);
|
||||
return (
|
||||
url.origin === origin &&
|
||||
(url.pathname === '/signin' || url.pathname === '/auth/error') &&
|
||||
url.searchParams.has('error')
|
||||
);
|
||||
return url.origin === origin && url.pathname === '/signin' && url.searchParams.has('error');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { expect, it } from 'vitest';
|
||||
import { authenticationHeaders, CLIENT_ADDRESS_HEADER } from './request.js';
|
||||
|
||||
it('uses the adapter address rather than browser-supplied client IP headers', () => {
|
||||
const request = new Request('https://directory.example/signin', {
|
||||
headers: {
|
||||
origin: 'https://another.example',
|
||||
[CLIENT_ADDRESS_HEADER]: '198.51.100.1',
|
||||
'x-real-ip': '198.51.100.2',
|
||||
'x-forwarded-for': '198.51.100.3'
|
||||
}
|
||||
});
|
||||
const headers = authenticationHeaders({
|
||||
request,
|
||||
getClientAddress: () => '192.0.2.1'
|
||||
});
|
||||
|
||||
expect(headers.get(CLIENT_ADDRESS_HEADER)).toBe('192.0.2.1');
|
||||
// The browser Origin is evidence for CSRF checks, not something to normalize.
|
||||
expect(headers.get('origin')).toBe(request.headers.get('origin'));
|
||||
expect(request.headers.get(CLIENT_ADDRESS_HEADER)).toBe('198.51.100.1');
|
||||
});
|
||||
@@ -1,5 +1,16 @@
|
||||
import { error, type RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export const CLIENT_ADDRESS_HEADER = 'x-directory-client-address';
|
||||
|
||||
export function authenticationHeaders(
|
||||
event: Pick<RequestEvent, 'request' | 'getClientAddress'>
|
||||
): Headers {
|
||||
const headers = new Headers(event.request.headers);
|
||||
// The adapter owns client-address/proxy policy. Never trust a browser-supplied IP header.
|
||||
headers.set(CLIENT_ADDRESS_HEADER, event.getClientAddress());
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function hasExpectedOrigin(event: RequestEvent, origin: string): boolean {
|
||||
return event.url.origin === origin && new URL(event.request.url).origin === origin;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtemp, rename, rm, stat, utimes, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest';
|
||||
import { applySetCookies } from 'better-auth/cookies';
|
||||
|
||||
const serverModule = new URL('../../../build/bootstrap/start.js', import.meta.url).href;
|
||||
const sentinel = 'PRIVATE_CONFIG_CREDENTIAL_LINK';
|
||||
@@ -171,55 +172,68 @@ 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 an HTTPS public origin over HTTP transport',
|
||||
origin: 'https://directory.example',
|
||||
environment: {
|
||||
...hostileEnvironment,
|
||||
HOST_HEADER: 'x-forwarded-host',
|
||||
PROTOCOL_HEADER: 'x-forwarded-proto',
|
||||
PORT_HEADER: 'x-forwarded-port'
|
||||
},
|
||||
contents: credentialsConfiguration.replace(
|
||||
'url: http://localhost',
|
||||
'url: https://directory.example'
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'with explicit legacy-named environment references',
|
||||
origin: 'http://localhost',
|
||||
environment: { ORIGIN: 'http://localhost', AUTH_SECRET: sentinel },
|
||||
contents: credentialsConfiguration
|
||||
.replace('url: http://localhost', 'url: ${ORIGIN}')
|
||||
.replace(`password: ${sentinel}`, 'password: ${AUTH_SECRET}')
|
||||
}
|
||||
])('starts YAML credentials auth $name', async ({ environment, contents }) => {
|
||||
])('starts YAML credentials auth $name', async ({ origin, 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' }
|
||||
});
|
||||
const transportHeaders = {
|
||||
accept: 'text/html',
|
||||
connection: 'close',
|
||||
host: 'foreign.example.com',
|
||||
'x-forwarded-host': 'foreign.example.com',
|
||||
'x-forwarded-proto': 'http',
|
||||
'x-forwarded-port': '80'
|
||||
};
|
||||
const gated = await fetch(address, { redirect: 'manual', headers: transportHeaders });
|
||||
expect(gated.status).toBe(303);
|
||||
const target = new URL(gated.headers.get('location')!, 'http://localhost');
|
||||
expect(target.origin).toBe('http://localhost');
|
||||
const target = new URL(gated.headers.get('location')!, origin);
|
||||
expect(target.origin).toBe(origin);
|
||||
expect(target.pathname).toBe('/signin');
|
||||
|
||||
const rejected = await fetch(`${address}/signin`, {
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
headers: { ...transportHeaders, origin: 'https://foreign.example.com' },
|
||||
body: new URLSearchParams({ username: sentinel, password: sentinel, redirectTo: '/' })
|
||||
});
|
||||
expect(rejected.status).toBe(403);
|
||||
|
||||
const response = await fetch(`${address}/signin`, {
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
headers: { origin: 'http://localhost', accept: 'text/html', connection: 'close' },
|
||||
headers: { ...transportHeaders, origin },
|
||||
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(response.status).toBe(303);
|
||||
expect(new URL(response.headers.get('location')!, origin).href).toBe(`${origin}/`);
|
||||
const headers = new Headers({ connection: 'close' });
|
||||
// Carry native cookies without asserting their names, format or browser Secure behavior.
|
||||
applySetCookies(headers, response.headers.getSetCookie());
|
||||
const authenticated = await fetch(address, { redirect: 'manual', headers });
|
||||
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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ vi.mock('./auth/index.js', () => ({ createAuthentication: mocks.auth }));
|
||||
const path = '/config/directory.yaml';
|
||||
const sentinel = 'PRIVATE-config-credential';
|
||||
const origin = 'https://directory.example.com';
|
||||
const frameworkEnv = { ORIGIN: origin, AUTH_URL: origin };
|
||||
const frameworkEnv = { ORIGIN: origin };
|
||||
const auth = { credentials: { username: 'family', password: sentinel } };
|
||||
const authentication: Authentication = {
|
||||
strategy: 'credentials',
|
||||
@@ -42,7 +42,6 @@ function configuration(title = 'Home', enabled = false): DirectoryConfiguration
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubEnv('ORIGIN', origin);
|
||||
vi.stubEnv('AUTH_URL', origin);
|
||||
mocks.path.mockReturnValue(path);
|
||||
mocks.publicDir.mockReturnValue('/mounted/public');
|
||||
mocks.load.mockResolvedValue(configuration());
|
||||
@@ -106,7 +105,7 @@ describe('application boot wiring', () => {
|
||||
expect(ready).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('constructs auth before monitoring and uses Kit private URLs, not interpolation values', async () => {
|
||||
it('constructs auth before monitoring from the prepared origin, not legacy auth settings', async () => {
|
||||
vi.stubEnv('ORIGIN', origin);
|
||||
vi.stubEnv('AUTH_URL', 'https://not-kit.example.com');
|
||||
mocks.load.mockResolvedValueOnce(configuration('Home', true));
|
||||
@@ -142,11 +141,6 @@ describe('application boot wiring', () => {
|
||||
prepared: origin,
|
||||
privateEnv: { ...frameworkEnv, ORIGIN: sentinel }
|
||||
},
|
||||
{
|
||||
name: 'Kit private AUTH_URL differs',
|
||||
prepared: origin,
|
||||
privateEnv: { ...frameworkEnv, AUTH_URL: sentinel }
|
||||
},
|
||||
{
|
||||
name: 'site.url changed after preparation',
|
||||
prepared: origin,
|
||||
|
||||
@@ -30,11 +30,7 @@ export async function bootApp(
|
||||
}
|
||||
|
||||
// Reject changes between framework URL preparation and the initial application load.
|
||||
if (
|
||||
config.site.url !== process.env.ORIGIN ||
|
||||
config.site.url !== frameworkEnv.ORIGIN ||
|
||||
config.site.url !== frameworkEnv.AUTH_URL
|
||||
) {
|
||||
if (config.site.url !== process.env.ORIGIN || config.site.url !== frameworkEnv.ORIGIN) {
|
||||
throw new DirectoryError({ code: 'auth_failed' });
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ ${extra}`;
|
||||
}
|
||||
|
||||
describe('framework environment preparation', () => {
|
||||
it('captures the final Vite CLI port before Kit snapshots stale framework URLs', async () => {
|
||||
it('captures the final Vite CLI port before Kit snapshots the stale framework origin', async () => {
|
||||
const web = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const vite = join(web, 'node_modules/vite/bin/vite.js');
|
||||
const host = '127.0.0.1';
|
||||
@@ -74,7 +74,7 @@ export default (env) => {
|
||||
await writeFile(
|
||||
join(directory, 'src/routes/+page.server.js'),
|
||||
`import { env } from '$env/dynamic/private';
|
||||
export const load = () => ({ settings: env.ORIGIN + '|' + env.AUTH_URL + '|' + process.env.PORT });`
|
||||
export const load = () => ({ settings: env.ORIGIN + '|' + process.env.PORT });`
|
||||
);
|
||||
await writeFile(
|
||||
join(directory, 'src/routes/+page.svelte'),
|
||||
@@ -114,7 +114,7 @@ export const load = () => ({ settings: env.ORIGIN + '|' + env.AUTH_URL + '|' + p
|
||||
},
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
expect(html).toContain(`http://localhost:${port}|http://localhost:${port}|${port}`);
|
||||
expect(html).toContain(`http://localhost:${port}|${port}`);
|
||||
} finally {
|
||||
child.kill('SIGTERM');
|
||||
const timeout = setTimeout(() => child.kill('SIGKILL'), 2000);
|
||||
@@ -136,12 +136,9 @@ export const load = () => ({ settings: env.ORIGIN + '|' + env.AUTH_URL + '|' + p
|
||||
AUTH_REDIRECT_PROXY_URL: sentinel,
|
||||
AUTH_SECRET: sentinel
|
||||
};
|
||||
const before = { ...env };
|
||||
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);
|
||||
expect(env).toEqual({ ...before, ORIGIN: origin });
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -162,9 +159,7 @@ export const load = () => ({ settings: env.ORIGIN + '|' + env.AUTH_URL + '|' + p
|
||||
};
|
||||
await prepareRuntimeEnvironment(env);
|
||||
expect(env.ORIGIN).toBe(expected);
|
||||
expect(env.AUTH_URL).toBe(expected);
|
||||
expect(env.NEXTAUTH_URL).toBe('');
|
||||
expect(env.AUTH_REDIRECT_PROXY_URL).toBe('');
|
||||
expect(env.AUTH_URL).toBe(sentinel);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -173,7 +168,7 @@ export const load = () => ({ settings: env.ORIGIN + '|' + env.AUTH_URL + '|' + p
|
||||
const env = { CONFIG_PATH: path, PORT: '0', ORIGIN: sentinel, AUTH_URL: sentinel };
|
||||
await prepareRuntimeEnvironment(env);
|
||||
expect(env.ORIGIN).toBe(origin);
|
||||
expect(env.AUTH_URL).toBe(origin);
|
||||
expect(env.AUTH_URL).toBe(sentinel);
|
||||
});
|
||||
|
||||
it('preserves explicit legacy-named environment interpolation in YAML', async () => {
|
||||
@@ -181,7 +176,7 @@ export const load = () => ({ settings: env.ORIGIN + '|' + env.AUTH_URL + '|' + p
|
||||
const env = { CONFIG_PATH: path, ORIGIN: origin, AUTH_URL: sentinel };
|
||||
await prepareRuntimeEnvironment(env);
|
||||
expect(env.ORIGIN).toBe(origin);
|
||||
expect(env.AUTH_URL).toBe(origin);
|
||||
expect(env.AUTH_URL).toBe(sentinel);
|
||||
});
|
||||
|
||||
it.each([{ PORT: '0' }, { SOCKET_PATH: '/tmp/directory.sock' }, { LISTEN_FDS: '1' }])(
|
||||
@@ -200,14 +195,7 @@ export const load = () => ({ settings: env.ORIGIN + '|' + env.AUTH_URL + '|' + p
|
||||
await expect(prepareRuntimeEnvironment(env)).rejects.toMatchObject({
|
||||
issue: {
|
||||
code: 'load_failed',
|
||||
issues: [
|
||||
{
|
||||
code: 'invalid_value',
|
||||
path: '/site/url',
|
||||
message:
|
||||
'Set site.url explicitly when using a random port, socket or socket activation.'
|
||||
}
|
||||
]
|
||||
issues: [expect.objectContaining({ code: 'invalid_value', path: '/site/url' })]
|
||||
}
|
||||
});
|
||||
expect(env).toEqual(before);
|
||||
|
||||
@@ -15,8 +15,6 @@ export async function prepareRuntimeEnvironment(
|
||||
});
|
||||
}
|
||||
|
||||
// adapter-node 5 still captures ORIGIN before Kit starts; authentication uses site.url directly.
|
||||
env.ORIGIN = origin;
|
||||
env.AUTH_URL = origin;
|
||||
env.NEXTAUTH_URL = '';
|
||||
env.AUTH_REDIRECT_PROXY_URL = '';
|
||||
}
|
||||
@@ -105,7 +105,7 @@ describe('directory page data', () => {
|
||||
it('includes authorized bookmark metadata but not credentials, identities, assets or root paths', () => {
|
||||
const event = eventFor('?link=photos', true);
|
||||
event.locals.session = {
|
||||
user: { name: 'PRIVATE_IDENTITY', email: 'PRIVATE_EMAIL' },
|
||||
user: { name: 'PRIVATE_IDENTITY' },
|
||||
expires: '2099-01-01'
|
||||
};
|
||||
Object.assign(event.locals, {
|
||||
|
||||
@@ -81,12 +81,6 @@ describe('sign-in page', () => {
|
||||
expect(auth).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not treat a session without a user as signed in', async () => {
|
||||
const { event, auth } = eventFor();
|
||||
auth.mockResolvedValue({ expires: '2099-01-01' });
|
||||
expect(await load(event)).toMatchObject({ strategy: 'credentials' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://outside.example/private',
|
||||
'//outside.example/private',
|
||||
@@ -100,7 +94,7 @@ describe('sign-in page', () => {
|
||||
const query = `?${new URLSearchParams({ callbackUrl })}`;
|
||||
const { event, auth } = eventFor(query);
|
||||
expect(await load(event)).toMatchObject({ callbackUrl: '/' });
|
||||
auth.mockResolvedValue({ user: {}, expires: '2099-01-01' });
|
||||
auth.mockResolvedValue({ user: { name: 'PRIVATE_IDENTITY' }, expires: '2099-01-01' });
|
||||
await expectRedirect(load(event), '/');
|
||||
});
|
||||
|
||||
|
||||
@@ -84,18 +84,6 @@ Use `credentials` for a basic username and password or `oidc` to turn it up to
|
||||
| `oidc.client_id` | Client ID registered with your provider. | Required for OIDC |
|
||||
| `oidc.client_secret` | Secret, or `'${DIRECTORY_OIDC_CLIENT_SECRET}'`. | Required for OIDC |
|
||||
|
||||
With auth enabled, `site.url` must use HTTPS, except for local testing at
|
||||
`http://localhost:3000` (or your dev port).
|
||||
OIDC's callback is your `site.url` followed by `/auth/callback/oidc`.
|
||||
|
||||
By default, restarting Directory signs everyone out. Set `secret` if you'd rather
|
||||
keep sessions; generate it once with `openssl rand -base64 32`. Changing it signs
|
||||
everyone out again. Ordinary config reloads keep the current session secret.
|
||||
|
||||
Passwords and client secrets can go directly in YAML. Keep that file private.
|
||||
The [example](directory.example.yaml) has commented setups for both auth methods.
|
||||
Restart Directory after changing auth settings.
|
||||
|
||||
## Images
|
||||
|
||||
Put `photos.png` in `public/` beside your Compose file and use
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ for sign-in and SSO. See [configuration](config.md) for all the juicy options.
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
you can edit most configurations without having to restart
|
||||
you can edit most configurations without having to restart.
|
||||
|
||||
## inspect logs
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ appearance:
|
||||
# username: admin
|
||||
# password: replace-with-your-own-password
|
||||
|
||||
# Or SSO. Register this callback with your provider:
|
||||
# Or SSO. Your provider must return an email for each identity.
|
||||
# Register the client for client_secret_basic authentication and this callback:
|
||||
# https://directory.example.com/auth/callback/oidc
|
||||
# auth:
|
||||
# oidc:
|
||||
@@ -30,8 +31,9 @@ appearance:
|
||||
# client_id: directory
|
||||
# client_secret: replace-with-your-client-secret
|
||||
|
||||
# Restarts sign everyone out unless you set auth.secret. To keep sessions,
|
||||
# generate a value once with: openssl rand -base64 32
|
||||
# Sessions expire seven days after sign-in, without renewal.
|
||||
# Restarts sign everyone out unless you set auth.secret. To keep unexpired sessions
|
||||
# across restarts, generate a value once with: openssl rand -base64 32
|
||||
# Add it inside your chosen auth block as: secret: 'your-generated-value'
|
||||
# Passwords and secrets can also use '${VAR}' from an optional .env file.
|
||||
# Keep files containing secrets private.
|
||||
|
||||
Generated
+272
-73
@@ -159,6 +159,9 @@ importers:
|
||||
'@takumi-rs/helpers':
|
||||
specifier: 2.18.2
|
||||
version: 2.18.2(preact@10.24.3)
|
||||
better-auth:
|
||||
specifier: 1.7.14
|
||||
version: 1.7.14(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vitest@4.1.7)
|
||||
bits-ui:
|
||||
specifier: 'catalog:'
|
||||
version: 2.18.1(@internationalized/date@3.12.4)(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))
|
||||
@@ -169,9 +172,6 @@ importers:
|
||||
specifier: 5.2.0
|
||||
version: 5.2.0
|
||||
devDependencies:
|
||||
'@auth/sveltekit':
|
||||
specifier: 1.11.3
|
||||
version: 1.11.3(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))
|
||||
'@iconify-json/ph':
|
||||
specifier: 'catalog:'
|
||||
version: 1.2.2
|
||||
@@ -245,36 +245,6 @@ packages:
|
||||
'@antfu/install-pkg@2.1.0':
|
||||
resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==}
|
||||
|
||||
'@auth/core@0.41.3':
|
||||
resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==}
|
||||
peerDependencies:
|
||||
'@simplewebauthn/browser': ^9.0.1
|
||||
'@simplewebauthn/server': ^9.0.2
|
||||
nodemailer: ^7.0.7 || ^8.0.5
|
||||
peerDependenciesMeta:
|
||||
'@simplewebauthn/browser':
|
||||
optional: true
|
||||
'@simplewebauthn/server':
|
||||
optional: true
|
||||
nodemailer:
|
||||
optional: true
|
||||
|
||||
'@auth/sveltekit@1.11.3':
|
||||
resolution: {integrity: sha512-nfI/CFHD9hpJG4W+u+xtMrw9XSrA1k6kbgbGc9PvbiEf67oPMuzLAvLDj6/3yd98kFz+51Rq04JLBckKp8T5Kg==}
|
||||
peerDependencies:
|
||||
'@simplewebauthn/browser': ^9.0.1
|
||||
'@simplewebauthn/server': ^9.0.3
|
||||
'@sveltejs/kit': ^1.0.0 || ^2.0.0
|
||||
nodemailer: ^7.0.7 || ^8.0.5
|
||||
svelte: ^3.54.0 || ^4.0.0 || ^5.0.0-0
|
||||
peerDependenciesMeta:
|
||||
'@simplewebauthn/browser':
|
||||
optional: true
|
||||
'@simplewebauthn/server':
|
||||
optional: true
|
||||
nodemailer:
|
||||
optional: true
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -296,6 +266,82 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@better-auth/core@1.7.14':
|
||||
resolution: {integrity: sha512-ByhdrGVwp/el+hoTYKSVOHIH23/lQj9TQ1txdGcWOg22tnfr3hJ6ukzySooeYj/R36XsQgBhkJqIp8mKsNeErA==}
|
||||
peerDependencies:
|
||||
'@better-auth/utils': ^0.4.2 || ^0.5.0
|
||||
'@better-fetch/fetch': 1.3.3
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
better-call: 1.4.4
|
||||
jose: ^6.1.0
|
||||
kysely: ^0.28.5 || ^0.29.0
|
||||
nanostores: ^1.0.1
|
||||
peerDependenciesMeta:
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
|
||||
'@better-auth/drizzle-adapter@1.7.14':
|
||||
resolution: {integrity: sha512-5RC8Ec5Ur15FWsS4As9/VrlRbB3+9EfShxIpA5A1FcD6IgPxFvzQNkAm2+FYx9LUSqx47NPf9hBTG2GUCMP9Ig==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.7.14
|
||||
'@better-auth/utils': ^0.4.2 || ^0.5.0
|
||||
drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
|
||||
peerDependenciesMeta:
|
||||
drizzle-orm:
|
||||
optional: true
|
||||
|
||||
'@better-auth/kysely-adapter@1.7.14':
|
||||
resolution: {integrity: sha512-4wkqj8a8JVpWDYLlgzfbk7eOaWCFkL6HHQd6CLamFKaC8vNsXew1x4v5dE5O6dGr8j4g5qMISS+BAvOuIvBevg==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.7.14
|
||||
'@better-auth/utils': ^0.4.2 || ^0.5.0
|
||||
kysely: ^0.28.17 || ^0.29.0
|
||||
peerDependenciesMeta:
|
||||
kysely:
|
||||
optional: true
|
||||
|
||||
'@better-auth/memory-adapter@1.7.14':
|
||||
resolution: {integrity: sha512-Ojv9BcmFyHuTHha9ZM/NUW1ut9J84wLngta+07Es0Ejn8kekqP5aHBpZd5Z8AvADs5iJUOWvQGFmGlPWAKJgBg==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.7.14
|
||||
'@better-auth/utils': ^0.4.2 || ^0.5.0
|
||||
|
||||
'@better-auth/mongo-adapter@1.7.14':
|
||||
resolution: {integrity: sha512-6IA0RSPZFMdRwwnLZ9oWnDIiD0y0o0azTHUfa01BNU2AFJgMDkeiASUV3bKcJBskzQoyQrff74eFYTsTSeeVUQ==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.7.14
|
||||
'@better-auth/utils': ^0.4.2 || ^0.5.0
|
||||
mongodb: ^6.0.0 || ^7.0.0
|
||||
peerDependenciesMeta:
|
||||
mongodb:
|
||||
optional: true
|
||||
|
||||
'@better-auth/prisma-adapter@1.7.14':
|
||||
resolution: {integrity: sha512-mF6t5xqeZNWzUe0ErmrSdzQmtslJscmu9X8Mp5QmROlpnnAzvSc70RILu8lHms8CePt7N34+KRH1QvYjGFrVGQ==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.7.14
|
||||
'@better-auth/utils': ^0.4.2 || ^0.5.0
|
||||
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
peerDependenciesMeta:
|
||||
'@prisma/client':
|
||||
optional: true
|
||||
prisma:
|
||||
optional: true
|
||||
|
||||
'@better-auth/telemetry@1.7.14':
|
||||
resolution: {integrity: sha512-PtKFzsH7gje/WJuUPDjckOoNFazsq8NMt3AdiXIPzHl7tZULcNGTKAX8K7+yn0rG6cO/4mtMgpOjhtilElOnmg==}
|
||||
peerDependencies:
|
||||
'@better-auth/core': ^1.7.14
|
||||
'@better-auth/utils': ^0.4.2 || ^0.5.0
|
||||
'@better-fetch/fetch': 1.3.3
|
||||
|
||||
'@better-auth/utils@0.5.1':
|
||||
resolution: {integrity: sha512-WETqw5vQ52uQ/xwWR4E8rTgT6fDEr/wnS+otDNkBCa/69281FCF32OKDdQUcahMCnjsvqXBNiEvAQHFB0EVRRg==}
|
||||
|
||||
'@better-fetch/fetch@1.3.3':
|
||||
resolution: {integrity: sha512-U6SlDt/GUWtK2JAxS/NhdoYTEim+EL1G5byz2TUj83KjLZBUpYzb5SmX0aXaCnbGkAjFKnV/o/0+cyXMbEcqqg==}
|
||||
|
||||
'@changesets/apply-release-plan@8.1.1':
|
||||
resolution: {integrity: sha512-nk2Hbq3M9NeHMdaIRBDvawKrQJzXB9sGyBUE5yWnIKtvdcNCPj75zBekJYr0gTT2ecJH3eFeTrc8eisPImcv0Q==}
|
||||
engines: {node: ^22.11 || ^24 || >=26}
|
||||
@@ -656,12 +702,17 @@ packages:
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||
|
||||
'@noble/ciphers@2.4.0':
|
||||
resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/hashes@2.4.0':
|
||||
resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@oxc-project/types@0.133.0':
|
||||
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
|
||||
|
||||
'@panva/hkdf@1.2.1':
|
||||
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||
|
||||
'@playwright/test@1.61.1':
|
||||
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1220,6 +1271,73 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
better-auth@1.7.14:
|
||||
resolution: {integrity: sha512-QxDCI0pOCCHDYmruisKhKyrvOJ6Icv9xg0RA6ZUysfKFXENYwhNNpwUlkE/NmViH2nDMgkKcymmLr55pnpR9vw==}
|
||||
peerDependencies:
|
||||
'@lynx-js/react': '*'
|
||||
'@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
'@sveltejs/kit': ^2.0.0 || ^3.0.0
|
||||
'@tanstack/react-start': ^1.0.0
|
||||
'@tanstack/solid-start': ^1.0.0
|
||||
drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1'
|
||||
drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0
|
||||
mongodb: ^6.0.0 || ^7.0.0
|
||||
mysql2: ^3.0.0
|
||||
next: ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||
pg: ^8.0.0
|
||||
prisma: ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
solid-js: ^1.0.0
|
||||
svelte: ^4.0.0 || ^5.0.0
|
||||
vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0
|
||||
vue: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
'@lynx-js/react':
|
||||
optional: true
|
||||
'@prisma/client':
|
||||
optional: true
|
||||
'@sveltejs/kit':
|
||||
optional: true
|
||||
'@tanstack/react-start':
|
||||
optional: true
|
||||
'@tanstack/solid-start':
|
||||
optional: true
|
||||
drizzle-kit:
|
||||
optional: true
|
||||
drizzle-orm:
|
||||
optional: true
|
||||
mongodb:
|
||||
optional: true
|
||||
mysql2:
|
||||
optional: true
|
||||
next:
|
||||
optional: true
|
||||
pg:
|
||||
optional: true
|
||||
prisma:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
solid-js:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vitest:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
better-call@1.4.4:
|
||||
resolution: {integrity: sha512-Gq4XdGFy4dmUxFEAX2nj0ycFcFbD9ZWh8YROouZoyXeF1M11TpYbplk0pDByTFF8Wz+MYa6W9052c9m7dD+mfw==}
|
||||
peerDependencies:
|
||||
zod: ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
bits-ui@2.18.1:
|
||||
resolution: {integrity: sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -1288,6 +1406,9 @@ packages:
|
||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
defu@6.1.7:
|
||||
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1584,6 +1705,10 @@ packages:
|
||||
known-css-properties@0.37.0:
|
||||
resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==}
|
||||
|
||||
kysely@0.29.8:
|
||||
resolution: {integrity: sha512-ixHU1H4R+LeC5Y0nAhYgiGRFpFRHm2DXKEmJSs/vbes9sKXyssyzxzE/NYiOJX5fY0BL28biCre3GqlCqUwyXQ==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
launch-editor@2.14.2:
|
||||
resolution: {integrity: sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==}
|
||||
|
||||
@@ -1717,12 +1842,13 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanostores@1.5.5:
|
||||
resolution: {integrity: sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==}
|
||||
engines: {node: ^20.0.0 || >=22.0.0}
|
||||
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
oauth4webapi@3.8.8:
|
||||
resolution: {integrity: sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==}
|
||||
|
||||
obug@2.2.1:
|
||||
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -1811,11 +1937,6 @@ packages:
|
||||
resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
preact-render-to-string@6.5.11:
|
||||
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
|
||||
peerDependencies:
|
||||
preact: '>=10'
|
||||
|
||||
preact@10.24.3:
|
||||
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
|
||||
|
||||
@@ -1860,6 +1981,9 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
rou3@0.9.2:
|
||||
resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==}
|
||||
|
||||
runed@0.35.1:
|
||||
resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==}
|
||||
peerDependencies:
|
||||
@@ -1878,9 +2002,6 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
set-cookie-parser@3.1.3:
|
||||
resolution: {integrity: sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==}
|
||||
|
||||
@@ -2164,6 +2285,9 @@ packages:
|
||||
zimmerframe@1.1.5:
|
||||
resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==}
|
||||
|
||||
zod@4.6.7:
|
||||
resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@antfu/install-pkg@1.1.0':
|
||||
@@ -2176,21 +2300,6 @@ snapshots:
|
||||
package-manager-detector: 1.9.0
|
||||
tinyexec: 1.3.1
|
||||
|
||||
'@auth/core@0.41.3':
|
||||
dependencies:
|
||||
'@panva/hkdf': 1.2.1
|
||||
jose: 6.2.12
|
||||
oauth4webapi: 3.8.8
|
||||
preact: 10.24.3
|
||||
preact-render-to-string: 6.5.11(preact@10.24.3)
|
||||
|
||||
'@auth/sveltekit@1.11.3(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))':
|
||||
dependencies:
|
||||
'@auth/core': 0.41.3
|
||||
'@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1))
|
||||
set-cookie-parser: 2.7.2
|
||||
svelte: 5.56.0(@typescript-eslint/types@8.60.0)
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
@@ -2206,6 +2315,56 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5)':
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.5.1
|
||||
'@better-fetch/fetch': 1.3.3
|
||||
'@standard-schema/spec': 1.1.0
|
||||
better-call: 1.4.4(zod@4.6.7)
|
||||
jose: 6.2.12
|
||||
kysely: 0.29.8
|
||||
nanostores: 1.5.5
|
||||
zod: 4.6.7
|
||||
|
||||
'@better-auth/drizzle-adapter@1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5))(@better-auth/utils@0.5.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5)
|
||||
'@better-auth/utils': 0.5.1
|
||||
|
||||
'@better-auth/kysely-adapter@1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(kysely@0.29.8)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5)
|
||||
'@better-auth/utils': 0.5.1
|
||||
optionalDependencies:
|
||||
kysely: 0.29.8
|
||||
|
||||
'@better-auth/memory-adapter@1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5))(@better-auth/utils@0.5.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5)
|
||||
'@better-auth/utils': 0.5.1
|
||||
|
||||
'@better-auth/mongo-adapter@1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5))(@better-auth/utils@0.5.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5)
|
||||
'@better-auth/utils': 0.5.1
|
||||
|
||||
'@better-auth/prisma-adapter@1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5))(@better-auth/utils@0.5.1)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5)
|
||||
'@better-auth/utils': 0.5.1
|
||||
|
||||
'@better-auth/telemetry@1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5)
|
||||
'@better-auth/utils': 0.5.1
|
||||
'@better-fetch/fetch': 1.3.3
|
||||
|
||||
'@better-auth/utils@0.5.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.4.0
|
||||
|
||||
'@better-fetch/fetch@1.3.3': {}
|
||||
|
||||
'@changesets/apply-release-plan@8.1.1':
|
||||
dependencies:
|
||||
'@changesets/config': 4.0.1
|
||||
@@ -2539,9 +2698,11 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.4
|
||||
optional: true
|
||||
|
||||
'@oxc-project/types@0.133.0': {}
|
||||
'@noble/ciphers@2.4.0': {}
|
||||
|
||||
'@panva/hkdf@1.2.1': {}
|
||||
'@noble/hashes@2.4.0': {}
|
||||
|
||||
'@oxc-project/types@0.133.0': {}
|
||||
|
||||
'@playwright/test@1.61.1':
|
||||
dependencies:
|
||||
@@ -3007,6 +3168,41 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
better-auth@1.7.14(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vitest@4.1.7):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5)
|
||||
'@better-auth/drizzle-adapter': 1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5))(@better-auth/utils@0.5.1)
|
||||
'@better-auth/kysely-adapter': 1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(kysely@0.29.8)
|
||||
'@better-auth/memory-adapter': 1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5))(@better-auth/utils@0.5.1)
|
||||
'@better-auth/mongo-adapter': 1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5))(@better-auth/utils@0.5.1)
|
||||
'@better-auth/prisma-adapter': 1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5))(@better-auth/utils@0.5.1)
|
||||
'@better-auth/telemetry': 1.7.14(@better-auth/core@1.7.14(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)(better-call@1.4.4(zod@4.6.7))(jose@6.2.12)(kysely@0.29.8)(nanostores@1.5.5))(@better-auth/utils@0.5.1)(@better-fetch/fetch@1.3.3)
|
||||
'@better-auth/utils': 0.5.1
|
||||
'@better-fetch/fetch': 1.3.3
|
||||
'@noble/ciphers': 2.4.0
|
||||
'@noble/hashes': 2.4.0
|
||||
better-call: 1.4.4(zod@4.6.7)
|
||||
defu: 6.1.7
|
||||
jose: 6.2.12
|
||||
kysely: 0.29.8
|
||||
nanostores: 1.5.5
|
||||
zod: 4.6.7
|
||||
optionalDependencies:
|
||||
'@sveltejs/kit': 2.70.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1))
|
||||
svelte: 5.56.0(@typescript-eslint/types@8.60.0)
|
||||
vitest: 4.1.7(@types/node@24.12.4)(@vitest/coverage-v8@4.1.7)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1))
|
||||
transitivePeerDependencies:
|
||||
- '@opentelemetry/api'
|
||||
|
||||
better-call@1.4.4(zod@4.6.7):
|
||||
dependencies:
|
||||
'@better-auth/utils': 0.5.1
|
||||
'@better-fetch/fetch': 1.3.3
|
||||
rou3: 0.9.2
|
||||
set-cookie-parser: 3.1.3
|
||||
optionalDependencies:
|
||||
zod: 4.6.7
|
||||
|
||||
bits-ui@2.18.1(@internationalized/date@3.12.4)(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0)):
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.8.0
|
||||
@@ -3060,6 +3256,8 @@ snapshots:
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
|
||||
defu@6.1.7: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
@@ -3351,6 +3549,8 @@ snapshots:
|
||||
|
||||
known-css-properties@0.37.0: {}
|
||||
|
||||
kysely@0.29.8: {}
|
||||
|
||||
launch-editor@2.14.2:
|
||||
dependencies:
|
||||
picocolors: 1.1.1
|
||||
@@ -3459,9 +3659,9 @@ snapshots:
|
||||
|
||||
nanoid@3.3.20: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
nanostores@1.5.5: {}
|
||||
|
||||
oauth4webapi@3.8.8: {}
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
obug@2.2.1: {}
|
||||
|
||||
@@ -3542,11 +3742,8 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.2
|
||||
|
||||
preact-render-to-string@6.5.11(preact@10.24.3):
|
||||
dependencies:
|
||||
preact: 10.24.3
|
||||
|
||||
preact@10.24.3: {}
|
||||
preact@10.24.3:
|
||||
optional: true
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
@@ -3623,6 +3820,8 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.64.7
|
||||
fsevents: 2.3.3
|
||||
|
||||
rou3@0.9.2: {}
|
||||
|
||||
runed@0.35.1(@sveltejs/kit@2.70.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0(@typescript-eslint/types@8.60.0))(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.12.4)(esbuild@0.28.2)(yaml@2.9.1)))(svelte@5.56.0(@typescript-eslint/types@8.60.0)):
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
@@ -3638,8 +3837,6 @@ snapshots:
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
set-cookie-parser@3.1.3: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
@@ -3862,3 +4059,5 @@ snapshots:
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zimmerframe@1.1.5: {}
|
||||
|
||||
zod@4.6.7: {}
|
||||
Reference in new issue
Block a user