feat(auth): refresh-token rotation, per-app role gate, custom error page

- jwt callback stores refresh_token + expires_at on initial sign-in and
  exchanges via Keycloak's token endpoint at expiresAt-30s. On refresh
  failure, marks session.user.error so consumers can force re-auth.
- New `requireClientRole(roles): boolean` option runs in the signIn
  callback against the access_token's realm_access.roles; false bounces
  the user to the configured error page with ?error=AccessDenied.
- New `pages` passthrough so consumers can route AccessDenied to a
  branded full-page message instead of NextAuth's default error UI.
- Realm roles are read by decoding the access_token, NOT from the OIDC
  `profile` (the userinfo endpoint never carries realm_access). Earlier
  code that read profile.realm_access.roles produced empty role arrays
  in both jwt and signIn — both callsites fixed via a shared
  rolesFromAccessToken helper.

Consumers: gscCRM v2.6.11..v2.6.19 (CRM enforces gsccrm_* groups via
requireClientRole + a /access-denied public route).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-13 12:25:50 +02:00
parent 960dfeba7c
commit 08a62d550c
2 changed files with 187 additions and 15 deletions

View File

@@ -1,9 +1,75 @@
import NextAuth, { type NextAuthResult } from "next-auth";
import Keycloak from "next-auth/providers/keycloak";
import type { JWT } from "next-auth/jwt";
import { redirect } from "next/navigation";
import type { CreateAuthOptions, SessionUser } from "./types";
/**
* Refresh the Keycloak access token using the stored refresh_token. Returns
* an updated JWT with new accessToken/expiresAt/refreshToken/idToken, or
* the original JWT marked with `error: "RefreshAccessTokenError"` if the
* refresh failed (caller surfaces this through the session so the client
* can re-trigger sign-in).
*
* Pre-refresh skew is handled by the caller; this function unconditionally
* exchanges the refresh_token.
*/
async function refreshAccessToken(
token: JWT,
keycloak: CreateAuthOptions["keycloak"],
): Promise<JWT> {
const refreshToken = (token as { refreshToken?: string }).refreshToken;
if (!refreshToken) {
return { ...token, error: "RefreshTokenMissing" };
}
try {
const res = await fetch(
`${keycloak.issuer}/protocol/openid-connect/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: keycloak.clientId,
client_secret: keycloak.clientSecret,
refresh_token: refreshToken,
}),
},
);
const body = (await res.json()) as {
access_token?: string;
expires_in?: number;
refresh_token?: string;
id_token?: string;
error?: string;
error_description?: string;
};
if (!res.ok || !body.access_token) {
console.error(
"@gsc/web-kit: refresh_token exchange failed",
res.status,
body,
);
return { ...token, error: "RefreshAccessTokenError" };
}
return {
...token,
accessToken: body.access_token,
expiresAt:
Math.floor(Date.now() / 1000) + (body.expires_in ?? 300),
// Keycloak rotates refresh tokens by default; fall back to the
// existing one when rotation is disabled at the realm.
refreshToken: body.refresh_token ?? refreshToken,
idToken: body.id_token ?? token.idToken,
error: undefined,
} as JWT;
} catch (err) {
console.error("@gsc/web-kit: refresh_token exchange threw", err);
return { ...token, error: "RefreshAccessTokenError" };
}
}
export type { SessionUser, CreateAuthOptions };
/**
@@ -62,6 +128,35 @@ export interface AuthBundle {
* },
* });
*/
/**
* Decode the Keycloak access_token (JWS) and return its `realm_access.roles`.
* Realm roles are only present in the access_token, never in the OIDC
* userinfo `profile` — so callbacks that need to gate on roles must reach
* into `account.access_token`, not `profile`. We don't verify the signature
* here: NextAuth has already verified the ID token via the OIDC flow, the
* access_token came back over the same TLS exchange, and the only use of
* the decoded roles is local sign-in policy.
*/
function rolesFromAccessToken(accessToken: string | undefined): string[] {
if (!accessToken) return [];
const parts = accessToken.split(".");
if (parts.length < 2) return [];
try {
const payload = parts[1];
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
const decoded = Buffer.from(
padded.replace(/-/g, "+").replace(/_/g, "/"),
"base64",
).toString("utf-8");
const claims = JSON.parse(decoded) as {
realm_access?: { roles?: string[] };
};
return claims.realm_access?.roles ?? [];
} catch {
return [];
}
}
export function createAuth(opts: CreateAuthOptions): AuthBundle {
// NextAuth v5: provider-specific paths like /api/auth/signin/keycloak
// are POST-only (CSRF-protected form submit). A GET redirect there
@@ -70,8 +165,6 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
// lists configured providers. Apps wanting one-click Keycloak can
// override signInPath with a custom page that calls signIn('keycloak').
const signInPath = opts.signInPath ?? "/api/auth/signin";
const defaultTenantId =
opts.defaultTenantId ?? "00000000-0000-0000-0000-000000000000";
const na = NextAuth({
providers: [
@@ -82,35 +175,76 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
}),
],
trustHost: true,
pages: opts.pages,
callbacks: {
async signIn({ account }) {
// Per-client access gate. The kit's consumers pass a predicate
// (e.g. "must be in gsccrm_* group") via `requireClientRole`; we
// run it against the access_token's `realm_access.roles`. Realm
// roles are NOT present in the OIDC userinfo `profile`, only in
// the access_token — so we decode it. Return false → NextAuth
// refuses to mint a session and bounces the user to the configured
// error page with `?error=AccessDenied`.
if (!opts.requireClientRole) return true;
const roles = rolesFromAccessToken(account?.access_token);
return opts.requireClientRole(roles);
},
async jwt({ token, account, profile }) {
// `account` + `profile` are only set on the initial sign-in.
// On subsequent calls the token is read from the encrypted JWT
// cookie, so the fields we set here persist for the session.
// Initial sign-in: capture identity claims + refresh material.
if (account && profile) {
// Realm convention: GSC identity ships as three claims sourced
// from FreeIPA user attributes via the gosecCloud realm's LDAP
// federation + per-client OIDC protocol mappers.
const p = profile as {
gscTenantId?: string;
gscCustomerId?: string;
gscSID?: string;
};
if (!p.gscTenantId || !p.gscCustomerId || !p.gscSID) {
throw new Error(
`Keycloak token missing required GSC identity claims (gscTenantId=${!!p.gscTenantId}, gscCustomerId=${!!p.gscCustomerId}, gscSID=${!!p.gscSID})`,
);
}
token.keycloakId = profile.sub;
token.tenantId =
(profile as { tenant_id?: string }).tenant_id ?? defaultTenantId;
token.tenantId = p.gscTenantId;
token.customerId = p.gscCustomerId;
token.gscSid = p.gscSID;
token.displayName =
profile.name ??
(profile as { preferred_username?: string }).preferred_username ??
"";
token.givenName = profile.given_name ?? "";
token.familyName = profile.family_name ?? "";
token.roles =
(profile as { realm_access?: { roles?: string[] } }).realm_access
?.roles ?? [];
// Realm roles are in the access_token, not the OIDC profile.
token.roles = rolesFromAccessToken(account.access_token);
token.accessToken = account.access_token;
token.idToken = account.id_token;
token.refreshToken = account.refresh_token;
// `expires_at` is Unix seconds (NextAuth's normalized form);
// fall back to `expires_in` (relative) if the provider omitted it.
token.expiresAt =
(account.expires_at as number | undefined) ??
Math.floor(Date.now() / 1000) +
((account.expires_in as number | undefined) ?? 300);
return token;
}
return token;
// Subsequent calls: refresh if the access token is about to expire.
// Skew of 30s avoids handing out a token that will time out mid-flight.
const expiresAt = (token as { expiresAt?: number }).expiresAt ?? 0;
if (Math.floor(Date.now() / 1000) < expiresAt - 30) {
return token;
}
return refreshAccessToken(token, opts.keycloak);
},
async session({ session, token }) {
if (!token) return session;
const user: SessionUser = {
id: token.sub as string,
keycloakId: (token.keycloakId as string) ?? (token.sub as string),
tenantId: (token.tenantId as string) ?? defaultTenantId,
tenantId: token.tenantId as string,
customerId: token.customerId as string,
gscSid: token.gscSid as string,
email: session.user?.email ?? "",
displayName: (token.displayName as string) ?? "",
givenName: (token.givenName as string) ?? "",
@@ -118,6 +252,7 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
roles: (token.roles as string[]) ?? [],
accessToken: (token.accessToken as string) ?? "",
idToken: token.idToken as string | undefined,
error: (token as { error?: string }).error,
};
// NextAuth's default `session.user` type is narrow; we replace
// it wholesale with our canonical shape. The cast keeps TS quiet