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 +249 -0
- package/assets/logo.svg +10 -0
- package/index.js +232 -0
- package/lib/clients.js +117 -0
- package/lib/grants.js +162 -0
- package/lib/htpasswd.js +253 -0
- package/lib/keys.js +64 -0
- package/lib/login-page.js +116 -0
- package/lib/pkce.js +23 -0
- package/lib/routes.js +314 -0
- package/lib/tokens.js +46 -0
- package/lib/verifiers.js +100 -0
- package/package.json +39 -0
- package/test/authorize.test.js +451 -0
- package/test/clients.test.js +83 -0
- package/test/identity.test.js +238 -0
- package/test/seam.test.js +164 -0
- package/test/verifiers.test.js +202 -0
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.
|
package/assets/logo.svg
ADDED
|
@@ -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
|
+
}
|