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

@@ -1,16 +1,20 @@
-- pgTAP: auth.users role/aud defaulting (migration 013)
-- pgTAP: auth.users role/aud defaulting (migrations 013 + 014)
-- Run with: psql -U postgres -d postgres -f supabase/tests/008_auth_user_role_default.sql
--
-- Covers the bug found on ambrosio (2026-04-15): GoTrue v2.158.1 inserts
-- OIDC-created users into auth.users with empty-string `role` and `aud`,
-- which later makes PostgREST's `SET LOCAL role = ''` fail with
-- `role "" does not exist`. Migration 013 adds a BEFORE INSERT trigger +
-- backfills the existing rows.
-- Covers two bugs observed on ambrosio:
-- * 2026-04-15 — GoTrue v2.158.1 inserts OIDC-created users into auth.users
-- with empty-string `role` and `aud`, later making PostgREST's
-- `SET LOCAL role = ''` fail with `role "" does not exist`.
-- Migration 013 adds a BEFORE INSERT trigger + backfills.
-- * 2026-04-20 — GoTrue additionally emits an UPDATE right after the
-- insert (pop ORM resends the whole struct incl. role='') which clobbers
-- the role that 013's trigger just filled in. Migration 014 adds a
-- matching BEFORE UPDATE trigger.
CREATE EXTENSION IF NOT EXISTS pgtap;
BEGIN;
SELECT plan(7);
SELECT plan(11);
-- ── Existence assertions ────────────────────────────────────────────────
SELECT has_function(
@@ -23,6 +27,11 @@ SELECT has_trigger(
'BEFORE INSERT trigger ensure_role_default exists on auth.users'
);
SELECT has_trigger(
'auth', 'users', 'ensure_role_default_update',
'BEFORE UPDATE trigger ensure_role_default_update exists on auth.users'
);
-- ── Backfill assertion ──────────────────────────────────────────────────
SELECT ok(
(SELECT count(*) FROM auth.users WHERE role IS NULL OR role = '' OR aud IS NULL OR aud = '') = 0,
@@ -85,5 +94,33 @@ SELECT is(
'Explicit role is preserved by the trigger'
);
-- ── UPDATE trigger behavior (migration 014) ────────────────────────────
-- Simulate GoTrue's pop-ORM resending role='' on the follow-up UPDATE.
-- The BEFORE UPDATE trigger must rewrite it to 'authenticated'.
UPDATE auth.users
SET role = '', aud = ''
WHERE id = 'f0000000-0000-0000-0000-000000000001';
SELECT is(
(SELECT role FROM auth.users WHERE id = 'f0000000-0000-0000-0000-000000000001'),
'authenticated',
'UPDATE clearing role to empty is reverted to authenticated'
);
SELECT is(
(SELECT aud FROM auth.users WHERE id = 'f0000000-0000-0000-0000-000000000001'),
'authenticated',
'UPDATE clearing aud to empty is reverted to authenticated'
);
-- And a legitimate UPDATE to some other field (e.g. raw_user_meta_data)
-- does not change role/aud.
UPDATE auth.users
SET raw_user_meta_data = '{"locale":"es"}'::jsonb
WHERE id = 'f0000000-0000-0000-0000-000000000003';
SELECT is(
(SELECT role FROM auth.users WHERE id = 'f0000000-0000-0000-0000-000000000003'),
'service_role',
'Unrelated UPDATE leaves an explicit role untouched'
);
SELECT * FROM finish();
ROLLBACK;