@skylab-kulubu/inscribed-auth 0.1.0 → 0.2.1

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
@@ -63,6 +63,17 @@ export const authOptions = createCmsAuthOptions({
63
63
  });
64
64
  ```
65
65
 
66
+ By default `createCmsAuthOptions` also:
67
+
68
+ - sets `pages.signIn` to `/api/signin` so **every** sign-in redirect (including
69
+ the silent re-auth after a token refresh fails) jumps straight into Keycloak
70
+ instead of NextAuth's "Sign in with X" picker. Mount that route (next file)
71
+ for it to work, or pass `signInPage: false` to keep the built-in picker.
72
+ - ends the Keycloak SSO session on sign-out (RP-initiated logout via
73
+ `id_token_hint`), so signing out of the app also signs the user out of
74
+ Keycloak — otherwise the next visit silently re-authenticates against the
75
+ still-live SSO session.
76
+
66
77
  ### `app/api/auth/[...nextauth]/route.js` — NextAuth handler
67
78
 
68
79
  ```js
@@ -76,12 +87,31 @@ export { handler as GET, handler as POST };
76
87
  ### `app/api/signin/route.js` — one-click sign-in
77
88
 
78
89
  `/api/signin?callbackUrl=...` jumps straight into the Keycloak flow, skipping
79
- NextAuth's provider-picker page. Link to it from anywhere (`<a href="/api/signin">`).
90
+ NextAuth's provider-picker page. `createCmsAuthOptions` wires this as the default
91
+ `pages.signIn`, so NextAuth sends unauthenticated users here automatically; you
92
+ can also link to it from anywhere (`<a href="/api/signin">`). **Mount it** — with
93
+ `signInPage` defaulting to `/api/signin`, sign-in 404s if this route is missing
94
+ (or set `signInPage: false`).
80
95
 
81
96
  ```js
82
97
  export { GET } from "@skylab-kulubu/inscribed-auth/signin";
83
98
  ```
84
99
 
100
+ The page shows an animated Skylab loader and, if sign-in hasn't completed after
101
+ 10 s (slow network, or a strict CSP that blocks the inline script), reveals a
102
+ "continue to sign in" link so the user is never stuck. Its theming auto-adapts
103
+ to light/dark (system canvas in light, a warm near-black `#1c1815` in dark).
104
+ Being a standalone
105
+ document it can't read your app's CSS, so pass concrete values to match:
106
+
107
+ ```js
108
+ import { createSignInRoute } from "@skylab-kulubu/inscribed-auth/signin";
109
+
110
+ // background/color accept any CSS value (hex, rgb, gradient); the loader and
111
+ // text are drawn in `color`.
112
+ export const GET = createSignInRoute({ background: "#0b1020", color: "#e5e7eb" });
113
+ ```
114
+
85
115
  ### `lib/cms.jsx` — the CMS page factory
86
116
 
87
117
  ```jsx
@@ -133,7 +163,6 @@ NEXTAUTH_SECRET=<run: openssl rand -base64 32>
133
163
  CMS_URL=https://<your-cms-host>
134
164
  # Optional:
135
165
  # CMS_CDN_URL=https://<your-cdn-host>
136
- # NEXT_PUBLIC_CMS_URL=https://<your-cms-host> # read by inscribed core, client-side
137
166
  ```
138
167
 
139
168
  | Var | Required | Purpose |
@@ -143,20 +172,29 @@ CMS_URL=https://<your-cms-host>
143
172
  | `KEYCLOAK_ISSUER` | ✅ | Realm issuer URL |
144
173
  | `NEXTAUTH_URL` | ✅ | App base URL for NextAuth |
145
174
  | `NEXTAUTH_SECRET` | ✅ | NextAuth JWT/session secret |
146
- | `CMS_URL` | ✅ | inscribed backend base URL |
147
- | `CMS_CDN_URL` | — | Asset CDN base (read by inscribed) |
148
- | `NEXT_PUBLIC_CMS_URL` | — | Client-side base URL (read by inscribed) |
175
+ | `CMS_URL` | ✅ | inscribed backend base URL (→ `config.baseUrl`) |
176
+ | `CMS_CDN_URL` | — | Asset CDN base ( `config.cdnUrl`) |
149
177
 
150
178
  ## Admin access
151
179
 
152
180
  Admin operations require the `cms:access` Keycloak **client role**, both for the
153
- logged-in user (admin UI) and the service account (sync). If sync returns `403`,
154
- `onSyncError` dumps the service token's `azp` / `aud` / `resource_access` claims
155
- and tells you exactly which role mapping is missing:
156
-
157
- ```
158
- Keycloak Admin Clients <client> Service account roles assign "cms:access"
159
- ```
181
+ logged-in user (admin UI) and the service account (sync). The role belongs to
182
+ the **inscribed backend's** Keycloak client (the resource server, e.g.
183
+ `skycms`) not the frontend login client (`KEYCLOAK_CLIENT_ID`). As long as the
184
+ backend client is mapped into the token audience, the role rides along under
185
+ `resource_access["<backend-client>"]`; the SDK reads roles from every client in
186
+ `resource_access`, so it doesn't matter that the role isn't keyed under the
187
+ frontend client / token `azp`.
188
+
189
+ Grant the backend client's `cms:access` role to each principal in Keycloak Admin:
190
+
191
+ - **Admin users** → Users → \<user\> → Role mapping → assign `cms:access`
192
+ - **Service account (sync)** → Clients → `KEYCLOAK_CLIENT_ID` → Service account
193
+ roles → assign `cms:access`
194
+
195
+ If sync still returns `403`, `onSyncError` dumps the service token's `azp` /
196
+ `aud` / `resource_access` claims and reports which client (if any) actually
197
+ holds `cms:access`.
160
198
 
161
199
  ## License
162
200
 
package/dist/config.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { g as getClientCredentialsToken, d as debugServiceTokenClaims } from './service-token-COa7hHuV.js';
1
+ import { g as getClientCredentialsToken, d as debugServiceTokenClaims } from './service-token-CHJDdnHv.js';
2
2
 
