Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion src/lib/auth/authService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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<string, ReturnType<typeof vi.fn>>;

Expand Down Expand Up @@ -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 () => {
Expand Down
33 changes: 32 additions & 1 deletion src/lib/auth/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -53,6 +70,20 @@ export async function signIn(email: string, password: string): Promise<User> {
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<void> {
const { error } = await supabase.auth.signInWithOAuth({
provider: AUTHENTIK_PROVIDER,
options: { redirectTo }
});
if (error) fail(error);
}

export async function signUp(email: string, password: string): Promise<SignUpOutcome> {
const trimmed = email.trim();
const { data, error } = await supabase.auth.signUp({ email: trimmed, password });
Expand Down
1 change: 1 addition & 0 deletions src/lib/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
signIn,
signInWithAuthentik,
signUp,
signOut,
requestPasswordReset,
Expand Down
70 changes: 70 additions & 0 deletions src/lib/components/auth/AuthForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
requestPasswordReset,
resendConfirmation,
signIn,
signInWithAuthentik,
signUp,
validateEmail,
validateSignIn,
Expand All @@ -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…' },
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -180,6 +220,19 @@
{/if}
</p>

{#if mode !== 'reset'}
<button
type="button"
class="nk-btn nk-btn--secondary nk-btn--lg nk-btn--block"
disabled={busy}
onclick={onAuthentikSignIn}
>
<i class="ph ph-key" aria-hidden="true"></i>
Continue with Authentik
</button>
<div class="divider" role="separator"><span>or</span></div>
{/if}

<form onsubmit={onSubmit} novalidate>
<div class="field">
<label class="field__label" for="auth-email">Email</label>
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading