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) <noreply@anthropic.com>
This commit is contained in:
50
src/auth/SessionExpirationGuard.tsx
Normal file
50
src/auth/SessionExpirationGuard.tsx
Normal file
@@ -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
|
||||
* `<SessionProvider>` (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<SessionUser> | 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -266,7 +266,11 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
|
||||
async function requireAuth(): Promise<SessionUser> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user