Fix auth flow: federated logout, login page move, and healthcheck

- Add federated logout endpoint that clears Auth.js session AND ends
  Zitadel SSO session via OIDC end_session endpoint
- Move sign-in page from /auth/signin to /login to avoid Auth.js
  route conflict causing ERR_TOO_MANY_REDIRECTS
- Add callbackUrl to all signIn calls so users land on /dashboard
- Store id_token in session for federated logout id_token_hint
- Fix Zitadel healthcheck using binary ready command (no curl needed)
- Update post_logout_redirect_uri in setup script

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikkel Georgsen 2026-02-08 12:55:35 +01:00
parent a22ba48709
commit c0cb2d25a0
12 changed files with 54 additions and 15 deletions

View file

@ -3,6 +3,7 @@
declare module '@auth/sveltekit' { declare module '@auth/sveltekit' {
interface Session { interface Session {
accessToken?: string; accessToken?: string;
idToken?: string;
} }
} }
@ -10,6 +11,7 @@ declare module '@auth/core/jwt' {
interface JWT { interface JWT {
accessToken?: string; accessToken?: string;
refreshToken?: string; refreshToken?: string;
idToken?: string;
expiresAt?: number; expiresAt?: number;
} }
} }

View file

@ -29,17 +29,19 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
if (account) { if (account) {
token.accessToken = account.access_token; token.accessToken = account.access_token;
token.refreshToken = account.refresh_token; token.refreshToken = account.refresh_token;
token.idToken = account.id_token;
token.expiresAt = account.expires_at; token.expiresAt = account.expires_at;
} }
return token; return token;
}, },
async session({ session, token }) { async session({ session, token }) {
session.accessToken = token.accessToken as string; session.accessToken = token.accessToken as string;
session.idToken = token.idToken as string;
return session; return session;
} }
}, },
pages: { pages: {
signIn: '/auth/signin' signIn: '/login'
}, },
trustHost: true trustHost: true
}); });

View file

@ -23,7 +23,7 @@
</a> </a>
{:else} {:else}
<button <button
onclick={() => signIn('zitadel')} onclick={() => signIn('zitadel', { callbackUrl: '/dashboard' })}
class="px-5 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg font-medium transition-colors cursor-pointer" class="px-5 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg font-medium transition-colors cursor-pointer"
> >
Sign In Sign In
@ -50,7 +50,7 @@
</a> </a>
{:else} {:else}
<button <button
onclick={() => signIn('zitadel')} onclick={() => signIn('zitadel', { callbackUrl: '/dashboard' })}
class="px-8 py-3 bg-blue-600 hover:bg-blue-500 rounded-lg text-lg font-medium transition-colors cursor-pointer" class="px-8 py-3 bg-blue-600 hover:bg-blue-500 rounded-lg text-lg font-medium transition-colors cursor-pointer"
> >
Get Started Get Started

View file

@ -0,0 +1,26 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
export const GET: RequestHandler = async (event) => {
const session = await event.locals.auth();
const idToken = session?.idToken;
// Build Zitadel end_session URL
const issuer = env.AUTH_ZITADEL_ISSUER || 'http://localhost:8080';
const endSessionUrl = new URL('/oidc/v1/end_session', issuer);
if (idToken) {
endSessionUrl.searchParams.set('id_token_hint', idToken);
}
// Use the registered post_logout_redirect_uri
endSessionUrl.searchParams.set('post_logout_redirect_uri', 'http://localhost:5173');
// Clear the Auth.js session cookies
event.cookies.delete('authjs.session-token', { path: '/' });
event.cookies.delete('__Secure-authjs.session-token', { path: '/' });
event.cookies.delete('authjs.callback-url', { path: '/' });
event.cookies.delete('authjs.csrf-token', { path: '/' });
throw redirect(302, endSessionUrl.toString());
};

View file

@ -4,7 +4,7 @@ import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => { export const load: LayoutServerLoad = async (event) => {
const session = await event.locals.auth(); const session = await event.locals.auth();
if (!session) { if (!session) {
throw redirect(303, '/auth/signin'); throw redirect(303, '/login');
} }
return { session }; return { session };
}; };

View file

