Migrate to stateless authentication #70

Merged
eleith merged 3 commits from feature/stateless-auth into main 2026-11-02 22:31:20 +00:00
35 changed files with 1226 additions and 1776 deletions

No files matched your search

+5
View File
@@ -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
View File
@@ -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);
}
});
-3
View File
@@ -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);
+41 -42
View File
@@ -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']) {
+5 -4
View File
@@ -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 [
+5 -1
View File
@@ -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' ||
+6 -28
View File
@@ -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') ?? '';
}
+1 -1
View File
@@ -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:",
+3 -3
View File
@@ -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>;
}
}
}
+2 -2
View File
@@ -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' }],
+178
View File
@@ -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);
});
+10 -5
View File
@@ -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);
});
+108
View File
@@ -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;
}
+186 -669
View File
@@ -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);
});
+78 -39
View File
@@ -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']
}
]);
});
+69 -48
View File
@@ -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();
};
}
+1 -5
View File
@@ -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');
});
+11
View File
@@ -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;
}
+42 -28
View File
@@ -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 });
});
+2 -8
View File
@@ -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,
+1 -5
View File
@@ -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 = '';
}
+1 -1
View File
@@ -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), '/');
});
-12
View File
@@ -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
View File
@@ -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
+5 -3
View File
@@ -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.
+272 -73
View File
@@ -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: {}