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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@gsc/web-kit",
|
"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.",
|
"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",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
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 type { SessionUser } from "./types";
|
||||||
|
export { SessionExpirationGuard } from "./SessionExpirationGuard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client-side hard navigation to the sign-in endpoint. Use when a
|
* 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> {
|
async function requireAuth(): Promise<SessionUser> {
|
||||||
const session = await na.auth();
|
const session = await na.auth();
|
||||||
const user = (session as unknown as { user?: SessionUser } | null)?.user;
|
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);
|
redirect(signInPath);
|
||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
Reference in New Issue
Block a user