diff --git a/.changeset/stateless-auth-foundation.md b/.changeset/stateless-auth-foundation.md new file mode 100644 index 0000000..8e1579b --- /dev/null +++ b/.changeset/stateless-auth-foundation.md @@ -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. diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index 4888557..3306ba8 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -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 = { username, password, redirectTo: '/' }, - headers: Record = {} + fields: Record = { 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); } }); diff --git a/apps/web/e2e/config-update.e2e.ts b/apps/web/e2e/config-update.e2e.ts index 65f33b3..ee58091 100644 --- a/apps/web/e2e/config-update.e2e.ts +++ b/apps/web/e2e/config-update.e2e.ts @@ -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); diff --git a/apps/web/e2e/configured-images.e2e.ts b/apps/web/e2e/configured-images.e2e.ts index 868745c..e6ede25 100644 --- a/apps/web/e2e/configured-images.e2e.ts +++ b/apps/web/e2e/configured-images.e2e.ts @@ -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 = ` @@ -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']) { diff --git a/apps/web/e2e/favicon.e2e.ts b/apps/web/e2e/favicon.e2e.ts index 3ae3ad3..6e40911 100644 --- a/apps/web/e2e/favicon.e2e.ts +++ b/apps/web/e2e/favicon.e2e.ts @@ -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 [ diff --git a/apps/web/e2e/support/oidc-issuer.ts b/apps/web/e2e/support/oidc-issuer.ts index f3c044f..a5771cb 100644 --- a/apps/web/e2e/support/oidc-issuer.ts +++ b/apps/web/e2e/support/oidc-issuer.ts @@ -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' || diff --git a/apps/web/e2e/support/session.ts b/apps/web/e2e/support/session.ts index 2dd964b..3a468ac 100644 --- a/apps/web/e2e/support/session.ts +++ b/apps/web/e2e/support/session.ts @@ -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; - maxAge: number; - }): Promise; - 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 { - 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') ?? ''; } diff --git a/apps/web/package.json b/apps/web/package.json index 9539f97..08bf49f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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:", diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index 2694a61..2cfb567 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -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; } } } diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index 4a3e04d..5bab3ce 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -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(), report: vi.fn(), load: vi.fn(), @@ -326,7 +326,7 @@ describe('server hooks', () => { } return bookmarkLogo(event as Parameters[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' }], diff --git a/apps/web/src/lib/server/auth/access.test.ts b/apps/web/src/lib/server/auth/access.test.ts new file mode 100644 index 0000000..d42567d --- /dev/null +++ b/apps/web/src/lib/server/auth/access.test.ts @@ -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[0]['resolve']; +type RequestOptions = { + method?: string; + headers?: Record; + 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().mockResolvedValue(null), session: null } + } as unknown as RequestEvent; + const resolve = vi.fn().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([ + { 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); +}); diff --git a/apps/web/src/lib/server/auth/access.ts b/apps/web/src/lib/server/auth/access.ts index e06bc38..607da78 100644 --- a/apps/web/src/lib/server/auth/access.ts +++ b/apps/web/src/lib/server/auth/access.ts @@ -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, { diff --git a/apps/web/src/lib/server/auth/actions.test.ts b/apps/web/src/lib/server/auth/actions.test.ts new file mode 100644 index 0000000..2f7669a --- /dev/null +++ b/apps/web/src/lib/server/auth/actions.test.ts @@ -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); +}); diff --git a/apps/web/src/lib/server/auth/actions.ts b/apps/web/src/lib/server/auth/actions.ts new file mode 100644 index 0000000..afd1e37 --- /dev/null +++ b/apps/web/src/lib/server/auth/actions.ts @@ -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, + origin: string, + strategy: 'credentials' | 'oidc' +): Promise { + 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, + origin: string +): Promise { + 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, + origin: string, + path: string, + body: Record +): Promise { + 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; +} diff --git a/apps/web/src/lib/server/auth/credentials.test.ts b/apps/web/src/lib/server/auth/credentials.test.ts new file mode 100644 index 0000000..b544445 --- /dev/null +++ b/apps/web/src/lib/server/auth/credentials.test.ts @@ -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(); + }); +}); diff --git a/apps/web/src/lib/server/auth/credentials.ts b/apps/web/src/lib/server/auth/credentials.ts new file mode 100644 index 0000000..fa12980 --- /dev/null +++ b/apps/web/src/lib/server/auth/credentials.ts @@ -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; + 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(); +} diff --git a/apps/web/src/lib/server/auth/form-actions.ts b/apps/web/src/lib/server/auth/form-actions.ts deleted file mode 100644 index c8846d2..0000000 --- a/apps/web/src/lib/server/auth/form-actions.ts +++ /dev/null @@ -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; - -export async function signInAction( - event: RequestEvent, - origin: string, - strategy: 'credentials' | 'oidc', - authJs: AuthJsHandlers -): Promise { - 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 { - 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; -} diff --git a/apps/web/src/lib/server/auth/index.test.ts b/apps/web/src/lib/server/auth/index.test.ts index bb8ae6f..61d6b36 100644 --- a/apps/web/src/lib/server/auth/index.test.ts +++ b/apps/web/src/lib/server/auth/index.test.ts @@ -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(); - 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 | 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 => { - 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): Promise { + 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); }); diff --git a/apps/web/src/lib/server/auth/index.ts b/apps/web/src/lib/server/auth/index.ts index 91a9ee5..0a34ea4 100644 --- a/apps/web/src/lib/server/auth/index.ts +++ b/apps/web/src/lib/server/auth/index.ts @@ -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; } -const WEEK_SECONDS = 7 * 24 * 60 * 60; - -const authJsLogger: NonNullable = { - 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({ + 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>; + +export function createSessionReader(auth: Auth | undefined, headers: Headers) { + async function readSession(): Promise { + 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 | undefined; + return () => (session ??= readSession()); +} diff --git a/apps/web/src/lib/server/auth/providers.test.ts b/apps/web/src/lib/server/auth/providers.test.ts deleted file mode 100644 index 6b5866a..0000000 --- a/apps/web/src/lib/server/auth/providers.test.ts +++ /dev/null @@ -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(); - 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 | 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'] - } - ]); -}); diff --git a/apps/web/src/lib/server/auth/providers.ts b/apps/web/src/lib/server/auth/providers.ts index b45036f..05bfc6f 100644 --- a/apps/web/src/lib/server/auth/providers.ts +++ b/apps/web/src/lib/server/auth/providers.ts @@ -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; - -export function buildProviders(config: DirectoryAuth): SvelteKitAuthConfig['providers'] { +export function buildAuthOptions( + config: DirectoryAuth, + origin: string +): Pick { 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> | 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(); + }; } diff --git a/apps/web/src/lib/server/auth/redirect.ts b/apps/web/src/lib/server/auth/redirect.ts index 4e40e28..391670b 100644 --- a/apps/web/src/lib/server/auth/redirect.ts +++ b/apps/web/src/lib/server/auth/redirect.ts @@ -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; } diff --git a/apps/web/src/lib/server/auth/request.test.ts b/apps/web/src/lib/server/auth/request.test.ts new file mode 100644 index 0000000..89492d3 --- /dev/null +++ b/apps/web/src/lib/server/auth/request.test.ts @@ -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'); +}); diff --git a/apps/web/src/lib/server/auth/request.ts b/apps/web/src/lib/server/auth/request.ts index 52a6a6a..74e9695 100644 --- a/apps/web/src/lib/server/auth/request.ts +++ b/apps/web/src/lib/server/auth/request.ts @@ -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 +): 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; } diff --git a/apps/web/src/lib/server/boot.process.test.ts b/apps/web/src/lib/server/boot.process.test.ts index 06569a0..54861de 100644 --- a/apps/web/src/lib/server/boot.process.test.ts +++ b/apps/web/src/lib/server/boot.process.test.ts @@ -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 }); }); diff --git a/apps/web/src/lib/server/boot.test.ts b/apps/web/src/lib/server/boot.test.ts index bac4e1b..1f95327 100644 --- a/apps/web/src/lib/server/boot.test.ts +++ b/apps/web/src/lib/server/boot.test.ts @@ -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, diff --git a/apps/web/src/lib/server/boot.ts b/apps/web/src/lib/server/boot.ts index 6494a6a..548f1c7 100644 --- a/apps/web/src/lib/server/boot.ts +++ b/apps/web/src/lib/server/boot.ts @@ -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' }); } diff --git a/apps/web/src/lib/server/runtime-environment.test.ts b/apps/web/src/lib/server/runtime-environment.test.ts index a554c51..126b3c9 100644 --- a/apps/web/src/lib/server/runtime-environment.test.ts +++ b/apps/web/src/lib/server/runtime-environment.test.ts @@ -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); diff --git a/apps/web/src/lib/server/runtime-environment.ts b/apps/web/src/lib/server/runtime-environment.ts index 8739db1..49f407d 100644 --- a/apps/web/src/lib/server/runtime-environment.ts +++ b/apps/web/src/lib/server/runtime-environment.ts @@ -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 = ''; } diff --git a/apps/web/src/routes/(protected)/page.test.ts b/apps/web/src/routes/(protected)/page.test.ts index 1a2f3e8..1489ede 100644 --- a/apps/web/src/routes/(protected)/page.test.ts +++ b/apps/web/src/routes/(protected)/page.test.ts @@ -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, { diff --git a/apps/web/src/routes/(public)/signin/page.test.ts b/apps/web/src/routes/(public)/signin/page.test.ts index 8c63d48..26b6ffa 100644 --- a/apps/web/src/routes/(public)/signin/page.test.ts +++ b/apps/web/src/routes/(public)/signin/page.test.ts @@ -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), '/'); }); diff --git a/docs/config.md b/docs/config.md index 8db6f0a..d6d52c7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 diff --git a/docs/deployment.md b/docs/deployment.md index a191ad9..69b4ee9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 diff --git a/docs/directory.example.yaml b/docs/directory.example.yaml index f5c4da7..d531be0 100644 --- a/docs/directory.example.yaml +++ b/docs/directory.example.yaml @@ -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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 042abdb..80021b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}