@skylab-kulubu/inscribed-auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +68 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +112 -0
- package/dist/server.js +214 -0
- package/dist/server.js.map +1 -0
- package/dist/service-token-COa7hHuV.d.ts +104 -0
- package/dist/signin.d.ts +67 -0
- package/dist/signin.js +96 -0
- package/dist/signin.js.map +1 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Skylab Kulübü
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# @skylab-kulubu/inscribed-auth
|
|
2
|
+
|
|
3
|
+
Skylab's opt-in **NextAuth + Keycloak** adapter for the [`inscribed`](https://www.npmjs.com/package/inscribed) CMS.
|
|
4
|
+
|
|
5
|
+
`inscribed` is auth- and vendor-neutral: out of the box it runs read-only/public.
|
|
6
|
+
This package is the Skylab layer that plugs admin auth and the build-time
|
|
7
|
+
service token into it, so a new Skylab Next.js app gets editing + sync by
|
|
8
|
+
**installing one package and setting env vars** — no hand-copied auth files.
|
|
9
|
+
|
|
10
|
+
It ships **two seam adapters**:
|
|
11
|
+
|
|
12
|
+
| Seam | What this package provides |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| **Auth** | NextAuth options (JWT access/refresh persistence, silent refresh, Keycloak client-role extraction), the `withCmsAuth` adapter, the `NextAuthCmsProvider` client wrapper, and a one-route auto-signin handler. |
|
|
15
|
+
| **Service token** | Keycloak client-credentials token for SSR content fetch + the `cms-sync` CLI. |
|
|
16
|
+
|
|
17
|
+
Transport is **not** provided — Skylab uses `inscribed`'s default REST transport.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @skylab-kulubu/inscribed-auth
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Peer dependencies (you already have most in a Next app):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install inscribed next next-auth@^4 react react-dom
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
> **Keep the app CommonJS** (do not set `"type": "module"` in the app's
|
|
32
|
+
> `package.json`). next-auth v4's Keycloak provider only resolves through
|
|
33
|
+
> Webpack's CJS/ESM interop; an ESM app breaks it. The one exception is
|
|
34
|
+
> `cms.config.mjs` below, which is `.mjs` on purpose.
|
|
35
|
+
|
|
36
|
+
## Entry points
|
|
37
|
+
|
|
38
|
+
| Import | Use from | Exports |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `@skylab-kulubu/inscribed-auth` | Client (`"use client"`) | `NextAuthCmsProvider` |
|
|
41
|
+
| `@skylab-kulubu/inscribed-auth/server` | Server only | `createCmsAuthOptions`, `withCmsAuth`, `isCmsAdmin`, `readCmsAuthMeta`, `getClientCredentialsToken`, `debugServiceTokenClaims` |
|
|
42
|
+
| `@skylab-kulubu/inscribed-auth/signin` | Server route | `GET`, `createSignInRoute` |
|
|
43
|
+
| `@skylab-kulubu/inscribed-auth/config` | `cms-sync` CLI / build-time | `getServiceToken`, `onSyncError` |
|
|
44
|
+
|
|
45
|
+
## Wiring (the five files + env)
|
|
46
|
+
|
|
47
|
+
### `lib/auth.js` — NextAuth options
|
|
48
|
+
|
|
49
|
+
The Keycloak **provider instance stays here, on your side** — never let it be
|
|
50
|
+
bundled by a dependency, or it resolves to `undefined` at runtime.
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
import KeycloakProvider from "next-auth/providers/keycloak";
|
|
54
|
+
import { createCmsAuthOptions } from "@skylab-kulubu/inscribed-auth/server";
|
|
55
|
+
|
|
56
|
+
export const authOptions = createCmsAuthOptions({
|
|
57
|
+
provider: KeycloakProvider({
|
|
58
|
+
clientId: process.env.KEYCLOAK_CLIENT_ID ?? "",
|
|
59
|
+
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
|
|
60
|
+
issuer: process.env.KEYCLOAK_ISSUER ?? "",
|
|
61
|
+
}),
|
|
62
|
+
adminRole: "cms:access", // Keycloak client role gating admin access (default)
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### `app/api/auth/[...nextauth]/route.js` — NextAuth handler
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import NextAuth from "next-auth";
|
|
70
|
+
import { authOptions } from "../../../../lib/auth.js";
|
|
71
|
+
|
|
72
|
+
const handler = NextAuth(authOptions);
|
|
73
|
+
export { handler as GET, handler as POST };
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### `app/api/signin/route.js` — one-click sign-in
|
|
77
|
+
|
|
78
|
+
`/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">`).
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
export { GET } from "@skylab-kulubu/inscribed-auth/signin";
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### `lib/cms.jsx` — the CMS page factory
|
|
86
|
+
|
|
87
|
+
```jsx
|
|
88
|
+
import { revalidateCmsSlug } from "inscribed/actions";
|
|
89
|
+
import { createCmsPage } from "inscribed/page";
|
|
90
|
+
import { NextAuthCmsProvider } from "@skylab-kulubu/inscribed-auth";
|
|
91
|
+
import { withCmsAuth, getClientCredentialsToken } from "@skylab-kulubu/inscribed-auth/server";
|
|
92
|
+
|
|
93
|
+
import { authOptions } from "./auth.js";
|
|
94
|
+
|
|
95
|
+
export const CmsPage = createCmsPage({
|
|
96
|
+
Provider: NextAuthCmsProvider,
|
|
97
|
+
config: {
|
|
98
|
+
baseUrl: process.env.CMS_URL ?? "http://localhost:5000",
|
|
99
|
+
cdnUrl: process.env.CMS_CDN_URL,
|
|
100
|
+
},
|
|
101
|
+
// Service token for the public SSR content fetch (no session required).
|
|
102
|
+
getServiceToken: getClientCredentialsToken,
|
|
103
|
+
// Adapts NextAuth into inscribed's auth-agnostic callbacks
|
|
104
|
+
// (getSession / deriveAdmin / deriveUserSub).
|
|
105
|
+
...withCmsAuth(authOptions),
|
|
106
|
+
// Must come through inscribed's `actions` entry so its "use server"
|
|
107
|
+
// directive survives bundling.
|
|
108
|
+
onAfterSave: revalidateCmsSlug,
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### `cms.config.mjs` — `cms-sync` CLI wiring (project root, `.mjs`)
|
|
113
|
+
|
|
114
|
+
The CLI is plain Node and `import()`s this file. One line:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
export { getServiceToken, onSyncError } from "@skylab-kulubu/inscribed-auth/config";
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### `.env.local`
|
|
121
|
+
|
|
122
|
+
```dotenv
|
|
123
|
+
# Keycloak (same client serves login + client_credentials sync)
|
|
124
|
+
KEYCLOAK_CLIENT_ID=<your-client-id>
|
|
125
|
+
KEYCLOAK_CLIENT_SECRET=<your-client-secret>
|
|
126
|
+
KEYCLOAK_ISSUER=https://<keycloak-host>/realms/<realm>
|
|
127
|
+
|
|
128
|
+
# NextAuth
|
|
129
|
+
NEXTAUTH_URL=http://localhost:3000
|
|
130
|
+
NEXTAUTH_SECRET=<run: openssl rand -base64 32>
|
|
131
|
+
|
|
132
|
+
# CMS backend
|
|
133
|
+
CMS_URL=https://<your-cms-host>
|
|
134
|
+
# Optional:
|
|
135
|
+
# CMS_CDN_URL=https://<your-cdn-host>
|
|
136
|
+
# NEXT_PUBLIC_CMS_URL=https://<your-cms-host> # read by inscribed core, client-side
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
| Var | Required | Purpose |
|
|
140
|
+
| --- | --- | --- |
|
|
141
|
+
| `KEYCLOAK_CLIENT_ID` | ✅ | Keycloak client (both grants) |
|
|
142
|
+
| `KEYCLOAK_CLIENT_SECRET` | ✅ | Client secret |
|
|
143
|
+
| `KEYCLOAK_ISSUER` | ✅ | Realm issuer URL |
|
|
144
|
+
| `NEXTAUTH_URL` | ✅ | App base URL for NextAuth |
|
|
145
|
+
| `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) |
|
|
149
|
+
|
|
150
|
+
## Admin access
|
|
151
|
+
|
|
152
|
+
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
|
+
```
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT © Skylab Kulübü — see [LICENSE](LICENSE).
|
|
164
|
+
|
|
165
|
+
Uses [`inscribed`](https://www.npmjs.com/package/inscribed) (LGPL-3.0-or-later) as
|
|
166
|
+
a peer dependency (not bundled); that license governs `inscribed` itself, not
|
|
167
|
+
this adapter.
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { g as getClientCredentialsToken, d as debugServiceTokenClaims } from './service-token-COa7hHuV.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file `@skylab-kulubu/inscribed-auth/config` — `cms-sync` CLI wiring.
|
|
5
|
+
*
|
|
6
|
+
* The `cms-sync` CLI is a plain Node binary - it can't receive function props
|
|
7
|
+
* the way the React tree does - so it loads the consumer's `cms.config.{js,mjs}`
|
|
8
|
+
* from the project root to learn how to obtain a service token (and, optionally,
|
|
9
|
+
* how to diagnose failures). This entry lets that config file be a one-liner:
|
|
10
|
+
*
|
|
11
|
+
* // cms.config.mjs (consumer project root)
|
|
12
|
+
* export { getServiceToken, onSyncError } from "@skylab-kulubu/inscribed-auth/config";
|
|
13
|
+
*
|
|
14
|
+
* Resolves to ESM so the CLI's dynamic `import()` works regardless of the
|
|
15
|
+
* consuming app's `"type"`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
/** Service token for build-time `POST /cms/sync`. */
|
|
20
|
+
const getServiceToken = getClientCredentialsToken;
|
|
21
|
+
|
|
22
|
+
/** Called when a sync fails - dumps Keycloak claims to explain 403s. */
|
|
23
|
+
const onSyncError = () => debugServiceTokenClaims();
|
|
24
|
+
|
|
25
|
+
export { getServiceToken, onSyncError };
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/lib/service-token.mjs
|
|
2
|
+
var cache = null;
|
|
3
|
+
async function getClientCredentialsToken() {
|
|
4
|
+
const clientId = process.env.KEYCLOAK_CLIENT_ID;
|
|
5
|
+
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
|
|
6
|
+
const issuer = process.env.KEYCLOAK_ISSUER;
|
|
7
|
+
if (!clientId || !clientSecret || !issuer) return "";
|
|
8
|
+
if (cache && cache.expiresAt > Date.now() + 3e4) return cache.token;
|
|
9
|
+
const res = await fetch(`${issuer}/protocol/openid-connect/token`, {
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
12
|
+
body: new URLSearchParams({
|
|
13
|
+
grant_type: "client_credentials",
|
|
14
|
+
client_id: clientId,
|
|
15
|
+
client_secret: clientSecret
|
|
16
|
+
}),
|
|
17
|
+
cache: "no-store"
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`[inscribed-auth] Keycloak token request failed: ${res.status} ${await res.text()}`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const { access_token, expires_in } = await res.json();
|
|
25
|
+
cache = { token: access_token, expiresAt: Date.now() + expires_in * 1e3 };
|
|
26
|
+
return access_token;
|
|
27
|
+
}
|
|
28
|
+
async function debugServiceTokenClaims() {
|
|
29
|
+
const { KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER } = process.env;
|
|
30
|
+
if (!KEYCLOAK_CLIENT_ID || !KEYCLOAK_CLIENT_SECRET || !KEYCLOAK_ISSUER) return;
|
|
31
|
+
const res = await fetch(`${KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
34
|
+
body: new URLSearchParams({
|
|
35
|
+
grant_type: "client_credentials",
|
|
36
|
+
client_id: KEYCLOAK_CLIENT_ID,
|
|
37
|
+
client_secret: KEYCLOAK_CLIENT_SECRET
|
|
38
|
+
})
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
console.error(`[cms-sync:debug] Token fetch failed: ${res.status} ${await res.text()}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const { access_token } = await res.json();
|
|
45
|
+
const [, payload] = access_token.split(".");
|
|
46
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
47
|
+
console.error("[cms-sync:debug] Service token claims:");
|
|
48
|
+
console.error(` azp: ${claims.azp}`);
|
|
49
|
+
console.error(` sub: ${claims.sub}`);
|
|
50
|
+
console.error(` aud: ${JSON.stringify(claims.aud)}`);
|
|
51
|
+
console.error(` scope: ${claims.scope}`);
|
|
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.`);
|
|
57
|
+
console.error(` Keycloak Admin -> Clients -> ${claims.azp} -> Service account roles -> Assign "cms:access".`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/config.mjs
|
|
62
|
+
var getServiceToken = getClientCredentialsToken;
|
|
63
|
+
var onSyncError = () => debugServiceTokenClaims();
|
|
64
|
+
export {
|
|
65
|
+
getServiceToken,
|
|
66
|
+
onSyncError
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +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":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as next_auth from 'next-auth';
|
|
2
|
+
import { CmsConfig, BlockResponse } from 'inscribed';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Drop-in replacement for `CmsProvider` when using NextAuth + Keycloak.
|
|
6
|
+
*
|
|
7
|
+
* The parent Server Component should:
|
|
8
|
+
* 1. Call `getServerSession(authOptions)` to get the session
|
|
9
|
+
* 2. Derive `isAdmin` (e.g. `session !== null`) and `userSub` (`session?.user?.id`)
|
|
10
|
+
* 3. Server-fetch `initialBlocks` with `getCmsContent`
|
|
11
|
+
* 4. Pass `onAfterSave={revalidateCmsSlug}` from `inscribed/actions`
|
|
12
|
+
*
|
|
13
|
+
* @param {{
|
|
14
|
+
* config: CmsConfig | { baseUrl: string },
|
|
15
|
+
* isAdmin: boolean,
|
|
16
|
+
* userSub: string | null,
|
|
17
|
+
* initialBlocks?: BlockResponse[],
|
|
18
|
+
* onAfterSave?: (slug: string) => void | Promise<void>,
|
|
19
|
+
* session?: import("next-auth").Session | null,
|
|
20
|
+
* children: React.ReactNode,
|
|
21
|
+
* }} props
|
|
22
|
+
*/
|
|
23
|
+
declare function NextAuthCmsProvider({ session, ...props }: {
|
|
24
|
+
config: CmsConfig | {
|
|
25
|
+
baseUrl: string;
|
|
26
|
+
};
|
|
27
|
+
isAdmin: boolean;
|
|
28
|
+
userSub: string | null;
|
|
29
|
+
initialBlocks?: BlockResponse[];
|
|
30
|
+
onAfterSave?: (slug: string) => void | Promise<void>;
|
|
31
|
+
session?: next_auth.Session | null;
|
|
32
|
+
children: React.ReactNode;
|
|
33
|
+
}): any;
|
|
34
|
+
|
|
35
|
+
export { NextAuthCmsProvider };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/index.jsx
|
|
4
|
+
import { SessionProvider, signIn, signOut, useSession } from "next-auth/react";
|
|
5
|
+
import { useCallback, useEffect, useMemo } from "react";
|
|
6
|
+
import { CmsProvider } from "inscribed";
|
|
7
|
+
import { jsx } from "react/jsx-runtime";
|
|
8
|
+
function Inner({ config, isAdmin, userSub, initialBlocks, onAfterSave, children }) {
|
|
9
|
+
const { data: session } = useSession();
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (session?.error === "RefreshAccessTokenError") signIn();
|
|
12
|
+
}, [session?.error]);
|
|
13
|
+
const getAccessToken = useCallback(
|
|
14
|
+
async () => (
|
|
15
|
+
/** @type {string} */
|
|
16
|
+
session?.accessToken ?? ""
|
|
17
|
+
),
|
|
18
|
+
[session?.accessToken]
|
|
19
|
+
);
|
|
20
|
+
const userInfo = useMemo(
|
|
21
|
+
() => session?.user ? {
|
|
22
|
+
name: session.user.name ?? null,
|
|
23
|
+
email: session.user.email ?? null,
|
|
24
|
+
image: session.user.image ?? null
|
|
25
|
+
} : null,
|
|
26
|
+
[session?.user?.name, session?.user?.email, session?.user?.image]
|
|
27
|
+
);
|
|
28
|
+
const onSignOut = useCallback(() => {
|
|
29
|
+
signOut({ callbackUrl: "/" });
|
|
30
|
+
}, []);
|
|
31
|
+
return /* @__PURE__ */ jsx(
|
|
32
|
+
CmsProvider,
|
|
33
|
+
{
|
|
34
|
+
config,
|
|
35
|
+
isAdmin,
|
|
36
|
+
userSub,
|
|
37
|
+
initialBlocks,
|
|
38
|
+
onAfterSave,
|
|
39
|
+
getAccessToken: isAdmin ? getAccessToken : void 0,
|
|
40
|
+
userInfo,
|
|
41
|
+
onSignOut,
|
|
42
|
+
children
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
function NextAuthCmsProvider({ session, ...props }) {
|
|
47
|
+
return /* @__PURE__ */ jsx(SessionProvider, { session, children: /* @__PURE__ */ jsx(Inner, { ...props }) });
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
NextAuthCmsProvider
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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":[]}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as next_auth from 'next-auth';
|
|
2
|
+
export { d as debugServiceTokenClaims, g as getClientCredentialsToken } from './service-token-COa7hHuV.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} CmsAuthMeta
|
|
6
|
+
* @property {string|null} adminRole
|
|
7
|
+
* @property {((session: *) => boolean)|null} isAdmin
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} CreateCmsAuthOptionsInput
|
|
11
|
+
* @property {*} provider
|
|
12
|
+
* Configured NextAuth provider instance. Import the provider module on
|
|
13
|
+
* the consumer side (typically `next-auth/providers/keycloak`) and pass
|
|
14
|
+
* the result of calling it. Required.
|
|
15
|
+
* @property {string} [adminRole]
|
|
16
|
+
* Keycloak client role required for admin access. Default `"cms:access"`.
|
|
17
|
+
* @property {(session: *) => boolean} [isAdmin]
|
|
18
|
+
* Override admin gating with arbitrary logic. Wins over `adminRole`.
|
|
19
|
+
* @property {number} [refreshLeadTimeMs]
|
|
20
|
+
* Refresh access tokens this many ms before expiry. Default 10 000.
|
|
21
|
+
* @property {Partial<import("next-auth").AuthOptions["callbacks"]>} [extraCallbacks]
|
|
22
|
+
* Extra callbacks merged on top of the built-in ones. Each callback runs
|
|
23
|
+
* AFTER the built-in version and receives the already-augmented value.
|
|
24
|
+
* @property {Partial<import("next-auth").AuthOptions>} [extraOptions]
|
|
25
|
+
* Anything else (pages, cookies, events, ...) merged onto the result.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Build a ready-to-use NextAuth `AuthOptions` object.
|
|
29
|
+
*
|
|
30
|
+
* @param {CreateCmsAuthOptionsInput} input
|
|
31
|
+
* @returns {import("next-auth").AuthOptions & { [CMS_META]: CmsAuthMeta }}
|
|
32
|
+
*/
|
|
33
|
+
declare function createCmsAuthOptions(input: CreateCmsAuthOptionsInput): next_auth.AuthOptions & {
|
|
34
|
+
[CMS_META]: CmsAuthMeta;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Read CMS admin metadata previously stamped by `createCmsAuthOptions`.
|
|
38
|
+
* Returns null if the options didn't come from the factory.
|
|
39
|
+
*
|
|
40
|
+
* @param {*} authOptions
|
|
41
|
+
* @returns {CmsAuthMeta|null}
|
|
42
|
+
*/
|
|
43
|
+
declare function readCmsAuthMeta(authOptions: any): CmsAuthMeta | null;
|
|
44
|
+
/**
|
|
45
|
+
* Decide whether a session belongs to a CMS admin. Uses `isAdmin` callback
|
|
46
|
+
* when provided, otherwise checks for the named Keycloak client role.
|
|
47
|
+
* Falls back to `false` when no metadata is available.
|
|
48
|
+
*
|
|
49
|
+
* @param {*} session
|
|
50
|
+
* @param {CmsAuthMeta|null} [meta]
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
declare function isCmsAdmin(session: any, meta?: CmsAuthMeta | null): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Adapt NextAuth `authOptions` into the auth-agnostic callbacks
|
|
56
|
+
* `createCmsPage` expects, so the inscribed core never has to import next-auth.
|
|
57
|
+
* Spread the result into the factory:
|
|
58
|
+
*
|
|
59
|
+
* import { withCmsAuth } from "@skylab-kulubu/inscribed-auth/server";
|
|
60
|
+
* createCmsPage({ ...withCmsAuth(authOptions), config, Provider, ... });
|
|
61
|
+
*
|
|
62
|
+
* - `getSession` resolves the server session via `getServerSession`.
|
|
63
|
+
* - `deriveAdmin` uses the admin metadata stamped by `createCmsAuthOptions`
|
|
64
|
+
* (falls back to `session != null` when the options didn't come from the
|
|
65
|
+
* factory).
|
|
66
|
+
* - `deriveUserSub` reads `session.user.id`.
|
|
67
|
+
*
|
|
68
|
+
* @param {import("next-auth").AuthOptions} authOptions
|
|
69
|
+
* @returns {{ getSession: () => Promise<*|null>, deriveAdmin: (session: *) => boolean, deriveUserSub: (session: *) => string | null }}
|
|
70
|
+
*/
|
|
71
|
+
declare function withCmsAuth(authOptions: next_auth.AuthOptions): {
|
|
72
|
+
getSession: () => Promise<any | null>;
|
|
73
|
+
deriveAdmin: (session: any) => boolean;
|
|
74
|
+
deriveUserSub: (session: any) => string | null;
|
|
75
|
+
};
|
|
76
|
+
type CmsAuthMeta = {
|
|
77
|
+
adminRole: string | null;
|
|
78
|
+
isAdmin: ((session: any) => boolean) | null;
|
|
79
|
+
};
|
|
80
|
+
type CreateCmsAuthOptionsInput = {
|
|
81
|
+
/**
|
|
82
|
+
* Configured NextAuth provider instance. Import the provider module on
|
|
83
|
+
* the consumer side (typically `next-auth/providers/keycloak`) and pass
|
|
84
|
+
* the result of calling it. Required.
|
|
85
|
+
*/
|
|
86
|
+
provider: any;
|
|
87
|
+
/**
|
|
88
|
+
* Keycloak client role required for admin access. Default `"cms:access"`.
|
|
89
|
+
*/
|
|
90
|
+
adminRole?: string | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* Override admin gating with arbitrary logic. Wins over `adminRole`.
|
|
93
|
+
*/
|
|
94
|
+
isAdmin?: ((session: any) => boolean) | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* Refresh access tokens this many ms before expiry. Default 10 000.
|
|
97
|
+
*/
|
|
98
|
+
refreshLeadTimeMs?: number | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* Extra callbacks merged on top of the built-in ones. Each callback runs
|
|
101
|
+
* AFTER the built-in version and receives the already-augmented value.
|
|
102
|
+
*/
|
|
103
|
+
extraCallbacks?: Partial<next_auth.AuthOptions["callbacks"]>;
|
|
104
|
+
/**
|
|
105
|
+
* Anything else (pages, cookies, events, ...) merged onto the result.
|
|
106
|
+
*/
|
|
107
|
+
extraOptions?: Partial<next_auth.NextAuthOptions> | undefined;
|
|
108
|
+
};
|
|
109
|
+
/** @type {unique symbol} */
|
|
110
|
+
declare const CMS_META: unique symbol;
|
|
111
|
+
|
|
112
|
+
export { createCmsAuthOptions, isCmsAdmin, readCmsAuthMeta, withCmsAuth };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// src/lib/options.js
|
|
2
|
+
import { getServerSession } from "next-auth";
|
|
3
|
+
var CMS_META = /* @__PURE__ */ Symbol.for("inscribed/auth.meta");
|
|
4
|
+
function createCmsAuthOptions(input) {
|
|
5
|
+
const {
|
|
6
|
+
provider,
|
|
7
|
+
adminRole = "cms:access",
|
|
8
|
+
isAdmin,
|
|
9
|
+
refreshLeadTimeMs = 1e4,
|
|
10
|
+
extraCallbacks,
|
|
11
|
+
extraOptions
|
|
12
|
+
} = input ?? {};
|
|
13
|
+
if (!provider) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
"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
|
+
}
|
|
18
|
+
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID ?? "";
|
|
19
|
+
const base = {
|
|
20
|
+
providers: [provider],
|
|
21
|
+
callbacks: {
|
|
22
|
+
async jwt(args) {
|
|
23
|
+
const { token, account } = args;
|
|
24
|
+
let next = token;
|
|
25
|
+
if (account) {
|
|
26
|
+
next.accessToken = account.access_token;
|
|
27
|
+
next.refreshToken = account.refresh_token;
|
|
28
|
+
next.accessTokenExpires = typeof account.expires_at === "number" ? account.expires_at * 1e3 : 0;
|
|
29
|
+
next.sub = account.providerAccountId ?? next.sub;
|
|
30
|
+
next.error = void 0;
|
|
31
|
+
next.clientRoles = readClientRoles(account.access_token, keycloakClientId);
|
|
32
|
+
} else if (
|
|
33
|
+
// 2. Previous refresh failed - bail until the user re-authenticates.
|
|
34
|
+
next.error !== "RefreshAccessTokenError" && // 3. Token still valid (with lead time) - return as-is.
|
|
35
|
+
(typeof next.accessTokenExpires !== "number" || Date.now() >= next.accessTokenExpires - refreshLeadTimeMs)
|
|
36
|
+
) {
|
|
37
|
+
next = await refreshAccessToken(next, keycloakClientId);
|
|
38
|
+
}
|
|
39
|
+
if (extraCallbacks?.jwt) {
|
|
40
|
+
const overridden = await extraCallbacks.jwt({ ...args, token: next });
|
|
41
|
+
if (overridden !== void 0) return overridden;
|
|
42
|
+
}
|
|
43
|
+
return next;
|
|
44
|
+
},
|
|
45
|
+
async session(args) {
|
|
46
|
+
const { session, token } = args;
|
|
47
|
+
session.accessToken = /** @type {string|undefined} */
|
|
48
|
+
token.accessToken;
|
|
49
|
+
session.error = token.error;
|
|
50
|
+
if (session.user) {
|
|
51
|
+
session.user.id = /** @type {string} */
|
|
52
|
+
token.sub ?? "";
|
|
53
|
+
session.user.clientRoles = /** @type {string[]} */
|
|
54
|
+
token.clientRoles ?? [];
|
|
55
|
+
}
|
|
56
|
+
if (extraCallbacks?.session) {
|
|
57
|
+
const overridden = await extraCallbacks.session(args);
|
|
58
|
+
if (overridden !== void 0) return overridden;
|
|
59
|
+
}
|
|
60
|
+
return session;
|
|
61
|
+
},
|
|
62
|
+
...extraCallbacks ? Object.fromEntries(
|
|
63
|
+
Object.entries(extraCallbacks).filter(
|
|
64
|
+
([key]) => key !== "jwt" && key !== "session"
|
|
65
|
+
)
|
|
66
|
+
) : {}
|
|
67
|
+
},
|
|
68
|
+
...extraOptions
|
|
69
|
+
};
|
|
70
|
+
const meta = {
|
|
71
|
+
adminRole: isAdmin ? null : adminRole,
|
|
72
|
+
isAdmin: isAdmin ?? null
|
|
73
|
+
};
|
|
74
|
+
return Object.assign(base, { [CMS_META]: meta });
|
|
75
|
+
}
|
|
76
|
+
function readCmsAuthMeta(authOptions) {
|
|
77
|
+
if (!authOptions || typeof authOptions !== "object") return null;
|
|
78
|
+
return authOptions[CMS_META] ?? null;
|
|
79
|
+
}
|
|
80
|
+
function isCmsAdmin(session, meta) {
|
|
81
|
+
if (!session) return false;
|
|
82
|
+
if (!meta) return false;
|
|
83
|
+
if (meta.isAdmin) return Boolean(meta.isAdmin(session));
|
|
84
|
+
if (meta.adminRole) {
|
|
85
|
+
const roles = session.user?.clientRoles ?? [];
|
|
86
|
+
return roles.includes(meta.adminRole);
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
function withCmsAuth(authOptions) {
|
|
91
|
+
if (!authOptions) {
|
|
92
|
+
throw new Error("withCmsAuth: `authOptions` is required");
|
|
93
|
+
}
|
|
94
|
+
const meta = readCmsAuthMeta(authOptions);
|
|
95
|
+
return {
|
|
96
|
+
getSession: () => getServerSession(authOptions),
|
|
97
|
+
deriveAdmin: meta ? (session) => isCmsAdmin(session, meta) : (session) => session != null,
|
|
98
|
+
deriveUserSub: (session) => session?.user?.id ?? null
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function readClientRoles(accessToken, clientId) {
|
|
102
|
+
if (!accessToken || !clientId) return [];
|
|
103
|
+
const segments = accessToken.split(".");
|
|
104
|
+
if (segments.length < 2) return [];
|
|
105
|
+
try {
|
|
106
|
+
const payload = JSON.parse(
|
|
107
|
+
Buffer.from(segments[1], "base64url").toString("utf8")
|
|
108
|
+
);
|
|
109
|
+
return payload?.resource_access?.[clientId]?.roles ?? [];
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function refreshAccessToken(token, keycloakClientId) {
|
|
115
|
+
try {
|
|
116
|
+
const issuer = process.env.KEYCLOAK_ISSUER ?? "";
|
|
117
|
+
const response = await fetch(`${issuer}/protocol/openid-connect/token`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
120
|
+
body: new URLSearchParams({
|
|
121
|
+
client_id: process.env.KEYCLOAK_CLIENT_ID ?? "",
|
|
122
|
+
client_secret: process.env.KEYCLOAK_CLIENT_SECRET ?? "",
|
|
123
|
+
grant_type: "refresh_token",
|
|
124
|
+
refresh_token: token.refreshToken
|
|
125
|
+
})
|
|
126
|
+
});
|
|
127
|
+
const refreshed = await response.json();
|
|
128
|
+
if (!response.ok) throw refreshed;
|
|
129
|
+
return {
|
|
130
|
+
...token,
|
|
131
|
+
accessToken: refreshed.access_token,
|
|
132
|
+
accessTokenExpires: Date.now() + refreshed.expires_in * 1e3,
|
|
133
|
+
refreshToken: refreshed.refresh_token ?? token.refreshToken,
|
|
134
|
+
clientRoles: readClientRoles(refreshed.access_token, keycloakClientId),
|
|
135
|
+
error: void 0
|
|
136
|
+
};
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error("[inscribed-auth] Refresh token error:", error);
|
|
139
|
+
return {
|
|
140
|
+
...token,
|
|
141
|
+
accessToken: void 0,
|
|
142
|
+
error: "RefreshAccessTokenError"
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/lib/service-token.mjs
|
|
148
|
+
var cache = null;
|
|
149
|
+
async function getClientCredentialsToken() {
|
|
150
|
+
const clientId = process.env.KEYCLOAK_CLIENT_ID;
|
|
151
|
+
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
|
|
152
|
+
const issuer = process.env.KEYCLOAK_ISSUER;
|
|
153
|
+
if (!clientId || !clientSecret || !issuer) return "";
|
|
154
|
+
if (cache && cache.expiresAt > Date.now() + 3e4) return cache.token;
|
|
155
|
+
const res = await fetch(`${issuer}/protocol/openid-connect/token`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
158
|
+
body: new URLSearchParams({
|
|
159
|
+
grant_type: "client_credentials",
|
|
160
|
+
client_id: clientId,
|
|
161
|
+
client_secret: clientSecret
|
|
162
|
+
}),
|
|
163
|
+
cache: "no-store"
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`[inscribed-auth] Keycloak token request failed: ${res.status} ${await res.text()}`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const { access_token, expires_in } = await res.json();
|
|
171
|
+
cache = { token: access_token, expiresAt: Date.now() + expires_in * 1e3 };
|
|
172
|
+
return access_token;
|
|
173
|
+
}
|
|
174
|
+
async function debugServiceTokenClaims() {
|
|
175
|
+
const { KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER } = process.env;
|
|
176
|
+
if (!KEYCLOAK_CLIENT_ID || !KEYCLOAK_CLIENT_SECRET || !KEYCLOAK_ISSUER) return;
|
|
177
|
+
const res = await fetch(`${KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
180
|
+
body: new URLSearchParams({
|
|
181
|
+
grant_type: "client_credentials",
|
|
182
|
+
client_id: KEYCLOAK_CLIENT_ID,
|
|
183
|
+
client_secret: KEYCLOAK_CLIENT_SECRET
|
|
184
|
+
})
|
|
185
|
+
});
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
console.error(`[cms-sync:debug] Token fetch failed: ${res.status} ${await res.text()}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const { access_token } = await res.json();
|
|
191
|
+
const [, payload] = access_token.split(".");
|
|
192
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
193
|
+
console.error("[cms-sync:debug] Service token claims:");
|
|
194
|
+
console.error(` azp: ${claims.azp}`);
|
|
195
|
+
console.error(` sub: ${claims.sub}`);
|
|
196
|
+
console.error(` aud: ${JSON.stringify(claims.aud)}`);
|
|
197
|
+
console.error(` scope: ${claims.scope}`);
|
|
198
|
+
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.`);
|
|
203
|
+
console.error(` Keycloak Admin -> Clients -> ${claims.azp} -> Service account roles -> Assign "cms:access".`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export {
|
|
207
|
+
createCmsAuthOptions,
|
|
208
|
+
debugServiceTokenClaims,
|
|
209
|
+
getClientCredentialsToken,
|
|
210
|
+
isCmsAdmin,
|
|
211
|
+
readCmsAuthMeta,
|
|
212
|
+
withCmsAuth
|
|
213
|
+
};
|
|
214
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +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":[]}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Keycloak client-credentials service-token provider.
|
|
3
|
+
*
|
|
4
|
+
* Part of `@skylab-kulubu/inscribed-auth`. The inscribed core is backend-neutral:
|
|
5
|
+
* it only knows the `ServiceTokenProvider` contract (`() => Promise<string>`)
|
|
6
|
+
* and defaults to "no token". This module implements that contract against
|
|
7
|
+
* Keycloak and is wired into the SDK via `createCmsPage({ getServiceToken })`
|
|
8
|
+
* (consumer `lib/cms.jsx`) and the `cms-sync` CLI (consumer `cms.config.mjs`,
|
|
9
|
+
* which re-exports from `@skylab-kulubu/inscribed-auth/config`).
|
|
10
|
+
*
|
|
11
|
+
* `.mjs` so the plain-Node `cms-sync` CLI can `import()` the resolved `./config`
|
|
12
|
+
* entry as ESM regardless of the consuming app's `"type"`. (Skylab apps stay
|
|
13
|
+
* CommonJS so next-auth v4's Keycloak provider resolves through Webpack's CJS
|
|
14
|
+
* interop — see the provider-injection note in `options.js`.)
|
|
15
|
+
*
|
|
16
|
+
* Server / build-time only. Reads KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET,
|
|
17
|
+
* KEYCLOAK_ISSUER. Returns "" when those vars are absent so reads degrade to
|
|
18
|
+
* unauthenticated. In-process cache shared across requests, re-fetched 30s
|
|
19
|
+
* before expiry.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** @type {{ token: string; expiresAt: number } | null} */
|
|
23
|
+
let cache = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Implements the SDK's `ServiceTokenProvider` contract: `() => Promise<string>`.
|
|
27
|
+
* @returns {Promise<string>}
|
|
28
|
+
*/
|
|
29
|
+
async function getClientCredentialsToken() {
|
|
30
|
+
const clientId = process.env.KEYCLOAK_CLIENT_ID;
|
|
31
|
+
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
|
|
32
|
+
const issuer = process.env.KEYCLOAK_ISSUER;
|
|
33
|
+
|
|
34
|
+
if (!clientId || !clientSecret || !issuer) return "";
|
|
35
|
+
|
|
36
|
+
if (cache && cache.expiresAt > Date.now() + 30_000) return cache.token;
|
|
37
|
+
|
|
38
|
+
const res = await fetch(`${issuer}/protocol/openid-connect/token`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
41
|
+
body: new URLSearchParams({
|
|
42
|
+
grant_type: "client_credentials",
|
|
43
|
+
client_id: clientId,
|
|
44
|
+
client_secret: clientSecret,
|
|
45
|
+
}),
|
|
46
|
+
cache: "no-store",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[inscribed-auth] Keycloak token request failed: ${res.status} ${await res.text()}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { access_token, expires_in } = await res.json();
|
|
56
|
+
cache = { token: access_token, expiresAt: Date.now() + expires_in * 1000 };
|
|
57
|
+
return access_token;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* On a sync failure, fetch the service token directly and dump the claims the
|
|
62
|
+
* backend's `CmsAccessPolicy` checks (`azp`, `aud`, `resource_access`). Most
|
|
63
|
+
* 403s come from the service account missing the `cms:access` role mapping in
|
|
64
|
+
* Keycloak - this prints exactly what's there. Wired as `onSyncError` in the
|
|
65
|
+
* `./config` entry.
|
|
66
|
+
*/
|
|
67
|
+
async function debugServiceTokenClaims() {
|
|
68
|
+
const { KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, KEYCLOAK_ISSUER } = process.env;
|
|
69
|
+
if (!KEYCLOAK_CLIENT_ID || !KEYCLOAK_CLIENT_SECRET || !KEYCLOAK_ISSUER) return;
|
|
70
|
+
|
|
71
|
+
const res = await fetch(`${KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
74
|
+
body: new URLSearchParams({
|
|
75
|
+
grant_type: "client_credentials",
|
|
76
|
+
client_id: KEYCLOAK_CLIENT_ID,
|
|
77
|
+
client_secret: KEYCLOAK_CLIENT_SECRET,
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
console.error(`[cms-sync:debug] Token fetch failed: ${res.status} ${await res.text()}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const { access_token } = await res.json();
|
|
86
|
+
const [, payload] = access_token.split(".");
|
|
87
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
88
|
+
|
|
89
|
+
console.error("[cms-sync:debug] Service token claims:");
|
|
90
|
+
console.error(` azp: ${claims.azp}`);
|
|
91
|
+
console.error(` sub: ${claims.sub}`);
|
|
92
|
+
console.error(` aud: ${JSON.stringify(claims.aud)}`);
|
|
93
|
+
console.error(` scope: ${claims.scope}`);
|
|
94
|
+
console.error(` resource_access: ${JSON.stringify(claims.resource_access)}`);
|
|
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.`);
|
|
100
|
+
console.error(` Keycloak Admin -> Clients -> ${claims.azp} -> Service account roles -> Assign "cms:access".`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export { debugServiceTokenClaims as d, getClientCredentialsToken as g };
|
package/dist/signin.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `@skylab-kulubu/inscribed-auth/signin` — signin route handler factory.
|
|
3
|
+
*
|
|
4
|
+
* Server-only App Router route handler that auto-submits the user to NextAuth's
|
|
5
|
+
* provider sign-in flow, skipping the "Sign in with X" confirm page that
|
|
6
|
+
* NextAuth shows when its sign-in URL is hit with GET. The route returns
|
|
7
|
+
* a tiny HTML document that fetches the CSRF token and POSTs the sign-in
|
|
8
|
+
* form via JavaScript - the same dance `next-auth/react`'s `signIn()`
|
|
9
|
+
* helper does, just done from a server route so the consumer can link to
|
|
10
|
+
* `/api/signin` from anywhere (server components, plain anchors, server
|
|
11
|
+
* actions) without bundling client-side helpers.
|
|
12
|
+
*
|
|
13
|
+
* Usage (consumer side):
|
|
14
|
+
*
|
|
15
|
+
* // app/api/signin/route.js
|
|
16
|
+
* export { GET } from "@skylab-kulubu/inscribed-auth/signin";
|
|
17
|
+
*
|
|
18
|
+
* Or with explicit provider id / forced callback URL:
|
|
19
|
+
*
|
|
20
|
+
* import { createSignInRoute } from "@skylab-kulubu/inscribed-auth/signin";
|
|
21
|
+
* export const GET = createSignInRoute({ provider: "keycloak" });
|
|
22
|
+
*
|
|
23
|
+
* The `callbackUrl` query parameter is forwarded to NextAuth so the user
|
|
24
|
+
* lands back where they came from after signing in. Defaults to "/".
|
|
25
|
+
*
|
|
26
|
+
* `<noscript>` users get a link to NextAuth's standard provider page
|
|
27
|
+
* (the one with the "Sign in with X" button) so sign-in still works
|
|
28
|
+
* without JavaScript - it just can't skip the extra click.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} CreateSignInRouteOptions
|
|
32
|
+
* @property {string} [provider]
|
|
33
|
+
* Provider id NextAuth registered. Default `"keycloak"`. Set to `null`
|
|
34
|
+
* (or `""`) to land on NextAuth's provider-picker page instead.
|
|
35
|
+
* @property {string} [defaultCallbackUrl]
|
|
36
|
+
* Where to send the user when no `?callbackUrl=` query is present. Default `"/"`.
|
|
37
|
+
* @property {string} [signInPath]
|
|
38
|
+
* Override the NextAuth sign-in mount path. Default `/api/auth/signin`.
|
|
39
|
+
* @property {string} [csrfPath]
|
|
40
|
+
* Override the NextAuth CSRF endpoint. Default `/api/auth/csrf`.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* @param {CreateSignInRouteOptions} [options]
|
|
44
|
+
*/
|
|
45
|
+
declare function createSignInRoute(options?: CreateSignInRouteOptions): (request: Request) => Response;
|
|
46
|
+
declare function GET(request: Request): Response;
|
|
47
|
+
type CreateSignInRouteOptions = {
|
|
48
|
+
/**
|
|
49
|
+
* Provider id NextAuth registered. Default `"keycloak"`. Set to `null`
|
|
50
|
+
* (or `""`) to land on NextAuth's provider-picker page instead.
|
|
51
|
+
*/
|
|
52
|
+
provider?: string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* Where to send the user when no `?callbackUrl=` query is present. Default `"/"`.
|
|
55
|
+
*/
|
|
56
|
+
defaultCallbackUrl?: string | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Override the NextAuth sign-in mount path. Default `/api/auth/signin`.
|
|
59
|
+
*/
|
|
60
|
+
signInPath?: string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Override the NextAuth CSRF endpoint. Default `/api/auth/csrf`.
|
|
63
|
+
*/
|
|
64
|
+
csrfPath?: string | undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export { type CreateSignInRouteOptions, GET, createSignInRoute };
|
package/dist/signin.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// src/signin.js
|
|
2
|
+
function createSignInRoute(options = {}) {
|
|
3
|
+
const {
|
|
4
|
+
provider = "keycloak",
|
|
5
|
+
defaultCallbackUrl = "/",
|
|
6
|
+
signInPath = "/api/auth/signin",
|
|
7
|
+
csrfPath = "/api/auth/csrf"
|
|
8
|
+
} = options;
|
|
9
|
+
const signInUrl = provider ? `${signInPath}/${provider}` : signInPath;
|
|
10
|
+
return function GET2(request) {
|
|
11
|
+
const url = new URL(request.url);
|
|
12
|
+
const callbackUrl = url.searchParams.get("callbackUrl") ?? defaultCallbackUrl;
|
|
13
|
+
const html = renderAutoSubmitPage({
|
|
14
|
+
signInUrl,
|
|
15
|
+
csrfPath,
|
|
16
|
+
callbackUrl,
|
|
17
|
+
// <noscript> fallback - NextAuth's own GET sign-in page, which
|
|
18
|
+
// shows the "Sign in with X" button. It's a worse UX (extra click)
|
|
19
|
+
// but it's the only thing that works without JS.
|
|
20
|
+
noscriptHref: `${signInUrl}?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
|
21
|
+
});
|
|
22
|
+
return new Response(html, {
|
|
23
|
+
status: 200,
|
|
24
|
+
headers: {
|
|
25
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
26
|
+
// Don't let browsers/proxies cache this - the form needs a fresh
|
|
27
|
+
// CSRF token on every visit.
|
|
28
|
+
"Cache-Control": "no-store"
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function renderAutoSubmitPage({ signInUrl, csrfPath, callbackUrl, noscriptHref }) {
|
|
34
|
+
const jsSignInUrl = escapeForScript(signInUrl);
|
|
35
|
+
const jsCsrfPath = escapeForScript(csrfPath);
|
|
36
|
+
const jsCallbackUrl = escapeForScript(callbackUrl);
|
|
37
|
+
const htmlNoscriptHref = escapeForHtmlAttribute(noscriptHref);
|
|
38
|
+
return `<!DOCTYPE html>
|
|
39
|
+
<html lang="en">
|
|
40
|
+
<head>
|
|
41
|
+
<meta charset="utf-8">
|
|
42
|
+
<title>Signing in\u2026</title>
|
|
43
|
+
<meta name="robots" content="noindex,nofollow">
|
|
44
|
+
<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; }
|
|
47
|
+
</style>
|
|
48
|
+
</head>
|
|
49
|
+
<body>
|
|
50
|
+
<p>Signing in\u2026</p>
|
|
51
|
+
<noscript>
|
|
52
|
+
<p><a href=${htmlNoscriptHref}>Continue to sign in</a></p>
|
|
53
|
+
</noscript>
|
|
54
|
+
<script>
|
|
55
|
+
(function () {
|
|
56
|
+
fetch(${jsCsrfPath}, { credentials: "same-origin" })
|
|
57
|
+
.then(function (r) { return r.json(); })
|
|
58
|
+
.then(function (data) {
|
|
59
|
+
var form = document.createElement("form");
|
|
60
|
+
form.method = "POST";
|
|
61
|
+
form.action = ${jsSignInUrl};
|
|
62
|
+
var fields = { csrfToken: data.csrfToken, callbackUrl: ${jsCallbackUrl} };
|
|
63
|
+
for (var key in fields) {
|
|
64
|
+
var input = document.createElement("input");
|
|
65
|
+
input.type = "hidden";
|
|
66
|
+
input.name = key;
|
|
67
|
+
input.value = fields[key];
|
|
68
|
+
form.appendChild(input);
|
|
69
|
+
}
|
|
70
|
+
document.body.appendChild(form);
|
|
71
|
+
form.submit();
|
|
72
|
+
})
|
|
73
|
+
.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)});
|
|
77
|
+
});
|
|
78
|
+
})();
|
|
79
|
+
</script>
|
|
80
|
+
</body>
|
|
81
|
+
</html>`;
|
|
82
|
+
}
|
|
83
|
+
var LINE_SEPARATOR = String.fromCharCode(8232);
|
|
84
|
+
var PARAGRAPH_SEPARATOR = String.fromCharCode(8233);
|
|
85
|
+
function escapeForScript(value) {
|
|
86
|
+
return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").split(LINE_SEPARATOR).join("\\u2028").split(PARAGRAPH_SEPARATOR).join("\\u2029");
|
|
87
|
+
}
|
|
88
|
+
function escapeForHtmlAttribute(value) {
|
|
89
|
+
return `"${value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">")}"`;
|
|
90
|
+
}
|
|
91
|
+
var GET = createSignInRoute();
|
|
92
|
+
export {
|
|
93
|
+
GET,
|
|
94
|
+
createSignInRoute
|
|
95
|
+
};
|
|
96
|
+
//# sourceMappingURL=signin.js.map
|
|
@@ -0,0 +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, \"&\")\n .replace(/\"/g, \""\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")}\"`;\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"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skylab-kulubu/inscribed-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Skylab's opt-in NextAuth + Keycloak adapter (auth + service token) for the inscribed CMS.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./server": {
|
|
17
|
+
"types": "./dist/server.d.ts",
|
|
18
|
+
"import": "./dist/server.js"
|
|
19
|
+
},
|
|
20
|
+
"./signin": {
|
|
21
|
+
"types": "./dist/signin.d.ts",
|
|
22
|
+
"import": "./dist/signin.js"
|
|
23
|
+
},
|
|
24
|
+
"./config": {
|
|
25
|
+
"types": "./dist/config.d.ts",
|
|
26
|
+
"import": "./dist/config.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsup",
|
|
31
|
+
"dev": "tsup --watch",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"inscribed": "^1.0.1",
|
|
36
|
+
"next": ">=14",
|
|
37
|
+
"next-auth": "^4",
|
|
38
|
+
"react": ">=18",
|
|
39
|
+
"react-dom": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"inscribed": "^1.0.1",
|
|
43
|
+
"next": ">=14",
|
|
44
|
+
"next-auth": "^4",
|
|
45
|
+
"react": ">=18",
|
|
46
|
+
"react-dom": ">=18",
|
|
47
|
+
"tsup": "^8",
|
|
48
|
+
"typescript": "^5"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
}
|
|
53
|
+
}
|