diff --git a/CLAUDE.md b/CLAUDE.md index 5723e95..5eda69d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,12 @@ bun run test:e2e # playwright install && playwright test `signUp()` returns a live session when `enable_confirmations = false` (local default) and a null session when it's on — both are success, so branch, don't assume. Production needs SMTP configured in Supabase or confirmation mail never sends. + Authentik sign-in (`signInWithAuthentik` in `$lib/auth`) is a custom OAuth provider + (`auth.custom_oauth_providers`) provisioned per-environment in the Supabase project + itself — dashboard/Management API only, no config.toml or migration equivalent, so a + fresh `supabase start` needs it added by hand before the button works. Failures on the + redirect back land as `error`/`error_description` URL-fragment params, not a catchable + rejection; `AuthForm.svelte` parses those on mount. - **Prettier:** tabs, single quotes, no trailing comma, `printWidth` 100. Run `bun run format`. - **Data rules (when the backend exists):** UUID PKs; timestamps `timestamptz` in UTC; RLS enabled default-deny before any table is exposed; schema changes are Supabase CLI diff --git a/src/lib/auth/authService.spec.ts b/src/lib/auth/authService.spec.ts index 7b38f12..f750431 100644 --- a/src/lib/auth/authService.spec.ts +++ b/src/lib/auth/authService.spec.ts @@ -5,6 +5,7 @@ vi.mock('$lib/supabaseClient', () => ({ supabase: { auth: { signInWithPassword: vi.fn(), + signInWithOAuth: vi.fn(), signUp: vi.fn(), signOut: vi.fn(), resetPasswordForEmail: vi.fn(), @@ -15,7 +16,14 @@ vi.mock('$lib/supabaseClient', () => ({ })); import { supabase } from '$lib/supabaseClient'; -import { AuthFailure, requestPasswordReset, signIn, signOut, signUp } from './authService'; +import { + AuthFailure, + requestPasswordReset, + signIn, + signInWithAuthentik, + signOut, + signUp +} from './authService'; const auth = supabase.auth as unknown as Record>; @@ -57,6 +65,31 @@ describe('signIn', () => { }); }); +describe('signInWithAuthentik', () => { + it('starts the redirect against the custom Authentik provider', async () => { + auth.signInWithOAuth.mockResolvedValue({ + data: { provider: 'custom:authentik', url: 'x' }, + error: null + }); + + await signInWithAuthentik('http://localhost:5173/signin'); + + expect(auth.signInWithOAuth).toHaveBeenCalledWith({ + provider: 'custom:authentik', + options: { redirectTo: 'http://localhost:5173/signin' } + }); + }); + + it('throws AuthFailure when Supabase rejects the request', async () => { + auth.signInWithOAuth.mockResolvedValue({ + data: { provider: null, url: null }, + error: new AuthError('nope', 500, 'unexpected_failure') + }); + + await expect(signInWithAuthentik()).rejects.toThrow(AuthFailure); + }); +}); + describe('signUp', () => { // The branch the sign-up UX hinges on: whether the project auto-confirms. it('reports "active" when a session comes back (confirmations disabled)', async () => { diff --git a/src/lib/auth/authService.ts b/src/lib/auth/authService.ts index c725f67..9eef4d1 100644 --- a/src/lib/auth/authService.ts +++ b/src/lib/auth/authService.ts @@ -6,9 +6,26 @@ // AuthError into product voice. It holds no state; see session.svelte.ts for that. import { supabase } from '$lib/supabaseClient'; -import type { User } from '@supabase/supabase-js'; +import type { Provider, User } from '@supabase/supabase-js'; import { authErrorMessage, isDuplicateAccount } from './authErrors'; +/** + * Identifier of the Authentik OIDC provider configured in Supabase + * (`auth.custom_oauth_providers`). Custom providers aren't part of supabase-js's + * `Provider` union — the cast reflects that the SDK's types haven't caught up with the + * feature, not that this value is unchecked; Supabase rejects an unknown identifier + * server-side. + * + * That provisioning lives in the Supabase project itself (dashboard / Management API), + * not in this repo — there's no local-CLI or migration equivalent for custom OAuth + * providers, so a fresh `supabase start` has no `custom:authentik` entry until one is + * added by hand. Since `signInWithOAuth` builds the `/authorize` URL client-side and + * navigates immediately, an unconfigured provider isn't caught by the `error` check + * below — it surfaces after the redirect, via the `error`/`error_description` fragment + * params AuthForm.svelte parses on mount. + */ +const AUTHENTIK_PROVIDER = 'custom:authentik' as Provider; + /** * Outcome of a sign-up. * @@ -53,6 +70,20 @@ export async function signIn(email: string, password: string): Promise { return data.user; } +/** + * Starts the Authentik sign-in redirect. Supabase's `/authorize` endpoint sends the + * browser to Authentik and back; there's no user to return here because the current page + * is about to navigate away — the session shows up via `restoreSession`/`onAuthStateChange` + * once the redirect lands back on `redirectTo` (see `$lib/session/session.svelte.ts`). + */ +export async function signInWithAuthentik(redirectTo?: string): Promise { + const { error } = await supabase.auth.signInWithOAuth({ + provider: AUTHENTIK_PROVIDER, + options: { redirectTo } + }); + if (error) fail(error); +} + export async function signUp(email: string, password: string): Promise { const trimmed = email.trim(); const { data, error } = await supabase.auth.signUp({ email: trimmed, password }); diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index 77d8399..a7b36b8 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -1,5 +1,6 @@ export { signIn, + signInWithAuthentik, signUp, signOut, requestPasswordReset, diff --git a/src/lib/components/auth/AuthForm.svelte b/src/lib/components/auth/AuthForm.svelte index 271062e..3fd29dc 100644 --- a/src/lib/components/auth/AuthForm.svelte +++ b/src/lib/components/auth/AuthForm.svelte @@ -6,6 +6,7 @@ requestPasswordReset, resendConfirmation, signIn, + signInWithAuthentik, signUp, validateEmail, validateSignIn, @@ -27,6 +28,23 @@ /** A successful resend is otherwise indistinguishable from nothing happening. */ let resent = $state(false); + // Supabase appends `error`/`error_description` to the URL fragment when the Authentik + // round trip itself fails (denied consent, misconfigured provider, etc.) — there's no + // onAuthStateChange event for that case, so the fragment is the only signal. Runs once + // on mount; the fragment is stripped right after so a refresh doesn't re-show it. + $effect(() => { + const params = new URLSearchParams(window.location.hash.slice(1)); + const description = params.get('error_description'); + if (!description) return; + console.error( + '[auth] Authentik redirect returned an error:', + params.get('error_code'), + description.replace(/\+/g, ' ') + ); + error = 'Something went wrong signing you in. Please try again.'; + history.replaceState(null, '', window.location.pathname + window.location.search); + }); + const COPY = { signin: { title: 'Welcome back', action: 'Sign in', busy: 'Signing in…' }, signup: { title: 'Set up your kitchen', action: 'Create account', busy: 'Creating…' }, @@ -96,6 +114,28 @@ } } + async function onAuthentikSignIn() { + if (busy) return; + busy = true; + error = null; + try { + // Carries the current `?next=` (if any) through the redirect so the layout + // guard can hand the user back to where they started once the session lands. + await signInWithAuthentik(window.location.href); + // A successful call has already navigated the browser to Authentik, so this + // normally never runs — `finally` below still resets `busy` for the case where + // it resolves without an actual navigation (blocked popup, bfcache restore). + } catch (err) { + console.error( + '[auth] Authentik sign-in failed:', + err instanceof AuthFailure ? (err.cause ?? err) : err + ); + error = err instanceof AuthFailure ? err.message : 'Something went wrong. Please try again.'; + } finally { + busy = false; + } + } + async function onResend() { if (!pending || busy) return; busy = true; @@ -180,6 +220,19 @@ {/if}

+ {#if mode !== 'reset'} + + + {/if} +
@@ -316,6 +369,23 @@ margin: 0 0 var(--space-6); } + /* ---------- SSO divider ---------- */ + .divider { + display: flex; + align-items: center; + gap: var(--space-3); + margin: var(--space-4) 0; + color: var(--text-muted); + font-size: var(--text-xs); + } + .divider::before, + .divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); + } + /* ---------- Fields ---------- */ .field { margin-bottom: var(--space-4); diff --git a/src/lib/components/auth/AuthForm.svelte.spec.ts b/src/lib/components/auth/AuthForm.svelte.spec.ts index c27904b..c7c90b7 100644 --- a/src/lib/components/auth/AuthForm.svelte.spec.ts +++ b/src/lib/components/auth/AuthForm.svelte.spec.ts @@ -1,20 +1,29 @@ import { page } from 'vitest/browser'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; // Mock the service seam, not supabase-js: these specs are about what the form shows, // and the service's own behaviour is covered in authService.spec.ts. -const { signIn, signUp, requestPasswordReset, resendConfirmation } = vi.hoisted(() => ({ - signIn: vi.fn(), - signUp: vi.fn(), - requestPasswordReset: vi.fn(), - resendConfirmation: vi.fn() -})); +const { signIn, signInWithAuthentik, signUp, requestPasswordReset, resendConfirmation } = + vi.hoisted(() => ({ + signIn: vi.fn(), + signInWithAuthentik: vi.fn(), + signUp: vi.fn(), + requestPasswordReset: vi.fn(), + resendConfirmation: vi.fn() + })); vi.mock('$lib/auth', async (importOriginal) => { // Keep the real validation + AuthFailure so the form's guard clauses are exercised. const actual = await importOriginal(); - return { ...actual, signIn, signUp, requestPasswordReset, resendConfirmation }; + return { + ...actual, + signIn, + signInWithAuthentik, + signUp, + requestPasswordReset, + resendConfirmation + }; }); import AuthForm from './AuthForm.svelte'; @@ -24,6 +33,12 @@ beforeEach(() => { vi.clearAllMocks(); }); +afterEach(() => { + // Several Authentik tests push a URL fragment to simulate the OAuth return trip — + // strip it so it doesn't bleed into the next test's fresh `render()`. + history.replaceState(null, '', window.location.pathname + window.location.search); +}); + describe('AuthForm.svelte', () => { it('opens in sign-in mode', async () => { render(AuthForm); @@ -200,4 +215,124 @@ describe('AuthForm.svelte', () => { // which addresses have accounts. await expect.element(page.getByText(/If an account exists/)).toBeInTheDocument(); }); + + it('starts the Authentik redirect with the current URL', async () => { + signInWithAuthentik.mockResolvedValue(undefined); + render(AuthForm); + await page.getByRole('button', { name: 'Continue with Authentik' }).click(); + + expect(signInWithAuthentik).toHaveBeenCalledWith(window.location.href); + }); + + it('shows the OAuth option on sign-up but not on the reset screen', async () => { + render(AuthForm); + await expect + .element(page.getByRole('button', { name: 'Continue with Authentik' })) + .toBeInTheDocument(); + + await page.getByRole('button', { name: 'Create an account' }).click(); + await expect + .element(page.getByRole('button', { name: 'Continue with Authentik' })) + .toBeInTheDocument(); + + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await page.getByRole('button', { name: 'Forgot it?' }).click(); + expect(document.body.textContent).not.toMatch(/Continue with Authentik/); + }); + + it('surfaces a failed Authentik redirect in the product voice', async () => { + signInWithAuthentik.mockRejectedValue( + new AuthFailure('Something went sideways signing you in.') + ); + render(AuthForm); + await page.getByRole('button', { name: 'Continue with Authentik' }).click(); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent('Something went sideways signing you in.'); + }); + + it('ignores a second click while a redirect is already in flight', async () => { + let resolveSignIn: (() => void) | undefined; + signInWithAuthentik.mockReturnValue( + new Promise((resolve) => { + resolveSignIn = resolve; + }) + ); + render(AuthForm); + // A real double-click can land both events before Svelte's `disabled` update + // reaches the DOM — bypass the locator's actionability wait and call the native + // DOM method twice in the same tick to exercise the `if (busy) return` guard + // itself, not just the disabled attribute that backs it up. + const button = page + .getByRole('button', { name: 'Continue with Authentik' }) + .element() as HTMLButtonElement; + + button.click(); + button.click(); + + expect(signInWithAuthentik).toHaveBeenCalledOnce(); + resolveSignIn?.(); + }); + + it('resets busy after a redirect call resolves without actually navigating', async () => { + let resolveSignIn: (() => void) | undefined; + signInWithAuthentik.mockReturnValue( + new Promise((resolve) => { + resolveSignIn = resolve; + }) + ); + render(AuthForm); + const button = page.getByRole('button', { name: 'Continue with Authentik' }); + + await button.click(); + await expect.element(button).toBeDisabled(); + + // In a real success this resolution never happens — the browser has already + // navigated to Authentik. This simulates the one case that matters: the promise + // settling with no navigation (blocked popup, bfcache restore), which must not + // leave the form permanently disabled. + resolveSignIn?.(); + await expect.element(button).not.toBeDisabled(); + }); + + it('clears a stale error banner when starting the Authentik redirect', async () => { + signIn.mockRejectedValue(new AuthFailure('That email and password don’t match.')); + let resolveSignIn: (() => void) | undefined; + signInWithAuthentik.mockReturnValue( + new Promise((resolve) => { + resolveSignIn = resolve; + }) + ); + render(AuthForm); + await page.getByLabelText('Email').fill('cook@example.com'); + await page.getByLabelText('Password').fill('password1'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + + await page.getByRole('button', { name: 'Continue with Authentik' }).click(); + + expect(document.body.textContent).not.toMatch(/don.t match/); + resolveSignIn?.(); + }); + + it('surfaces an Authentik error returned in the URL fragment', async () => { + // Supabase appends `error`/`error_description` to the redirect URL's fragment + // when the round trip itself fails (denied consent, misconfigured provider) — + // there's no onAuthStateChange event for this, so the fragment is the only signal. + history.replaceState( + null, + '', + `${window.location.pathname}#error=server_error&error_description=${encodeURIComponent( + 'Provider misconfigured' + )}` + ); + + render(AuthForm); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent('Something went wrong signing you in. Please try again.'); + expect(window.location.hash).toBe(''); + }); }); diff --git a/supabase/config.toml b/supabase/config.toml index dfc1485..4ae0ef4 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -164,10 +164,14 @@ site_url = "http://localhost:5173" # The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. # external_url = "" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +# Wildcarded to the full path + query: OAuth (feature "Authentik sign-in") sends +# `redirectTo` as the current page's full URL (e.g. `/signin?next=%2Fplan`) so the `?next=` +# survives the round trip, and site_url itself only matches its own bare origin exactly. additional_redirect_urls = [ - "http://127.0.0.1:5173", - "http://localhost:4173", - "http://127.0.0.1:4173" + "http://localhost:5173/**", + "http://127.0.0.1:5173/**", + "http://localhost:4173/**", + "http://127.0.0.1:4173/**" ] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600