fix(auth): RP-initiated Keycloak logout + PKCE verifier race

Clicking logout previously only called supabase.auth.signOut(): Keycloak's
SSO cookie was untouched, so the (app) layout's $effect immediately
re-fired login() and Keycloak silently re-authenticated — logout looked
broken. That same chain also surfaced "PKCE code verifier not found in
storage" on prod, since signOut()'s async storage clear could race
signInWithOAuth()'s verifier write, or the $effect could double-fire and
overwrite verifiers mid-flight.

- logout(): signOut() then redirect to Keycloak's end_session endpoint
  (client_id + post_logout_redirect_uri=/logged-out). Keycloak shows a
  one-click "Do you want to log out?" confirmation because GoTrue's proxy
  flow does not expose the id_token, so we can't pass id_token_hint.
- isLoggingOut flag + guard in (app) layout $effect: prevents auto-relogin
  during the logout navigation.
- New public /logged-out route, outside the (app) group.
- A-05 rewritten, new A-07 (fresh login must prompt for credentials after
  RP-initiated logout).
- pgTAP 008 extended with 4 assertions for migration 014's UPDATE trigger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 09:35:17 +02:00
parent 9067ab8f0c
commit ca12516005
7 changed files with 165 additions and 17 deletions

View File

@@ -2,7 +2,7 @@
import type { Snippet } from 'svelte';
import { page } from '$app/stores';
import { isAuthenticated, authLoading } from '$lib/stores/auth';
import { login } from '$lib/auth';
import { login, isLoggingOut } from '$lib/auth';
import DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte';
import MobileTopBar from '$lib/components/layout/MobileTopBar.svelte';
import BottomTabBar from '$lib/components/layout/BottomTabBar.svelte';
@@ -25,8 +25,11 @@
// When auth resolves as unauthenticated, redirect to Keycloak.
// This runs after onAuthStateChange fires (authLoading = false), so there
// is no race with Supabase's async localStorage initialisation.
// `isLoggingOut` suppresses re-login while logout() is mid-flight between
// signOut() and the Keycloak end-session redirect — otherwise we'd race
// the navigation and clobber the PKCE verifier.
$effect(() => {
if (!$authLoading && !$isAuthenticated) {
if (!$authLoading && !$isAuthenticated && !isLoggingOut) {
login();
}
});

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { login } from '$lib/auth';
import * as m from '$lib/paraglide/messages';
</script>
<div class="flex h-screen items-center justify-center bg-background">
<div class="max-w-sm px-6 text-center">
<h1 class="mb-2 text-[20px] font-semibold tracking-[-0.01em] text-slate-900 dark:text-slate-50">
{m.logged_out_title()}
</h1>
<p class="mb-6 text-sm text-text-secondary">{m.logged_out_subtitle()}</p>
<button
onclick={() => login()}
class="rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:opacity-90 dark:bg-slate-50 dark:text-slate-900"
>
{m.logged_out_sign_in()}
</button>
</div>
</div>