@skylab-kulubu/inscribed-auth 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ It ships **two seam adapters**:
11
11
 
12
12
  | Seam | What this package provides |
13
13
  | --- | --- |
14
- | **Auth** | NextAuth options (JWT access/refresh persistence, silent refresh, Keycloak client-role extraction), the `withCmsAuth` adapter, the `NextAuthCmsProvider` client wrapper, and a one-route auto-signin handler. |
14
+ | **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. |
15
15
  | **Service token** | Keycloak client-credentials token for SSR content fetch + the `cms-sync` CLI. |
16
16
 
17
17
  Transport is **not** provided — Skylab uses `inscribed`'s default REST transport.
@@ -175,6 +175,28 @@ CMS_URL=https://<your-cms-host>
175
175
  | `CMS_URL` | ✅ | inscribed backend base URL (→ `config.baseUrl`) |
176
176
  | `CMS_CDN_URL` | — | Asset CDN base (→ `config.cdnUrl`) |
177
177
 
178
+ ## Session shape
179
+
180
+ `createCmsAuthOptions` augments the NextAuth session with these fields (on top of
181
+ NextAuth's defaults):
182
+
183
+ ```js
184
+ const session = await getServerSession(authOptions); // server
185
+ // or useSession() on the client
186
+
187
+ session.accessToken; // string — the raw Keycloak access token
188
+ session.user.id; // string — Keycloak subject (`sub`)
189
+ session.user.clientRoles; // string[] — roles aggregated across every client
190
+ // in `resource_access` (includes `cms:access`)
191
+ session.user.realmRoles; // string[] — `realm_access.roles` (realm-wide roles)
192
+ ```
193
+
194
+ Both role arrays are extracted from the access token on sign-in and re-extracted
195
+ on every silent refresh, and default to `[]` (never `undefined`). Use
196
+ `clientRoles` for resource-server permissions like `cms:access` (see below) and
197
+ `realmRoles` for realm-wide roles. For admin gating specifically, prefer
198
+ `isCmsAdmin(session, meta)` / `withCmsAuth` over checking the arrays by hand.
199
+
178
200
  ## Admin access
179
201
 
180
202
  Admin operations require the `cms:access` Keycloak **client role**, both for the
package/dist/config.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { g as getClientCredentialsToken, d as debugServiceTokenClaims } from './service-token-CHJDdnHv.js';
1
+ import { g as getClientCredentialsToken, d as debugServiceTokenClaims } from './service-token-BANrKR5z.js';
2
2
 
3
3
  /**
4
4
  * @file `@skylab-kulubu/inscribed-auth/config` — `cms-sync` CLI wiring.
@@ -19,7 +19,7 @@ import { g as getClientCredentialsToken, d as debugServiceTokenClaims } from './
19
19
  /** Service token for build-time `POST /cms/sync`. */
20
20
  const getServiceToken = getClientCredentialsToken;
21
21
 
22
- /** Called when a sync fails - dumps Keycloak claims to explain 403s. */
23
- const onSyncError = () => debugServiceTokenClaims();
22
+ /** Called when a sync fails - probes the backend + dumps Keycloak claims. */
23
+ const onSyncError = (err) => debugServiceTokenClaims(err);
24
24
 
25
25
  export { getServiceToken, onSyncError };
package/dist/config.js CHANGED
@@ -25,9 +25,12 @@ async function getClientCredentialsToken() {
25
25
  cache = { token: access_token, expiresAt: Date.now() + expires_in * 1e3 };
26
26
  return access_token;
27
27
  }
28
- async function debugServiceTokenClaims() {
28
+ async function debugServiceTokenClaims(err) {
29
29
  const { KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER } = process.env;
30
30
  if (!KEYCLOAK_CLIENT_ID || !KEYCLOAK_CLIENT_SECRET || !KEYCLOAK_ISSUER) return;
31
+ if (err) {
32
+ console.error(`[cms-sync:debug] sync failed: ${err instanceof Error ? err.message : String(err)}`);
33
+ }
31
34
  const res = await fetch(`${KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
32
35
  method: "POST",
33
36
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -44,29 +47,47 @@ async function debugServiceTokenClaims() {
44
47
  const { access_token } = await res.json();
45
48
  const [, payload] = access_token.split(".");
46
49
  const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
50
+ const cmsUrl = process.env.CMS_URL;
51
+ if (!cmsUrl) {
52
+ console.error(`[cms-sync:debug] CMS_URL is not set - skipping the backend probe.`);
53
+ } else {
54
+ const baseUrl = cmsUrl.replace(/\/+$/, "");
55
+ try {
56
+ const probe = await fetch(`${baseUrl}/cms/sync`, {
57
+ method: "POST",
58
+ headers: {
59
+ "Content-Type": "application/json",
60
+ Authorization: `Bearer ${access_token}`
61
+ },
62
+ body: JSON.stringify({ __inscribedAuthProbe: true })
63
+ });
64
+ const body = (await probe.text()).slice(0, 500);
65
+ console.error(`[cms-sync:debug] POST ${baseUrl}/cms/sync -> ${probe.status} ${probe.statusText}`);
66
+ if (body) console.error(` backend body: ${body}`);
67
+ if (probe.status === 401 || probe.status === 403) {
68
+ console.error(` -> auth rejected by the backend; cross-check the token claims below.`);
69
+ } else {
70
+ console.error(` -> token accepted (this status is validation, not auth) - a real 403 would be data/policy-specific.`);
71
+ }
72
+ } catch (e) {
73
+ console.error(`[cms-sync:debug] probe to ${baseUrl}/cms/sync failed: ${e instanceof Error ? e.message : String(e)}`);
74
+ }
75
+ }
47
76
  console.error("[cms-sync:debug] Service token claims:");
48
77
  console.error(` azp: ${claims.azp}`);
49
- console.error(` sub: ${claims.sub}`);
50
78
  console.error(` aud: ${JSON.stringify(claims.aud)}`);
51
- console.error(` scope: ${claims.scope}`);
52
79
  console.error(` resource_access: ${JSON.stringify(claims.resource_access)}`);
53
80
  const ra = claims.resource_access ?? {};
54
81
  const holder = Object.keys(ra).find((c) => ra[c]?.roles?.includes("cms:access"));
55
- if (holder) {
56
- console.error(` -> "cms:access" found under resource_access["${holder}"].`);
57
- if (holder !== claims.azp) {
58
- console.error(` (owned by backend client "${holder}", not azp "${claims.azp}" - expected)`);
59
- }
60
- } else {
61
- console.error(` ! "cms:access" role missing from every client in resource_access.`);
62
- console.error(` Assign the backend client's "cms:access" role to this service account:`);
63
- console.error(` Keycloak Admin -> Clients -> ${claims.azp} -> Service account roles -> Assign "cms:access".`);
82
+ if (!holder) {
83
+ console.error(` ! "cms:access" missing from every client in resource_access - assign it to the`);
84
+ console.error(` service account: Keycloak -> Clients -> ${claims.azp} -> Service account roles.`);
64
85
  }
65
86
  }
66
87
 
67
88
  // src/config.mjs
68
89
  var getServiceToken = getClientCredentialsToken;
69
- var onSyncError = () => debugServiceTokenClaims();
90
+ var onSyncError = (err) => debugServiceTokenClaims(err);
70
91
  export {
71
92
  getServiceToken,
72
93
  onSyncError
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/lib/service-token.mjs","../src/config.mjs"],"sourcesContent":["/**\n * @file Keycloak client-credentials service-token provider.\n *\n * Part of `@skylab-kulubu/inscribed-auth`. The inscribed core is backend-neutral:\n * it only knows the `ServiceTokenProvider` contract (`() => Promise<string>`)\n * and defaults to \"no token\". This module implements that contract against\n * Keycloak and is wired into the SDK via `createCmsPage({ getServiceToken })`\n * (consumer `lib/cms.jsx`) and the `cms-sync` CLI (consumer `cms.config.mjs`,\n * which re-exports from `@skylab-kulubu/inscribed-auth/config`).\n *\n * `.mjs` so the plain-Node `cms-sync` CLI can `import()` the resolved `./config`\n * entry as ESM regardless of the consuming app's `\"type\"`. (Skylab apps stay\n * CommonJS so next-auth v4's Keycloak provider resolves through Webpack's CJS\n * interop — see the provider-injection note in `options.js`.)\n *\n * Server / build-time only. Reads KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET,\n * KEYCLOAK_ISSUER. Returns \"\" when those vars are absent so reads degrade to\n * unauthenticated. In-process cache shared across requests, re-fetched 30s\n * before expiry.\n */\n\n/** @type {{ token: string; expiresAt: number } | null} */\nlet cache = null;\n\n/**\n * Implements the SDK's `ServiceTokenProvider` contract: `() => Promise<string>`.\n * @returns {Promise<string>}\n */\nexport async function getClientCredentialsToken() {\n const clientId = process.env.KEYCLOAK_CLIENT_ID;\n const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;\n const issuer = process.env.KEYCLOAK_ISSUER;\n\n if (!clientId || !clientSecret || !issuer) return \"\";\n\n if (cache && cache.expiresAt > Date.now() + 30_000) return cache.token;\n\n const res = await fetch(`${issuer}/protocol/openid-connect/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"client_credentials\",\n client_id: clientId,\n client_secret: clientSecret,\n }),\n cache: \"no-store\",\n });\n\n if (!res.ok) {\n throw new Error(\n `[inscribed-auth] Keycloak token request failed: ${res.status} ${await res.text()}`,\n );\n }\n\n const { access_token, expires_in } = await res.json();\n cache = { token: access_token, expiresAt: Date.now() + expires_in * 1000 };\n return access_token;\n}\n\n/**\n * Drop the cached service token so the next call re-fetches. Intentionally NOT\n * part of the public `./server` surface — kept here for internal use and for\n * anyone who deep-imports the raw module (e.g. token rotation in tests).\n */\nexport function invalidateClientCredentialsToken() {\n cache = null;\n}\n\n/**\n * On a sync failure, fetch the service token directly and dump the claims the\n * backend's `CmsAccessPolicy` checks (`azp`, `aud`, `resource_access`). Most\n * 403s come from the service account missing the `cms:access` role mapping in\n * Keycloak - this prints exactly what's there. Wired as `onSyncError` in the\n * `./config` entry.\n */\nexport async function debugServiceTokenClaims() {\n const { KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER } = process.env;\n if (!KEYCLOAK_CLIENT_ID || !KEYCLOAK_CLIENT_SECRET || !KEYCLOAK_ISSUER) return;\n\n const res = await fetch(`${KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"client_credentials\",\n client_id: KEYCLOAK_CLIENT_ID,\n client_secret: KEYCLOAK_CLIENT_SECRET,\n }),\n });\n if (!res.ok) {\n console.error(`[cms-sync:debug] Token fetch failed: ${res.status} ${await res.text()}`);\n return;\n }\n\n const { access_token } = await res.json();\n const [, payload] = access_token.split(\".\");\n const claims = JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\"));\n\n console.error(\"[cms-sync:debug] Service token claims:\");\n console.error(` azp: ${claims.azp}`);\n console.error(` sub: ${claims.sub}`);\n console.error(` aud: ${JSON.stringify(claims.aud)}`);\n console.error(` scope: ${claims.scope}`);\n console.error(` resource_access: ${JSON.stringify(claims.resource_access)}`);\n\n // `cms:access` is a *client role* of the inscribed backend's Keycloak client\n // (e.g. \"skycms\"), assigned to this service account. It therefore lands under\n // resource_access[<backend-client>], NOT under azp (this frontend client).\n // Scan every client so we report where the role actually is.\n const ra = claims.resource_access ?? {};\n const holder = Object.keys(ra).find((c) => ra[c]?.roles?.includes(\"cms:access\"));\n if (holder) {\n console.error(` -> \"cms:access\" found under resource_access[\"${holder}\"].`);\n if (holder !== claims.azp) {\n console.error(` (owned by backend client \"${holder}\", not azp \"${claims.azp}\" - expected)`);\n }\n } else {\n console.error(` ! \"cms:access\" role missing from every client in resource_access.`);\n console.error(` Assign the backend client's \"cms:access\" role to this service account:`);\n console.error(` Keycloak Admin -> Clients -> ${claims.azp} -> Service account roles -> Assign \"cms:access\".`);\n }\n}\n","/**\n * @file `@skylab-kulubu/inscribed-auth/config` — `cms-sync` CLI wiring.\n *\n * The `cms-sync` CLI is a plain Node binary - it can't receive function props\n * the way the React tree does - so it loads the consumer's `cms.config.{js,mjs}`\n * from the project root to learn how to obtain a service token (and, optionally,\n * how to diagnose failures). This entry lets that config file be a one-liner:\n *\n * // cms.config.mjs (consumer project root)\n * export { getServiceToken, onSyncError } from \"@skylab-kulubu/inscribed-auth/config\";\n *\n * Resolves to ESM so the CLI's dynamic `import()` works regardless of the\n * consuming app's `\"type\"`.\n */\n\nimport {\n getClientCredentialsToken,\n debugServiceTokenClaims,\n} from \"./lib/service-token.mjs\";\n\n/** Service token for build-time `POST /cms/sync`. */\nexport const getServiceToken = getClientCredentialsToken;\n\n/** Called when a sync fails - dumps Keycloak claims to explain 403s. */\nexport const onSyncError = () => debugServiceTokenClaims();\n"],"mappings":";AAsBA,IAAI,QAAQ;AAMZ,eAAsB,4BAA4B;AAChD,QAAM,WAAW,QAAQ,IAAI;AAC7B,QAAM,eAAe,QAAQ,IAAI;AACjC,QAAM,SAAS,QAAQ,IAAI;AAE3B,MAAI,CAAC,YAAY,CAAC,gBAAgB,CAAC,OAAQ,QAAO;AAElD,MAAI,SAAS,MAAM,YAAY,KAAK,IAAI,IAAI,IAAQ,QAAO,MAAM;AAEjE,QAAM,MAAM,MAAM,MAAM,GAAG,MAAM,kCAAkC;AAAA,IACjE,QAAQ;AAAA,IACR,SAAS,EAAE,gBAAgB,oCAAoC;AAAA,IAC/D,MAAM,IAAI,gBAAgB;AAAA,MACxB,YAAY;AAAA,MACZ,WAAW;AAAA,MACX,eAAe;AAAA,IACjB,CAAC;AAAA,IACD,OAAO;AAAA,EACT,CAAC;AAED,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,IAAI;AAAA,MACR,mDAAmD,IAAI,MAAM,IAAI,MAAM,IAAI,KAAK,CAAC;AAAA,IACnF;AAAA,EACF;AAEA,QAAM,EAAE,cAAc,WAAW,IAAI,MAAM,IAAI,KAAK;AACpD,UAAQ,EAAE,OAAO,cAAc,WAAW,KAAK,IAAI,IAAI,aAAa,IAAK;AACzE,SAAO;AACT;AAkBA,eAAsB,0BAA0B;AAC9C,QAAM,EAAE,oBAAoB,wBAAwB,gBAAgB,IAAI,QAAQ;AAChF,MAAI,CAAC,sBAAsB,CAAC,0BAA0B,CAAC,gBAAiB;AAExE,QAAM,MAAM,MAAM,MAAM,GAAG,eAAe,kCAAkC;AAAA,IAC1E,QAAQ;AAAA,IACR,SAAS,EAAE,gBAAgB,oCAAoC;AAAA,IAC/D,MAAM,IAAI,gBAAgB;AAAA,MACxB,YAAY;AAAA,MACZ,WAAW;AAAA,MACX,eAAe;AAAA,IACjB,CAAC;AAAA,EACH,CAAC;AACD,MAAI,CAAC,IAAI,IAAI;AACX,YAAQ,MAAM,wCAAwC,IAAI,MAAM,IAAI,MAAM,IAAI,KAAK,CAAC,EAAE;AACtF;AAAA,EACF;AAEA,QAAM,EAAE,aAAa,IAAI,MAAM,IAAI,KAAK;AACxC,QAAM,CAAC,EAAE,OAAO,IAAI,aAAa,MAAM,GAAG;AAC1C,QAAM,SAAS,KAAK,MAAM,OAAO,KAAK,SAAS,WAAW,EAAE,SAAS,MAAM,CAAC;AAE5E,UAAQ,MAAM,wCAAwC;AACtD,UAAQ,MAAM,sBAAsB,OAAO,GAAG,EAAE;AAChD,UAAQ,MAAM,sBAAsB,OAAO,GAAG,EAAE;AAChD,UAAQ,MAAM,sBAAsB,KAAK,UAAU,OAAO,GAAG,CAAC,EAAE;AAChE,UAAQ,MAAM,sBAAsB,OAAO,KAAK,EAAE;AAClD,UAAQ,MAAM,sBAAsB,KAAK,UAAU,OAAO,eAAe,CAAC,EAAE;AAM5E,QAAM,KAAK,OAAO,mBAAmB,CAAC;AACtC,QAAM,SAAS,OAAO,KAAK,EAAE,EAAE,KAAK,CAAC,MAAM,GAAG,CAAC,GAAG,OAAO,SAAS,YAAY,CAAC;AAC/E,MAAI,QAAQ;AACV,YAAQ,MAAM,kDAAkD,MAAM,KAAK;AAC3E,QAAI,WAAW,OAAO,KAAK;AACzB,cAAQ,MAAM,kCAAkC,MAAM,eAAe,OAAO,GAAG,eAAe;AAAA,IAChG;AAAA,EACF,OAAO;AACL,YAAQ,MAAM,qEAAqE;AACnF,YAAQ,MAAM,6EAA6E;AAC3F,YAAQ,MAAM,qCAAqC,OAAO,GAAG,mDAAmD;AAAA,EAClH;AACF;;;ACnGO,IAAM,kBAAkB;AAGxB,IAAM,cAAc,MAAM,wBAAwB;","names":[]}
1
+ {"version":3,"sources":["../src/lib/service-token.mjs","../src/config.mjs"],"sourcesContent":["/**\n * @file Keycloak client-credentials service-token provider.\n *\n * Part of `@skylab-kulubu/inscribed-auth`. The inscribed core is backend-neutral:\n * it only knows the `ServiceTokenProvider` contract (`() => Promise<string>`)\n * and defaults to \"no token\". This module implements that contract against\n * Keycloak and is wired into the SDK via `createCmsPage({ getServiceToken })`\n * (consumer `lib/cms.jsx`) and the `cms-sync` CLI (consumer `cms.config.mjs`,\n * which re-exports from `@skylab-kulubu/inscribed-auth/config`).\n *\n * `.mjs` so the plain-Node `cms-sync` CLI can `import()` the resolved `./config`\n * entry as ESM regardless of the consuming app's `\"type\"`. (Skylab apps stay\n * CommonJS so next-auth v4's Keycloak provider resolves through Webpack's CJS\n * interop — see the provider-injection note in `options.js`.)\n *\n * Server / build-time only. Reads KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET,\n * KEYCLOAK_ISSUER. Returns \"\" when those vars are absent so reads degrade to\n * unauthenticated. In-process cache shared across requests, re-fetched 30s\n * before expiry.\n */\n\n/** @type {{ token: string; expiresAt: number } | null} */\nlet cache = null;\n\n/**\n * Implements the SDK's `ServiceTokenProvider` contract: `() => Promise<string>`.\n * @returns {Promise<string>}\n */\nexport async function getClientCredentialsToken() {\n const clientId = process.env.KEYCLOAK_CLIENT_ID;\n const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;\n const issuer = process.env.KEYCLOAK_ISSUER;\n\n if (!clientId || !clientSecret || !issuer) return \"\";\n\n if (cache && cache.expiresAt > Date.now() + 30_000) return cache.token;\n\n const res = await fetch(`${issuer}/protocol/openid-connect/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"client_credentials\",\n client_id: clientId,\n client_secret: clientSecret,\n }),\n cache: \"no-store\",\n });\n\n if (!res.ok) {\n throw new Error(\n `[inscribed-auth] Keycloak token request failed: ${res.status} ${await res.text()}`,\n );\n }\n\n const { access_token, expires_in } = await res.json();\n cache = { token: access_token, expiresAt: Date.now() + expires_in * 1000 };\n return access_token;\n}\n\n/**\n * Drop the cached service token so the next call re-fetches. Intentionally NOT\n * part of the public `./server` surface — kept here for internal use and for\n * anyone who deep-imports the raw module (e.g. token rotation in tests).\n */\nexport function invalidateClientCredentialsToken() {\n cache = null;\n}\n\n/**\n * On a sync failure, surface the *real* reason: re-probe the backend with the\n * service token to print the actual HTTP status + response body, then dump the\n * Keycloak claims (`azp`, `aud`, `resource_access`) for cross-reference.\n *\n * The numeric status/body isn't available from `onSyncError` - inscribed catches\n * the per-slug `CmsApiError(status, detail)`, prints only `detail`, and hands us\n * a generic aggregate - so we re-issue one request to read it directly. The\n * probe body carries no `slug`, so an authorized token gets a harmless 4xx\n * validation error (nothing is created or deleted) and an unauthorized one gets\n * 401/403. Wired as `onSyncError` in the `./config` entry.\n *\n * @param {unknown} [err] The aggregate error inscribed passed to `onSyncError`.\n */\nexport async function debugServiceTokenClaims(err) {\n const { KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER } = process.env;\n if (!KEYCLOAK_CLIENT_ID || !KEYCLOAK_CLIENT_SECRET || !KEYCLOAK_ISSUER) return;\n\n if (err) {\n console.error(`[cms-sync:debug] sync failed: ${err instanceof Error ? err.message : String(err)}`);\n }\n\n const res = await fetch(`${KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"client_credentials\",\n client_id: KEYCLOAK_CLIENT_ID,\n client_secret: KEYCLOAK_CLIENT_SECRET,\n }),\n });\n if (!res.ok) {\n console.error(`[cms-sync:debug] Token fetch failed: ${res.status} ${await res.text()}`);\n return;\n }\n\n const { access_token } = await res.json();\n const [, payload] = access_token.split(\".\");\n const claims = JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\"));\n\n const cmsUrl = process.env.CMS_URL;\n if (!cmsUrl) {\n console.error(`[cms-sync:debug] CMS_URL is not set - skipping the backend probe.`);\n } else {\n const baseUrl = cmsUrl.replace(/\\/+$/, \"\");\n try {\n const probe = await fetch(`${baseUrl}/cms/sync`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${access_token}`,\n },\n body: JSON.stringify({ __inscribedAuthProbe: true }),\n });\n const body = (await probe.text()).slice(0, 500);\n console.error(`[cms-sync:debug] POST ${baseUrl}/cms/sync -> ${probe.status} ${probe.statusText}`);\n if (body) console.error(` backend body: ${body}`);\n if (probe.status === 401 || probe.status === 403) {\n console.error(` -> auth rejected by the backend; cross-check the token claims below.`);\n } else {\n console.error(` -> token accepted (this status is validation, not auth) - a real 403 would be data/policy-specific.`);\n }\n } catch (e) {\n console.error(`[cms-sync:debug] probe to ${baseUrl}/cms/sync failed: ${e instanceof Error ? e.message : String(e)}`);\n }\n }\n\n console.error(\"[cms-sync:debug] Service token claims:\");\n console.error(` azp: ${claims.azp}`);\n console.error(` aud: ${JSON.stringify(claims.aud)}`);\n console.error(` resource_access: ${JSON.stringify(claims.resource_access)}`);\n\n const ra = claims.resource_access ?? {};\n const holder = Object.keys(ra).find((c) => ra[c]?.roles?.includes(\"cms:access\"));\n if (!holder) {\n console.error(` ! \"cms:access\" missing from every client in resource_access - assign it to the`);\n console.error(` service account: Keycloak -> Clients -> ${claims.azp} -> Service account roles.`);\n }\n}","/**\n * @file `@skylab-kulubu/inscribed-auth/config` — `cms-sync` CLI wiring.\n *\n * The `cms-sync` CLI is a plain Node binary - it can't receive function props\n * the way the React tree does - so it loads the consumer's `cms.config.{js,mjs}`\n * from the project root to learn how to obtain a service token (and, optionally,\n * how to diagnose failures). This entry lets that config file be a one-liner:\n *\n * // cms.config.mjs (consumer project root)\n * export { getServiceToken, onSyncError } from \"@skylab-kulubu/inscribed-auth/config\";\n *\n * Resolves to ESM so the CLI's dynamic `import()` works regardless of the\n * consuming app's `\"type\"`.\n */\n\nimport {\n getClientCredentialsToken,\n debugServiceTokenClaims,\n} from \"./lib/service-token.mjs\";\n\n/** Service token for build-time `POST /cms/sync`. */\nexport const getServiceToken = getClientCredentialsToken;\n\n/** Called when a sync fails - probes the backend + dumps Keycloak claims. */\nexport const onSyncError = (err) => debugServiceTokenClaims(err);"],"mappings":"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","names":[]}
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as next_auth from 'next-auth';
2
- export { d as debugServiceTokenClaims, g as getClientCredentialsToken } from './service-token-CHJDdnHv.js';
2
+ export { d as debugServiceTokenClaims, g as getClientCredentialsToken } from './service-token-BANrKR5z.js';
3
3
 
4
4
  /**
5
5
  * @typedef {Object} CmsAuthMeta
package/dist/server.js CHANGED
@@ -30,6 +30,7 @@ function createCmsAuthOptions(input) {
30
30
  next.error = void 0;
31
31
  next.idToken = account.id_token;
32
32
  next.clientRoles = readClientRoles(account.access_token);
33
+ next.realmRoles = readRealmRoles(account.access_token);
33
34
  } else if (
34
35
  // 2. Previous refresh failed - bail until the user re-authenticates.
35
36
  next.error !== "RefreshAccessTokenError" && // 3. Token still valid (with lead time) - return as-is.
@@ -53,6 +54,8 @@ function createCmsAuthOptions(input) {
53
54
  token.sub ?? "";
54
55
  session.user.clientRoles = /** @type {string[]} */
55
56
  token.clientRoles ?? [];
57
+ session.user.realmRoles = /** @type {string[]} */
58
+ token.realmRoles ?? [];
56
59
  }
57
60
  if (extraCallbacks?.session) {
58
61
  const overridden = await extraCallbacks.session(args);
@@ -131,6 +134,19 @@ function readClientRoles(accessToken) {
131
134
  return [];
132
135
  }
133
136
  }
137
+ function readRealmRoles(accessToken) {
138
+ if (!accessToken) return [];
139
+ const segments = accessToken.split(".");
140
+ if (segments.length < 2) return [];
141
+ try {
142
+ const payload = JSON.parse(
143
+ Buffer.from(segments[1], "base64url").toString("utf8")
144
+ );
145
+ return payload?.realm_access?.roles ?? [];
146
+ } catch {
147
+ return [];
148
+ }
149
+ }
134
150
  async function refreshAccessToken(token) {
135
151
  try {
136
152
  const issuer = process.env.KEYCLOAK_ISSUER ?? "";
@@ -153,6 +169,7 @@ async function refreshAccessToken(token) {
153
169
  refreshToken: refreshed.refresh_token ?? token.refreshToken,
154
170
  idToken: refreshed.id_token ?? token.idToken,
155
171
  clientRoles: readClientRoles(refreshed.access_token),
172
+ realmRoles: readRealmRoles(refreshed.access_token),
156
173
  error: void 0
157
174
  };
158
175
  } catch (error) {
@@ -204,9 +221,12 @@ async function getClientCredentialsToken() {
204
221
  cache = { token: access_token, expiresAt: Date.now() + expires_in * 1e3 };
205
222
  return access_token;
206
223
  }
207
- async function debugServiceTokenClaims() {
224
+ async function debugServiceTokenClaims(err) {
208
225
  const { KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER } = process.env;
209
226
  if (!KEYCLOAK_CLIENT_ID || !KEYCLOAK_CLIENT_SECRET || !KEYCLOAK_ISSUER) return;
227
+ if (err) {
228
+ console.error(`[cms-sync:debug] sync failed: ${err instanceof Error ? err.message : String(err)}`);
229
+ }
210
230
  const res = await fetch(`${KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
211
231
  method: "POST",
212
232
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -223,23 +243,41 @@ async function debugServiceTokenClaims() {
223
243
  const { access_token } = await res.json();
224
244
  const [, payload] = access_token.split(".");
225
245
  const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
246
+ const cmsUrl = process.env.CMS_URL;
247
+ if (!cmsUrl) {
248
+ console.error(`[cms-sync:debug] CMS_URL is not set - skipping the backend probe.`);
249
+ } else {
250
+ const baseUrl = cmsUrl.replace(/\/+$/, "");
251
+ try {
252
+ const probe = await fetch(`${baseUrl}/cms/sync`, {
253
+ method: "POST",
254
+ headers: {
255
+ "Content-Type": "application/json",
256
+ Authorization: `Bearer ${access_token}`
257
+ },
258
+ body: JSON.stringify({ __inscribedAuthProbe: true })
259
+ });
260
+ const body = (await probe.text()).slice(0, 500);
261
+ console.error(`[cms-sync:debug] POST ${baseUrl}/cms/sync -> ${probe.status} ${probe.statusText}`);
262
+ if (body) console.error(` backend body: ${body}`);
263
+ if (probe.status === 401 || probe.status === 403) {
264
+ console.error(` -> auth rejected by the backend; cross-check the token claims below.`);
265
+ } else {
266
+ console.error(` -> token accepted (this status is validation, not auth) - a real 403 would be data/policy-specific.`);
267
+ }
268
+ } catch (e) {
269
+ console.error(`[cms-sync:debug] probe to ${baseUrl}/cms/sync failed: ${e instanceof Error ? e.message : String(e)}`);
270
+ }
271
+ }
226
272
  console.error("[cms-sync:debug] Service token claims:");
227
273
  console.error(` azp: ${claims.azp}`);
228
- console.error(` sub: ${claims.sub}`);
229
274
  console.error(` aud: ${JSON.stringify(claims.aud)}`);
230
- console.error(` scope: ${claims.scope}`);
231
275
  console.error(` resource_access: ${JSON.stringify(claims.resource_access)}`);
232
276
  const ra = claims.resource_access ?? {};
233
277
  const holder = Object.keys(ra).find((c) => ra[c]?.roles?.includes("cms:access"));
234
- if (holder) {
235
- console.error(` -> "cms:access" found under resource_access["${holder}"].`);
236
- if (holder !== claims.azp) {
237
- console.error(` (owned by backend client "${holder}", not azp "${claims.azp}" - expected)`);
238
- }
239
- } else {
240
- console.error(` ! "cms:access" role missing from every client in resource_access.`);
241
- console.error(` Assign the backend client's "cms:access" role to this service account:`);
242
- console.error(` Keycloak Admin -> Clients -> ${claims.azp} -> Service account roles -> Assign "cms:access".`);
278
+ if (!holder) {
279
+ console.error(` ! "cms:access" missing from every client in resource_access - assign it to the`);
280
+ console.error(` service account: Keycloak -> Clients -> ${claims.azp} -> Service account roles.`);
243
281
  }
244
282
  }
245
283
  export {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/lib/options.js","../src/lib/service-token.mjs"],"sourcesContent":["/**\n * @file Pre-wired NextAuth options for inscribed CMS apps (Skylab auth adapter).\n *\n * Part of `@skylab-kulubu/inscribed-auth`. The inscribed core is auth-agnostic;\n * this module is Skylab's NextAuth + Keycloak server wiring on top of it.\n *\n * `createCmsAuthOptions()` returns a complete `AuthOptions` object suitable\n * for `NextAuth(...)` in `app/api/auth/[...nextauth]/route.js`. It bundles:\n * - JWT callback that persists Keycloak access/refresh tokens\n * - Silent refresh of expired access tokens\n * - Client-role extraction from the access token\n * - Session callback that exposes `accessToken`, `user.id`, `user.clientRoles`\n *\n * The OAuth provider itself stays on the consumer side - import\n * `next-auth/providers/keycloak` (or any other provider) in your own\n * `lib/auth.js` and pass the configured instance via `provider`. Importing\n * NextAuth provider modules from a bundled package breaks Webpack's\n * CJS/ESM interop and the provider resolves to `undefined` at runtime.\n *\n * Admin metadata (`adminRole` / `isAdmin`) is stamped onto the returned\n * options so `createCmsPage({ ...withCmsAuth(authOptions) })` can derive admin\n * gating automatically without the caller wiring a separate `deriveAdmin`.\n */\n\nimport { getServerSession } from \"next-auth\";\n\n/** @type {unique symbol} */\nconst CMS_META = Symbol.for(\"inscribed/auth.meta\");\n\n/**\n * @typedef {Object} CmsAuthMeta\n * @property {string|null} adminRole\n * @property {((session: *) => boolean)|null} isAdmin\n */\n\n/**\n * @typedef {Object} CreateCmsAuthOptionsInput\n * @property {*} provider\n * Configured NextAuth provider instance. Import the provider module on\n * the consumer side (typically `next-auth/providers/keycloak`) and pass\n * the result of calling it. Required.\n * @property {string} [adminRole]\n * Keycloak client role required for admin access. Default `\"cms:access\"`.\n * @property {(session: *) => boolean} [isAdmin]\n * Override admin gating with arbitrary logic. Wins over `adminRole`.\n * @property {number} [refreshLeadTimeMs]\n * Refresh access tokens this many ms before expiry. Default 10 000.\n * @property {string|false} [signInPage]\n * Path NextAuth redirects unauthenticated users to. Defaults to\n * `\"/api/signin\"` (the package's auto-submit route) so sign-in jumps straight\n * into Keycloak instead of NextAuth's built-in \"Sign in with X\" picker. Set\n * `false` to keep the picker.\n * @property {Partial<import(\"next-auth\").AuthOptions[\"callbacks\"]>} [extraCallbacks]\n * Extra callbacks merged on top of the built-in ones. Each callback runs\n * AFTER the built-in version and receives the already-augmented value.\n * @property {Partial<import(\"next-auth\").AuthOptions>} [extraOptions]\n * Anything else (pages, cookies, events, ...) merged onto the result.\n */\n\n/**\n * Build a ready-to-use NextAuth `AuthOptions` object.\n *\n * @param {CreateCmsAuthOptionsInput} input\n * @returns {import(\"next-auth\").AuthOptions & { [CMS_META]: CmsAuthMeta }}\n */\nexport function createCmsAuthOptions(input) {\n const {\n provider,\n adminRole = \"cms:access\",\n isAdmin,\n refreshLeadTimeMs = 10_000,\n signInPage = \"/api/signin\",\n extraCallbacks,\n extraOptions,\n } = input ?? {};\n\n if (!provider) {\n throw new Error(\n \"createCmsAuthOptions: `provider` is required. Import a NextAuth provider \" +\n \"(e.g. `next-auth/providers/keycloak`) in your own auth file and pass \" +\n \"the configured instance.\",\n );\n }\n\n /** @type {import(\"next-auth\").AuthOptions} */\n const base = {\n providers: [provider],\n callbacks: {\n async jwt(args) {\n const { token, account } = args;\n let next = token;\n\n // 1. Initial sign-in: copy tokens off the OAuth account.\n if (account) {\n next.accessToken = account.access_token;\n next.refreshToken = account.refresh_token;\n next.accessTokenExpires =\n typeof account.expires_at === \"number\" ? account.expires_at * 1000 : 0;\n next.sub = account.providerAccountId ?? next.sub;\n next.error = undefined;\n next.idToken = account.id_token;\n next.clientRoles = readClientRoles(account.access_token);\n } else if (\n // 2. Previous refresh failed - bail until the user re-authenticates.\n next.error !== \"RefreshAccessTokenError\" &&\n // 3. Token still valid (with lead time) - return as-is.\n (typeof next.accessTokenExpires !== \"number\" ||\n Date.now() >= next.accessTokenExpires - refreshLeadTimeMs)\n ) {\n // 4. Expired (or about to) - silently refresh.\n next = await refreshAccessToken(next);\n }\n\n if (extraCallbacks?.jwt) {\n const overridden = await extraCallbacks.jwt({ ...args, token: next });\n if (overridden !== undefined) return overridden;\n }\n return next;\n },\n\n async session(args) {\n const { session, token } = args;\n session.accessToken = /** @type {string|undefined} */ (token.accessToken);\n session.error = token.error;\n if (session.user) {\n session.user.id = /** @type {string} */ (token.sub ?? \"\");\n session.user.clientRoles =\n /** @type {string[]} */ (token.clientRoles ?? []);\n }\n\n if (extraCallbacks?.session) {\n const overridden = await extraCallbacks.session(args);\n if (overridden !== undefined) return overridden;\n }\n return session;\n },\n\n ...(extraCallbacks\n ? Object.fromEntries(\n Object.entries(extraCallbacks).filter(\n ([key]) => key !== \"jwt\" && key !== \"session\",\n ),\n )\n : {}),\n },\n ...extraOptions,\n pages: {\n ...(signInPage ? { signIn: signInPage } : {}),\n ...extraOptions?.pages,\n },\n events: {\n ...extraOptions?.events,\n // Federated (RP-initiated) logout: also end the Keycloak SSO session, so\n // the next sign-in doesn't silently re-authenticate against a still-live\n // session. A consumer-supplied signOut (preserved by the spread above)\n // still runs afterwards.\n async signOut(message) {\n const token = message && \"token\" in message ? message.token : null;\n await endKeycloakSession(token?.idToken);\n if (typeof extraOptions?.events?.signOut === \"function\") {\n await extraOptions.events.signOut(message);\n }\n },\n },\n };\n\n /** @type {CmsAuthMeta} */\n const meta = {\n adminRole: isAdmin ? null : adminRole,\n isAdmin: isAdmin ?? null,\n };\n\n return Object.assign(base, { [CMS_META]: meta });\n}\n\n/**\n * Read CMS admin metadata previously stamped by `createCmsAuthOptions`.\n * Returns null if the options didn't come from the factory.\n *\n * @param {*} authOptions\n * @returns {CmsAuthMeta|null}\n */\nexport function readCmsAuthMeta(authOptions) {\n if (!authOptions || typeof authOptions !== \"object\") return null;\n return authOptions[CMS_META] ?? null;\n}\n\n/**\n * Decide whether a session belongs to a CMS admin. Uses `isAdmin` callback\n * when provided, otherwise checks for the named Keycloak client role.\n * Falls back to `false` when no metadata is available.\n *\n * @param {*} session\n * @param {CmsAuthMeta|null} [meta]\n * @returns {boolean}\n */\nexport function isCmsAdmin(session, meta) {\n if (!session) return false;\n if (!meta) return false;\n if (meta.isAdmin) return Boolean(meta.isAdmin(session));\n if (meta.adminRole) {\n /** @type {string[]} */\n const roles = session.user?.clientRoles ?? [];\n return roles.includes(meta.adminRole);\n }\n return false;\n}\n\n/**\n * Adapt NextAuth `authOptions` into the auth-agnostic callbacks\n * `createCmsPage` expects, so the inscribed core never has to import next-auth.\n * Spread the result into the factory:\n *\n * import { withCmsAuth } from \"@skylab-kulubu/inscribed-auth/server\";\n * createCmsPage({ ...withCmsAuth(authOptions), config, Provider, ... });\n *\n * - `getSession` resolves the server session via `getServerSession`.\n * - `deriveAdmin` uses the admin metadata stamped by `createCmsAuthOptions`\n * (falls back to `session != null` when the options didn't come from the\n * factory).\n * - `deriveUserSub` reads `session.user.id`.\n *\n * @param {import(\"next-auth\").AuthOptions} authOptions\n * @returns {{ getSession: () => Promise<*|null>, deriveAdmin: (session: *) => boolean, deriveUserSub: (session: *) => string | null }}\n */\nexport function withCmsAuth(authOptions) {\n if (!authOptions) {\n throw new Error(\"withCmsAuth: `authOptions` is required\");\n }\n const meta = readCmsAuthMeta(authOptions);\n return {\n getSession: () => getServerSession(authOptions),\n deriveAdmin: meta\n ? (session) => isCmsAdmin(session, meta)\n : (session) => session != null,\n deriveUserSub: (session) => session?.user?.id ?? null,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Internals\n// ---------------------------------------------------------------------------\n\n/**\n * Decode a Keycloak access token (JWT) and return the principal's client roles\n * aggregated across every entry in `resource_access`. We aggregate rather than\n * scope to a single client because the admin role (`cms:access`) is a client\n * role of the inscribed backend's Keycloak client (e.g. \"skycms\"), not the\n * frontend client the token was issued to (`azp`/`KEYCLOAK_CLIENT_ID`). That\n * backend client only appears in `resource_access` because it's mapped into the\n * token audience, so reading the role straight from `resource_access` is exact\n * and needs no extra config. Signature isn't verified - the token came from\n * Keycloak directly via the OAuth flow, so trust is established.\n *\n * @param {string|undefined} accessToken\n * @returns {string[]}\n */\nfunction readClientRoles(accessToken) {\n if (!accessToken) return [];\n const segments = accessToken.split(\".\");\n if (segments.length < 2) return [];\n try {\n const payload = JSON.parse(\n Buffer.from(segments[1], \"base64url\").toString(\"utf8\"),\n );\n const resourceAccess = payload?.resource_access ?? {};\n return Object.values(resourceAccess).flatMap((client) => client?.roles ?? []);\n } catch {\n return [];\n }\n}\n\n/**\n * Exchange the refresh token for a new access token at Keycloak.\n *\n * @param {*} token\n */\nasync function refreshAccessToken(token) {\n try {\n const issuer = process.env.KEYCLOAK_ISSUER ?? \"\";\n const response = await fetch(`${issuer}/protocol/openid-connect/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n client_id: process.env.KEYCLOAK_CLIENT_ID ?? \"\",\n client_secret: process.env.KEYCLOAK_CLIENT_SECRET ?? \"\",\n grant_type: \"refresh_token\",\n refresh_token: token.refreshToken,\n }),\n });\n\n const refreshed = await response.json();\n if (!response.ok) throw refreshed;\n\n return {\n ...token,\n accessToken: refreshed.access_token,\n accessTokenExpires: Date.now() + refreshed.expires_in * 1000,\n refreshToken: refreshed.refresh_token ?? token.refreshToken,\n idToken: refreshed.id_token ?? token.idToken,\n clientRoles: readClientRoles(refreshed.access_token),\n error: undefined,\n };\n } catch (error) {\n // eslint-disable-next-line no-console\n console.error(\"[inscribed-auth] Refresh token error:\", error);\n return {\n ...token,\n accessToken: undefined,\n error: \"RefreshAccessTokenError\",\n };\n }\n}\n\n/**\n * RP-initiated (federated) logout: end the user's Keycloak SSO session so the\n * next sign-in doesn't silently re-authenticate against a still-live session.\n * Best-effort - the NextAuth session is already cleared by the time this runs,\n * so a failure here just means the Keycloak session lingers until it expires.\n *\n * @param {string|undefined} idToken\n */\nasync function endKeycloakSession(idToken) {\n const issuer = process.env.KEYCLOAK_ISSUER;\n if (!idToken || !issuer) return;\n try {\n await fetch(\n `${issuer}/protocol/openid-connect/logout?${new URLSearchParams({\n id_token_hint: idToken,\n })}`,\n );\n } catch {\n /* swallow: best-effort SSO logout */\n }\n}\n","/**\n * @file Keycloak client-credentials service-token provider.\n *\n * Part of `@skylab-kulubu/inscribed-auth`. The inscribed core is backend-neutral:\n * it only knows the `ServiceTokenProvider` contract (`() => Promise<string>`)\n * and defaults to \"no token\". This module implements that contract against\n * Keycloak and is wired into the SDK via `createCmsPage({ getServiceToken })`\n * (consumer `lib/cms.jsx`) and the `cms-sync` CLI (consumer `cms.config.mjs`,\n * which re-exports from `@skylab-kulubu/inscribed-auth/config`).\n *\n * `.mjs` so the plain-Node `cms-sync` CLI can `import()` the resolved `./config`\n * entry as ESM regardless of the consuming app's `\"type\"`. (Skylab apps stay\n * CommonJS so next-auth v4's Keycloak provider resolves through Webpack's CJS\n * interop — see the provider-injection note in `options.js`.)\n *\n * Server / build-time only. Reads KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET,\n * KEYCLOAK_ISSUER. Returns \"\" when those vars are absent so reads degrade to\n * unauthenticated. In-process cache shared across requests, re-fetched 30s\n * before expiry.\n */\n\n/** @type {{ token: string; expiresAt: number } | null} */\nlet cache = null;\n\n/**\n * Implements the SDK's `ServiceTokenProvider` contract: `() => Promise<string>`.\n * @returns {Promise<string>}\n */\nexport async function getClientCredentialsToken() {\n const clientId = process.env.KEYCLOAK_CLIENT_ID;\n const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;\n const issuer = process.env.KEYCLOAK_ISSUER;\n\n if (!clientId || !clientSecret || !issuer) return \"\";\n\n if (cache && cache.expiresAt > Date.now() + 30_000) return cache.token;\n\n const res = await fetch(`${issuer}/protocol/openid-connect/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"client_credentials\",\n client_id: clientId,\n client_secret: clientSecret,\n }),\n cache: \"no-store\",\n });\n\n if (!res.ok) {\n throw new Error(\n `[inscribed-auth] Keycloak token request failed: ${res.status} ${await res.text()}`,\n );\n }\n\n const { access_token, expires_in } = await res.json();\n cache = { token: access_token, expiresAt: Date.now() + expires_in * 1000 };\n return access_token;\n}\n\n/**\n * Drop the cached service token so the next call re-fetches. Intentionally NOT\n * part of the public `./server` surface — kept here for internal use and for\n * anyone who deep-imports the raw module (e.g. token rotation in tests).\n */\nexport function invalidateClientCredentialsToken() {\n cache = null;\n}\n\n/**\n * On a sync failure, fetch the service token directly and dump the claims the\n * backend's `CmsAccessPolicy` checks (`azp`, `aud`, `resource_access`). Most\n * 403s come from the service account missing the `cms:access` role mapping in\n * Keycloak - this prints exactly what's there. Wired as `onSyncError` in the\n * `./config` entry.\n */\nexport async function debugServiceTokenClaims() {\n const { KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER } = process.env;\n if (!KEYCLOAK_CLIENT_ID || !KEYCLOAK_CLIENT_SECRET || !KEYCLOAK_ISSUER) return;\n\n const res = await fetch(`${KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"client_credentials\",\n client_id: KEYCLOAK_CLIENT_ID,\n client_secret: KEYCLOAK_CLIENT_SECRET,\n }),\n });\n if (!res.ok) {\n console.error(`[cms-sync:debug] Token fetch failed: ${res.status} ${await res.text()}`);\n return;\n }\n\n const { access_token } = await res.json();\n const [, payload] = access_token.split(\".\");\n const claims = JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\"));\n\n console.error(\"[cms-sync:debug] Service token claims:\");\n console.error(` azp: ${claims.azp}`);\n console.error(` sub: ${claims.sub}`);\n console.error(` aud: ${JSON.stringify(claims.aud)}`);\n console.error(` scope: ${claims.scope}`);\n console.error(` resource_access: ${JSON.stringify(claims.resource_access)}`);\n\n // `cms:access` is a *client role* of the inscribed backend's Keycloak client\n // (e.g. \"skycms\"), assigned to this service account. It therefore lands under\n // resource_access[<backend-client>], NOT under azp (this frontend client).\n // Scan every client so we report where the role actually is.\n const ra = claims.resource_access ?? {};\n const holder = Object.keys(ra).find((c) => ra[c]?.roles?.includes(\"cms:access\"));\n if (holder) {\n console.error(` -> \"cms:access\" found under resource_access[\"${holder}\"].`);\n if (holder !== claims.azp) {\n console.error(` (owned by backend client \"${holder}\", not azp \"${claims.azp}\" - expected)`);\n }\n } else {\n console.error(` ! \"cms:access\" role missing from every client in resource_access.`);\n console.error(` Assign the backend client's \"cms:access\" role to this service account:`);\n console.error(` Keycloak Admin -> Clients -> ${claims.azp} -> Service account roles -> Assign \"cms:access\".`);\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/lib/options.js","../src/lib/service-token.mjs"],"sourcesContent":["/**\n * @file Pre-wired NextAuth options for inscribed CMS apps (Skylab auth adapter).\n *\n * Part of `@skylab-kulubu/inscribed-auth`. The inscribed core is auth-agnostic;\n * this module is Skylab's NextAuth + Keycloak server wiring on top of it.\n *\n * `createCmsAuthOptions()` returns a complete `AuthOptions` object suitable\n * for `NextAuth(...)` in `app/api/auth/[...nextauth]/route.js`. It bundles:\n * - JWT callback that persists Keycloak access/refresh tokens\n * - Silent refresh of expired access tokens\n * - Client-role extraction from the access token\n * - Session callback that exposes `accessToken`, `user.id`, `user.clientRoles`,\n * `user.realmRoles`\n *\n * The OAuth provider itself stays on the consumer side - import\n * `next-auth/providers/keycloak` (or any other provider) in your own\n * `lib/auth.js` and pass the configured instance via `provider`. Importing\n * NextAuth provider modules from a bundled package breaks Webpack's\n * CJS/ESM interop and the provider resolves to `undefined` at runtime.\n *\n * Admin metadata (`adminRole` / `isAdmin`) is stamped onto the returned\n * options so `createCmsPage({ ...withCmsAuth(authOptions) })` can derive admin\n * gating automatically without the caller wiring a separate `deriveAdmin`.\n */\n\nimport { getServerSession } from \"next-auth\";\n\n/** @type {unique symbol} */\nconst CMS_META = Symbol.for(\"inscribed/auth.meta\");\n\n/**\n * @typedef {Object} CmsAuthMeta\n * @property {string|null} adminRole\n * @property {((session: *) => boolean)|null} isAdmin\n */\n\n/**\n * @typedef {Object} CreateCmsAuthOptionsInput\n * @property {*} provider\n * Configured NextAuth provider instance. Import the provider module on\n * the consumer side (typically `next-auth/providers/keycloak`) and pass\n * the result of calling it. Required.\n * @property {string} [adminRole]\n * Keycloak client role required for admin access. Default `\"cms:access\"`.\n * @property {(session: *) => boolean} [isAdmin]\n * Override admin gating with arbitrary logic. Wins over `adminRole`.\n * @property {number} [refreshLeadTimeMs]\n * Refresh access tokens this many ms before expiry. Default 10 000.\n * @property {string|false} [signInPage]\n * Path NextAuth redirects unauthenticated users to. Defaults to\n * `\"/api/signin\"` (the package's auto-submit route) so sign-in jumps straight\n * into Keycloak instead of NextAuth's built-in \"Sign in with X\" picker. Set\n * `false` to keep the picker.\n * @property {Partial<import(\"next-auth\").AuthOptions[\"callbacks\"]>} [extraCallbacks]\n * Extra callbacks merged on top of the built-in ones. Each callback runs\n * AFTER the built-in version and receives the already-augmented value.\n * @property {Partial<import(\"next-auth\").AuthOptions>} [extraOptions]\n * Anything else (pages, cookies, events, ...) merged onto the result.\n */\n\n/**\n * Build a ready-to-use NextAuth `AuthOptions` object.\n *\n * @param {CreateCmsAuthOptionsInput} input\n * @returns {import(\"next-auth\").AuthOptions & { [CMS_META]: CmsAuthMeta }}\n */\nexport function createCmsAuthOptions(input) {\n const {\n provider,\n adminRole = \"cms:access\",\n isAdmin,\n refreshLeadTimeMs = 10_000,\n signInPage = \"/api/signin\",\n extraCallbacks,\n extraOptions,\n } = input ?? {};\n\n if (!provider) {\n throw new Error(\n \"createCmsAuthOptions: `provider` is required. Import a NextAuth provider \" +\n \"(e.g. `next-auth/providers/keycloak`) in your own auth file and pass \" +\n \"the configured instance.\",\n );\n }\n\n /** @type {import(\"next-auth\").AuthOptions} */\n const base = {\n providers: [provider],\n callbacks: {\n async jwt(args) {\n const { token, account } = args;\n let next = token;\n\n // 1. Initial sign-in: copy tokens off the OAuth account.\n if (account) {\n next.accessToken = account.access_token;\n next.refreshToken = account.refresh_token;\n next.accessTokenExpires =\n typeof account.expires_at === \"number\" ? account.expires_at * 1000 : 0;\n next.sub = account.providerAccountId ?? next.sub;\n next.error = undefined;\n next.idToken = account.id_token;\n next.clientRoles = readClientRoles(account.access_token);\n next.realmRoles = readRealmRoles(account.access_token);\n } else if (\n // 2. Previous refresh failed - bail until the user re-authenticates.\n next.error !== \"RefreshAccessTokenError\" &&\n // 3. Token still valid (with lead time) - return as-is.\n (typeof next.accessTokenExpires !== \"number\" ||\n Date.now() >= next.accessTokenExpires - refreshLeadTimeMs)\n ) {\n // 4. Expired (or about to) - silently refresh.\n next = await refreshAccessToken(next);\n }\n\n if (extraCallbacks?.jwt) {\n const overridden = await extraCallbacks.jwt({ ...args, token: next });\n if (overridden !== undefined) return overridden;\n }\n return next;\n },\n\n async session(args) {\n const { session, token } = args;\n session.accessToken = /** @type {string|undefined} */ (token.accessToken);\n session.error = token.error;\n if (session.user) {\n session.user.id = /** @type {string} */ (token.sub ?? \"\");\n session.user.clientRoles =\n /** @type {string[]} */ (token.clientRoles ?? []);\n session.user.realmRoles =\n /** @type {string[]} */ (token.realmRoles ?? []);\n }\n\n if (extraCallbacks?.session) {\n const overridden = await extraCallbacks.session(args);\n if (overridden !== undefined) return overridden;\n }\n return session;\n },\n\n ...(extraCallbacks\n ? Object.fromEntries(\n Object.entries(extraCallbacks).filter(\n ([key]) => key !== \"jwt\" && key !== \"session\",\n ),\n )\n : {}),\n },\n ...extraOptions,\n pages: {\n ...(signInPage ? { signIn: signInPage } : {}),\n ...extraOptions?.pages,\n },\n events: {\n ...extraOptions?.events,\n // Federated (RP-initiated) logout: also end the Keycloak SSO session, so\n // the next sign-in doesn't silently re-authenticate against a still-live\n // session. A consumer-supplied signOut (preserved by the spread above)\n // still runs afterwards.\n async signOut(message) {\n const token = message && \"token\" in message ? message.token : null;\n await endKeycloakSession(token?.idToken);\n if (typeof extraOptions?.events?.signOut === \"function\") {\n await extraOptions.events.signOut(message);\n }\n },\n },\n };\n\n /** @type {CmsAuthMeta} */\n const meta = {\n adminRole: isAdmin ? null : adminRole,\n isAdmin: isAdmin ?? null,\n };\n\n return Object.assign(base, { [CMS_META]: meta });\n}\n\n/**\n * Read CMS admin metadata previously stamped by `createCmsAuthOptions`.\n * Returns null if the options didn't come from the factory.\n *\n * @param {*} authOptions\n * @returns {CmsAuthMeta|null}\n */\nexport function readCmsAuthMeta(authOptions) {\n if (!authOptions || typeof authOptions !== \"object\") return null;\n return authOptions[CMS_META] ?? null;\n}\n\n/**\n * Decide whether a session belongs to a CMS admin. Uses `isAdmin` callback\n * when provided, otherwise checks for the named Keycloak client role.\n * Falls back to `false` when no metadata is available.\n *\n * @param {*} session\n * @param {CmsAuthMeta|null} [meta]\n * @returns {boolean}\n */\nexport function isCmsAdmin(session, meta) {\n if (!session) return false;\n if (!meta) return false;\n if (meta.isAdmin) return Boolean(meta.isAdmin(session));\n if (meta.adminRole) {\n /** @type {string[]} */\n const roles = session.user?.clientRoles ?? [];\n return roles.includes(meta.adminRole);\n }\n return false;\n}\n\n/**\n * Adapt NextAuth `authOptions` into the auth-agnostic callbacks\n * `createCmsPage` expects, so the inscribed core never has to import next-auth.\n * Spread the result into the factory:\n *\n * import { withCmsAuth } from \"@skylab-kulubu/inscribed-auth/server\";\n * createCmsPage({ ...withCmsAuth(authOptions), config, Provider, ... });\n *\n * - `getSession` resolves the server session via `getServerSession`.\n * - `deriveAdmin` uses the admin metadata stamped by `createCmsAuthOptions`\n * (falls back to `session != null` when the options didn't come from the\n * factory).\n * - `deriveUserSub` reads `session.user.id`.\n *\n * @param {import(\"next-auth\").AuthOptions} authOptions\n * @returns {{ getSession: () => Promise<*|null>, deriveAdmin: (session: *) => boolean, deriveUserSub: (session: *) => string | null }}\n */\nexport function withCmsAuth(authOptions) {\n if (!authOptions) {\n throw new Error(\"withCmsAuth: `authOptions` is required\");\n }\n const meta = readCmsAuthMeta(authOptions);\n return {\n getSession: () => getServerSession(authOptions),\n deriveAdmin: meta\n ? (session) => isCmsAdmin(session, meta)\n : (session) => session != null,\n deriveUserSub: (session) => session?.user?.id ?? null,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Internals\n// ---------------------------------------------------------------------------\n\n/**\n * Decode a Keycloak access token (JWT) and return the principal's client roles\n * aggregated across every entry in `resource_access`. We aggregate rather than\n * scope to a single client because the admin role (`cms:access`) is a client\n * role of the inscribed backend's Keycloak client (e.g. \"skycms\"), not the\n * frontend client the token was issued to (`azp`/`KEYCLOAK_CLIENT_ID`). That\n * backend client only appears in `resource_access` because it's mapped into the\n * token audience, so reading the role straight from `resource_access` is exact\n * and needs no extra config. Signature isn't verified - the token came from\n * Keycloak directly via the OAuth flow, so trust is established.\n *\n * @param {string|undefined} accessToken\n * @returns {string[]}\n */\nfunction readClientRoles(accessToken) {\n if (!accessToken) return [];\n const segments = accessToken.split(\".\");\n if (segments.length < 2) return [];\n try {\n const payload = JSON.parse(\n Buffer.from(segments[1], \"base64url\").toString(\"utf8\"),\n );\n const resourceAccess = payload?.resource_access ?? {};\n return Object.values(resourceAccess).flatMap((client) => client?.roles ?? []);\n } catch {\n return [];\n }\n}\n\n/**\n * Decode a Keycloak access token (JWT) and return the principal's realm roles\n * (`realm_access.roles`). These are realm-wide roles, distinct from the\n * per-client roles in `resource_access` that {@link readClientRoles} reads.\n * Signature isn't verified - the token came from Keycloak directly via the\n * OAuth flow, so trust is established.\n *\n * @param {string|undefined} accessToken\n * @returns {string[]}\n */\nfunction readRealmRoles(accessToken) {\n if (!accessToken) return [];\n const segments = accessToken.split(\".\");\n if (segments.length < 2) return [];\n try {\n const payload = JSON.parse(\n Buffer.from(segments[1], \"base64url\").toString(\"utf8\"),\n );\n return payload?.realm_access?.roles ?? [];\n } catch {\n return [];\n }\n}\n\n/**\n * Exchange the refresh token for a new access token at Keycloak.\n *\n * @param {*} token\n */\nasync function refreshAccessToken(token) {\n try {\n const issuer = process.env.KEYCLOAK_ISSUER ?? \"\";\n const response = await fetch(`${issuer}/protocol/openid-connect/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n client_id: process.env.KEYCLOAK_CLIENT_ID ?? \"\",\n client_secret: process.env.KEYCLOAK_CLIENT_SECRET ?? \"\",\n grant_type: \"refresh_token\",\n refresh_token: token.refreshToken,\n }),\n });\n\n const refreshed = await response.json();\n if (!response.ok) throw refreshed;\n\n return {\n ...token,\n accessToken: refreshed.access_token,\n accessTokenExpires: Date.now() + refreshed.expires_in * 1000,\n refreshToken: refreshed.refresh_token ?? token.refreshToken,\n idToken: refreshed.id_token ?? token.idToken,\n clientRoles: readClientRoles(refreshed.access_token),\n realmRoles: readRealmRoles(refreshed.access_token),\n error: undefined,\n };\n } catch (error) {\n // eslint-disable-next-line no-console\n console.error(\"[inscribed-auth] Refresh token error:\", error);\n return {\n ...token,\n accessToken: undefined,\n error: \"RefreshAccessTokenError\",\n };\n }\n}\n\n/**\n * RP-initiated (federated) logout: end the user's Keycloak SSO session so the\n * next sign-in doesn't silently re-authenticate against a still-live session.\n * Best-effort - the NextAuth session is already cleared by the time this runs,\n * so a failure here just means the Keycloak session lingers until it expires.\n *\n * @param {string|undefined} idToken\n */\nasync function endKeycloakSession(idToken) {\n const issuer = process.env.KEYCLOAK_ISSUER;\n if (!idToken || !issuer) return;\n try {\n await fetch(\n `${issuer}/protocol/openid-connect/logout?${new URLSearchParams({\n id_token_hint: idToken,\n })}`,\n );\n } catch {\n /* swallow: best-effort SSO logout */\n }\n}\n","/**\n * @file Keycloak client-credentials service-token provider.\n *\n * Part of `@skylab-kulubu/inscribed-auth`. The inscribed core is backend-neutral:\n * it only knows the `ServiceTokenProvider` contract (`() => Promise<string>`)\n * and defaults to \"no token\". This module implements that contract against\n * Keycloak and is wired into the SDK via `createCmsPage({ getServiceToken })`\n * (consumer `lib/cms.jsx`) and the `cms-sync` CLI (consumer `cms.config.mjs`,\n * which re-exports from `@skylab-kulubu/inscribed-auth/config`).\n *\n * `.mjs` so the plain-Node `cms-sync` CLI can `import()` the resolved `./config`\n * entry as ESM regardless of the consuming app's `\"type\"`. (Skylab apps stay\n * CommonJS so next-auth v4's Keycloak provider resolves through Webpack's CJS\n * interop — see the provider-injection note in `options.js`.)\n *\n * Server / build-time only. Reads KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET,\n * KEYCLOAK_ISSUER. Returns \"\" when those vars are absent so reads degrade to\n * unauthenticated. In-process cache shared across requests, re-fetched 30s\n * before expiry.\n */\n\n/** @type {{ token: string; expiresAt: number } | null} */\nlet cache = null;\n\n/**\n * Implements the SDK's `ServiceTokenProvider` contract: `() => Promise<string>`.\n * @returns {Promise<string>}\n */\nexport async function getClientCredentialsToken() {\n const clientId = process.env.KEYCLOAK_CLIENT_ID;\n const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;\n const issuer = process.env.KEYCLOAK_ISSUER;\n\n if (!clientId || !clientSecret || !issuer) return \"\";\n\n if (cache && cache.expiresAt > Date.now() + 30_000) return cache.token;\n\n const res = await fetch(`${issuer}/protocol/openid-connect/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"client_credentials\",\n client_id: clientId,\n client_secret: clientSecret,\n }),\n cache: \"no-store\",\n });\n\n if (!res.ok) {\n throw new Error(\n `[inscribed-auth] Keycloak token request failed: ${res.status} ${await res.text()}`,\n );\n }\n\n const { access_token, expires_in } = await res.json();\n cache = { token: access_token, expiresAt: Date.now() + expires_in * 1000 };\n return access_token;\n}\n\n/**\n * Drop the cached service token so the next call re-fetches. Intentionally NOT\n * part of the public `./server` surface — kept here for internal use and for\n * anyone who deep-imports the raw module (e.g. token rotation in tests).\n */\nexport function invalidateClientCredentialsToken() {\n cache = null;\n}\n\n/**\n * On a sync failure, surface the *real* reason: re-probe the backend with the\n * service token to print the actual HTTP status + response body, then dump the\n * Keycloak claims (`azp`, `aud`, `resource_access`) for cross-reference.\n *\n * The numeric status/body isn't available from `onSyncError` - inscribed catches\n * the per-slug `CmsApiError(status, detail)`, prints only `detail`, and hands us\n * a generic aggregate - so we re-issue one request to read it directly. The\n * probe body carries no `slug`, so an authorized token gets a harmless 4xx\n * validation error (nothing is created or deleted) and an unauthorized one gets\n * 401/403. Wired as `onSyncError` in the `./config` entry.\n *\n * @param {unknown} [err] The aggregate error inscribed passed to `onSyncError`.\n */\nexport async function debugServiceTokenClaims(err) {\n const { KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER } = process.env;\n if (!KEYCLOAK_CLIENT_ID || !KEYCLOAK_CLIENT_SECRET || !KEYCLOAK_ISSUER) return;\n\n if (err) {\n console.error(`[cms-sync:debug] sync failed: ${err instanceof Error ? err.message : String(err)}`);\n }\n\n const res = await fetch(`${KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"client_credentials\",\n client_id: KEYCLOAK_CLIENT_ID,\n client_secret: KEYCLOAK_CLIENT_SECRET,\n }),\n });\n if (!res.ok) {\n console.error(`[cms-sync:debug] Token fetch failed: ${res.status} ${await res.text()}`);\n return;\n }\n\n const { access_token } = await res.json();\n const [, payload] = access_token.split(\".\");\n const claims = JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\"));\n\n const cmsUrl = process.env.CMS_URL;\n if (!cmsUrl) {\n console.error(`[cms-sync:debug] CMS_URL is not set - skipping the backend probe.`);\n } else {\n const baseUrl = cmsUrl.replace(/\\/+$/, \"\");\n try {\n const probe = await fetch(`${baseUrl}/cms/sync`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${access_token}`,\n },\n body: JSON.stringify({ __inscribedAuthProbe: true }),\n });\n const body = (await probe.text()).slice(0, 500);\n console.error(`[cms-sync:debug] POST ${baseUrl}/cms/sync -> ${probe.status} ${probe.statusText}`);\n if (body) console.error(` backend body: ${body}`);\n if (probe.status === 401 || probe.status === 403) {\n console.error(` -> auth rejected by the backend; cross-check the token claims below.`);\n } else {\n console.error(` -> token accepted (this status is validation, not auth) - a real 403 would be data/policy-specific.`);\n }\n } catch (e) {\n console.error(`[cms-sync:debug] probe to ${baseUrl}/cms/sync failed: ${e instanceof Error ? e.message : String(e)}`);\n }\n }\n\n console.error(\"[cms-sync:debug] Service token claims:\");\n console.error(` azp: ${claims.azp}`);\n console.error(` aud: ${JSON.stringify(claims.aud)}`);\n console.error(` resource_access: ${JSON.stringify(claims.resource_access)}`);\n\n const ra = claims.resource_access ?? {};\n const holder = Object.keys(ra).find((c) => ra[c]?.roles?.includes(\"cms:access\"));\n if (!holder) {\n console.error(` ! \"cms:access\" missing from every client in resource_access - assign it to the`);\n console.error(` service account: Keycloak -> Clients -> ${claims.azp} -> Service account roles.`);\n }\n}"],"mappings":"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","names":[]}
@@ -58,16 +58,27 @@ async function getClientCredentialsToken() {
58
58
  }
59
59
 
60
60
  /**
61
- * On a sync failure, fetch the service token directly and dump the claims the
62
- * backend's `CmsAccessPolicy` checks (`azp`, `aud`, `resource_access`). Most
63
- * 403s come from the service account missing the `cms:access` role mapping in
64
- * Keycloak - this prints exactly what's there. Wired as `onSyncError` in the
65
- * `./config` entry.
61
+ * On a sync failure, surface the *real* reason: re-probe the backend with the
62
+ * service token to print the actual HTTP status + response body, then dump the
63
+ * Keycloak claims (`azp`, `aud`, `resource_access`) for cross-reference.
64
+ *
65
+ * The numeric status/body isn't available from `onSyncError` - inscribed catches
66
+ * the per-slug `CmsApiError(status, detail)`, prints only `detail`, and hands us
67
+ * a generic aggregate - so we re-issue one request to read it directly. The
68
+ * probe body carries no `slug`, so an authorized token gets a harmless 4xx
69
+ * validation error (nothing is created or deleted) and an unauthorized one gets
70
+ * 401/403. Wired as `onSyncError` in the `./config` entry.
71
+ *
72
+ * @param {unknown} [err] The aggregate error inscribed passed to `onSyncError`.
66
73
  */
67
- async function debugServiceTokenClaims() {
74
+ async function debugServiceTokenClaims(err) {
68
75
  const { KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER } = process.env;
69
76
  if (!KEYCLOAK_CLIENT_ID || !KEYCLOAK_CLIENT_SECRET || !KEYCLOAK_ISSUER) return;
70
77
 
78
+ if (err) {
79
+ console.error(`[cms-sync:debug] sync failed: ${err instanceof Error ? err.message : String(err)}`);
80
+ }
81
+
71
82
  const res = await fetch(`${KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
72
83
  method: "POST",
73
84
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -86,28 +97,43 @@ async function debugServiceTokenClaims() {
86
97
  const [, payload] = access_token.split(".");
87
98
  const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
88
99
 
100
+ const cmsUrl = process.env.CMS_URL;
101
+ if (!cmsUrl) {
102
+ console.error(`[cms-sync:debug] CMS_URL is not set - skipping the backend probe.`);
103
+ } else {
104
+ const baseUrl = cmsUrl.replace(/\/+$/, "");
105
+ try {
106
+ const probe = await fetch(`${baseUrl}/cms/sync`, {
107
+ method: "POST",
108
+ headers: {
109
+ "Content-Type": "application/json",
110
+ Authorization: `Bearer ${access_token}`,
111
+ },
112
+ body: JSON.stringify({ __inscribedAuthProbe: true }),
113
+ });
114
+ const body = (await probe.text()).slice(0, 500);
115
+ console.error(`[cms-sync:debug] POST ${baseUrl}/cms/sync -> ${probe.status} ${probe.statusText}`);
116
+ if (body) console.error(` backend body: ${body}`);
117
+ if (probe.status === 401 || probe.status === 403) {
118
+ console.error(` -> auth rejected by the backend; cross-check the token claims below.`);
119
+ } else {
120
+ console.error(` -> token accepted (this status is validation, not auth) - a real 403 would be data/policy-specific.`);
121
+ }
122
+ } catch (e) {
123
+ console.error(`[cms-sync:debug] probe to ${baseUrl}/cms/sync failed: ${e instanceof Error ? e.message : String(e)}`);
124
+ }
125
+ }
126
+
89
127
  console.error("[cms-sync:debug] Service token claims:");
90
128
  console.error(` azp: ${claims.azp}`);
91
- console.error(` sub: ${claims.sub}`);
92
129
  console.error(` aud: ${JSON.stringify(claims.aud)}`);
93
- console.error(` scope: ${claims.scope}`);
94
130
  console.error(` resource_access: ${JSON.stringify(claims.resource_access)}`);
95
131
 
96
- // `cms:access` is a *client role* of the inscribed backend's Keycloak client
97
- // (e.g. "skycms"), assigned to this service account. It therefore lands under
98
- // resource_access[<backend-client>], NOT under azp (this frontend client).
99
- // Scan every client so we report where the role actually is.
100
132
  const ra = claims.resource_access ?? {};
101
133
  const holder = Object.keys(ra).find((c) => ra[c]?.roles?.includes("cms:access"));
102
- if (holder) {
103
- console.error(` -> "cms:access" found under resource_access["${holder}"].`);
104
- if (holder !== claims.azp) {
105
- console.error(` (owned by backend client "${holder}", not azp "${claims.azp}" - expected)`);
106
- }
107
- } else {
108
- console.error(` ! "cms:access" role missing from every client in resource_access.`);
109
- console.error(` Assign the backend client's "cms:access" role to this service account:`);
110
- console.error(` Keycloak Admin -> Clients -> ${claims.azp} -> Service account roles -> Assign "cms:access".`);
134
+ if (!holder) {
135
+ console.error(` ! "cms:access" missing from every client in resource_access - assign it to the`);
136
+ console.error(` service account: Keycloak -> Clients -> ${claims.azp} -> Service account roles.`);
111
137
  }
112
138
  }
113
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skylab-kulubu/inscribed-auth",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Skylab's opt-in NextAuth + Keycloak adapter (auth + service token) for the inscribed CMS.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,14 +32,14 @@
32
32
  "prepublishOnly": "npm run build"
33
33
  },
34
34
  "peerDependencies": {
35
- "inscribed": "^1.0.1",
35
+ "inscribed": "^1.1.4",
36
36
  "next": ">=14",
37
37
  "next-auth": "^4",
38
38
  "react": ">=18",
39
39
  "react-dom": ">=18"
40
40
  },
41
41
  "devDependencies": {
42
- "inscribed": "^1.0.1",
42
+ "inscribed": "^1.1.4",
43
43
  "next": ">=14",
44
44
  "next-auth": "^4",
45
45
  "react": ">=18",