From 08a62d550c93ea06252df0a1490dbcb53360c6c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 12:25:50 +0200 Subject: [PATCH] feat(auth): refresh-token rotation, per-app role gate, custom error page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/auth/server.ts | 159 +++++++++++++++++++++++++++++++++++++++++---- src/auth/types.ts | 43 +++++++++++- 2 files changed, 187 insertions(+), 15 deletions(-) diff --git a/src/auth/server.ts b/src/auth/server.ts index 8a88aa8..18b1f6c 100644 --- a/src/auth/server.ts +++ b/src/auth/server.ts @@ -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 { + 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 diff --git a/src/auth/types.ts b/src/auth/types.ts index 14b6e70..e2601ec 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -7,8 +7,16 @@ export interface SessionUser { id: string; /** Same as `id`. Exposed separately so callers can name-distinguish. */ keycloakId: string; - /** Tenant the user is operating in. May fall back to defaultTenantId. */ + /** Tenant the user is operating in. From the Keycloak `gscTenantId` claim. */ tenantId: string; + /** Customer the user belongs to. From the Keycloak `gscCustomerId` claim. */ + customerId: string; + /** + * GSC composite security identifier (tenant + customer + user parts). + * From the Keycloak `gscSID` claim. Distinct from any Keycloak/OIDC + * session `sid`. + */ + gscSid: string; email: string; displayName: string; givenName: string; @@ -19,6 +27,13 @@ export interface SessionUser { accessToken: string; /** Keycloak ID token. Optional; only present if the IdP returns it. */ idToken?: string; + /** + * Set when the kit's silent refresh failed (refresh_token expired or + * revoked). Clients should treat this as a forced re-auth signal — + * any value here means the accessToken is stale and won't be refreshed + * by the kit on its own. + */ + error?: string; } /** @@ -30,12 +45,34 @@ export interface CreateAuthOptions { clientSecret: string; issuer: string; }; - /** Used when Keycloak claims don't carry a `tenant_id`. */ - defaultTenantId?: string; /** * Where `requireAuth()` / the middleware redirects unauthenticated * users. Default: `/api/auth/signin/keycloak` (NextAuth's auto-redirect * endpoint). Override if the app has a custom landing page. */ signInPath?: string; + + /** + * Optional gate: predicate that runs on initial sign-in against the + * Keycloak `realm_access.roles` array. Return `true` to allow the + * sign-in, `false` to deny — denial bounces the user to NextAuth's + * error route with `?error=AccessDenied` and no session is created. + * + * Use this to enforce "must be in _* group" semantics without + * relying solely on per-endpoint role gates in downstream services. + * Example for gscCRM: + * requireClientRole: (roles) => roles.some((r) => r.startsWith("gsccrm_")) + */ + requireClientRole?: (roles: string[]) => boolean; + + /** + * NextAuth page overrides. Set `pages.error` to a custom branded + * full-page message route when `requireClientRole` may deny sign-in, + * so users see something meaningful instead of NextAuth's default + * error page. The route should be public (skipped by auth middleware). + */ + pages?: { + error?: string; + signIn?: string; + }; }