mikser-io-auth 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,249 @@
1
+ # mikser-io-auth
2
+
3
+ > Authentication for mikser — HTTP Basic and JWT over Apache-format
4
+ > `htpasswd` / `htgroup` files in the working folder.
5
+
6
+ ## What it is
7
+
8
+ Mikser's engine ships an authentication *seam* (ADR-0012): a verifier
9
+ contract, a constant-time static-token verifier, and the loopback policy
10
+ that used to be hand-copied into every plugin that mounted a route. It ships
11
+ no identity — no users, no groups, no login.
12
+
13
+ This package is the identity half. It plugs in wherever a static token
14
+ plugs in, because it implements the same contract:
15
+
16
+ ```js
17
+ { name, async verify(req), challenge?(req, res) }
18
+ ```
19
+
20
+ **Design choices, and why:**
21
+
22
+ - **Users and groups are files, not a database.** `users.htpasswd` and
23
+ `groups.htgroup` sit in the working folder next to the content they
24
+ govern. That is ADR-0002 applied to identity: reviewable in a pull
25
+ request, diffable, deployable by copying a directory, editable with
26
+ `htpasswd(1)` — a tool that predates every framework this project will
27
+ outlive. The format is not ours to version or migrate.
28
+
29
+ - **The files are provisioned, never written at runtime.** No signup, no
30
+ password reset, no "create user" tool. A build tool has operators, not
31
+ members. The moment mikser writes an htpasswd file it has a locking
32
+ problem and a database it won't admit to. Edits *are* picked up without a
33
+ restart — the files are re-read when their mtime moves.
34
+
35
+ - **bcrypt is the format worth trusting.** `$apr1$` and `{SHA}` are
36
+ supported because `htpasswd` emits them and an operator's existing file
37
+ should keep working; both are weak. DES-crypt and MD5-crypt are refused
38
+ outright rather than half-supported — silently rejecting is safer than a
39
+ subtly wrong implementation that accepts something. Use `htpasswd -B`.
40
+
41
+ - **The signing key is a file, not a boot-time secret.** Regenerating a key
42
+ on boot silently invalidates every live token whenever the process
43
+ cycles, which presents as an intermittent auth bug and is miserable to
44
+ diagnose. `auth.key` is written `0600` on first run and reused after.
45
+
46
+ - **Scope is computed from the files, never from the request.** A client
47
+ cannot ask for capabilities it doesn't hold. Downstream enforcement is
48
+ scope-only with no per-request re-read, which is safe *because* of this
49
+ invariant — it is the load-bearing piece, not optional hardening.
50
+
51
+ - **Basic for people, JWT for clients.** HTTP Basic is browser-native and
52
+ needs no flow, which makes it right for `api` / `forms` / `decap`. MCP
53
+ clients expect Bearer and a discovery document, so they get JWT.
54
+
55
+ ## Use
56
+
57
+ The whole thing, minimally:
58
+
59
+ ```js
60
+ import { auth } from 'mikser-io-auth'
61
+ import { api } from 'mikser-io'
62
+ import { mcp } from 'mikser-io-mcp'
63
+
64
+ const identity = auth()
65
+
66
+ export default async () => ({
67
+ plugins: [
68
+ identity,
69
+ api({ endpoints: { admin: { auth: identity } } }),
70
+ mcp({ endpoints: { remote: { auth: identity } } }),
71
+ ],
72
+ })
73
+ ```
74
+
75
+ That is a working setup. `htpasswd -B -c users.htpasswd alice` and you can
76
+ sign in; an agent can self-register and connect. Everything below is
77
+ optional and narrows what you already have.
78
+
79
+ `identity` is both the plugin and the verifier, so `auth: identity` accepts
80
+ whichever credential the caller has — Basic from a browser, Bearer from an
81
+ agent. Use `identity.basic()` or `identity.jwt()` only to deliberately
82
+ exclude one.
83
+
84
+ With no capability map configured, an authenticated user is **unscoped**:
85
+ the endpoint's own `operations` list is the only limit, exactly as for a
86
+ static token. Add capabilities when you want them to mean something:
87
+
88
+ ```js
89
+ const identity = auth({
90
+ // paths are relative to the working folder; these are the defaults
91
+ users: 'users.htpasswd',
92
+ groups: 'groups.htgroup',
93
+ key: 'auth.key',
94
+
95
+ // groups → capabilities. The files stay pure identity; this decides
96
+ // what a group is allowed to do. Once this exists, a user whose groups
97
+ // grant nothing can do nothing.
98
+ capabilities: {
99
+ editors: ['api:update', 'mcp:use'],
100
+ admins: ['api:update', 'api:delete', 'mcp:use'],
101
+ },
102
+
103
+ // groups → rows, ANDed with the endpoint's own query
104
+ scopes: {
105
+ editors: { 'meta.href': { $regex: '^/web' } },
106
+ },
107
+
108
+ issuer: 'https://cms.example.com',
109
+ })
110
+ ```
111
+
112
+ The sign-in page names the deployment from mikser's own external URL
113
+ (`runtime.options.url`), falling back to the host you connected to. It is
114
+ not a config option — the host in the address bar is the most honest name
115
+ there is, because it *is* the thing in front of you and so cannot name a
116
+ different deployment.
117
+
118
+ `auth()` returns a value that is both the lifecycle plugin and the factory
119
+ for the verifiers — because config is evaluated before the runtime exists,
120
+ so a verifier has to be nameable at config time while its store can only be
121
+ opened once the working folder is known. Verifiers resolve their store
122
+ lazily; plugin order doesn't matter, and forgetting to add `identity` to
123
+ `plugins:` fails with a message saying so.
124
+
125
+ ### The files
126
+
127
+ ```
128
+ <workingFolder>/
129
+ users.htpasswd alice:$2y$10$… htpasswd -B -c users.htpasswd alice
130
+ groups.htgroup editors: alice bob
131
+ auth.key {"kid":…,"privateJwk":…} generated on first run, 0600
132
+ ```
133
+
134
+ Keep `auth.key` out of version control — losing it invalidates every issued
135
+ token; leaking it lets anyone mint one.
136
+
137
+ ### Endpoints
138
+
139
+ Mounted at `base` (default `/auth`) when mikser runs with `--server`:
140
+
141
+ | | |
142
+ | --- | --- |
143
+ | `GET /auth/authorize` | the sign-in page |
144
+ | `POST /auth/authorize` | verify against `users.htpasswd`, redirect back with a code |
145
+ | `POST /auth/token` | `authorization_code`, `refresh_token`, or `password` |
146
+ | `POST /auth/register` | RFC 7591 self-registration — the only way a client exists |
147
+ | `GET /auth/jwks.json` | the public half of the signing key |
148
+ | `GET /auth/.well-known/oauth-authorization-server` | RFC 8414 metadata |
149
+ | `GET /auth/logo.svg` | the mark on the sign-in page |
150
+
151
+ For a script or CLI, skip the browser entirely:
152
+
153
+ ```bash
154
+ curl -u alice:alice-pw -X POST https://cms.example.com/auth/token
155
+ ```
156
+
157
+ ### Clients — there is no client config
158
+
159
+ Agents register themselves (RFC 7591). There is no list to maintain, no
160
+ per-agent redirect to write down, and no way to declare one.
161
+
162
+ A `clients:` map sounds harmless and isn't: it makes the set of agents that
163
+ can connect equal to the set somebody thought to write down, so every new
164
+ agent becomes a config change and a deploy. And an agent whose UI takes a
165
+ URL and nothing else has no field to type a `client_id` into — it registers
166
+ or it cannot connect at all.
167
+
168
+ Tune the bounds if you need to:
169
+
170
+ ```js
171
+ auth({ dcr: { maxPerIp: 5, windowMs: 3600_000, maxClients: 1000 } })
172
+ ```
173
+
174
+ Public clients either way: PKCE (S256) required, no `client_secret`, because
175
+ a browser or a native agent cannot keep one.
176
+
177
+ `POST /auth/register` is **unauthenticated by necessity** — you need a
178
+ `client_id` before you can authenticate, so a token requirement would make the
179
+ endpoint useless to the only callers that need it. That is safe because a
180
+ registered client can do nothing on its own: it holds no tokens and represents
181
+ no person, and cannot act until someone signs in on the page, where what they
182
+ can do comes from their groups rather than from anything the client asked for.
183
+ What is at risk is table volume, not access — hence `maxPerIp` (in-process,
184
+ counts every request including rejected ones) and `maxClients` (a row count,
185
+ the bound that survives a restart).
186
+
187
+ Registration names are attacker-controlled, so they are length-capped and
188
+ HTML-escaped where they render.
189
+
190
+ Every registration mints a **new** `client_id` — RFC 7591 has no get-or-create
191
+ — so a reinstall or a second machine leaves another row behind. Registrations
192
+ nobody ever signed in with are pruned after `pruneClientsAfterDays` (30).
193
+
194
+ ### Redirect URIs
195
+
196
+ Matched exactly, with one exception: RFC 8252 §7.3 requires the **port of a
197
+ loopback URI to be ignored**, because a native client binds an ephemeral port
198
+ it cannot know in advance. Every MCP client that opens a browser depends on
199
+ this. Scheme, host, path, query and fragment still match exactly.
200
+
201
+ A self-registering client is held to a stricter rule than an operator writing
202
+ config, because nobody reviewed it: `https` anywhere, `http` only on loopback,
203
+ and never a fragment.
204
+
205
+ ### The sign-in page
206
+
207
+ Deliberately identical to WhiteBox's — same layout, type scale, tokens and
208
+ pending state — because mikser and WhiteBox are the same company's products
209
+ and someone who administers both should not have to wonder which one they are
210
+ looking at. The mark is the only difference, and `logo:` overrides it.
211
+
212
+ The page names two things, and the second is the one that matters: **which**
213
+ deployment (`appName`), and **who** is asking for access (the client). Without
214
+ the second, signing in to your own site and handing an agent your permissions
215
+ look identical.
216
+
217
+ ### Grants
218
+
219
+ Authorization codes (60s, single-use) and refresh tokens (30d, rotated on
220
+ every use) live in the engine's sqlite (ADR-0009), under `mikser_auth_*`.
221
+ Identity stays in files; this is session bookkeeping.
222
+
223
+ The engine wipes that database when its schema stamp changes, so **upgrading
224
+ mikser signs everyone out**. Codes are irrelevant at 60 seconds; re-issuing
225
+ refresh tokens after an engine upgrade is the price of not inventing a second
226
+ persistence story.
227
+
228
+ ## Not implemented
229
+
230
+ Deliberately out of scope: self-service registration, password reset, invites,
231
+ and any cross-plugin permission catalog. A build server has operators, not
232
+ members.
233
+
234
+ ## Auth on a mikser endpoint
235
+
236
+ Reachability is unchanged from the engine's rule, with one difference that
237
+ matters:
238
+
239
+ | config | behaviour |
240
+ | --- | --- |
241
+ | nothing | loopback only, unless `allowRemote` |
242
+ | `token: '…'` | valid token from anywhere; loopback still reaches it *without* the token |
243
+ | `auth: identity.basic()` | the verifier gates every request — **no loopback bypass** |
244
+ | `auth: identity.jwt()` | same, plus RFC 9728 discovery on MCP endpoints |
245
+
246
+ A static token keeps the internet out, not the developer running the build.
247
+ A real verifier gets no bypass: if you wired it up, an unauthenticated
248
+ loopback caller — another process on a shared box, an SSRF hop — is exactly
249
+ what you were buying protection from.
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="123.751 38.751 148.498 148.498" role="img" aria-label="mikser">
2
+ <title>mikser</title>
3
+ <g style="isolation:isolate">
4
+ <g style="mix-blend-mode:multiply">
5
+ <circle cx="198" cy="92" r="46" fill="#0A0907"></circle>
6
+ <circle cx="222.249" cy="134" r="46" fill="#0A0907" opacity="0.85"></circle>
7
+ <circle cx="173.751" cy="134" r="46" fill="#FF3F00"></circle>
8
+ </g>
9
+ </g>
10
+ </svg>
package/index.js ADDED
@@ -0,0 +1,232 @@
1
+ import path from 'node:path'
2
+ import { registerRoute, anyOf } from 'mikser-io'
3
+
4
+ import { createIdentityStore, parseHtpasswd, parseHtgroup, verifyPassword } from './lib/htpasswd.js'
5
+ import { loadOrCreateKey, jwks, ALG } from './lib/keys.js'
6
+ import { issueToken, createTokenVerifier } from './lib/tokens.js'
7
+ import { basic, jwt } from './lib/verifiers.js'
8
+ import { redirectUriAllowed, validateRedirectUri, registerDynamicClient } from './lib/clients.js'
9
+ import { challengeFromVerifier, verifyPkce, opaqueToken } from './lib/pkce.js'
10
+ import { loginPage } from './lib/login-page.js'
11
+ import { mountRoutes } from './lib/routes.js'
12
+ import * as grants from './lib/grants.js'
13
+
14
+ export { parseHtpasswd, parseHtgroup, verifyPassword, createIdentityStore }
15
+ export { loadOrCreateKey, jwks, ALG }
16
+ export { issueToken, createTokenVerifier }
17
+ export { redirectUriAllowed, validateRedirectUri, registerDynamicClient }
18
+ export { challengeFromVerifier, verifyPkce, opaqueToken }
19
+ export { loginPage }
20
+ export { grants }
21
+
22
+ /**
23
+ * Authentication for mikser (ADR-0012).
24
+ *
25
+ * Returns a value that is BOTH the lifecycle plugin and the factory for the
26
+ * verifiers other plugins gate on — because config is evaluated before the
27
+ * runtime exists, and a verifier has to be nameable at config time while its
28
+ * store can only be opened once the working folder is known:
29
+ *
30
+ * const identity = auth({
31
+ * capabilities: { editors: ['api:update', 'mcp:use'] },
32
+ * issuer: 'https://cms.example.com',
33
+ * })
34
+ *
35
+ * export default async () => ({
36
+ * plugins: [
37
+ * identity,
38
+ * api({ endpoints: { admin: { auth: identity.basic() } } }),
39
+ * mcp({ endpoints: { remote: { auth: identity.jwt() } } }),
40
+ * ],
41
+ * })
42
+ *
43
+ * Verifiers are created eagerly and resolve their store lazily, so the order
44
+ * of `plugins:` doesn't matter and a config-time typo still surfaces at boot.
45
+ */
46
+ export function auth(options = {}) {
47
+ const {
48
+ users = 'users.htpasswd',
49
+ groups = 'groups.htgroup',
50
+ key = 'auth.key',
51
+ capabilities = {},
52
+ scopes = {},
53
+ issuer,
54
+ audience = issuer,
55
+ base = '/auth',
56
+ ttl = '1h',
57
+ realm = 'mikser',
58
+ dcr = {},
59
+ pruneClientsAfterDays = 30,
60
+ logo,
61
+ } = options
62
+
63
+ // Filled in at onLoad, read by the verifiers at request time.
64
+ let state = null
65
+ const ready = () => {
66
+ if (!state) {
67
+ throw new Error(
68
+ 'mikser-io-auth: a verifier was used before the auth plugin loaded. ' +
69
+ 'Add the value returned by auth() to your `plugins:` array.'
70
+ )
71
+ }
72
+ return state
73
+ }
74
+
75
+ // A lazy façade over the store, so basic() can be constructed at config
76
+ // time and still read files that only exist once mikser has a working folder.
77
+ const lazyStore = {
78
+ authenticate: (u, p) => ready().store.authenticate(u, p),
79
+ groupsOf: (u) => ready().store.groupsOf(u),
80
+ capabilitiesOf: (u) => ready().store.capabilitiesOf(u),
81
+ reload: () => ready().store.reload(),
82
+ }
83
+
84
+ const plugin = ({ runtime, onLoad, onLoaded, useLogger }) => {
85
+ onLoad(async () => {
86
+ const logger = useLogger()
87
+ const workingFolder = runtime.options.workingFolder
88
+ const resolve = (f) => (path.isAbsolute(f) ? f : path.join(workingFolder, f))
89
+
90
+ const store = createIdentityStore({
91
+ usersFile: resolve(users),
92
+ groupsFile: resolve(groups),
93
+ groups: capabilities,
94
+ scopes,
95
+ logger,
96
+ })
97
+ const signingKey = await loadOrCreateKey({ keyFile: resolve(key), logger })
98
+
99
+ state = {
100
+ store,
101
+ key: signingKey,
102
+ verifyToken: createTokenVerifier({
103
+ key: signingKey,
104
+ issuer: issuer ?? runtime.options.url,
105
+ audience: audience ?? runtime.options.url,
106
+ }),
107
+ logger,
108
+ }
109
+ })
110
+
111
+ onLoaded(async () => {
112
+ const app = runtime.options.app
113
+ if (!app) return // no server this run — verifiers still work in-process
114
+
115
+ const logger = useLogger()
116
+ // Express comes from the host, not from our own node_modules —
117
+ // same instance as runtime.options.app, same pattern as the
118
+ // forms plugin uses.
119
+ const { default: express } = await import('express').catch(() => {
120
+ throw new Error('mikser-io-auth: express is required — npm install express')
121
+ })
122
+ const router = express.Router()
123
+ router.use(express.urlencoded({ extended: false, limit: '8kb' }))
124
+ router.use(express.json({ limit: '8kb' }))
125
+
126
+ const originOf = (req) => issuer ?? `${req.protocol}://${req.get('host')}`
127
+
128
+ // What the sign-in page calls this deployment. Not a config
129
+ // option: mikser already knows its external URL, and failing
130
+ // that, the host in the address bar is the most honest name
131
+ // there is — it IS the thing you connected to, so it cannot
132
+ // name a different deployment than the one in front of you.
133
+ const nameOf = (req) => {
134
+ try {
135
+ if (runtime.options.url) return new URL(runtime.options.url).host
136
+ } catch { /* not a URL — fall through */ }
137
+ return req.get('host')
138
+ }
139
+
140
+ mountRoutes(router, {
141
+ base,
142
+ nameOf,
143
+ logoUrl: logo ?? `${base}/logo.svg`,
144
+ realm,
145
+ ttl,
146
+ ready,
147
+ issuerFor: originOf,
148
+ audienceFor: (req) => audience ?? originOf(req),
149
+ dcr,
150
+ logger,
151
+ })
152
+
153
+ app.use(base, router)
154
+
155
+ // Codes are 60s and refresh tokens 30d; without a sweep the rows
156
+ // accumulate for the life of the database. Once at boot is enough
157
+ // for a build tool — the checks that matter (expiry, single use)
158
+ // are enforced on read, not by the sweep.
159
+ try {
160
+ const swept = grants.sweepExpired()
161
+ if (swept.codes || swept.refresh) {
162
+ logger?.debug?.('auth: swept %d expired code(s), %d refresh token(s)',
163
+ swept.codes, swept.refresh)
164
+ }
165
+ // Every registration mints a NEW client_id — there is no
166
+ // "get or create" in RFC 7591 — so a reinstall or a second
167
+ // machine leaves another row behind. Only ones nobody ever
168
+ // signed in with are dropped; a config-declared client is
169
+ // never touched, because it lives in config, not this table.
170
+ if (pruneClientsAfterDays) {
171
+ const pruned = grants.pruneUnusedClients({
172
+ olderThanMs: pruneClientsAfterDays * 24 * 60 * 60 * 1000,
173
+ })
174
+ if (pruned) logger?.info?.('auth: pruned %d unused client registration(s)', pruned)
175
+ }
176
+ } catch (err) {
177
+ logger?.debug?.('auth: could not sweep expired grants — %s', err.message)
178
+ }
179
+
180
+ registerRoute({
181
+ path: base,
182
+ plugin: 'auth',
183
+ reachability: 'public',
184
+ streaming: false,
185
+ label: 'Auth',
186
+ detail: `(authorize, token, register, jwks; alg=${ALG})`,
187
+ authLabel: 'public',
188
+ })
189
+ logger?.info?.('Auth mounted at %s (users=%s, groups=%s)', base, users, groups)
190
+ })
191
+ }
192
+
193
+ // Verifier factories. Both close over the lazy store/state, so they can
194
+ // be handed to another plugin's `auth:` option at config time.
195
+ plugin.basic = (opts = {}) => basic({ store: lazyStore, realm, ...opts })
196
+
197
+ plugin.jwt = (opts = {}) => jwt({
198
+ verifyToken: (token) => ready().verifyToken(token),
199
+ issuer,
200
+ scopes: [...new Set(Object.values(capabilities).flat())],
201
+ ...opts,
202
+ })
203
+
204
+ plugin.store = lazyStore
205
+
206
+ // The plugin IS a verifier, accepting either credential:
207
+ //
208
+ // api({ endpoints: { admin: { auth: identity } } })
209
+ //
210
+ // rather than making every call site pick basic() or jwt() when the
211
+ // honest answer is "whichever the caller has". A browser sends Basic, an
212
+ // agent sends its Bearer, and the two never collide — each verifier
213
+ // reports "not mine" for the other's scheme, so the composite reaches the
214
+ // right one. Reach for .basic() or .jwt() only to deliberately EXCLUDE
215
+ // one, which is rare.
216
+ const composite = anyOf(plugin.basic(), plugin.jwt())
217
+ plugin.verify = (req) => composite.verify(req)
218
+ plugin.challenge = (req, res) => composite.challenge(req, res)
219
+ Object.defineProperties(plugin, {
220
+ // A function's own `name` is non-writable, so plain assignment
221
+ // throws in a module. defineProperty is the only way to give the
222
+ // verifier the name that shows up in route logs.
223
+ name: { value: 'auth', configurable: true },
224
+ authorizationServers: { get: () => composite.authorizationServers },
225
+ resource: { get: () => composite.resource },
226
+ scopesSupported: { get: () => composite.scopesSupported },
227
+ })
228
+
229
+ return plugin
230
+ }
231
+
232
+ export default auth
package/lib/clients.js ADDED
@@ -0,0 +1,117 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ // Clients register themselves (RFC 7591). There is no operator-maintained
4
+ // list, and no way to declare one.
5
+ //
6
+ // The alternative was a `clients:` config map, which sounds harmless and
7
+ // isn't: it makes the set of agents that can connect equal to the set
8
+ // somebody thought to write down, so every new agent is a config change and
9
+ // a deploy. Worse, an agent whose UI takes a URL and nothing else has no
10
+ // field to type a client_id into — it registers or it cannot connect at all.
11
+ // A server whose whole purpose is that agents connect to it should not ship
12
+ // a default where they can't.
13
+ //
14
+ // Public clients only: PKCE is required and there is no client_secret,
15
+ // because a browser or a native agent cannot keep one.
16
+
17
+ const MAX_REDIRECT_URIS = 10
18
+ const MAX_CLIENT_NAME = 80
19
+
20
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', '[::1]', 'localhost'])
21
+
22
+ const isLoopbackUrl = (url) =>
23
+ url.hostname === '127.0.0.1' || url.hostname === '[::1]' || url.hostname === '::1' ||
24
+ url.hostname === 'localhost'
25
+
26
+ // Exact match, with ONE exception: RFC 8252 §7.3 requires an authorization
27
+ // server to ignore the port of a loopback redirect, because a native client
28
+ // binds an ephemeral port it cannot know in advance. Every MCP client that
29
+ // opens a browser depends on this. Everything else about the URI — scheme,
30
+ // host, path, query, fragment — must still match exactly.
31
+ export function redirectUriAllowed(client, redirectUri) {
32
+ if (!redirectUri) return false
33
+ if (client.redirectUris.includes(redirectUri)) return true
34
+
35
+ let asked
36
+ try { asked = new URL(redirectUri) } catch { return false }
37
+ if (!isLoopbackUrl(asked)) return false
38
+
39
+ return client.redirectUris.some(registered => {
40
+ let reg
41
+ try { reg = new URL(registered) } catch { return false }
42
+ return isLoopbackUrl(reg)
43
+ && reg.protocol === asked.protocol
44
+ && reg.hostname === asked.hostname
45
+ && reg.pathname === asked.pathname
46
+ && reg.search === asked.search
47
+ && reg.hash === asked.hash
48
+ })
49
+ }
50
+
51
+ // Throws with an RFC 7591 error code, which the route maps onto the body.
52
+ export class RegistrationError extends Error {
53
+ constructor(code, description) {
54
+ super(description)
55
+ this.code = code
56
+ }
57
+ }
58
+
59
+ // What a self-registering client may ask to be redirected to. Stricter than
60
+ // what an operator may write in config, because nobody reviewed this one.
61
+ export function validateRedirectUri(value) {
62
+ if (typeof value !== 'string' || !value) return 'must be a string'
63
+ let url
64
+ try { url = new URL(value) } catch { return 'is not an absolute URI' }
65
+ if (url.hash) return 'must not contain a fragment'
66
+ if (url.protocol === 'https:') return null
67
+ if (url.protocol === 'http:') {
68
+ return LOOPBACK_HOSTS.has(url.hostname) ? null : 'must use https, except on loopback'
69
+ }
70
+ return `scheme ${url.protocol} is not allowed — use https, or http on loopback`
71
+ }
72
+
73
+ // Register a client that named itself.
74
+ //
75
+ // Unauthenticated by necessity: you need a client_id BEFORE you can
76
+ // authenticate, so requiring a token here would make the endpoint useless to
77
+ // the only callers that need it. That is safe because a registered client can
78
+ // do NOTHING on its own — it holds no tokens and represents no person, and
79
+ // cannot act until someone signs in on the page, where what they can do comes
80
+ // from their groups rather than from anything the client asked for.
81
+ //
82
+ // What is at risk is table volume, not access. `maxClients` bounds it in the
83
+ // durable place — a rate limiter lives in process memory and does not survive
84
+ // a restart, while rows do.
85
+ export function registerDynamicClient({ name, redirectUris, maxClients, store }) {
86
+ if (!Array.isArray(redirectUris) || !redirectUris.length) {
87
+ throw new RegistrationError('invalid_redirect_uri', 'redirect_uris must be a non-empty array')
88
+ }
89
+ if (redirectUris.length > MAX_REDIRECT_URIS) {
90
+ throw new RegistrationError('invalid_redirect_uri', `at most ${MAX_REDIRECT_URIS} redirect_uris`)
91
+ }
92
+ for (const uri of redirectUris) {
93
+ const problem = validateRedirectUri(uri)
94
+ if (problem) {
95
+ throw new RegistrationError('invalid_redirect_uri', `redirect_uri ${JSON.stringify(uri)} ${problem}`)
96
+ }
97
+ }
98
+
99
+ if (maxClients != null && store.countDynamicClients() >= maxClients) {
100
+ throw new RegistrationError('invalid_client_metadata',
101
+ 'this server is not accepting new client registrations right now')
102
+ }
103
+
104
+ // The name is client-supplied and ends up on the sign-in page, so it is
105
+ // length-capped here and HTML-escaped at render. A client that sends no
106
+ // name gets a neutral one rather than a blank line where the agent's
107
+ // identity should be — which would make the page's whole point moot.
108
+ const clientName = (typeof name === 'string' && name.trim())
109
+ ? name.trim().slice(0, MAX_CLIENT_NAME)
110
+ : 'Unnamed client'
111
+
112
+ return store.insertDynamicClient({
113
+ clientId: randomUUID(),
114
+ name: clientName,
115
+ redirectUris,
116
+ })
117
+ }