From 162438973316d485e21684045583ac75202b4cb9 Mon Sep 17 00:00:00 2001
From: Robert Hamilton
Date: Sat, 1 Aug 2026 21:00:56 -0500
Subject: [PATCH 1/2] feat(auth): wire up Authentik sign-in via Supabase custom
OAuth provider
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Supabase's custom_oauth_providers table already had a healthy Authentik
integration configured, but the app never called signInWithOAuth and
had no button for it — the provider was unreachable from the client.
Add signInWithAuthentik() (authService.ts) and a "Continue with
Authentik" button on the sign-in/sign-up screens, redirecting back
through the current URL so any `?next=` survives the round trip.
Claude-Session: https://claude.ai/code/session_015PrayZEbYqmTBCxQKWFLse
---
src/lib/auth/authService.spec.ts | 35 ++++++++++-
src/lib/auth/authService.ts | 25 +++++++-
src/lib/auth/index.ts | 1 +
src/lib/components/auth/AuthForm.svelte | 51 ++++++++++++++++
.../components/auth/AuthForm.svelte.spec.ts | 59 ++++++++++++++++---
5 files changed, 162 insertions(+), 9 deletions(-)
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..a9545fa 100644
--- a/src/lib/auth/authService.ts
+++ b/src/lib/auth/authService.ts
@@ -6,9 +6,18 @@
// 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.
+ */
+const AUTHENTIK_PROVIDER = 'custom:authentik' as Provider;
+
/**
* Outcome of a sign-up.
*
@@ -53,6 +62,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..f93c896 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,
@@ -96,6 +97,26 @@
}
}
+ 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);
+ // No further state change here: a successful call has already navigated the
+ // browser to Authentik, so anything after this point never runs.
+ } 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.';
+ busy = false;
+ }
+ }
+
async function onResend() {
if (!pending || busy) return;
busy = true;
@@ -180,6 +201,19 @@
{/if}
+ {#if mode !== 'reset'}
+
+ or
+ {/if}
+