@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { signOut } from '@auth/sveltekit/client';
let { children } = $props(); let { children } = $props();
let session = $derived($page.data.session); let session = $derived($page.data.session);
let sidebarOpen = $state(false); let sidebarOpen = $state(false);
@ -131,7 +129,7 @@
</div> </div>
{/if} {/if}
<button <button
onclick={() => signOut()} onclick={() => window.location.href = '/api/auth/federated-logout'}
class="text-sm text-slate-500 hover:text-red-600 dark:text-slate-400 dark:hover:text-red-400 transition-colors cursor-pointer" class="text-sm text-slate-500 hover:text-red-600 dark:text-slate-400 dark:hover:text-red-400 transition-colors cursor-pointer"
title="Sign out" title="Sign out"
> >

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { signOut } from '@auth/sveltekit/client';
let session = $derived($page.data.session); let session = $derived($page.data.session);
const zitadelAccountUrl = import.meta.env.PUBLIC_ZITADEL_ACCOUNT_URL || '#'; const zitadelAccountUrl = import.meta.env.PUBLIC_ZITADEL_ACCOUNT_URL || '#';
@ -91,7 +90,7 @@
Sign out of your PVM account on this device. Sign out of your PVM account on this device.
</p> </p>
<button <button
onclick={() => signOut({ callbackUrl: '/' })} onclick={() => window.location.href = '/api/auth/federated-logout'}
class="inline-flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer" class="inline-flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors cursor-pointer"
> >
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">

View file

@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
if (session) {
throw redirect(303, '/dashboard');
}
};

View file

@ -17,7 +17,7 @@
<div class="bg-white/5 border border-white/10 rounded-2xl p-8 backdrop-blur-sm"> <div class="bg-white/5 border border-white/10 rounded-2xl p-8 backdrop-blur-sm">
<!-- OIDC Sign In --> <!-- OIDC Sign In -->
<button <button
onclick={() => signIn('zitadel')} onclick={() => signIn('zitadel', { callbackUrl: '/dashboard' })}
class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors cursor-pointer" class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors cursor-pointer"
> >
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@ -37,7 +37,7 @@
<div class="space-y-3"> <div class="space-y-3">
<button <button
onclick={() => signIn('zitadel')} onclick={() => signIn('zitadel', { callbackUrl: '/dashboard' })}
class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 text-white rounded-lg font-medium transition-colors cursor-pointer" class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 text-white rounded-lg font-medium transition-colors cursor-pointer"
> >
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
@ -50,7 +50,7 @@
</button> </button>
<button <button
onclick={() => signIn('zitadel')} onclick={() => signIn('zitadel', { callbackUrl: '/dashboard' })}
class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 text-white rounded-lg font-medium transition-colors cursor-pointer" class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 text-white rounded-lg font-medium transition-colors cursor-pointer"
> >
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
@ -60,7 +60,7 @@
</button> </button>
<button <button
onclick={() => signIn('zitadel')} onclick={() => signIn('zitadel', { callbackUrl: '/dashboard' })}
class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 text-white rounded-lg font-medium transition-colors cursor-pointer" class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 text-white rounded-lg font-medium transition-colors cursor-pointer"
> >
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="#1877F2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="#1877F2">

View file

@ -39,8 +39,9 @@ services:
condition: service_healthy condition: service_healthy
volumes: volumes:
- ./machinekey:/machinekey - ./machinekey:/machinekey
- ./zitadel-healthcheck.yaml:/zitadel-healthcheck.yaml:ro
healthcheck: healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8080/debug/healthz"] test: ["CMD", "/app/zitadel", "ready", "--config", "/zitadel-healthcheck.yaml"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 15 retries: 15

View file

@ -102,7 +102,7 @@ create_oidc_app() {
-d '{ -d '{
"name": "PVM Dashboard", "name": "PVM Dashboard",
"redirectUris": ["http://localhost:5173/auth/callback/zitadel"], "redirectUris": ["http://localhost:5173/auth/callback/zitadel"],
"postLogoutRedirectUris": ["http://localhost:5173"], "postLogoutRedirectUris": ["http://localhost:5173", "http://localhost:5173/login"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"], "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"],
"appType": "OIDC_APP_TYPE_WEB", "appType": "OIDC_APP_TYPE_WEB",

View file

@ -0,0 +1,2 @@
TLS:
Mode: disabled