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}