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 }; /** * 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; POST: (request: Request) => Promise; }; /** * 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; /** 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 { 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, }; }