From 7802f134c187b03ed85353cb3855ff7fececd6ab Mon Sep 17 00:00:00 2001 From: eleith Date: Thu, 29 Oct 2026 16:38:37 -0700 Subject: [PATCH] refactor(web): replace Auth.js with stateless Better Auth --- .changeset/stateless-better-auth.md | 5 + apps/web/e2e/auth.e2e.ts | 25 +- apps/web/e2e/support/auth.ts | 34 +- apps/web/e2e/theme.e2e.ts | 13 +- apps/web/package.json | 2 +- apps/web/src/app.d.ts | 10 +- apps/web/src/hooks.server.test.ts | 4 +- apps/web/src/hooks.server.ts | 4 +- apps/web/src/lib/auth-client.ts | 3 + apps/web/src/lib/components/AdminNav.svelte | 65 +++- apps/web/src/lib/server/auth/index.test.ts | 242 ++++++++++++ apps/web/src/lib/server/auth/index.ts | 83 +++-- .../web/src/lib/server/auth/providers.test.ts | 78 +--- apps/web/src/lib/server/auth/providers.ts | 133 +++++-- apps/web/src/lib/server/config/reload.test.ts | 17 + apps/web/src/lib/server/config/reload.ts | 9 +- apps/web/src/lib/server/redirect.test.ts | 2 + apps/web/src/lib/server/redirect.ts | 10 +- .../src/routes/(auth)/admin/+page.server.ts | 7 +- apps/web/src/routes/signin/+page.server.ts | 44 +-- apps/web/src/routes/signin/+page.svelte | 106 ++++-- apps/web/src/routes/signin/server.test.ts | 30 +- pnpm-lock.yaml | 350 ++++++++++++++---- 23 files changed, 936 insertions(+), 340 deletions(-) create mode 100644 .changeset/stateless-better-auth.md create mode 100644 apps/web/src/lib/auth-client.ts create mode 100644 apps/web/src/lib/server/auth/index.test.ts diff --git a/.changeset/stateless-better-auth.md b/.changeset/stateless-better-auth.md new file mode 100644 index 00000000..6a0d6e4e --- /dev/null +++ b/.changeset/stateless-better-auth.md @@ -0,0 +1,5 @@ +--- +'@when/web': patch +--- + +Replace Auth.js with stateless Better Auth for admin sign-in, including native request throttling and fixed seven-day sessions. Existing sessions require signing in again. diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index 1d1adf06..cedb23d7 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -1,5 +1,5 @@ import { expect, test } from './support/test.ts'; -import { ADMIN, signInAsAdmin } from './support/auth.ts'; +import { ADMIN, signInAsAdminViaForm } from './support/auth.ts'; test('an admin page sends a signed-out visitor to sign in', async ({ page }) => { await page.goto('/admin'); @@ -20,11 +20,30 @@ test('the wrong password is rejected', async ({ page }) => { await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page.getByRole('alert')).toContainText('Invalid username or password'); + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeEnabled(); await expect(page).not.toHaveURL('/admin'); }); -test('the fixture credentials reach the admin dashboard', async ({ page }) => { - await signInAsAdmin(page); +test('the fixture credentials reach the admin dashboard without a pending banner flash', async ({ + page +}) => { + let releaseLogin!: () => void; + const waiting = new Promise((resolve) => { + releaseLogin = resolve; + }); + await page.route('**/auth/sign-in/credentials', async (route) => { + await waiting; + await route.continue(); + }); + const signingIn = signInAsAdminViaForm(page); + try { + await expect(page.locator('.signin-form')).toHaveAttribute('aria-busy', 'true'); + await expect(page.getByRole('button', { name: 'Signing in…' })).toBeDisabled(); + await expect(page.locator('.error-banner')).toHaveCount(0); + } finally { + releaseLogin(); + await signingIn; + } await expect(page).toHaveURL('/admin'); }); diff --git a/apps/web/e2e/support/auth.ts b/apps/web/e2e/support/auth.ts index bef35b86..43e32d8b 100644 --- a/apps/web/e2e/support/auth.ts +++ b/apps/web/e2e/support/auth.ts @@ -1,12 +1,40 @@ -import type { Page } from '@playwright/test'; +import type { Cookie, Page } from '@playwright/test'; // Matches auth.credentials in the fixture config. export const ADMIN = { username: 'admin', password: 'e2e-admin-password' }; -export async function signInAsAdmin(page: Page): Promise { - await page.goto('/signin'); +// Each worker has its own module state and server. Reuse only stateless auth cookies, +// never a test's theme/timezone preferences or other browser state. +const sessions = new Map(); +const sessionCookie = /^(?:__Secure-)?better-auth\.session_(?:token|data)(?:\.\d+)?$/; + +async function submitAdminForm(page: Page): Promise { await page.getByLabel('Username').fill(ADMIN.username); await page.getByLabel('Password').fill(ADMIN.password); await page.getByRole('button', { name: 'Sign in' }).click(); await page.waitForURL('**/admin'); } + +// Auth-critical coverage must exercise the real form, even when a session is cached. +export async function signInAsAdminViaForm(page: Page): Promise { + await page.goto('/signin'); + await submitAdminForm(page); +} + +export async function signInAsAdmin(page: Page): Promise { + await page.goto('/signin'); + const origin = new URL(page.url()).origin; + const cached = sessions.get(origin); + if (cached) { + await page.context().addCookies(cached); + await page.goto('/admin'); + await page.waitForURL('**/admin'); + return; + } + + await submitAdminForm(page); + sessions.set( + origin, + (await page.context().cookies(origin)).filter((cookie) => sessionCookie.test(cookie.name)) + ); +} diff --git a/apps/web/e2e/theme.e2e.ts b/apps/web/e2e/theme.e2e.ts index 758eea24..0c0315f6 100644 --- a/apps/web/e2e/theme.e2e.ts +++ b/apps/web/e2e/theme.e2e.ts @@ -261,7 +261,7 @@ test('the nonced head bootstrap applies the saved palette without hydration', as expect(violations).toEqual([]); }); -test('admin icon actions stay ordered, tappable and separate from the real sign-out form', async ({ +test('admin icon actions stay ordered and tappable, and theme changes do not sign out', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1280, height: 900 }); @@ -276,11 +276,6 @@ test('admin icon actions stay ordered, tappable and separate from the real sign- await expect(toggle.locator('svg')).toHaveCount(1); await expect(toggle.locator('svg text')).toHaveCount(0); await expect(page.getByRole('navigation', { name: 'Admin breadcrumb' })).toHaveText(''); - await expect(page.locator('.nav-actions > button + form')).toHaveAttribute( - 'action', - '/admin?/signout' - ); - await expect(page.locator('.signout-form')).toHaveAttribute('method', 'POST'); const toggleBounds = await touchTarget(toggle); const signoutBounds = await touchTarget(signout); expect(toggleBounds.x + toggleBounds.width).toBeLessThanOrEqual(signoutBounds.x); @@ -292,7 +287,9 @@ test('admin icon actions stay ordered, tappable and separate from the real sign- let signoutPosts = 0; page.on('request', (request) => { - if (request.method() === 'POST' && request.url().endsWith('/admin?/signout')) signoutPosts++; + if (request.method() === 'POST' && new URL(request.url()).pathname === '/auth/sign-out') { + signoutPosts++; + } }); await toggle.click(); await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); @@ -334,4 +331,6 @@ test('admin icon actions stay ordered, tappable and separate from the real sign- expect(signoutPosts).toBe(1); await expect(themeButton(page)).toHaveCount(0); await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + const denied = await page.request.get('/admin', { headers: { accept: 'application/json' } }); + expect(denied.status()).toBe(403); }); diff --git a/apps/web/package.json b/apps/web/package.json index a0c159d9..71ae5cbb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,7 +50,6 @@ "vitest": "4.1.7" }, "dependencies": { - "@auth/sveltekit": "1.11.3", "@dicebear/core": "10.4.0", "@internationalized/date": "3.12.2", "@when/calendar": "workspace:*", @@ -58,6 +57,7 @@ "@when/db": "workspace:*", "@when/jobs": "workspace:*", "@when/video-chat": "workspace:*", + "better-auth": "1.7.14", "bits-ui": "2.18.1", "kysely": "0.28.17", "mrmime": "2.0.1", diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index eaac19dc..5e81e5ca 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,10 +1,16 @@ +import type { WhenSession } from '$lib/server/auth'; + // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { namespace App { // interface Error {} - // interface Locals {} - // interface PageData {} + interface Locals { + auth: () => Promise; + } + interface PageData { + session?: WhenSession | null; + } // interface PageState {} // interface Platform {} } diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index b4c49a50..b643b90a 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -6,9 +6,7 @@ vi.mock('$lib/server/boot', () => ({ bootApp: async () => {} })); vi.mock('$lib/server/appearance', () => ({ themeStyleTag: () => '' })); vi.mock('$lib/server/state', () => ({ getConfig: () => validConfig })); vi.mock('$lib/server/auth', () => ({ - getAuth: () => ({ - handle: (({ event, resolve }) => resolve(event)) as Handle - }) + authHandle: (({ event, resolve }) => resolve(event)) as Handle })); import { authGate, securityHeaders } from './hooks.server'; diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index c5ad507d..31bdf535 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,6 +1,6 @@ import { sequence } from '@sveltejs/kit/hooks'; import { redirect, error, type Handle } from '@sveltejs/kit'; -import { getAuth } from '$lib/server/auth'; +import { authHandle } from '$lib/server/auth'; import { localRedirect } from '$lib/server/redirect'; import { bootApp } from '$lib/server/boot'; import { logger } from '$lib/server/logger'; @@ -48,4 +48,4 @@ export const authGate: Handle = async ({ event, resolve }) => { }); }; -export const handle = sequence(securityHeaders, getAuth().handle, authGate); +export const handle = sequence(securityHeaders, authHandle, authGate); diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts new file mode 100644 index 00000000..dc6cb3da --- /dev/null +++ b/apps/web/src/lib/auth-client.ts @@ -0,0 +1,3 @@ +import { createAuthClient } from 'better-auth/svelte'; + +export const authClient = createAuthClient({ basePath: '/auth' }); diff --git a/apps/web/src/lib/components/AdminNav.svelte b/apps/web/src/lib/components/AdminNav.svelte index dc7439d4..afd7780b 100644 --- a/apps/web/src/lib/components/AdminNav.svelte +++ b/apps/web/src/lib/components/AdminNav.svelte @@ -1,6 +1,8 @@
@@ -41,18 +67,36 @@ + {#if signoutError} + + {/if}