3
3
  /**
4
4
  * @file `@skylab-kulubu/inscribed-auth/config` — `cms-sync` CLI wiring.
package/dist/config.js CHANGED
@@ -50,10 +50,16 @@ async function debugServiceTokenClaims() {
50
50
  console.error(` aud: ${JSON.stringify(claims.aud)}`);
51
51
  console.error(` scope: ${claims.scope}`);
52
52
  console.error(` resource_access: ${JSON.stringify(claims.resource_access)}`);
53
- const ourRoles = claims.resource_access?.[claims.azp]?.roles ?? [];
54
- console.error(` -> roles for "${claims.azp}": ${JSON.stringify(ourRoles)}`);
55
- if (!ourRoles.includes("cms:access")) {
56
- console.error(` ! "cms:access" role missing on service account.`);
53
+ const ra = claims.resource_access ?? {};
54
+ 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:`);
57
63
  console.error(` Keycloak Admin -> Clients -> ${claims.azp} -> Service account roles -> Assign "cms:access".`);
58
64
  }
59
65
  }
@@ -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 const ourRoles = claims.resource_access?.[claims.azp]?.roles ?? [];\n console.error(` -> roles for \"${claims.azp}\": ${JSON.stringify(ourRoles)}`);\n if (!ourRoles.includes(\"cms:access\")) {\n console.error(` ! \"cms:access\" role missing on 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;AAE5E,QAAM,WAAW,OAAO,kBAAkB,OAAO,GAAG,GAAG,SAAS,CAAC;AACjE,UAAQ,MAAM,mBAAmB,OAAO,GAAG,MAAM,KAAK,UAAU,QAAQ,CAAC,EAAE;AAC3E,MAAI,CAAC,SAAS,SAAS,YAAY,GAAG;AACpC,YAAQ,MAAM,mDAAmD;AACjE,YAAQ,MAAM,qCAAqC,OAAO,GAAG,mDAAmD;AAAA,EAClH;AACF;;;ACzFO,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, 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":[]}
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { jsx } from "react/jsx-runtime";
8
8
  function Inner({ config, isAdmin, userSub, initialBlocks, onAfterSave, children }) {
9
9
  const { data: session } = useSession();
10
10
  useEffect(() => {
11
- if (session?.error === "RefreshAccessTokenError") signIn();
11
+ if (session?.error === "RefreshAccessTokenError") signIn("keycloak");
12
12
  }, [session?.error]);
13
13
  const getAccessToken = useCallback(
14
14
  async () => (
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.jsx"],"sourcesContent":["\"use client\";\n\n/**\n * @file `@skylab-kulubu/inscribed-auth` — client entry.\n *\n * NextAuth-aware wrapper around inscribed's `CmsProvider`. The inscribed core is\n * auth-agnostic; this is Skylab's NextAuth + Keycloak client wiring.\n *\n * Wraps children with NextAuth's `SessionProvider` (so you don't need to add it\n * to `layout.jsx`) and feeds `useSession()` into `CmsProvider` so admin saves\n * include a valid `Authorization: Bearer` header automatically.\n *\n * This is the only client surface in the package; keeping it isolated in the\n * `.` entry is what lets the top-level `\"use client\"` above survive bundling.\n */\n\nimport { SessionProvider, signIn, signOut, useSession } from \"next-auth/react\";\nimport { useCallback, useEffect, useMemo } from \"react\";\n\nimport { CmsProvider } from \"inscribed\";\n\n/**\n * @import { CmsConfig, BlockResponse } from \"inscribed\"\n */\n\n/**\n * Inner component: needs to be inside `SessionProvider` to call `useSession`.\n *\n * @param {{\n * config: CmsConfig | { baseUrl: string },\n * isAdmin: boolean,\n * userSub: string | null,\n * initialBlocks?: BlockResponse[],\n * onAfterSave?: (slug: string) => void | Promise<void>,\n * children: React.ReactNode,\n * }} props\n */\nfunction Inner({ config, isAdmin, userSub, initialBlocks, onAfterSave, children }) {\n const { data: session } = useSession();\n\n useEffect(() => {\n if (session?.error === \"RefreshAccessTokenError\") signIn();\n }, [session?.error]);\n\n const getAccessToken = useCallback(\n async () => /** @type {string} */ (session?.accessToken ?? \"\"),\n [session?.accessToken],\n );\n\n // Surface identity for the admin panel footer. Re-build only when the\n // underlying values change so CmsProvider's memo doesn't bust on every\n // render of this component.\n const userInfo = useMemo(\n () =>\n session?.user\n ? {\n name: session.user.name ?? null,\n email: session.user.email ?? null,\n image: session.user.image ?? null,\n }\n : null,\n [session?.user?.name, session?.user?.email, session?.user?.image],\n );\n\n const onSignOut = useCallback(() => {\n signOut({ callbackUrl: \"/\" });\n }, []);\n\n return (\n <CmsProvider config={config} isAdmin={isAdmin} userSub={userSub}\n initialBlocks={initialBlocks} onAfterSave={onAfterSave}\n getAccessToken={isAdmin ? getAccessToken : undefined}\n userInfo={userInfo}\n onSignOut={onSignOut}\n >\n {children}\n </CmsProvider>\n );\n}\n\n/**\n * Drop-in replacement for `CmsProvider` when using NextAuth + Keycloak.\n *\n * The parent Server Component should:\n * 1. Call `getServerSession(authOptions)` to get the session\n * 2. Derive `isAdmin` (e.g. `session !== null`) and `userSub` (`session?.user?.id`)\n * 3. Server-fetch `initialBlocks` with `getCmsContent`\n * 4. Pass `onAfterSave={revalidateCmsSlug}` from `inscribed/actions`\n *\n * @param {{\n * config: CmsConfig | { baseUrl: string },\n * isAdmin: boolean,\n * userSub: string | null,\n * initialBlocks?: BlockResponse[],\n * onAfterSave?: (slug: string) => void | Promise<void>,\n * session?: import(\"next-auth\").Session | null,\n * children: React.ReactNode,\n * }} props\n */\nexport function NextAuthCmsProvider({ session, ...props }) {\n return (\n <SessionProvider session={session}>\n <Inner {...props} />\n </SessionProvider>\n );\n}\n"],"mappings":";;;AAgBA,SAAS,iBAAiB,QAAQ,SAAS,kBAAkB;AAC7D,SAAS,aAAa,WAAW,eAAe;AAEhD,SAAS,mBAAmB;AAkDxB;AAhCJ,SAAS,MAAM,EAAE,QAAQ,SAAS,SAAS,eAAe,aAAa,SAAS,GAAG;AACjF,QAAM,EAAE,MAAM,QAAQ,IAAI,WAAW;AAErC,YAAU,MAAM;AACd,QAAI,SAAS,UAAU,0BAA2B,QAAO;AAAA,EAC3D,GAAG,CAAC,SAAS,KAAK,CAAC;AAEnB,QAAM,iBAAiB;AAAA,IACrB;AAAA;AAAA,MAAmC,SAAS,eAAe;AAAA;AAAA,IAC3D,CAAC,SAAS,WAAW;AAAA,EACvB;AAKA,QAAM,WAAW;AAAA,IACf,MACE,SAAS,OACL;AAAA,MACE,MAAM,QAAQ,KAAK,QAAQ;AAAA,MAC3B,OAAO,QAAQ,KAAK,SAAS;AAAA,MAC7B,OAAO,QAAQ,KAAK,SAAS;AAAA,IAC/B,IACA;AAAA,IACN,CAAC,SAAS,MAAM,MAAM,SAAS,MAAM,OAAO,SAAS,MAAM,KAAK;AAAA,EAClE;AAEA,QAAM,YAAY,YAAY,MAAM;AAClC,YAAQ,EAAE,aAAa,IAAI,CAAC;AAAA,EAC9B,GAAG,CAAC,CAAC;AAEL,SACE;AAAA,IAAC;AAAA;AAAA,MAAY;AAAA,MAAgB;AAAA,MAAkB;AAAA,MAC7C;AAAA,MAA8B;AAAA,MAC9B,gBAAgB,UAAU,iBAAiB;AAAA,MAC3C;AAAA,MACA;AAAA,MAEC;AAAA;AAAA,EACH;AAEJ;AAqBO,SAAS,oBAAoB,EAAE,SAAS,GAAG,MAAM,GAAG;AACzD,SACE,oBAAC,mBAAgB,SACf,8BAAC,SAAO,GAAG,OAAO,GACpB;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../src/index.jsx"],"sourcesContent":["\"use client\";\n\n/**\n * @file `@skylab-kulubu/inscribed-auth` — client entry.\n *\n * NextAuth-aware wrapper around inscribed's `CmsProvider`. The inscribed core is\n * auth-agnostic; this is Skylab's NextAuth + Keycloak client wiring.\n *\n * Wraps children with NextAuth's `SessionProvider` (so you don't need to add it\n * to `layout.jsx`) and feeds `useSession()` into `CmsProvider` so admin saves\n * include a valid `Authorization: Bearer` header automatically.\n *\n * This is the only client surface in the package; keeping it isolated in the\n * `.` entry is what lets the top-level `\"use client\"` above survive bundling.\n */\n\nimport { SessionProvider, signIn, signOut, useSession } from \"next-auth/react\";\nimport { useCallback, useEffect, useMemo } from \"react\";\n\nimport { CmsProvider } from \"inscribed\";\n\n/**\n * @import { CmsConfig, BlockResponse } from \"inscribed\"\n */\n\n/**\n * Inner component: needs to be inside `SessionProvider` to call `useSession`.\n *\n * @param {{\n * config: CmsConfig | { baseUrl: string },\n * isAdmin: boolean,\n * userSub: string | null,\n * initialBlocks?: BlockResponse[],\n * onAfterSave?: (slug: string) => void | Promise<void>,\n * children: React.ReactNode,\n * }} props\n */\nfunction Inner({ config, isAdmin, userSub, initialBlocks, onAfterSave, children }) {\n const { data: session } = useSession();\n\n useEffect(() => {\n // Pass the provider id explicitly. Bare `signIn()` redirects to NextAuth's\n // provider-picker (\"Sign in with Keycloak\") interstitial; `signIn(\"keycloak\")`\n // POSTs straight into the Keycloak flow and silently re-auths while the\n // Keycloak SSO session is still alive - no extra page, no extra click.\n if (session?.error === \"RefreshAccessTokenError\") signIn(\"keycloak\");\n }, [session?.error]);\n\n const getAccessToken = useCallback(\n async () => /** @type {string} */ (session?.accessToken ?? \"\"),\n [session?.accessToken],\n );\n\n // Surface identity for the admin panel footer. Re-build only when the\n // underlying values change so CmsProvider's memo doesn't bust on every\n // render of this component.\n const userInfo = useMemo(\n () =>\n session?.user\n ? {\n name: session.user.name ?? null,\n email: session.user.email ?? null,\n image: session.user.image ?? null,\n }\n : null,\n [session?.user?.name, session?.user?.email, session?.user?.image],\n );\n\n const onSignOut = useCallback(() => {\n signOut({ callbackUrl: \"/\" });\n }, []);\n\n return (\n <CmsProvider config={config} isAdmin={isAdmin} userSub={userSub}\n initialBlocks={initialBlocks} onAfterSave={onAfterSave}\n getAccessToken={isAdmin ? getAccessToken : undefined}\n userInfo={userInfo}\n onSignOut={onSignOut}\n >\n {children}\n </CmsProvider>\n );\n}\n\n/**\n * Drop-in replacement for `CmsProvider` when using NextAuth + Keycloak.\n *\n * The parent Server Component should:\n * 1. Call `getServerSession(authOptions)` to get the session\n * 2. Derive `isAdmin` (e.g. `session !== null`) and `userSub` (`session?.user?.id`)\n * 3. Server-fetch `initialBlocks` with `getCmsContent`\n * 4. Pass `onAfterSave={revalidateCmsSlug}` from `inscribed/actions`\n *\n * @param {{\n * config: CmsConfig | { baseUrl: string },\n * isAdmin: boolean,\n * userSub: string | null,\n * initialBlocks?: BlockResponse[],\n * onAfterSave?: (slug: string) => void | Promise<void>,\n * session?: import(\"next-auth\").Session | null,\n * children: React.ReactNode,\n * }} props\n */\nexport function NextAuthCmsProvider({ session, ...props }) {\n return (\n <SessionProvider session={session}>\n <Inner {...props} />\n </SessionProvider>\n );\n}"],"mappings":";;;AAgBA,SAAS,iBAAiB,QAAQ,SAAS,kBAAkB;AAC7D,SAAS,aAAa,WAAW,eAAe;AAEhD,SAAS,mBAAmB;AAsDxB;AApCJ,SAAS,MAAM,EAAE,QAAQ,SAAS,SAAS,eAAe,aAAa,SAAS,GAAG;AACjF,QAAM,EAAE,MAAM,QAAQ,IAAI,WAAW;AAErC,YAAU,MAAM;AAKd,QAAI,SAAS,UAAU,0BAA2B,QAAO,UAAU;AAAA,EACrE,GAAG,CAAC,SAAS,KAAK,CAAC;AAEnB,QAAM,iBAAiB;AAAA,IACrB;AAAA;AAAA,MAAmC,SAAS,eAAe;AAAA;AAAA,IAC3D,CAAC,SAAS,WAAW;AAAA,EACvB;AAKA,QAAM,WAAW;AAAA,IACf,MACE,SAAS,OACL;AAAA,MACE,MAAM,QAAQ,KAAK,QAAQ;AAAA,MAC3B,OAAO,QAAQ,KAAK,SAAS;AAAA,MAC7B,OAAO,QAAQ,KAAK,SAAS;AAAA,IAC/B,IACA;AAAA,IACN,CAAC,SAAS,MAAM,MAAM,SAAS,MAAM,OAAO,SAAS,MAAM,KAAK;AAAA,EAClE;AAEA,QAAM,YAAY,YAAY,MAAM;AAClC,YAAQ,EAAE,aAAa,IAAI,CAAC;AAAA,EAC9B,GAAG,CAAC,CAAC;AAEL,SACE;AAAA,IAAC;AAAA;AAAA,MAAY;AAAA,MAAgB;AAAA,MAAkB;AAAA,MAC7C;AAAA,MAA8B;AAAA,MAC9B,gBAAgB,UAAU,iBAAiB;AAAA,MAC3C;AAAA,MACA;AAAA,MAEC;AAAA;AAAA,EACH;AAEJ;AAqBO,SAAS,oBAAoB,EAAE,SAAS,GAAG,MAAM,GAAG;AACzD,SACE,oBAAC,mBAAgB,SACf,8BAAC,SAAO,GAAG,OAAO,GACpB;AAEJ;","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-COa7hHuV.js';
2
+ export { d as debugServiceTokenClaims, g as getClientCredentialsToken } from './service-token-CHJDdnHv.js';
3
3
 
4
4
  /**
5
5
  * @typedef {Object} CmsAuthMeta
@@ -18,6 +18,11 @@ export { d as debugServiceTokenClaims, g as getClientCredentialsToken } from './
18
18
  * Override admin gating with arbitrary logic. Wins over `adminRole`.
19
19
  * @property {number} [refreshLeadTimeMs]
20
20
  * Refresh access tokens this many ms before expiry. Default 10 000.
21
+ * @property {string|false} [signInPage]
22
+ * Path NextAuth redirects unauthenticated users to. Defaults to
23
+ * `"/api/signin"` (the package's auto-submit route) so sign-in jumps straight
24
+ * into Keycloak instead of NextAuth's built-in "Sign in with X" picker. Set
25
+ * `false` to keep the picker.
21
26
  * @property {Partial<import("next-auth").AuthOptions["callbacks"]>} [extraCallbacks]
22
27
  * Extra callbacks merged on top of the built-in ones. Each callback runs
23
28
  * AFTER the built-in version and receives the already-augmented value.
@@ -96,6 +101,13 @@ type CreateCmsAuthOptionsInput = {
96
101
  * Refresh access tokens this many ms before expiry. Default 10 000.
97
102
  */
98
103
  refreshLeadTimeMs?: number | undefined;
