Skylab's opt-in NextAuth + Keycloak adapter for the inscribed CMS.
inscribed is auth- and vendor-neutral: out of the box it runs read-only/public.
This package is the Skylab layer that plugs admin auth and the build-time
service token into it, so a new Skylab Next.js app gets editing + sync by
installing one package and setting env vars — no hand-copied auth files.
It ships two seam adapters:
| Seam | What this package provides |
|---|---|
| Auth | NextAuth options (JWT access/refresh persistence, silent refresh, Keycloak client- and realm-role extraction), the withCmsAuth adapter, the NextAuthCmsProvider client wrapper, and a one-route auto-signin handler. |
| Service token | Keycloak client-credentials token for SSR content fetch + the cms-sync CLI. |
Transport is not provided — Skylab uses inscribed's default REST transport.
npm install @skylab-kulubu/inscribed-authPeer dependencies (you already have most in a Next app):
npm install inscribed next next-auth@^4 react react-domKeep the app CommonJS (do not set
"type": "module"in the app'spackage.json). next-auth v4's Keycloak provider only resolves through Webpack's CJS/ESM interop; an ESM app breaks it. The one exception iscms.config.mjsbelow, which is.mjson purpose.
| Import | Use from | Exports |
|---|---|---|
@skylab-kulubu/inscribed-auth |
Client ("use client") |
NextAuthCmsProvider |
@skylab-kulubu/inscribed-auth/server |
Server only | createCmsAuthOptions, withCmsAuth, isCmsAdmin, readCmsAuthMeta, getClientCredentialsToken, debugServiceTokenClaims |
@skylab-kulubu/inscribed-auth/signin |
Server route | GET, createSignInRoute |
@skylab-kulubu/inscribed-auth/config |
cms-sync CLI / build-time |
getServiceToken, onSyncError |
The Keycloak provider instance stays here, on your side — never let it be
bundled by a dependency, or it resolves to undefined at runtime.
import KeycloakProvider from "next-auth/providers/keycloak";
import { createCmsAuthOptions } from "@skylab-kulubu/inscribed-auth/server";
export const authOptions = createCmsAuthOptions({
provider: KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID ?? "",
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
issuer: process.env.KEYCLOAK_ISSUER ?? "",
}),
adminRole: "cms:access", // Keycloak client role gating admin access (default)
});By default createCmsAuthOptions also:
- sets
pages.signInto/api/signinso every sign-in redirect (including the silent re-auth after a token refresh fails) jumps straight into Keycloak instead of NextAuth's "Sign in with X" picker. Mount that route (next file) for it to work, or passsignInPage: falseto keep the built-in picker. - ends the Keycloak SSO session on sign-out (RP-initiated logout via
id_token_hint), so signing out of the app also signs the user out of Keycloak — otherwise the next visit silently re-authenticates against the still-live SSO session.
import NextAuth from "next-auth";
import { authOptions } from "../../../../lib/auth.js";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };/api/signin?callbackUrl=... jumps straight into the Keycloak flow, skipping
NextAuth's provider-picker page. createCmsAuthOptions wires this as the default
pages.signIn, so NextAuth sends unauthenticated users here automatically; you
can also link to it from anywhere (<a href="/api/signin">). Mount it — with
signInPage defaulting to /api/signin, sign-in 404s if this route is missing
(or set signInPage: false).
export { GET } from "@skylab-kulubu/inscribed-auth/signin";The page shows an animated Skylab loader and, if sign-in hasn't completed after
10 s (slow network, or a strict CSP that blocks the inline script), reveals a
"continue to sign in" link so the user is never stuck. Its theming auto-adapts
to light/dark (system canvas in light, a warm near-black #1c1815 in dark).
Being a standalone
document it can't read your app's CSS, so pass concrete values to match:
import { createSignInRoute } from "@skylab-kulubu/inscribed-auth/signin";
// background/color accept any CSS value (hex, rgb, gradient); the loader and
// text are drawn in `color`.
export const GET = createSignInRoute({ background: "#0b1020", color: "#e5e7eb" });import { revalidateCmsSlug } from "inscribed/actions";
import { createCmsPage } from "inscribed/page";
import { NextAuthCmsProvider } from "@skylab-kulubu/inscribed-auth";
import { withCmsAuth, getClientCredentialsToken } from "@skylab-kulubu/inscribed-auth/server";
import { authOptions } from "./auth.js";
export const CmsPage = createCmsPage({
Provider: NextAuthCmsProvider,
config: {
baseUrl: process.env.CMS_URL ?? "http://localhost:5000",
cdnUrl: process.env.CMS_CDN_URL,
},
// Service token for the public SSR content fetch (no session required).
getServiceToken: getClientCredentialsToken,
// Adapts NextAuth into inscribed's auth-agnostic callbacks
// (getSession / deriveAdmin / deriveUserSub).
...withCmsAuth(authOptions),
// Must come through inscribed's `actions` entry so its "use server"
// directive survives bundling.
onAfterSave: revalidateCmsSlug,
});The CLI is plain Node and import()s this file. One line:
export { getServiceToken, onSyncError } from "@skylab-kulubu/inscribed-auth/config";# Keycloak (same client serves login + client_credentials sync)
KEYCLOAK_CLIENT_ID=<your-client-id>
KEYCLOAK_CLIENT_SECRET=<your-client-secret>
KEYCLOAK_ISSUER=https://<keycloak-host>/realms/<realm>
# NextAuth
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=<run: openssl rand -base64 32>
# CMS backend
CMS_URL=https://<your-cms-host>
# Optional:
# CMS_CDN_URL=https://<your-cdn-host>| Var | Required | Purpose |
|---|---|---|
KEYCLOAK_CLIENT_ID |
✅ | Keycloak client (both grants) |
KEYCLOAK_CLIENT_SECRET |
✅ | Client secret |
KEYCLOAK_ISSUER |
✅ | Realm issuer URL |
NEXTAUTH_URL |
✅ | App base URL for NextAuth |
NEXTAUTH_SECRET |
✅ | NextAuth JWT/session secret |
CMS_URL |
✅ | inscribed backend base URL (→ config.baseUrl) |
CMS_CDN_URL |
— | Asset CDN base (→ config.cdnUrl) |
createCmsAuthOptions augments the NextAuth session with these fields (on top of
NextAuth's defaults):
const session = await getServerSession(authOptions); // server
// or useSession() on the client
session.accessToken; // string — the raw Keycloak access token
session.user.id; // string — Keycloak subject (`sub`)
session.user.clientRoles; // string[] — roles aggregated across every client
// in `resource_access` (includes `cms:access`)
session.user.realmRoles; // string[] — `realm_access.roles` (realm-wide roles)Both role arrays are extracted from the access token on sign-in and re-extracted
on every silent refresh, and default to [] (never undefined). Use
clientRoles for resource-server permissions like cms:access (see below) and
realmRoles for realm-wide roles. For admin gating specifically, prefer
isCmsAdmin(session, meta) / withCmsAuth over checking the arrays by hand.
Admin operations require the cms:access Keycloak client role, both for the
logged-in user (admin UI) and the service account (sync). The role belongs to
the inscribed backend's Keycloak client (the resource server, e.g.
skycms) — not the frontend login client (KEYCLOAK_CLIENT_ID). As long as the
backend client is mapped into the token audience, the role rides along under
resource_access["<backend-client>"]; the SDK reads roles from every client in
resource_access, so it doesn't matter that the role isn't keyed under the
frontend client / token azp.
Grant the backend client's cms:access role to each principal in Keycloak Admin:
- Admin users → Users → <user> → Role mapping → assign
cms:access - Service account (sync) → Clients →
KEYCLOAK_CLIENT_ID→ Service account roles → assigncms:access
If sync still returns 403, onSyncError dumps the service token's azp /
aud / resource_access claims and reports which client (if any) actually
holds cms:access.
MIT © Skylab Kulübü — see LICENSE.
Uses inscribed (LGPL-3.0-or-later) as
a peer dependency (not bundled); that license governs inscribed itself, not
this adapter.