- 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>
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
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 };
|
|
|
|
/**
|
|
* Shape of the GET/POST route handlers NextAuth returns. Declared
|
|
* structurally (Request → Response) rather than via
|
|
* `NextAuthResult["handlers"]` — that type embeds `NextRequest` from
|
|
* the kit's own copy of `next`, which conflicts with the consumer's
|
|
* `next` and produces a spurious `RouteHandlerConfig` mismatch in
|
|
* `.next/types/validator.ts` for every app's `[...nextauth]/route.ts`.
|
|
*
|
|
* Web-standard `Request`/`Response` are valid for Next.js route
|
|
* handlers (NextRequest extends Request), and TS function-parameter
|
|
* contravariance makes this assignable wherever the validator wants
|
|
* `(request: NextRequest, ctx) => ...`.
|
|
*/
|
|
export type AuthRouteHandlers = {
|
|
GET: (request: Request) => Promise<Response>;
|
|
POST: (request: Request) => Promise<Response>;
|
|
};
|
|
|
|
/**
|
|
* Result of `createAuth()`. Mirrors NextAuth's return value plus
|
|
* `requireAuth` and the resolved `signInPath` so consumers don't have to
|
|
* thread these around.
|
|
*/
|
|
export interface AuthBundle {
|
|
handlers: AuthRouteHandlers;
|
|
signIn: NextAuthResult["signIn"];
|
|
signOut: NextAuthResult["signOut"];
|
|
auth: NextAuthResult["auth"];
|
|
|
|
/**
|
|
* Server-only. Returns the current session user or redirects to the
|
|
* sign-in path. Use as the first line of an RSC layout/page when the
|
|
* route should be authenticated.
|
|
*/
|
|
requireAuth: () => Promise<SessionUser>;
|
|
|
|
/** Resolved sign-in URL; useful for client-side redirect helpers. */
|
|
signInPath: string;
|
|
}
|
|
|
|
/**
|
|
* One-line factory that wires NextAuth v5 to Keycloak with the canonical
|
|
* GSC session shape.
|
|
*
|
|
* @example
|
|
* // src/auth.ts
|
|
* import { createAuth } from "@gsc/web-kit/auth/server";
|
|
* export const { handlers, signIn, signOut, auth, requireAuth } =
|
|
* createAuth({
|
|
* keycloak: {
|
|
* clientId: process.env.AUTH_KEYCLOAK_ID!,
|
|
* clientSecret: process.env.AUTH_KEYCLOAK_SECRET!,
|
|
* issuer: process.env.AUTH_KEYCLOAK_ISSUER!,
|
|
* },
|
|
* });
|
|
*/
|
|
/**
|
|
* 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
|
|
// bounces to /api/auth/error?error=Configuration ("UnknownAction").
|
|
// /api/auth/signin (no provider) is the GET-accessible page that
|
|
// 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 na = NextAuth({
|
|
providers: [
|
|
Keycloak({
|
|
clientId: opts.keycloak.clientId,
|
|
clientSecret: opts.keycloak.clientSecret,
|
|
issuer: opts.keycloak.issuer,
|
|
}),
|
|
],
|
|
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 }) {
|
|
// 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 = 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 ?? "";
|
|
// 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;
|
|
}
|
|
|
|
// 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,
|
|
customerId: token.customerId as string,
|
|
gscSid: token.gscSid as string,
|
|
email: session.user?.email ?? "",
|
|
displayName: (token.displayName as string) ?? "",
|
|
givenName: (token.givenName as string) ?? "",
|
|
familyName: (token.familyName as string) ?? "",
|
|
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
|
|
// while preserving the runtime payload.
|
|
(session as unknown as { user: SessionUser }).user = user;
|
|
return session;
|
|
},
|
|
},
|
|
});
|
|
|
|
async function requireAuth(): Promise<SessionUser> {
|
|
const session = await na.auth();
|
|
const user = (session as unknown as { user?: SessionUser } | null)?.user;
|
|
if (!user || !user.accessToken) {
|
|
redirect(signInPath);
|
|
}
|
|
return user;
|
|
}
|
|
|
|
return {
|
|
// Cast through unknown: NextAuth's handler param type references its
|
|
// own NextRequest; the AuthRouteHandlers shape is structurally
|
|
// compatible (NextRequest extends Request), but TS can't prove the
|
|
// function-parameter contravariance across the two `next` copies on
|
|
// its own.
|
|
handlers: na.handlers as unknown as AuthRouteHandlers,
|
|
signIn: na.signIn,
|
|
signOut: na.signOut,
|
|
auth: na.auth,
|
|
requireAuth,
|
|
signInPath,
|
|
};
|
|
}
|