From 85a31eb3d60cd6f7ba3f03c424ca39c3e826f9b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 19:44:29 +0200 Subject: [PATCH] feat(auth): force re-auth on failed refresh requireAuth() now redirects on user.error so SSR catches stale sessions that the cookie-presence middleware can't see. New SessionExpirationGuard (client) listens on useSession() and calls signIn("keycloak") when the JWT carries RefreshAccessTokenError or RefreshTokenMissing. Without it, a tab idle past the Keycloak SSO lifetime sat on a dead accessToken until a downstream API 401'd, with no UI-level redirect. Bumps to 0.4.1. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/auth/SessionExpirationGuard.tsx | 50 +++++++++++++++++++++++++++++ src/auth/index.ts | 1 + src/auth/server.ts | 6 +++- 4 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/auth/SessionExpirationGuard.tsx diff --git a/package.json b/package.json index 3ef8bdc..37bf0c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gsc/web-kit", - "version": "0.4.0", + "version": "0.4.1", "description": "GSC web app skeleton — layout, auth, data, forms, feedback, navigation, chrome. Built on @limitless/ui. Drop into a Next.js app and just write pages.", "license": "MIT", "type": "module", diff --git a/src/auth/SessionExpirationGuard.tsx b/src/auth/SessionExpirationGuard.tsx new file mode 100644 index 0000000..9e7544a --- /dev/null +++ b/src/auth/SessionExpirationGuard.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useSession, signIn } from "next-auth/react"; + +import type { SessionUser } from "./types"; + +/** + * Drop-in client component that re-triggers Keycloak sign-in when the + * kit's server-side refresh flow gives up (`session.user.error` set to + * `RefreshAccessTokenError` or `RefreshTokenMissing`). + * + * Without this, a UI tab left open past the Keycloak SSO session lifetime + * holds a stale `accessToken`: the middleware sees a cookie, `requireAuth` + * sees an `accessToken` string, and the user only finds out things are + * broken when a downstream API returns 401. Mount this inside a + * `` (typically alongside other providers in the locale + * root) so the session refetch interval surfaces the error within minutes. + * + * Renders nothing. + */ +export function SessionExpirationGuard() { + const { data: session } = useSession(); + const error = (session?.user as Partial | undefined)?.error; + // Guard against React's StrictMode double-invoke + the brief window + // between calling signIn and the page navigating away. + const firedRef = useRef(false); + + useEffect(() => { + if (!error || firedRef.current) return; + if ( + error !== "RefreshAccessTokenError" && + error !== "RefreshTokenMissing" + ) { + return; + } + firedRef.current = true; + const callbackUrl = + typeof window !== "undefined" + ? window.location.pathname + window.location.search + : "/"; + // signIn() POSTs the CSRF-protected provider endpoint, which then + // initiates a fresh OIDC flow; the stale session cookie is replaced + // atomically when the flow completes. Keycloak's own SSO cookie may + // still log the user in transparently if it hasn't also expired. + void signIn("keycloak", { callbackUrl }); + }, [error]); + + return null; +} diff --git a/src/auth/index.ts b/src/auth/index.ts index b2b7ee7..011bc34 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -7,6 +7,7 @@ */ export type { SessionUser } from "./types"; +export { SessionExpirationGuard } from "./SessionExpirationGuard"; /** * Client-side hard navigation to the sign-in endpoint. Use when a diff --git a/src/auth/server.ts b/src/auth/server.ts index 18b1f6c..9cfb6b6 100644 --- a/src/auth/server.ts +++ b/src/auth/server.ts @@ -266,7 +266,11 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle { async function requireAuth(): Promise { const session = await na.auth(); const user = (session as unknown as { user?: SessionUser } | null)?.user; - if (!user || !user.accessToken) { + // `user.error` is set when the kit's silent refresh failed (refresh + // token expired/revoked or Keycloak rejected the exchange). The + // session cookie is still present, so the middleware won't catch + // this — bouncing here is what forces a fresh sign-in flow. + if (!user || !user.accessToken || user.error) { redirect(signInPath); } return user;