104
+ /**
105
+ * Path NextAuth redirects unauthenticated users to. Defaults to
106
+ * `"/api/signin"` (the package's auto-submit route) so sign-in jumps straight
107
+ * into Keycloak instead of NextAuth's built-in "Sign in with X" picker. Set
108
+ * `false` to keep the picker.
109
+ */
110
+ signInPage?: string | false | undefined;
99
111
  /**
100
112
  * Extra callbacks merged on top of the built-in ones. Each callback runs
101
113
  * AFTER the built-in version and receives the already-augmented value.
package/dist/server.js CHANGED
@@ -7,6 +7,7 @@ function createCmsAuthOptions(input) {
7
7
  adminRole = "cms:access",
8
8
  isAdmin,
9
9
  refreshLeadTimeMs = 1e4,
10
+ signInPage = "/api/signin",
10
11
  extraCallbacks,
11
12
  extraOptions
12
13
  } = input ?? {};
@@ -15,7 +16,6 @@ function createCmsAuthOptions(input) {
15
16
  "createCmsAuthOptions: `provider` is required. Import a NextAuth provider (e.g. `next-auth/providers/keycloak`) in your own auth file and pass the configured instance."
16
17
  );
17
18
  }
18
- const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID ?? "";
19
19
  const base = {
20
20
  providers: [provider],
21
21
  callbacks: {
@@ -28,13 +28,14 @@ function createCmsAuthOptions(input) {
28
28
  next.accessTokenExpires = typeof account.expires_at === "number" ? account.expires_at * 1e3 : 0;
29
29
  next.sub = account.providerAccountId ?? next.sub;
30
30
  next.error = void 0;
31
- next.clientRoles = readClientRoles(account.access_token, keycloakClientId);
31
+ next.idToken = account.id_token;
32
+ next.clientRoles = readClientRoles(account.access_token);
32
33
  } else if (
33
34
  // 2. Previous refresh failed - bail until the user re-authenticates.
34
35
  next.error !== "RefreshAccessTokenError" && // 3. Token still valid (with lead time) - return as-is.
35
36
  (typeof next.accessTokenExpires !== "number" || Date.now() >= next.accessTokenExpires - refreshLeadTimeMs)
36
37
  ) {
37
- next = await refreshAccessToken(next, keycloakClientId);
38
+ next = await refreshAccessToken(next);
38
39
  }
39
40
  if (extraCallbacks?.jwt) {
40
41
  const overridden = await extraCallbacks.jwt({ ...args, token: next });
@@ -65,7 +66,25 @@ function createCmsAuthOptions(input) {
65
66
  )
66
67
  ) : {}
67
68
  },
68
- ...extraOptions
69
+ ...extraOptions,
70
+ pages: {
71
+ ...signInPage ? { signIn: signInPage } : {},
72
+ ...extraOptions?.pages
73
+ },
74
+ events: {
75
+ ...extraOptions?.events,
76
+ // Federated (RP-initiated) logout: also end the Keycloak SSO session, so
77
+ // the next sign-in doesn't silently re-authenticate against a still-live
78
+ // session. A consumer-supplied signOut (preserved by the spread above)
79
+ // still runs afterwards.
80
+ async signOut(message) {
81
+ const token = message && "token" in message ? message.token : null;
82
+ await endKeycloakSession(token?.idToken);
83
+ if (typeof extraOptions?.events?.signOut === "function") {
84
+ await extraOptions.events.signOut(message);
85
+ }
86
+ }
87
+ }
69
88
  };
70
89
  const meta = {
71
90
  adminRole: isAdmin ? null : adminRole,
@@ -98,20 +117,21 @@ function withCmsAuth(authOptions) {
98
117
  deriveUserSub: (session) => session?.user?.id ?? null
99
118
  };
100
119
  }
101
- function readClientRoles(accessToken, clientId) {
102
- if (!accessToken || !clientId) return [];
120
+ function readClientRoles(accessToken) {
121
+ if (!accessToken) return [];
103
122
  const segments = accessToken.split(".");
104
123
  if (segments.length < 2) return [];
105
124
  try {
106
125
  const payload = JSON.parse(
107
126
  Buffer.from(segments[1], "base64url").toString("utf8")
108
127
  );
109
- return payload?.resource_access?.[clientId]?.roles ?? [];
128
+ const resourceAccess = payload?.resource_access ?? {};
129
+ return Object.values(resourceAccess).flatMap((client) => client?.roles ?? []);
110
130
  } catch {
111
131
  return [];
112
132
  }
113
133
  }
114
- async function refreshAccessToken(token, keycloakClientId) {
134
+ async function refreshAccessToken(token) {
115
135
  try {
116
136
  const issuer = process.env.KEYCLOAK_ISSUER ?? "";
117
137
  const response = await fetch(`${issuer}/protocol/openid-connect/token`, {
@@ -131,7 +151,8 @@ async function refreshAccessToken(token, keycloakClientId) {
131
151
  accessToken: refreshed.access_token,
132
152
  accessTokenExpires: Date.now() + refreshed.expires_in * 1e3,
133
153
  refreshToken: refreshed.refresh_token ?? token.refreshToken,
134
- clientRoles: readClientRoles(refreshed.access_token, keycloakClientId),
154
+ idToken: refreshed.id_token ?? token.idToken,
155
+ clientRoles: readClientRoles(refreshed.access_token),
135
156
  error: void 0
136
157
  };
137
158
  } catch (error) {
@@ -143,6 +164,18 @@ async function refreshAccessToken(token, keycloakClientId) {
143
164
  };
144
165
  }
145
166
  }
167
+ async function endKeycloakSession(idToken) {
168
+ const issuer = process.env.KEYCLOAK_ISSUER;
169
+ if (!idToken || !issuer) return;
170
+ try {
171
+ await fetch(
172
+ `${issuer}/protocol/openid-connect/logout?${new URLSearchParams({
173
+ id_token_hint: idToken
174
+ })}`
175
+ );
176
+ } catch {
177
+ }
178
+ }
146
179
 
147
180
  // src/lib/service-token.mjs
148
181
  var cache = null;
@@ -196,10 +229,16 @@ async function debugServiceTokenClaims() {
196
229
  console.error(` aud: ${JSON.stringify(claims.aud)}`);
197
230
  console.error(` scope: ${claims.scope}`);
198
231
  console.error(` resource_access: ${JSON.stringify(claims.resource_access)}`);
199
- const ourRoles = claims.resource_access?.[claims.azp]?.roles ?? [];
200
- console.error(` -> roles for "${claims.azp}": ${JSON.stringify(ourRoles)}`);
201
- if (!ourRoles.includes("cms:access")) {
202
- console.error(` ! "cms:access" role missing on service account.`);
232
+ const ra = claims.resource_access ?? {};
233
+ 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:`);
203
242
  console.error(` Keycloak Admin -> Clients -> ${claims.azp} -> Service account roles -> Assign "cms:access".`);
204
243
  }
205
244
  }
@@ -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 {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 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 const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID ?? \"\";\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.clientRoles = readClientRoles(account.access_token, keycloakClientId);\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, keycloakClientId);\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 };\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 roles scoped to the\n * given client. Signature isn't verified - the token came from Keycloak\n * directly via the OAuth flow, so trust is established.\n *\n * @param {string|undefined} accessToken\n * @param {string} clientId\n * @returns {string[]}\n */\nfunction readClientRoles(accessToken, clientId) {\n if (!accessToken || !clientId) 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?.resource_access?.[clientId]?.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 * @param {string} keycloakClientId\n */\nasync function refreshAccessToken(token, keycloakClientId) {\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 clientRoles: readClientRoles(refreshed.access_token, keycloakClientId),\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 * @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 const ourRoles = claims.resource_access?.[claims.azp]?.roles ?? [];\n console.error(` -> roles for \"${claims.azp}\": ${JSON.stringify(ourRoles)}`);\n if (!ourRoles.includes(\"cms:access\")) {\n console.error(` ! \"cms:access\" role missing on 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 *\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":[]}
@@ -93,10 +93,20 @@ async function debugServiceTokenClaims() {
93
93
  console.error(` scope: ${claims.scope}`);
94
94
  console.error(` resource_access: ${JSON.stringify(claims.resource_access)}`);
95
95
 
96
- const ourRoles = claims.resource_access?.[claims.azp]?.roles ?? [];
97
- console.error(` -> roles for "${claims.azp}": ${JSON.stringify(ourRoles)}`);
98
- if (!ourRoles.includes("cms:access")) {
99
- console.error(` ! "cms:access" role missing on service account.`);
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
+ const ra = claims.resource_access ?? {};
101
+ 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:`);
100
110
  console.error(` Keycloak Admin -> Clients -> ${claims.azp} -> Service account roles -> Assign "cms:access".`);
101
111
  }
102
112
  }
package/dist/signin.d.ts CHANGED
@@ -38,6 +38,17 @@
38
38
  * Override the NextAuth sign-in mount path. Default `/api/auth/signin`.
39
39
  * @property {string} [csrfPath]
40
40
  * Override the NextAuth CSRF endpoint. Default `/api/auth/csrf`.
41
+ * @property {string} [background]
42
+ * CSS background for the sign-in splash. Defaults to `light-dark(Canvas,
43
+ * #1c1815)` — light mode follows the system canvas, dark mode uses a warm
44
+ * near-black. Pass your app's background as a concrete value (hex, `rgb()`,
45
+ * gradient, or your own `light-dark(...)`) to match its theme. This is a
46
+ * separate document, so app-defined custom properties (`var(--...)`) aren't
47
+ * available here — pass the resolved value or wire it from the same
48
+ * source/env the app uses.
49
+ * @property {string} [color]
50
+ * CSS foreground color for the logo + text. Defaults to `CanvasText`. The
51
+ * loader is drawn in `currentColor`, so this is what tints it.
41
52
  */
42
53
  /**
43
54
  * @param {CreateSignInRouteOptions} [options]
@@ -62,6 +73,21 @@ type CreateSignInRouteOptions = {
62
73
  * Override the NextAuth CSRF endpoint. Default `/api/auth/csrf`.
63
74
  */
64
75
  csrfPath?: string | undefined;
76
+ /**
77
+ * CSS background for the sign-in splash. Defaults to `light-dark(Canvas,
78
+ * #1c1815)` — light mode follows the system canvas, dark mode uses a warm
79
+ * near-black. Pass your app's background as a concrete value (hex, `rgb()`,
80
+ * gradient, or your own `light-dark(...)`) to match its theme. This is a
81
+ * separate document, so app-defined custom properties (`var(--...)`) aren't
82
+ * available here — pass the resolved value or wire it from the same
83
+ * source/env the app uses.
84
+ */
85
+ background?: string | undefined;
86
+ /**
87
+ * CSS foreground color for the logo + text. Defaults to `CanvasText`. The
88
+ * loader is drawn in `currentColor`, so this is what tints it.
89
+ */
90
+ color?: string | undefined;
65
91
  };
66
92
 
67
93
  export { type CreateSignInRouteOptions, GET, createSignInRoute };
package/dist/signin.js CHANGED
@@ -4,7 +4,9 @@ function createSignInRoute(options = {}) {
4
4
  provider = "keycloak",
5
5
  defaultCallbackUrl = "/",
6
6
  signInPath = "/api/auth/signin",
7
- csrfPath = "/api/auth/csrf"
7
+ csrfPath = "/api/auth/csrf",
8
+ background = "light-dark(Canvas, #1c1815)",
9
+ color = "CanvasText"
8
10
  } = options;
9
11
  const signInUrl = provider ? `${signInPath}/${provider}` : signInPath;
10
12
  return function GET2(request) {
@@ -14,6 +16,8 @@ function createSignInRoute(options = {}) {
14
16
  signInUrl,
15
17
  csrfPath,
16
18
  callbackUrl,
19
+ background,
20
+ color,
17
21
  // <noscript> fallback - NextAuth's own GET sign-in page, which
18
22
  // shows the "Sign in with X" button. It's a worse UX (extra click)
19
23
  // but it's the only thing that works without JS.
@@ -30,27 +34,130 @@ function createSignInRoute(options = {}) {
30
34
  });
31
35
  };
32
36
  }
33
- function renderAutoSubmitPage({ signInUrl, csrfPath, callbackUrl, noscriptHref }) {
37
+ var LOGO_PATHS = {
38
+ p12f85900: "M202.27 271.08C202.27 271.42 202.14 277.72 201.97 285.09C201.8 292.46 201.87 298.27 202.13 298.01C202.39 297.75 204.01 292.65 205.72 286.69L208.83 275.84L205.55 273.15C203.74 271.67 202.27 270.74 202.28 271.07L202.27 271.08Z",
39
+ p15984f0: "M254.51 96.18C259.05 97.81 261.03 102.32 261.5 112.09L261.86 119.63L263.66 115.21C264.03 114.13 264.4 113.05 264.78 111.97C264.94 108.59 265.16 103.34 265.27 96.87C265.45 86.64 265.03 83.81 266.57 79.93C268.03 76.25 270.98 73.18 276.83 67.09L285.42 58.14L289.06 47.76C290.39 43.98 291.67 40.79 292.48 39.15C292.81 38.48 293.06 38.08 293.21 38C293.35 38.07 293.61 38.48 293.94 39.15C294.75 40.79 296.03 43.98 297.36 47.76L301 58.14L309.59 67.09C315.44 73.18 318.39 76.25 319.85 79.93C321.39 83.81 320.97 86.63 321.15 96.87C321.26 103.34 321.48 108.59 321.64 111.97C322.01 113.05 322.38 114.13 322.76 115.21L324.56 119.63L324.92 112.09C325.39 102.32 327.37 97.81 331.91 96.18C333.56 95.59 335 95.05 335.11 94.98C335.35 94.83 320.48 53.26 314.4 37.09C303.07 6.93 301.53 3.78 296.83 1.27C295.42 0.52 294.65 0.12 293.94 0.02C293.66 9.31323e-10 293.46 -0.01 293.2 0C292.95 0 292.75 9.31323e-10 292.48 0.02C291.77 0.12 291 0.52 289.59 1.27C284.89 3.77 283.35 6.93 272.02 37.09C265.95 53.26 251.08 94.83 251.31 94.98C251.41 95.04 252.85 95.58 254.51 96.18Z",
40
+ p1a20d600: "M300.36 401.43C298.05 399.2 295.67 398.18 293.2 398.37C290.74 398.19 288.37 399.2 286.06 401.43C281.41 405.92 250.43 443.34 249.35 445.77C248.76 447.1 248.46 449.28 248.68 450.63C249.47 455.44 285.34 548.6 287.14 550.51C289.04 552.53 291.22 553.7 293.21 553.9C293.21 553.9 293.21 553.9 293.22 553.9C295.2 553.7 297.38 552.53 299.29 550.51C301.09 548.6 336.96 455.44 337.75 450.63C337.97 449.29 337.67 447.1 337.08 445.77C336 443.34 305.02 405.93 300.37 401.43H300.36ZM305.97 484.19C299.28 502.24 293.6 517.1 293.35 517.22C293.34 517.22 293.29 517.15 293.23 517.03V517C293.23 517 293.23 517 293.23 517.02C293.23 517.02 293.23 517.02 293.23 517V517.03C293.17 517.16 293.12 517.23 293.11 517.22C292.86 517.11 287.18 502.25 280.49 484.19C273.8 466.14 268.38 451.21 268.44 451.01C268.86 449.67 292.39 422.73 293.11 422.77C293.13 422.77 293.18 422.8 293.23 422.83C293.28 422.79 293.33 422.76 293.35 422.76C294.06 422.72 317.6 449.67 318.02 451C318.08 451.2 312.66 466.13 305.97 484.18V484.19Z",
41
+ p1cd4e100: "M156.39 277.82V437.85L174.06 420.19L191.73 402.53V323.38C191.73 279.85 191.48 244.25 191.17 244.27C190.86 244.29 182.91 251.84 173.5 261.06L156.39 277.81V277.82Z",
42
+ p1dae6200: "M585.43 359.08C584.42 356.66 514 285.9 509.93 283.22C508.79 282.47 506.67 281.86 505.22 281.85C502.56 281.85 455.76 294.86 449.76 297.27C443.91 299.62 442.21 304.11 444.6 310.89C447.69 319.64 467.07 359.3 469.19 361.22C472.21 363.95 486.86 365.69 538.48 369.43C576.82 372.21 580.5 372.11 584.74 368.14C586.7 366.3 586.96 362.78 585.42 359.09L585.43 359.08ZM486.71 346.88L481.34 346.4L473.69 329.81C469.49 320.69 466.19 312.84 466.37 312.38C466.63 311.71 500.83 301.22 502.22 301.38C502.43 301.4 513.85 312.45 527.6 325.92C541.35 339.39 552.6 350.64 552.6 350.92C552.6 351.35 502.15 348.26 486.7 346.88H486.71Z",
43
+ p20f79d00: "M284.09 79.39L275.34 88.23L275.08 235.98L274.82 383.73H293.23H293.95H311.62L311.36 235.98L311.1 88.23L302.35 79.39C298.65 75.66 295.38 72.46 293.95 71.17C293.56 70.82 293.31 70.61 293.23 70.58C293.15 70.62 292.9 70.83 292.51 71.18C291.08 72.47 287.81 75.67 284.11 79.4L284.09 79.39Z",
44
+ p28bb8280: "M136.66 297.27C130.66 294.86 83.86 281.84 81.2 281.85C79.75 281.85 77.63 282.47 76.49 283.22C72.42 285.9 2 356.66 0.99 359.08C-0.55 362.77 -0.29 366.3 1.67 368.13C5.91 372.1 9.59 372.21 47.93 369.42C99.56 365.68 114.21 363.94 117.22 361.21C119.34 359.29 138.72 319.62 141.81 310.88C144.2 304.1 142.5 299.62 136.65 297.26L136.66 297.27ZM112.73 329.81L105.08 346.4L99.71 346.88C84.27 348.26 33.81 351.35 33.81 350.92C33.81 350.64 45.06 339.39 58.81 325.92C72.56 312.45 83.98 301.4 84.19 301.38C85.58 301.22 119.78 311.71 120.04 312.38C120.22 312.84 116.92 320.68 112.72 329.81H112.73Z",
45
+ p2cc72200: "M251.13 383.35V244.62C251.13 168.32 251.04 105.89 250.94 105.89C250.84 105.89 242.88 113.76 233.27 123.38L215.79 140.87V383.35H251.13Z",
46
+ p3097fa00: "M395.27 244.28C394.96 244.26 394.71 279.86 394.71 323.39V402.54L412.38 420.2L430.05 437.86V277.83L412.94 261.08C403.53 251.87 395.58 244.31 395.27 244.29V244.28Z",
47
+ p36c88b00: "M320.98 368.13V378.84C322.24 379.74 323.5 380.64 324.77 381.54V359.68C323.51 358.72 322.25 357.75 320.98 356.79V368.14V368.13Z",
48
+ p3b5dc900: "M392.63 160.9L381.16 160.78V179.44L387.36 179.87C390.77 180.11 406.25 180.66 421.76 181.09C461.49 182.2 494.23 183.36 494.54 183.68C494.76 183.9 419.91 243.04 415.44 246.17C414.1 247.11 414.39 247.59 420.22 253.98C423.64 257.72 426.65 260.78 426.91 260.77C427.18 260.77 440.59 250.44 456.72 237.83C472.85 225.22 489.09 212.56 492.81 209.69C496.53 206.83 505.82 199.46 513.46 193.32C524.59 184.37 527.7 181.46 529.15 178.64C532.97 171.23 529.66 167.27 518.62 166.05C506.9 164.75 466.22 162.84 423.63 161.59C412.88 161.27 398.92 160.96 392.61 160.9H392.63Z",
49
+ p3cad3800: "M384.3 298.01C384.56 298.27 384.64 292.46 384.46 285.09C384.29 277.72 384.15 271.42 384.16 271.08C384.16 270.74 382.69 271.68 380.89 273.16L377.61 275.85L380.72 286.7C382.43 292.67 384.04 297.76 384.31 298.02L384.3 298.01Z",
50
+ p4c50c00: "M261.67 381.54C262.93 380.64 264.19 379.74 265.46 378.84V356.79C264.2 357.75 262.94 358.72 261.67 359.68V381.54Z",
51
+ p543f872: "M370.64 383.35V140.87L353.16 123.38C343.55 113.76 335.6 105.89 335.49 105.89C335.38 105.89 335.3 168.32 335.3 244.62V383.35H370.64Z",
52
+ p596f400: "M213.15 391.35L201.88 398.68C201.54 400.62 201.3 402.3 200.64 404.18C198.89 409.17 186.19 420.53 181.71 424.97C177.48 429.15 172.75 433.77 168.62 438.06C165 441.82 157.27 448.3 152.12 444.77C150.93 443.95 150.05 443.12 149.18 441.96C147.49 439.71 145.46 431.83 145.5 431.49C145.59 430.65 140.15 446.25 146.13 450.76C147.39 451.71 149 452.13 149.46 452.23C152.29 452.85 155.07 451.97 158.13 450.79C165.4 447.97 173.68 441.39 180.31 437.02C194.5 427.67 208.44 417.81 222.54 408.1C228.96 403.68 236.37 398.88 242.73 394.58C243.34 394.17 244.14 393.3 244.49 392.28H215.41C215.19 392.28 213.81 391.48 213.16 391.35H213.15Z",
53
+ p5ee5d00: "M379.16 133.04C380.94 136.68 383.44 137.87 389 137.73C396.82 137.53 442.29 129.24 445.2 127.48C447.15 126.3 452.13 115.85 457.1 102.5C468.6 71.62 483.02 30.41 483.81 26.11C484.47 22.55 483.05 17.5 480.99 16.06C480.08 15.42 477.77 14.9 475.84 14.9C471.63 14.9 469.88 15.77 443.21 31.15C432.46 37.35 414 47.92 402.18 54.64C385.46 64.15 380.38 67.39 379.25 69.26C377.87 71.54 377.79 73.28 377.82 100.99C377.84 127.41 377.98 130.58 379.17 133.03L379.16 133.04ZM396.23 98.56L396.25 79.2L426.42 61.91C443.01 52.4 456.93 44.48 457.35 44.33C457.78 44.17 457.9 44.58 457.63 45.29C457.37 45.98 454.75 53.38 451.82 61.75C448.89 70.11 445.69 79.15 444.71 81.84C443.73 84.53 440.99 92.31 438.62 99.13C436.25 105.95 434.27 111.61 434.21 111.7C434.12 111.84 397.28 117.93 396.52 117.93C396.35 117.93 396.22 109.22 396.23 98.57V98.56Z",
54
+ p7800: "M129.34 102.51C134.31 115.85 139.29 126.31 141.24 127.49C144.14 129.24 189.62 137.53 197.44 137.74C203 137.88 205.5 136.69 207.28 133.05C208.47 130.61 208.61 127.44 208.63 101.01C208.66 73.3 208.58 71.55 207.2 69.28C206.06 67.4 200.99 64.17 184.27 54.66C172.46 47.94 154 37.37 143.24 31.17C116.57 15.79 114.82 14.92 110.61 14.92C108.68 14.92 106.37 15.44 105.46 16.08C103.4 17.52 101.98 22.57 102.64 26.13C103.43 30.43 117.85 71.64 129.35 102.52L129.34 102.51ZM129.08 44.32C129.5 44.48 143.41 52.39 160.01 61.9L190.18 79.19L190.2 98.55C190.21 109.2 190.08 117.91 189.91 117.91C189.14 117.91 152.3 111.82 152.22 111.68C152.17 111.59 150.18 105.94 147.81 99.11C145.44 92.29 142.7 84.5 141.72 81.82C140.74 79.13 137.54 70.09 134.61 61.73C131.68 53.37 129.07 45.96 128.8 45.27C128.53 44.56 128.65 44.14 129.08 44.31V44.32Z",
55
+ p9522b80: "M72.95 193.32C80.58 199.46 89.87 206.83 93.6 209.69C97.32 212.55 113.56 225.22 129.69 237.83C145.82 250.44 159.23 260.77 159.5 260.77C159.77 260.77 162.78 257.72 166.19 253.98C172.02 247.6 172.31 247.11 170.97 246.17C166.5 243.04 91.65 183.9 91.87 183.68C92.18 183.37 124.92 182.2 164.65 181.09C180.16 180.66 195.64 180.11 199.05 179.87L205.25 179.44V160.78L193.78 160.9C187.47 160.97 173.52 161.28 162.76 161.59C120.17 162.84 79.49 164.75 67.77 166.05C56.73 167.27 53.42 171.23 57.24 178.64C58.69 181.46 61.8 184.37 72.93 193.32H72.95Z",
56
+ pe3cee00: "M440.93 431.49C440.97 431.83 438.94 439.71 437.25 441.96C436.38 443.12 435.5 443.95 434.31 444.77C429.16 448.3 421.43 441.82 417.81 438.06C413.68 433.77 408.94 429.15 404.72 424.97C400.24 420.54 387.54 409.17 385.79 404.18C385.13 402.29 384.89 400.62 384.55 398.68L373.28 391.35C372.63 391.48 371.25 392.28 371.03 392.28H341.95C342.3 393.31 343.1 394.17 343.71 394.58C350.07 398.88 357.47 403.68 363.9 408.1C378 417.81 391.95 427.67 406.13 437.02C412.77 441.39 421.05 447.97 428.31 450.79C431.37 451.98 434.15 452.86 436.98 452.23C437.43 452.13 439.05 451.71 440.31 450.76C446.29 446.25 440.85 430.65 440.94 431.49H440.93Z"
57
+ };
58
+ var LOGO_GROUPS = [
59
+ ["p20f79d00"],
60
+ ["p15984f0", "p1a20d600"],
61
+ ["p2cc72200", "p543f872", "p4c50c00", "p36c88b00"],
62
+ ["p1cd4e100", "p3097fa00", "p12f85900", "p3cad3800"],
63
+ ["p28bb8280", "p1dae6200", "p7800", "p5ee5d00"],
64
+ ["p9522b80", "p3b5dc900"],
65
+ ["p596f400", "pe3cee00"]
66
+ ];
67
+ function logoGroupIndex(key) {
68
+ for (let g = 0; g < LOGO_GROUPS.length; g++) {
69
+ if (LOGO_GROUPS[g].includes(key)) return g;
70
+ }
71
+ return LOGO_GROUPS.length;
72
+ }
73
+ function renderLogoPaths() {
74
+ return Object.keys(LOGO_PATHS).map((key) => {
75
+ const delay = (logoGroupIndex(key) * 0.1).toFixed(1);
76
+ return `<path d="${LOGO_PATHS[key]}" fill-rule="evenodd" clip-rule="evenodd" style="animation-delay:${delay}s"/>`;
77
+ }).join("");
78
+ }
79
+ function renderAutoSubmitPage({ signInUrl, csrfPath, callbackUrl, noscriptHref, background, color }) {
34
80
  const jsSignInUrl = escapeForScript(signInUrl);
35
81
  const jsCsrfPath = escapeForScript(csrfPath);
36
82
  const jsCallbackUrl = escapeForScript(callbackUrl);
37
83
  const htmlNoscriptHref = escapeForHtmlAttribute(noscriptHref);
84
+ const bg = cssValue(background, "light-dark(Canvas, #1c1815)");
85
+ const fg = cssValue(color, "CanvasText");
38
86
  return `<!DOCTYPE html>
39
- <html lang="en">
87
+ <html lang="tr">
40
88
  <head>
41
89
  <meta charset="utf-8">
42
- <title>Signing in\u2026</title>
90
+ <meta name="viewport" content="width=device-width, initial-scale=1">
91
+ <title>Giri\u015F yap\u0131l\u0131yor\u2026</title>
43
92
  <meta name="robots" content="noindex,nofollow">
44
93
  <style>
45
- body { margin: 0; min-height: 100dvh; display: grid; place-items: center;
46
- font: 14px/1.5 system-ui, -apple-system, sans-serif; color: #6b7280; }
94
+ * { box-sizing: border-box; }
95
+ html, body { height: 100%; }
96
+ html { color-scheme: light dark; }
97
+ body {
98
+ margin: 0; min-height: 100dvh;
99
+ display: grid; place-items: center;
100
+ background: ${bg}; color: ${fg};
101
+ font: 14px/1.5 system-ui, -apple-system, sans-serif;
102
+ }
103
+ .loader { position: relative; width: 96px; height: 96px; }
104
+ .glow {
105
+ position: absolute; inset: 0; border-radius: 9999px;
106
+ background: radial-gradient(circle, color-mix(in srgb, currentColor 9%, transparent) 0%, transparent 70%);
107
+ filter: blur(8px);
108
+ animation: glowPulse 3.2s ease-in-out infinite;
109
+ }
110
+ .logo { display: block; width: 100%; height: 100%; }
111
+ .logo path {
112
+ fill: url(#skylab-shimmer);
113
+ transform-box: view-box; transform-origin: 293px 277px;
114
+ opacity: 0;
115
+ animation: pathPulse 3.2s ease-in-out infinite;
116
+ }
117
+ .fallback {
118
+ position: fixed; left: 50%; top: calc(50% + 84px);
119
+ transform: translateX(-50%);
120
+ margin: 0; text-align: center; white-space: nowrap;
121
+ opacity: 0; pointer-events: none;
122
+ animation: reveal .5s ease-out 10s forwards;
123
+ }
124
+ .fallback.show { opacity: 1; pointer-events: auto; animation: none; }
125
+ .fallback a { color: inherit; text-underline-offset: 3px; }
126
+ @keyframes glowPulse {
127
+ 0% { opacity: 0; } 30% { opacity: .6; } 50% { opacity: 1; }
128
+ 75% { opacity: .6; } 100% { opacity: 0; }
129
+ }
130
+ @keyframes pathPulse {
131
+ 0% { opacity: 0; transform: scale(.97); }
132
+ 37.5% { opacity: 1; transform: scale(1); }
133
+ 68.75% { opacity: 1; transform: scale(1); }
134
+ 100% { opacity: 0; transform: scale(.97); }
135
+ }
136
+ @keyframes reveal { to { opacity: 1; pointer-events: auto; } }
137
+ @media (prefers-reduced-motion: reduce) {
138
+ .glow, .logo path { animation: none; }
139
+ .logo path { opacity: 1; }
140
+ }
47
141
  </style>
142
+ <noscript><style>.fallback { opacity: 1 !important; pointer-events: auto !important; animation: none !important; }</style></noscript>
48
143
  </head>
49
144
  <body>
50
- <p>Signing in\u2026</p>
51
- <noscript>
52
- <p><a href=${htmlNoscriptHref}>Continue to sign in</a></p>
53
- </noscript>
145
+ <div class="loader" role="status" aria-label="Giri\u015F yap\u0131l\u0131yor">
146
+ <div class="glow"></div>
147
+ <svg class="logo" fill="none" preserveAspectRatio="xMidYMid meet" viewBox="0 0 586 554" aria-hidden="true">
148
+ <defs>
149
+ <linearGradient id="skylab-shimmer" gradientUnits="userSpaceOnUse" x1="-100" y1="0" x2="100" y2="0">
150
+ <animate attributeName="x1" values="-100;700" dur="3.2s" calcMode="spline" keyTimes="0;1" keySplines="0.42 0 0.58 1" repeatCount="indefinite"/>
151
+ <animate attributeName="x2" values="100;900" dur="3.2s" calcMode="spline" keyTimes="0;1" keySplines="0.42 0 0.58 1" repeatCount="indefinite"/>
152
+ <stop offset="0" stop-color="currentColor" stop-opacity="0.4"/>
153
+ <stop offset="0.5" stop-color="currentColor" stop-opacity="1"/>
154
+ <stop offset="1" stop-color="currentColor" stop-opacity="0.4"/>
155
+ </linearGradient>
156
+ </defs>
157
+ ${renderLogoPaths()}
158
+ </svg>
159
+ </div>
160
+ <p class="fallback" id="inscribed-fallback"><a href=${htmlNoscriptHref}>Giri\u015F i\u015Flemine devam et</a></p>
54
161
  <script>
55
162
  (function () {
56
163
  fetch(${jsCsrfPath}, { credentials: "same-origin" })
@@ -71,9 +178,9 @@ function renderAutoSubmitPage({ signInUrl, csrfPath, callbackUrl, noscriptHref }
71
178
  form.submit();
72
179
  })
73
180
  .catch(function () {
74
- // CSRF fetch failed - drop the user on NextAuth's confirm page so
75
- // they can still complete sign-in manually.
76
- window.location.replace(${escapeForScript(noscriptHref)});
181
+ // Swallow. The "continue" link reveals itself via the pure-CSS 10s timer
182
+ // (and immediately for no-JS users), so the fallback is consistently 10s
183
+ // - we don't short-circuit it on a fast error.
77
184
  });
78
185
  })();
79
186
  </script>
@@ -88,6 +195,10 @@ function escapeForScript(value) {
88
195
  function escapeForHtmlAttribute(value) {
89
196
  return `"${value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}"`;
90
197
  }
198
+ function cssValue(value, fallback) {
199
+ if (typeof value !== "string" || value.trim() === "") return fallback;
200
+ return value.replace(/[<>{};]/g, "").trim();
201
+ }
91
202
  var GET = createSignInRoute();
92
203
  export {
93
204
  GET,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/signin.js"],"sourcesContent":["/**\n * @file `@skylab-kulubu/inscribed-auth/signin` — signin route handler factory.\n *\n * Server-only App Router route handler that auto-submits the user to NextAuth's\n * provider sign-in flow, skipping the \"Sign in with X\" confirm page that\n * NextAuth shows when its sign-in URL is hit with GET. The route returns\n * a tiny HTML document that fetches the CSRF token and POSTs the sign-in\n * form via JavaScript - the same dance `next-auth/react`'s `signIn()`\n * helper does, just done from a server route so the consumer can link to\n * `/api/signin` from anywhere (server components, plain anchors, server\n * actions) without bundling client-side helpers.\n *\n * Usage (consumer side):\n *\n * // app/api/signin/route.js\n * export { GET } from \"@skylab-kulubu/inscribed-auth/signin\";\n *\n * Or with explicit provider id / forced callback URL:\n *\n * import { createSignInRoute } from \"@skylab-kulubu/inscribed-auth/signin\";\n * export const GET = createSignInRoute({ provider: \"keycloak\" });\n *\n * The `callbackUrl` query parameter is forwarded to NextAuth so the user\n * lands back where they came from after signing in. Defaults to \"/\".\n *\n * `<noscript>` users get a link to NextAuth's standard provider page\n * (the one with the \"Sign in with X\" button) so sign-in still works\n * without JavaScript - it just can't skip the extra click.\n */\n\n/**\n * @typedef {Object} CreateSignInRouteOptions\n * @property {string} [provider]\n * Provider id NextAuth registered. Default `\"keycloak\"`. Set to `null`\n * (or `\"\"`) to land on NextAuth's provider-picker page instead.\n * @property {string} [defaultCallbackUrl]\n * Where to send the user when no `?callbackUrl=` query is present. Default `\"/\"`.\n * @property {string} [signInPath]\n * Override the NextAuth sign-in mount path. Default `/api/auth/signin`.\n * @property {string} [csrfPath]\n * Override the NextAuth CSRF endpoint. Default `/api/auth/csrf`.\n */\n\n/**\n * @param {CreateSignInRouteOptions} [options]\n */\nexport function createSignInRoute(options = {}) {\n const {\n provider = \"keycloak\",\n defaultCallbackUrl = \"/\",\n signInPath = \"/api/auth/signin\",\n csrfPath = \"/api/auth/csrf\",\n } = options;\n\n const signInUrl = provider ? `${signInPath}/${provider}` : signInPath;\n\n /**\n * @param {Request} request\n * @returns {Response}\n */\n return function GET(request) {\n const url = new URL(request.url);\n const callbackUrl = url.searchParams.get(\"callbackUrl\") ?? defaultCallbackUrl;\n\n const html = renderAutoSubmitPage({\n signInUrl,\n csrfPath,\n callbackUrl,\n // <noscript> fallback - NextAuth's own GET sign-in page, which\n // shows the \"Sign in with X\" button. It's a worse UX (extra click)\n // but it's the only thing that works without JS.\n noscriptHref: `${signInUrl}?callbackUrl=${encodeURIComponent(callbackUrl)}`,\n });\n\n return new Response(html, {\n status: 200,\n headers: {\n \"Content-Type\": \"text/html; charset=utf-8\",\n // Don't let browsers/proxies cache this - the form needs a fresh\n // CSRF token on every visit.\n \"Cache-Control\": \"no-store\",\n },\n });\n };\n}\n\n/**\n * Build the auto-submit HTML page. All user-controlled values are\n * embedded with `escapeForScript` to prevent `</script>` and other XSS\n * vectors when the value lands inside the inline `<script>` block.\n *\n * @param {{ signInUrl: string, csrfPath: string, callbackUrl: string, noscriptHref: string }} args\n */\nfunction renderAutoSubmitPage({ signInUrl, csrfPath, callbackUrl, noscriptHref }) {\n const jsSignInUrl = escapeForScript(signInUrl);\n const jsCsrfPath = escapeForScript(csrfPath);\n const jsCallbackUrl = escapeForScript(callbackUrl);\n const htmlNoscriptHref = escapeForHtmlAttribute(noscriptHref);\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Signing in…</title>\n<meta name=\"robots\" content=\"noindex,nofollow\">\n<style>\n body { margin: 0; min-height: 100dvh; display: grid; place-items: center;\n font: 14px/1.5 system-ui, -apple-system, sans-serif; color: #6b7280; }\n</style>\n</head>\n<body>\n<p>Signing in…</p>\n<noscript>\n <p><a href=${htmlNoscriptHref}>Continue to sign in</a></p>\n</noscript>\n<script>\n(function () {\n fetch(${jsCsrfPath}, { credentials: \"same-origin\" })\n .then(function (r) { return r.json(); })\n .then(function (data) {\n var form = document.createElement(\"form\");\n form.method = \"POST\";\n form.action = ${jsSignInUrl};\n var fields = { csrfToken: data.csrfToken, callbackUrl: ${jsCallbackUrl} };\n for (var key in fields) {\n var input = document.createElement(\"input\");\n input.type = \"hidden\";\n input.name = key;\n input.value = fields[key];\n form.appendChild(input);\n }\n document.body.appendChild(form);\n form.submit();\n })\n .catch(function () {\n // CSRF fetch failed - drop the user on NextAuth's confirm page so\n // they can still complete sign-in manually.\n window.location.replace(${escapeForScript(noscriptHref)});\n });\n})();\n</script>\n</body>\n</html>`;\n}\n\n// U+2028 / U+2029 are valid string contents in JSON output but illegal in\n// JavaScript string literals - inline <script> would parse the response\n// as a syntax error. Built from char codes so this source file itself\n// stays free of literal separator characters that confuse parsers/diffs.\nconst LINE_SEPARATOR = String.fromCharCode(0x2028);\nconst PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);\n\n/**\n * Encode a string as a JavaScript string literal safe for inline `<script>`.\n * `JSON.stringify` handles quotes/control chars; the extra replacements\n * neutralise sequences that could otherwise close the script tag or\n * smuggle in HTML.\n *\n * @param {string} value\n */\nfunction escapeForScript(value) {\n return JSON.stringify(value)\n .replace(/</g, \"\\\\u003c\")\n .replace(/>/g, \"\\\\u003e\")\n .replace(/&/g, \"\\\\u0026\")\n .split(LINE_SEPARATOR).join(\"\\\\u2028\")\n .split(PARAGRAPH_SEPARATOR).join(\"\\\\u2029\");\n}\n\n/**\n * Encode a string for use as an HTML attribute value with surrounding\n * double quotes embedded by the caller.\n *\n * @param {string} value\n */\nfunction escapeForHtmlAttribute(value) {\n return `\"${value\n .replace(/&/g, \"&amp;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")}\"`;\n}\n\n/** Default GET handler — `export { GET } from \"@skylab-kulubu/inscribed-auth/signin\"`. */\nexport const GET = createSignInRoute();\n"],"mappings":";AA8CO,SAAS,kBAAkB,UAAU,CAAC,GAAG;AAC9C,QAAM;AAAA,IACJ,WAAW;AAAA,IACX,qBAAqB;AAAA,IACrB,aAAa;AAAA,IACb,WAAW;AAAA,EACb,IAAI;AAEJ,QAAM,YAAY,WAAW,GAAG,UAAU,IAAI,QAAQ,KAAK;AAM3D,SAAO,SAASA,KAAI,SAAS;AAC3B,UAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,UAAM,cAAc,IAAI,aAAa,IAAI,aAAa,KAAK;AAE3D,UAAM,OAAO,qBAAqB;AAAA,MAChC;AAAA,MACA;AAAA,MACA;AAAA;AAAA;AAAA;AAAA,MAIA,cAAc,GAAG,SAAS,gBAAgB,mBAAmB,WAAW,CAAC;AAAA,IAC3E,CAAC;AAED,WAAO,IAAI,SAAS,MAAM;AAAA,MACxB,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,gBAAgB;AAAA;AAAA;AAAA,QAGhB,iBAAiB;AAAA,MACnB;AAAA,IACF,CAAC;AAAA,EACH;AACF;AASA,SAAS,qBAAqB,EAAE,WAAW,UAAU,aAAa,aAAa,GAAG;AAChF,QAAM,cAAc,gBAAgB,SAAS;AAC7C,QAAM,aAAa,gBAAgB,QAAQ;AAC3C,QAAM,gBAAgB,gBAAgB,WAAW;AACjD,QAAM,mBAAmB,uBAAuB,YAAY;AAE5D,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,eAcM,gBAAgB;AAAA;AAAA;AAAA;AAAA,UAIrB,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA,sBAKE,WAAW;AAAA,+DAC8B,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gCAc5C,gBAAgB,YAAY,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAM7D;AAMA,IAAM,iBAAiB,OAAO,aAAa,IAAM;AACjD,IAAM,sBAAsB,OAAO,aAAa,IAAM;AAUtD,SAAS,gBAAgB,OAAO;AAC9B,SAAO,KAAK,UAAU,KAAK,EACxB,QAAQ,MAAM,SAAS,EACvB,QAAQ,MAAM,SAAS,EACvB,QAAQ,MAAM,SAAS,EACvB,MAAM,cAAc,EAAE,KAAK,SAAS,EACpC,MAAM,mBAAmB,EAAE,KAAK,SAAS;AAC9C;AAQA,SAAS,uBAAuB,OAAO;AACrC,SAAO,IAAI,MACR,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,CAAC;AAC1B;AAGO,IAAM,MAAM,kBAAkB;","names":["GET"]}
1
+ {"version":3,"sources":["../src/signin.js"],"sourcesContent":["/**\n * @file `@skylab-kulubu/inscribed-auth/signin` — signin route handler factory.\n *\n * Server-only App Router route handler that auto-submits the user to NextAuth's\n * provider sign-in flow, skipping the \"Sign in with X\" confirm page that\n * NextAuth shows when its sign-in URL is hit with GET. The route returns\n * a tiny HTML document that fetches the CSRF token and POSTs the sign-in\n * form via JavaScript - the same dance `next-auth/react`'s `signIn()`\n * helper does, just done from a server route so the consumer can link to\n * `/api/signin` from anywhere (server components, plain anchors, server\n * actions) without bundling client-side helpers.\n *\n * Usage (consumer side):\n *\n * // app/api/signin/route.js\n * export { GET } from \"@skylab-kulubu/inscribed-auth/signin\";\n *\n * Or with explicit provider id / forced callback URL:\n *\n * import { createSignInRoute } from \"@skylab-kulubu/inscribed-auth/signin\";\n * export const GET = createSignInRoute({ provider: \"keycloak\" });\n *\n * The `callbackUrl` query parameter is forwarded to NextAuth so the user\n * lands back where they came from after signing in. Defaults to \"/\".\n *\n * `<noscript>` users get a link to NextAuth's standard provider page\n * (the one with the \"Sign in with X\" button) so sign-in still works\n * without JavaScript - it just can't skip the extra click.\n */\n\n/**\n * @typedef {Object} CreateSignInRouteOptions\n * @property {string} [provider]\n * Provider id NextAuth registered. Default `\"keycloak\"`. Set to `null`\n * (or `\"\"`) to land on NextAuth's provider-picker page instead.\n * @property {string} [defaultCallbackUrl]\n * Where to send the user when no `?callbackUrl=` query is present. Default `\"/\"`.\n * @property {string} [signInPath]\n * Override the NextAuth sign-in mount path. Default `/api/auth/signin`.\n * @property {string} [csrfPath]\n * Override the NextAuth CSRF endpoint. Default `/api/auth/csrf`.\n * @property {string} [background]\n * CSS background for the sign-in splash. Defaults to `light-dark(Canvas,\n * #1c1815)` — light mode follows the system canvas, dark mode uses a warm\n * near-black. Pass your app's background as a concrete value (hex, `rgb()`,\n * gradient, or your own `light-dark(...)`) to match its theme. This is a\n * separate document, so app-defined custom properties (`var(--...)`) aren't\n * available here — pass the resolved value or wire it from the same\n * source/env the app uses.\n * @property {string} [color]\n * CSS foreground color for the logo + text. Defaults to `CanvasText`. The\n * loader is drawn in `currentColor`, so this is what tints it.\n */\n\n/**\n * @param {CreateSignInRouteOptions} [options]\n */\nexport function createSignInRoute(options = {}) {\n const {\n provider = \"keycloak\",\n defaultCallbackUrl = \"/\",\n signInPath = \"/api/auth/signin\",\n csrfPath = \"/api/auth/csrf\",\n background = \"light-dark(Canvas, #1c1815)\",\n color = \"CanvasText\",\n } = options;\n\n const signInUrl = provider ? `${signInPath}/${provider}` : signInPath;\n\n /**\n * @param {Request} request\n * @returns {Response}\n */\n return function GET(request) {\n const url = new URL(request.url);\n const callbackUrl = url.searchParams.get(\"callbackUrl\") ?? defaultCallbackUrl;\n\n const html = renderAutoSubmitPage({\n signInUrl,\n csrfPath,\n callbackUrl,\n background,\n color,\n // <noscript> fallback - NextAuth's own GET sign-in page, which\n // shows the \"Sign in with X\" button. It's a worse UX (extra click)\n // but it's the only thing that works without JS.\n noscriptHref: `${signInUrl}?callbackUrl=${encodeURIComponent(callbackUrl)}`,\n });\n\n return new Response(html, {\n status: 200,\n headers: {\n \"Content-Type\": \"text/html; charset=utf-8\",\n // Don't let browsers/proxies cache this - the form needs a fresh\n // CSRF token on every visit.\n \"Cache-Control\": \"no-store\",\n },\n });\n };\n}\n\n// ---------------------------------------------------------------------------\n// Brand loader. The app ships a `<SkylabLoader>` React component (framer-motion),\n// but this route returns a plain HTML string with no client framework, so the\n// same logo is reproduced here as static inline SVG driven by CSS + SMIL. That\n// also means the animation keeps running under a strict CSP that blocks the\n// inline <script> - the loader never freezes silently.\n// ---------------------------------------------------------------------------\n\n/** Logo glyph outlines, keyed for the staggered draw. */\nconst LOGO_PATHS = {\n p12f85900: \"M202.27 271.08C202.27 271.42 202.14 277.72 201.97 285.09C201.8 292.46 201.87 298.27 202.13 298.01C202.39 297.75 204.01 292.65 205.72 286.69L208.83 275.84L205.55 273.15C203.74 271.67 202.27 270.74 202.28 271.07L202.27 271.08Z\",\n p15984f0: \"M254.51 96.18C259.05 97.81 261.03 102.32 261.5 112.09L261.86 119.63L263.66 115.21C264.03 114.13 264.4 113.05 264.78 111.97C264.94 108.59 265.16 103.34 265.27 96.87C265.45 86.64 265.03 83.81 266.57 79.93C268.03 76.25 270.98 73.18 276.83 67.09L285.42 58.14L289.06 47.76C290.39 43.98 291.67 40.79 292.48 39.15C292.81 38.48 293.06 38.08 293.21 38C293.35 38.07 293.61 38.48 293.94 39.15C294.75 40.79 296.03 43.98 297.36 47.76L301 58.14L309.59 67.09C315.44 73.18 318.39 76.25 319.85 79.93C321.39 83.81 320.97 86.63 321.15 96.87C321.26 103.34 321.48 108.59 321.64 111.97C322.01 113.05 322.38 114.13 322.76 115.21L324.56 119.63L324.92 112.09C325.39 102.32 327.37 97.81 331.91 96.18C333.56 95.59 335 95.05 335.11 94.98C335.35 94.83 320.48 53.26 314.4 37.09C303.07 6.93 301.53 3.78 296.83 1.27C295.42 0.52 294.65 0.12 293.94 0.02C293.66 9.31323e-10 293.46 -0.01 293.2 0C292.95 0 292.75 9.31323e-10 292.48 0.02C291.77 0.12 291 0.52 289.59 1.27C284.89 3.77 283.35 6.93 272.02 37.09C265.95 53.26 251.08 94.83 251.31 94.98C251.41 95.04 252.85 95.58 254.51 96.18Z\",\n p1a20d600: \"M300.36 401.43C298.05 399.2 295.67 398.18 293.2 398.37C290.74 398.19 288.37 399.2 286.06 401.43C281.41 405.92 250.43 443.34 249.35 445.77C248.76 447.1 248.46 449.28 248.68 450.63C249.47 455.44 285.34 548.6 287.14 550.51C289.04 552.53 291.22 553.7 293.21 553.9C293.21 553.9 293.21 553.9 293.22 553.9C295.2 553.7 297.38 552.53 299.29 550.51C301.09 548.6 336.96 455.44 337.75 450.63C337.97 449.29 337.67 447.1 337.08 445.77C336 443.34 305.02 405.93 300.37 401.43H300.36ZM305.97 484.19C299.28 502.24 293.6 517.1 293.35 517.22C293.34 517.22 293.29 517.15 293.23 517.03V517C293.23 517 293.23 517 293.23 517.02C293.23 517.02 293.23 517.02 293.23 517V517.03C293.17 517.16 293.12 517.23 293.11 517.22C292.86 517.11 287.18 502.25 280.49 484.19C273.8 466.14 268.38 451.21 268.44 451.01C268.86 449.67 292.39 422.73 293.11 422.77C293.13 422.77 293.18 422.8 293.23 422.83C293.28 422.79 293.33 422.76 293.35 422.76C294.06 422.72 317.6 449.67 318.02 451C318.08 451.2 312.66 466.13 305.97 484.18V484.19Z\",\n p1cd4e100: \"M156.39 277.82V437.85L174.06 420.19L191.73 402.53V323.38C191.73 279.85 191.48 244.25 191.17 244.27C190.86 244.29 182.91 251.84 173.5 261.06L156.39 277.81V277.82Z\",\n p1dae6200: \"M585.43 359.08C584.42 356.66 514 285.9 509.93 283.22C508.79 282.47 506.67 281.86 505.22 281.85C502.56 281.85 455.76 294.86 449.76 297.27C443.91 299.62 442.21 304.11 444.6 310.89C447.69 319.64 467.07 359.3 469.19 361.22C472.21 363.95 486.86 365.69 538.48 369.43C576.82 372.21 580.5 372.11 584.74 368.14C586.7 366.3 586.96 362.78 585.42 359.09L585.43 359.08ZM486.71 346.88L481.34 346.4L473.69 329.81C469.49 320.69 466.19 312.84 466.37 312.38C466.63 311.71 500.83 301.22 502.22 301.38C502.43 301.4 513.85 312.45 527.6 325.92C541.35 339.39 552.6 350.64 552.6 350.92C552.6 351.35 502.15 348.26 486.7 346.88H486.71Z\",\n p20f79d00: \"M284.09 79.39L275.34 88.23L275.08 235.98L274.82 383.73H293.23H293.95H311.62L311.36 235.98L311.1 88.23L302.35 79.39C298.65 75.66 295.38 72.46 293.95 71.17C293.56 70.82 293.31 70.61 293.23 70.58C293.15 70.62 292.9 70.83 292.51 71.18C291.08 72.47 287.81 75.67 284.11 79.4L284.09 79.39Z\",\n p28bb8280: \"M136.66 297.27C130.66 294.86 83.86 281.84 81.2 281.85C79.75 281.85 77.63 282.47 76.49 283.22C72.42 285.9 2 356.66 0.99 359.08C-0.55 362.77 -0.29 366.3 1.67 368.13C5.91 372.1 9.59 372.21 47.93 369.42C99.56 365.68 114.21 363.94 117.22 361.21C119.34 359.29 138.72 319.62 141.81 310.88C144.2 304.1 142.5 299.62 136.65 297.26L136.66 297.27ZM112.73 329.81L105.08 346.4L99.71 346.88C84.27 348.26 33.81 351.35 33.81 350.92C33.81 350.64 45.06 339.39 58.81 325.92C72.56 312.45 83.98 301.4 84.19 301.38C85.58 301.22 119.78 311.71 120.04 312.38C120.22 312.84 116.92 320.68 112.72 329.81H112.73Z\",\n p2cc72200: \"M251.13 383.35V244.62C251.13 168.32 251.04 105.89 250.94 105.89C250.84 105.89 242.88 113.76 233.27 123.38L215.79 140.87V383.35H251.13Z\",\n p3097fa00: \"M395.27 244.28C394.96 244.26 394.71 279.86 394.71 323.39V402.54L412.38 420.2L430.05 437.86V277.83L412.94 261.08C403.53 251.87 395.58 244.31 395.27 244.29V244.28Z\",\n p36c88b00: \"M320.98 368.13V378.84C322.24 379.74 323.5 380.64 324.77 381.54V359.68C323.51 358.72 322.25 357.75 320.98 356.79V368.14V368.13Z\",\n p3b5dc900: \"M392.63 160.9L381.16 160.78V179.44L387.36 179.87C390.77 180.11 406.25 180.66 421.76 181.09C461.49 182.2 494.23 183.36 494.54 183.68C494.76 183.9 419.91 243.04 415.44 246.17C414.1 247.11 414.39 247.59 420.22 253.98C423.64 257.72 426.65 260.78 426.91 260.77C427.18 260.77 440.59 250.44 456.72 237.83C472.85 225.22 489.09 212.56 492.81 209.69C496.53 206.83 505.82 199.46 513.46 193.32C524.59 184.37 527.7 181.46 529.15 178.64C532.97 171.23 529.66 167.27 518.62 166.05C506.9 164.75 466.22 162.84 423.63 161.59C412.88 161.27 398.92 160.96 392.61 160.9H392.63Z\",\n p3cad3800: \"M384.3 298.01C384.56 298.27 384.64 292.46 384.46 285.09C384.29 277.72 384.15 271.42 384.16 271.08C384.16 270.74 382.69 271.68 380.89 273.16L377.61 275.85L380.72 286.7C382.43 292.67 384.04 297.76 384.31 298.02L384.3 298.01Z\",\n p4c50c00: \"M261.67 381.54C262.93 380.64 264.19 379.74 265.46 378.84V356.79C264.2 357.75 262.94 358.72 261.67 359.68V381.54Z\",\n p543f872: \"M370.64 383.35V140.87L353.16 123.38C343.55 113.76 335.6 105.89 335.49 105.89C335.38 105.89 335.3 168.32 335.3 244.62V383.35H370.64Z\",\n p596f400: \"M213.15 391.35L201.88 398.68C201.54 400.62 201.3 402.3 200.64 404.18C198.89 409.17 186.19 420.53 181.71 424.97C177.48 429.15 172.75 433.77 168.62 438.06C165 441.82 157.27 448.3 152.12 444.77C150.93 443.95 150.05 443.12 149.18 441.96C147.49 439.71 145.46 431.83 145.5 431.49C145.59 430.65 140.15 446.25 146.13 450.76C147.39 451.71 149 452.13 149.46 452.23C152.29 452.85 155.07 451.97 158.13 450.79C165.4 447.97 173.68 441.39 180.31 437.02C194.5 427.67 208.44 417.81 222.54 408.1C228.96 403.68 236.37 398.88 242.73 394.58C243.34 394.17 244.14 393.3 244.49 392.28H215.41C215.19 392.28 213.81 391.48 213.16 391.35H213.15Z\",\n p5ee5d00: \"M379.16 133.04C380.94 136.68 383.44 137.87 389 137.73C396.82 137.53 442.29 129.24 445.2 127.48C447.15 126.3 452.13 115.85 457.1 102.5C468.6 71.62 483.02 30.41 483.81 26.11C484.47 22.55 483.05 17.5 480.99 16.06C480.08 15.42 477.77 14.9 475.84 14.9C471.63 14.9 469.88 15.77 443.21 31.15C432.46 37.35 414 47.92 402.18 54.64C385.46 64.15 380.38 67.39 379.25 69.26C377.87 71.54 377.79 73.28 377.82 100.99C377.84 127.41 377.98 130.58 379.17 133.03L379.16 133.04ZM396.23 98.56L396.25 79.2L426.42 61.91C443.01 52.4 456.93 44.48 457.35 44.33C457.78 44.17 457.9 44.58 457.63 45.29C457.37 45.98 454.75 53.38 451.82 61.75C448.89 70.11 445.69 79.15 444.71 81.84C443.73 84.53 440.99 92.31 438.62 99.13C436.25 105.95 434.27 111.61 434.21 111.7C434.12 111.84 397.28 117.93 396.52 117.93C396.35 117.93 396.22 109.22 396.23 98.57V98.56Z\",\n p7800: \"M129.34 102.51C134.31 115.85 139.29 126.31 141.24 127.49C144.14 129.24 189.62 137.53 197.44 137.74C203 137.88 205.5 136.69 207.28 133.05C208.47 130.61 208.61 127.44 208.63 101.01C208.66 73.3 208.58 71.55 207.2 69.28C206.06 67.4 200.99 64.17 184.27 54.66C172.46 47.94 154 37.37 143.24 31.17C116.57 15.79 114.82 14.92 110.61 14.92C108.68 14.92 106.37 15.44 105.46 16.08C103.4 17.52 101.98 22.57 102.64 26.13C103.43 30.43 117.85 71.64 129.35 102.52L129.34 102.51ZM129.08 44.32C129.5 44.48 143.41 52.39 160.01 61.9L190.18 79.19L190.2 98.55C190.21 109.2 190.08 117.91 189.91 117.91C189.14 117.91 152.3 111.82 152.22 111.68C152.17 111.59 150.18 105.94 147.81 99.11C145.44 92.29 142.7 84.5 141.72 81.82C140.74 79.13 137.54 70.09 134.61 61.73C131.68 53.37 129.07 45.96 128.8 45.27C128.53 44.56 128.65 44.14 129.08 44.31V44.32Z\",\n p9522b80: \"M72.95 193.32C80.58 199.46 89.87 206.83 93.6 209.69C97.32 212.55 113.56 225.22 129.69 237.83C145.82 250.44 159.23 260.77 159.5 260.77C159.77 260.77 162.78 257.72 166.19 253.98C172.02 247.6 172.31 247.11 170.97 246.17C166.5 243.04 91.65 183.9 91.87 183.68C92.18 183.37 124.92 182.2 164.65 181.09C180.16 180.66 195.64 180.11 199.05 179.87L205.25 179.44V160.78L193.78 160.9C187.47 160.97 173.52 161.28 162.76 161.59C120.17 162.84 79.49 164.75 67.77 166.05C56.73 167.27 53.42 171.23 57.24 178.64C58.69 181.46 61.8 184.37 72.93 193.32H72.95Z\",\n pe3cee00: \"M440.93 431.49C440.97 431.83 438.94 439.71 437.25 441.96C436.38 443.12 435.5 443.95 434.31 444.77C429.16 448.3 421.43 441.82 417.81 438.06C413.68 433.77 408.94 429.15 404.72 424.97C400.24 420.54 387.54 409.17 385.79 404.18C385.13 402.29 384.89 400.62 384.55 398.68L373.28 391.35C372.63 391.48 371.25 392.28 371.03 392.28H341.95C342.3 393.31 343.1 394.17 343.71 394.58C350.07 398.88 357.47 403.68 363.9 408.1C378 417.81 391.95 427.67 406.13 437.02C412.77 441.39 421.05 447.97 428.31 450.79C431.37 451.98 434.15 452.86 436.98 452.23C437.43 452.13 439.05 451.71 440.31 450.76C446.29 446.25 440.85 430.65 440.94 431.49H440.93Z\",\n};\n\n/** Draw order / stagger buckets - paths in the same bucket animate together. */\nconst LOGO_GROUPS = [\n [\"p20f79d00\"],\n [\"p15984f0\", \"p1a20d600\"],\n [\"p2cc72200\", \"p543f872\", \"p4c50c00\", \"p36c88b00\"],\n [\"p1cd4e100\", \"p3097fa00\", \"p12f85900\", \"p3cad3800\"],\n [\"p28bb8280\", \"p1dae6200\", \"p7800\", \"p5ee5d00\"],\n [\"p9522b80\", \"p3b5dc900\"],\n [\"p596f400\", \"pe3cee00\"],\n];\n\n/** @param {string} key */\nfunction logoGroupIndex(key) {\n for (let g = 0; g < LOGO_GROUPS.length; g++) {\n if (LOGO_GROUPS[g].includes(key)) return g;\n }\n return LOGO_GROUPS.length;\n}\n\n/** Build the `<path>` markup, staggering each group's draw by 0.1s. */\nfunction renderLogoPaths() {\n return Object.keys(LOGO_PATHS)\n .map((key) => {\n const delay = (logoGroupIndex(key) * 0.1).toFixed(1);\n return `<path d=\"${LOGO_PATHS[key]}\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" style=\"animation-delay:${delay}s\"/>`;\n })\n .join(\"\");\n}\n\n/**\n * Build the auto-submit HTML page: a centered, animated Skylab loader that\n * silently POSTs the user into the provider flow. User-controlled values are\n * escaped with `escapeForScript` / `escapeForHtmlAttribute` so they can't break\n * out of the inline `<script>` or the link attribute.\n *\n * A \"continue to sign in\" link sits below the loader, hidden, and is revealed:\n * - after 10s by a pure-CSS animation - covers a slow/hung CSRF fetch AND a\n * strict CSP that blocks the script (the loader would otherwise spin\n * forever with no escape),\n * - immediately for no-JS users (a `<noscript>` style override),\n * - immediately if the fetch errors (the script tags it `.show`).\n *\n * @param {{ signInUrl: string, csrfPath: string, callbackUrl: string, noscriptHref: string, background: string, color: string }} args\n */\nfunction renderAutoSubmitPage({ signInUrl, csrfPath, callbackUrl, noscriptHref, background, color }) {\n const jsSignInUrl = escapeForScript(signInUrl);\n const jsCsrfPath = escapeForScript(csrfPath);\n const jsCallbackUrl = escapeForScript(callbackUrl);\n const htmlNoscriptHref = escapeForHtmlAttribute(noscriptHref);\n const bg = cssValue(background, \"light-dark(Canvas, #1c1815)\");\n const fg = cssValue(color, \"CanvasText\");\n\n return `<!DOCTYPE html>\n<html lang=\"tr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Giriş yapılıyor…</title>\n<meta name=\"robots\" content=\"noindex,nofollow\">\n<style>\n * { box-sizing: border-box; }\n html, body { height: 100%; }\n html { color-scheme: light dark; }\n body {\n margin: 0; min-height: 100dvh;\n display: grid; place-items: center;\n background: ${bg}; color: ${fg};\n font: 14px/1.5 system-ui, -apple-system, sans-serif;\n }\n .loader { position: relative; width: 96px; height: 96px; }\n .glow {\n position: absolute; inset: 0; border-radius: 9999px;\n background: radial-gradient(circle, color-mix(in srgb, currentColor 9%, transparent) 0%, transparent 70%);\n filter: blur(8px);\n animation: glowPulse 3.2s ease-in-out infinite;\n }\n .logo { display: block; width: 100%; height: 100%; }\n .logo path {\n fill: url(#skylab-shimmer);\n transform-box: view-box; transform-origin: 293px 277px;\n opacity: 0;\n animation: pathPulse 3.2s ease-in-out infinite;\n }\n .fallback {\n position: fixed; left: 50%; top: calc(50% + 84px);\n transform: translateX(-50%);\n margin: 0; text-align: center; white-space: nowrap;\n opacity: 0; pointer-events: none;\n animation: reveal .5s ease-out 10s forwards;\n }\n .fallback.show { opacity: 1; pointer-events: auto; animation: none; }\n .fallback a { color: inherit; text-underline-offset: 3px; }\n @keyframes glowPulse {\n 0% { opacity: 0; } 30% { opacity: .6; } 50% { opacity: 1; }\n 75% { opacity: .6; } 100% { opacity: 0; }\n }\n @keyframes pathPulse {\n 0% { opacity: 0; transform: scale(.97); }\n 37.5% { opacity: 1; transform: scale(1); }\n 68.75% { opacity: 1; transform: scale(1); }\n 100% { opacity: 0; transform: scale(.97); }\n }\n @keyframes reveal { to { opacity: 1; pointer-events: auto; } }\n @media (prefers-reduced-motion: reduce) {\n .glow, .logo path { animation: none; }\n .logo path { opacity: 1; }\n }\n</style>\n<noscript><style>.fallback { opacity: 1 !important; pointer-events: auto !important; animation: none !important; }</style></noscript>\n</head>\n<body>\n<div class=\"loader\" role=\"status\" aria-label=\"Giriş yapılıyor\">\n <div class=\"glow\"></div>\n <svg class=\"logo\" fill=\"none\" preserveAspectRatio=\"xMidYMid meet\" viewBox=\"0 0 586 554\" aria-hidden=\"true\">\n <defs>\n <linearGradient id=\"skylab-shimmer\" gradientUnits=\"userSpaceOnUse\" x1=\"-100\" y1=\"0\" x2=\"100\" y2=\"0\">\n <animate attributeName=\"x1\" values=\"-100;700\" dur=\"3.2s\" calcMode=\"spline\" keyTimes=\"0;1\" keySplines=\"0.42 0 0.58 1\" repeatCount=\"indefinite\"/>\n <animate attributeName=\"x2\" values=\"100;900\" dur=\"3.2s\" calcMode=\"spline\" keyTimes=\"0;1\" keySplines=\"0.42 0 0.58 1\" repeatCount=\"indefinite\"/>\n <stop offset=\"0\" stop-color=\"currentColor\" stop-opacity=\"0.4\"/>\n <stop offset=\"0.5\" stop-color=\"currentColor\" stop-opacity=\"1\"/>\n <stop offset=\"1\" stop-color=\"currentColor\" stop-opacity=\"0.4\"/>\n </linearGradient>\n </defs>\n ${renderLogoPaths()}\n </svg>\n</div>\n<p class=\"fallback\" id=\"inscribed-fallback\"><a href=${htmlNoscriptHref}>Giriş işlemine devam et</a></p>\n<script>\n(function () {\n fetch(${jsCsrfPath}, { credentials: \"same-origin\" })\n .then(function (r) { return r.json(); })\n .then(function (data) {\n var form = document.createElement(\"form\");\n form.method = \"POST\";\n form.action = ${jsSignInUrl};\n var fields = { csrfToken: data.csrfToken, callbackUrl: ${jsCallbackUrl} };\n for (var key in fields) {\n var input = document.createElement(\"input\");\n input.type = \"hidden\";\n input.name = key;\n input.value = fields[key];\n form.appendChild(input);\n }\n document.body.appendChild(form);\n form.submit();\n })\n .catch(function () {\n // Swallow. The \"continue\" link reveals itself via the pure-CSS 10s timer\n // (and immediately for no-JS users), so the fallback is consistently 10s\n // - we don't short-circuit it on a fast error.\n });\n})();\n</script>\n</body>\n</html>`;\n}\n\n// U+2028 / U+2029 are valid string contents in JSON output but illegal in\n// JavaScript string literals - inline <script> would parse the response\n// as a syntax error. Built from char codes so this source file itself\n// stays free of literal separator characters that confuse parsers/diffs.\nconst LINE_SEPARATOR = String.fromCharCode(0x2028);\nconst PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);\n\n/**\n * Encode a string as a JavaScript string literal safe for inline `<script>`.\n * `JSON.stringify` handles quotes/control chars; the extra replacements\n * neutralise sequences that could otherwise close the script tag or\n * smuggle in HTML.\n *\n * @param {string} value\n */\nfunction escapeForScript(value) {\n return JSON.stringify(value)\n .replace(/</g, \"\\\\u003c\")\n .replace(/>/g, \"\\\\u003e\")\n .replace(/&/g, \"\\\\u0026\")\n .split(LINE_SEPARATOR).join(\"\\\\u2028\")\n .split(PARAGRAPH_SEPARATOR).join(\"\\\\u2029\");\n}\n\n/**\n * Encode a string for use as an HTML attribute value with surrounding\n * double quotes embedded by the caller.\n *\n * @param {string} value\n */\nfunction escapeForHtmlAttribute(value) {\n return `\"${value\n .replace(/&/g, \"&amp;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")}\"`;\n}\n\n/**\n * Sanitise a consumer-provided CSS color/background before it lands in the\n * inline `<style>` block. These come from server-side config (trusted), but we\n * still strip characters that could close the declaration or the `<style>`.\n *\n * @param {unknown} value\n * @param {string} fallback\n */\nfunction cssValue(value, fallback) {\n if (typeof value !== \"string\" || value.trim() === \"\") return fallback;\n return value.replace(/[<>{};]/g, \"\").trim();\n}\n\n/** Default GET handler — `export { GET } from \"@skylab-kulubu/inscribed-auth/signin\"`. */\nexport const GET = createSignInRoute();\n"],"mappings":";AAyDO,SAAS,kBAAkB,UAAU,CAAC,GAAG;AAC9C,QAAM;AAAA,IACJ,WAAW;AAAA,IACX,qBAAqB;AAAA,IACrB,aAAa;AAAA,IACb,WAAW;AAAA,IACX,aAAa;AAAA,IACb,QAAQ;AAAA,EACV,IAAI;AAEJ,QAAM,YAAY,WAAW,GAAG,UAAU,IAAI,QAAQ,KAAK;AAM3D,SAAO,SAASA,KAAI,SAAS;AAC3B,UAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,UAAM,cAAc,IAAI,aAAa,IAAI,aAAa,KAAK;AAE3D,UAAM,OAAO,qBAAqB;AAAA,MAChC;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA;AAAA;AAAA;AAAA,MAIA,cAAc,GAAG,SAAS,gBAAgB,mBAAmB,WAAW,CAAC;AAAA,IAC3E,CAAC;AAED,WAAO,IAAI,SAAS,MAAM;AAAA,MACxB,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,gBAAgB;AAAA;AAAA;AAAA,QAGhB,iBAAiB;AAAA,MACnB;AAAA,IACF,CAAC;AAAA,EACH;AACF;AAWA,IAAM,aAAa;AAAA,EACjB,WAAW;AAAA,EACX,UAAU;AAAA,EACV,WAAW;AAAA,EACX,WAAW;AAAA,EACX,WAAW;AAAA,EACX,WAAW;AAAA,EACX,WAAW;AAAA,EACX,WAAW;AAAA,EACX,WAAW;AAAA,EACX,WAAW;AAAA,EACX,WAAW;AAAA,EACX,WAAW;AAAA,EACX,UAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,UAAU;AAAA,EACV,OAAO;AAAA,EACP,UAAU;AAAA,EACV,UAAU;AACZ;AAGA,IAAM,cAAc;AAAA,EAClB,CAAC,WAAW;AAAA,EACZ,CAAC,YAAY,WAAW;AAAA,EACxB,CAAC,aAAa,YAAY,YAAY,WAAW;AAAA,EACjD,CAAC,aAAa,aAAa,aAAa,WAAW;AAAA,EACnD,CAAC,aAAa,aAAa,SAAS,UAAU;AAAA,EAC9C,CAAC,YAAY,WAAW;AAAA,EACxB,CAAC,YAAY,UAAU;AACzB;AAGA,SAAS,eAAe,KAAK;AAC3B,WAAS,IAAI,GAAG,IAAI,YAAY,QAAQ,KAAK;AAC3C,QAAI,YAAY,CAAC,EAAE,SAAS,GAAG,EAAG,QAAO;AAAA,EAC3C;AACA,SAAO,YAAY;AACrB;AAGA,SAAS,kBAAkB;AACzB,SAAO,OAAO,KAAK,UAAU,EAC1B,IAAI,CAAC,QAAQ;AACZ,UAAM,SAAS,eAAe,GAAG,IAAI,KAAK,QAAQ,CAAC;AACnD,WAAO,YAAY,WAAW,GAAG,CAAC,oEAAoE,KAAK;AAAA,EAC7G,CAAC,EACA,KAAK,EAAE;AACZ;AAiBA,SAAS,qBAAqB,EAAE,WAAW,UAAU,aAAa,cAAc,YAAY,MAAM,GAAG;AACnG,QAAM,cAAc,gBAAgB,SAAS;AAC7C,QAAM,aAAa,gBAAgB,QAAQ;AAC3C,QAAM,gBAAgB,gBAAgB,WAAW;AACjD,QAAM,mBAAmB,uBAAuB,YAAY;AAC5D,QAAM,KAAK,SAAS,YAAY,6BAA6B;AAC7D,QAAM,KAAK,SAAS,OAAO,YAAY;AAEvC,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAcS,EAAE,YAAY,EAAE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAyD5B,gBAAgB,CAAC;AAAA;AAAA;AAAA,sDAG+B,gBAAgB;AAAA;AAAA;AAAA,UAG5D,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA,sBAKE,WAAW;AAAA,+DAC8B,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAoB5E;AAMA,IAAM,iBAAiB,OAAO,aAAa,IAAM;AACjD,IAAM,sBAAsB,OAAO,aAAa,IAAM;AAUtD,SAAS,gBAAgB,OAAO;AAC9B,SAAO,KAAK,UAAU,KAAK,EACxB,QAAQ,MAAM,SAAS,EACvB,QAAQ,MAAM,SAAS,EACvB,QAAQ,MAAM,SAAS,EACvB,MAAM,cAAc,EAAE,KAAK,SAAS,EACpC,MAAM,mBAAmB,EAAE,KAAK,SAAS;AAC9C;AAQA,SAAS,uBAAuB,OAAO;AACrC,SAAO,IAAI,MACR,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,CAAC;AAC1B;AAUA,SAAS,SAAS,OAAO,UAAU;AACjC,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,MAAM,GAAI,QAAO;AAC7D,SAAO,MAAM,QAAQ,YAAY,EAAE,EAAE,KAAK;AAC5C;AAGO,IAAM,MAAM,kBAAkB;","names":["GET"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skylab-kulubu/inscribed-auth",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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",