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
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
7
|
+
import bcrypt from 'bcryptjs'
|
|
8
|
+
|
|
9
|
+
import { runtime } from 'mikser-io'
|
|
10
|
+
import { auth } from '../index.js'
|
|
11
|
+
|
|
12
|
+
// Boot the plugin against a REAL express app and a REAL mikser database, by
|
|
13
|
+
// driving the lifecycle hooks the engine itself drives. The grant store is
|
|
14
|
+
// sqlite-backed (ADR-0009), so faking it would test nothing about the code
|
|
15
|
+
// that actually runs.
|
|
16
|
+
let server, port, dir
|
|
17
|
+
|
|
18
|
+
// There is no config path for a client — every one registers itself, so the
|
|
19
|
+
// tests do too. CLIENT is filled in by before().
|
|
20
|
+
let CLIENT
|
|
21
|
+
const REDIRECT = 'http://127.0.0.1/callback'
|
|
22
|
+
|
|
23
|
+
before(async () => {
|
|
24
|
+
dir = await mkdtemp(path.join(tmpdir(), 'mikser-auth-flow-'))
|
|
25
|
+
const runtimeFolder = path.join(dir, 'runtime')
|
|
26
|
+
await mkdir(runtimeFolder, { recursive: true })
|
|
27
|
+
|
|
28
|
+
await writeFile(path.join(dir, 'users.htpasswd'),
|
|
29
|
+
`alice:${bcrypt.hashSync('alice-pw', 10)}\ncarol:${bcrypt.hashSync('carol-pw', 10)}\n`)
|
|
30
|
+
await writeFile(path.join(dir, 'groups.htgroup'), 'editors: alice\n')
|
|
31
|
+
|
|
32
|
+
const { default: express } = await import('express')
|
|
33
|
+
const app = express()
|
|
34
|
+
|
|
35
|
+
runtime.options = {
|
|
36
|
+
...runtime.options,
|
|
37
|
+
app,
|
|
38
|
+
workingFolder: dir,
|
|
39
|
+
runtimeFolder,
|
|
40
|
+
}
|
|
41
|
+
runtime.config = { ...runtime.config, database: { filename: 'test.sqlite' } }
|
|
42
|
+
// The engine's own subsystems log through runtime.engine.logger.
|
|
43
|
+
const quiet = { info(){}, warn(){}, error(){}, debug(){}, trace(){}, fatal(){} }
|
|
44
|
+
runtime.engine = { ...runtime.engine, logger: quiet }
|
|
45
|
+
|
|
46
|
+
// Drive the engine's real lifecycle. Order matters and is not obvious:
|
|
47
|
+
// some engine schemas (mikser_journal) register in onInitialize rather
|
|
48
|
+
// than at module eval, because registering at module-eval would hit the
|
|
49
|
+
// schemas Map while database/index.js is still evaluating. So initialize
|
|
50
|
+
// has to run before loaded, or the database opens without them.
|
|
51
|
+
for (const hook of runtime.hooks.initialize) await hook()
|
|
52
|
+
for (const hook of runtime.hooks.loaded) await hook()
|
|
53
|
+
|
|
54
|
+
runtime.options.url = 'https://test-mikser.example'
|
|
55
|
+
|
|
56
|
+
const plugin = auth({
|
|
57
|
+
capabilities: { editors: ['api:list', 'api:update'] },
|
|
58
|
+
scopes: { editors: { 'meta.href': { $regex: '^/web' } } },
|
|
59
|
+
dcr: { maxPerIp: 500 },
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const load = [], loaded = []
|
|
63
|
+
plugin({
|
|
64
|
+
runtime,
|
|
65
|
+
onLoad: (cb) => load.push(cb),
|
|
66
|
+
onLoaded: (cb) => loaded.push(cb),
|
|
67
|
+
useLogger: () => ({ info(){}, warn(){}, error(){}, debug(){}, trace(){} }),
|
|
68
|
+
})
|
|
69
|
+
for (const cb of load) await cb()
|
|
70
|
+
for (const cb of loaded) await cb()
|
|
71
|
+
|
|
72
|
+
server = await new Promise(resolve => {
|
|
73
|
+
const s = app.listen(0, () => resolve(s))
|
|
74
|
+
})
|
|
75
|
+
port = server.address().port
|
|
76
|
+
|
|
77
|
+
const reg = await fetch(`http://127.0.0.1:${port}/auth/register`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'content-type': 'application/json' },
|
|
80
|
+
body: JSON.stringify({ client_name: 'Test Client', redirect_uris: [REDIRECT] }),
|
|
81
|
+
})
|
|
82
|
+
CLIENT = (await reg.json()).client_id
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
after(async () => {
|
|
86
|
+
await new Promise(r => server?.close(r))
|
|
87
|
+
await rm(dir, { recursive: true, force: true })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const url = (p) => `http://127.0.0.1:${port}${p}`
|
|
91
|
+
const b64url = (b) => b.toString('base64url')
|
|
92
|
+
|
|
93
|
+
function pkcePair() {
|
|
94
|
+
const verifier = b64url(randomBytes(32))
|
|
95
|
+
const challenge = b64url(createHash('sha256').update(verifier).digest())
|
|
96
|
+
return { verifier, challenge }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const authorizeUrl = (challenge, over = {}) => {
|
|
100
|
+
const q = new URLSearchParams({
|
|
101
|
+
response_type: 'code', client_id: CLIENT, redirect_uri: REDIRECT,
|
|
102
|
+
code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
|
|
103
|
+
...over,
|
|
104
|
+
})
|
|
105
|
+
return url(`/auth/authorize?${q}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const signIn = (challenge, creds, over = {}) => fetch(url('/auth/authorize'), {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
111
|
+
redirect: 'manual',
|
|
112
|
+
body: new URLSearchParams({
|
|
113
|
+
response_type: 'code', client_id: CLIENT, redirect_uri: REDIRECT,
|
|
114
|
+
code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
|
|
115
|
+
...creds, ...over,
|
|
116
|
+
}),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const token = (body) => fetch(url('/auth/token'), {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
122
|
+
body: new URLSearchParams(body),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('GET /authorize — the login page', () => {
|
|
126
|
+
it('renders the form, naming the deployment and the client asking', async () => {
|
|
127
|
+
const res = await fetch(authorizeUrl(pkcePair().challenge))
|
|
128
|
+
assert.equal(res.status, 200)
|
|
129
|
+
assert.match(res.headers.get('content-type'), /text\/html/)
|
|
130
|
+
const html = await res.text()
|
|
131
|
+
assert.match(html, /Sign in to test-mikser\.example/)
|
|
132
|
+
assert.match(html, /to give <strong>Test Client<\/strong> access/)
|
|
133
|
+
assert.match(html, /name="username"[^>]*autocomplete="username"/)
|
|
134
|
+
assert.match(html, /autocomplete="current-password"/)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('threads the authorization request through hidden fields', async () => {
|
|
138
|
+
const { challenge } = pkcePair()
|
|
139
|
+
const html = await (await fetch(authorizeUrl(challenge))).text()
|
|
140
|
+
for (const [name, value] of [
|
|
141
|
+
['response_type', 'code'], ['client_id', CLIENT],
|
|
142
|
+
['redirect_uri', REDIRECT], ['code_challenge', challenge],
|
|
143
|
+
['code_challenge_method', 'S256'], ['state', 'xyz'],
|
|
144
|
+
]) {
|
|
145
|
+
assert.ok(html.includes(`name="${name}" value="${value.replace(/&/g, '&')}"`),
|
|
146
|
+
`hidden field ${name} missing`)
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('serves the mikser mark', async () => {
|
|
151
|
+
const html = await (await fetch(authorizeUrl(pkcePair().challenge))).text()
|
|
152
|
+
assert.match(html, /<img src="\/auth\/logo\.svg"/)
|
|
153
|
+
const logo = await fetch(url('/auth/logo.svg'))
|
|
154
|
+
assert.equal(logo.status, 200)
|
|
155
|
+
assert.match(logo.headers.get('content-type'), /image\/svg\+xml/)
|
|
156
|
+
assert.match(await logo.text(), /aria-label="mikser"/)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('refuses an unknown client and an unregistered redirect WITHOUT redirecting', async () => {
|
|
160
|
+
// Redirecting to an unvalidated URI is itself the vulnerability —
|
|
161
|
+
// an open redirect through the authorization endpoint.
|
|
162
|
+
const bad = await fetch(authorizeUrl(pkcePair().challenge, { client_id: 'nope' }), { redirect: 'manual' })
|
|
163
|
+
assert.equal(bad.status, 400)
|
|
164
|
+
assert.equal(bad.headers.get('location'), null)
|
|
165
|
+
|
|
166
|
+
const evil = await fetch(authorizeUrl(pkcePair().challenge, { redirect_uri: 'https://evil.example.com/' }),
|
|
167
|
+
{ redirect: 'manual' })
|
|
168
|
+
assert.equal(evil.status, 400)
|
|
169
|
+
assert.equal(evil.headers.get('location'), null)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('requires PKCE S256, redirecting the error to the validated client', async () => {
|
|
173
|
+
const plain = await fetch(authorizeUrl('abc', { code_challenge_method: 'plain' }), { redirect: 'manual' })
|
|
174
|
+
assert.equal(plain.status, 302)
|
|
175
|
+
const loc = new URL(plain.headers.get('location'))
|
|
176
|
+
assert.equal(loc.searchParams.get('error'), 'invalid_request')
|
|
177
|
+
assert.match(loc.searchParams.get('error_description'), /PKCE/)
|
|
178
|
+
assert.equal(loc.searchParams.get('state'), 'xyz')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('POST /authorize — sign in', () => {
|
|
183
|
+
it('redirects back with a code and the original state', async () => {
|
|
184
|
+
const res = await signIn(pkcePair().challenge, { username: 'alice', password: 'alice-pw' })
|
|
185
|
+
assert.equal(res.status, 302)
|
|
186
|
+
const loc = new URL(res.headers.get('location'))
|
|
187
|
+
assert.equal(loc.origin + loc.pathname, REDIRECT)
|
|
188
|
+
assert.ok(loc.searchParams.get('code'))
|
|
189
|
+
assert.equal(loc.searchParams.get('state'), 'xyz')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('re-renders with an error on bad credentials, keeping the request intact', async () => {
|
|
193
|
+
const { challenge } = pkcePair()
|
|
194
|
+
const res = await signIn(challenge, { username: 'alice', password: 'wrong' })
|
|
195
|
+
assert.equal(res.status, 401)
|
|
196
|
+
const html = await res.text()
|
|
197
|
+
assert.match(html, /Incorrect username or password/)
|
|
198
|
+
// The hidden fields survive, so retrying does not lose the request.
|
|
199
|
+
assert.ok(html.includes(`name="code_challenge" value="${challenge}"`))
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('does not reveal whether the username exists', async () => {
|
|
203
|
+
const a = await signIn(pkcePair().challenge, { username: 'alice', password: 'wrong' })
|
|
204
|
+
const b = await signIn(pkcePair().challenge, { username: 'ghost', password: 'wrong' })
|
|
205
|
+
assert.equal(a.status, b.status)
|
|
206
|
+
assert.equal((await a.text()).replace(/value="[^"]*"/g, ''),
|
|
207
|
+
(await b.text()).replace(/value="[^"]*"/g, ''))
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('POST /token — authorization_code', () => {
|
|
212
|
+
async function getCode(challenge, username = 'alice', password = 'alice-pw') {
|
|
213
|
+
const res = await signIn(challenge, { username, password })
|
|
214
|
+
return new URL(res.headers.get('location')).searchParams.get('code')
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it('exchanges code + verifier for an access token carrying file-derived claims', async () => {
|
|
218
|
+
const { verifier, challenge } = pkcePair()
|
|
219
|
+
const code = await getCode(challenge)
|
|
220
|
+
const res = await token({
|
|
221
|
+
grant_type: 'authorization_code', code,
|
|
222
|
+
redirect_uri: REDIRECT, code_verifier: verifier, client_id: CLIENT,
|
|
223
|
+
})
|
|
224
|
+
assert.equal(res.status, 200)
|
|
225
|
+
const body = await res.json()
|
|
226
|
+
assert.equal(body.token_type, 'Bearer')
|
|
227
|
+
assert.ok(body.refresh_token)
|
|
228
|
+
assert.equal(body.scope, 'api:list api:update')
|
|
229
|
+
|
|
230
|
+
const claims = JSON.parse(Buffer.from(body.access_token.split('.')[1], 'base64url').toString())
|
|
231
|
+
assert.equal(claims.sub, 'alice')
|
|
232
|
+
assert.equal(claims.scope, 'api:list api:update')
|
|
233
|
+
assert.deepEqual(claims.mks_scope, { 'meta.href': { $regex: '^/web' } })
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('rejects a wrong PKCE verifier', async () => {
|
|
237
|
+
const { challenge } = pkcePair()
|
|
238
|
+
const code = await getCode(challenge)
|
|
239
|
+
const res = await token({
|
|
240
|
+
grant_type: 'authorization_code', code,
|
|
241
|
+
redirect_uri: REDIRECT, code_verifier: pkcePair().verifier, client_id: CLIENT,
|
|
242
|
+
})
|
|
243
|
+
assert.equal(res.status, 400)
|
|
244
|
+
assert.equal((await res.json()).error, 'invalid_grant')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('burns the code — a replay is refused', async () => {
|
|
248
|
+
const { verifier, challenge } = pkcePair()
|
|
249
|
+
const code = await getCode(challenge)
|
|
250
|
+
const first = await token({
|
|
251
|
+
grant_type: 'authorization_code', code,
|
|
252
|
+
redirect_uri: REDIRECT, code_verifier: verifier, client_id: CLIENT,
|
|
253
|
+
})
|
|
254
|
+
assert.equal(first.status, 200)
|
|
255
|
+
const replay = await token({
|
|
256
|
+
grant_type: 'authorization_code', code,
|
|
257
|
+
redirect_uri: REDIRECT, code_verifier: verifier, client_id: CLIENT,
|
|
258
|
+
})
|
|
259
|
+
assert.equal(replay.status, 400)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('refuses a code redeemed against a different redirect_uri or client', async () => {
|
|
263
|
+
const { verifier, challenge } = pkcePair()
|
|
264
|
+
const code = await getCode(challenge)
|
|
265
|
+
const wrongRedirect = await token({
|
|
266
|
+
grant_type: 'authorization_code', code,
|
|
267
|
+
redirect_uri: 'http://127.0.0.1/other', code_verifier: verifier, client_id: CLIENT,
|
|
268
|
+
})
|
|
269
|
+
assert.equal(wrongRedirect.status, 400)
|
|
270
|
+
|
|
271
|
+
const wrongClient = await token({
|
|
272
|
+
grant_type: 'authorization_code', code,
|
|
273
|
+
redirect_uri: REDIRECT, code_verifier: verifier, client_id: 'someone-else',
|
|
274
|
+
})
|
|
275
|
+
assert.equal(wrongClient.status, 400)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('gives a user in no group a token with no capabilities and no scope', async () => {
|
|
279
|
+
const { verifier, challenge } = pkcePair()
|
|
280
|
+
const code = await getCode(challenge, 'carol', 'carol-pw')
|
|
281
|
+
const body = await (await token({
|
|
282
|
+
grant_type: 'authorization_code', code,
|
|
283
|
+
redirect_uri: REDIRECT, code_verifier: verifier, client_id: CLIENT,
|
|
284
|
+
})).json()
|
|
285
|
+
const claims = JSON.parse(Buffer.from(body.access_token.split('.')[1], 'base64url').toString())
|
|
286
|
+
assert.equal(claims.sub, 'carol')
|
|
287
|
+
assert.equal(claims.scope, '')
|
|
288
|
+
assert.equal(claims.mks_scope, undefined)
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
describe('POST /token — refresh_token', () => {
|
|
293
|
+
async function fullFlow() {
|
|
294
|
+
const { verifier, challenge } = pkcePair()
|
|
295
|
+
const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
|
|
296
|
+
const code = new URL(res.headers.get('location')).searchParams.get('code')
|
|
297
|
+
return (await token({
|
|
298
|
+
grant_type: 'authorization_code', code,
|
|
299
|
+
redirect_uri: REDIRECT, code_verifier: verifier, client_id: CLIENT,
|
|
300
|
+
})).json()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
it('rotates: the old token stops working, the new one works', async () => {
|
|
304
|
+
const first = await fullFlow()
|
|
305
|
+
const second = await (await token({
|
|
306
|
+
grant_type: 'refresh_token', refresh_token: first.refresh_token, client_id: CLIENT,
|
|
307
|
+
})).json()
|
|
308
|
+
assert.ok(second.access_token)
|
|
309
|
+
assert.ok(second.refresh_token)
|
|
310
|
+
assert.notEqual(second.refresh_token, first.refresh_token)
|
|
311
|
+
|
|
312
|
+
const reuse = await token({
|
|
313
|
+
grant_type: 'refresh_token', refresh_token: first.refresh_token, client_id: CLIENT,
|
|
314
|
+
})
|
|
315
|
+
assert.equal(reuse.status, 400)
|
|
316
|
+
|
|
317
|
+
const again = await token({
|
|
318
|
+
grant_type: 'refresh_token', refresh_token: second.refresh_token, client_id: CLIENT,
|
|
319
|
+
})
|
|
320
|
+
assert.equal(again.status, 200)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('refuses a refresh token presented by another client', async () => {
|
|
324
|
+
const first = await fullFlow()
|
|
325
|
+
const res = await token({
|
|
326
|
+
grant_type: 'refresh_token', refresh_token: first.refresh_token, client_id: 'someone-else',
|
|
327
|
+
})
|
|
328
|
+
assert.equal(res.status, 400)
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
describe('discovery', () => {
|
|
333
|
+
it('advertises the endpoints an MCP client needs', async () => {
|
|
334
|
+
const doc = await (await fetch(url('/auth/.well-known/oauth-authorization-server'))).json()
|
|
335
|
+
assert.match(doc.authorization_endpoint, /\/auth\/authorize$/)
|
|
336
|
+
assert.match(doc.token_endpoint, /\/auth\/token$/)
|
|
337
|
+
assert.match(doc.jwks_uri, /\/auth\/jwks\.json$/)
|
|
338
|
+
assert.deepEqual(doc.code_challenge_methods_supported, ['S256'])
|
|
339
|
+
assert.deepEqual(doc.response_types_supported, ['code'])
|
|
340
|
+
assert.ok(doc.grant_types_supported.includes('authorization_code'))
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('publishes a JWKS with no private component', async () => {
|
|
344
|
+
const doc = await (await fetch(url('/auth/jwks.json'))).json()
|
|
345
|
+
assert.equal(doc.keys.length, 1)
|
|
346
|
+
assert.equal(doc.keys[0].d, undefined)
|
|
347
|
+
assert.ok(doc.keys[0].kid)
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
describe('POST /register — any agent, no operator config (RFC 7591)', () => {
|
|
352
|
+
const register = (body) => fetch(url('/auth/register'), {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: { 'content-type': 'application/json' },
|
|
355
|
+
body: JSON.stringify(body),
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('lets an agent name itself and get a usable client_id', async () => {
|
|
359
|
+
const res = await register({
|
|
360
|
+
client_name: 'Some Other Agent',
|
|
361
|
+
redirect_uris: ['http://127.0.0.1:9321/oauth/callback'],
|
|
362
|
+
})
|
|
363
|
+
assert.equal(res.status, 201)
|
|
364
|
+
const body = await res.json()
|
|
365
|
+
assert.ok(body.client_id)
|
|
366
|
+
assert.equal(body.client_name, 'Some Other Agent')
|
|
367
|
+
assert.equal(body.token_endpoint_auth_method, 'none')
|
|
368
|
+
assert.equal(body.client_secret, undefined)
|
|
369
|
+
assert.deepEqual(body.response_types, ['code'])
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('the registered client completes a whole sign-in, and the page names IT', async () => {
|
|
373
|
+
// The point of the whole feature: an agent nobody configured can
|
|
374
|
+
// connect, and the person signing in is told which agent it is.
|
|
375
|
+
const reg = await (await register({
|
|
376
|
+
client_name: 'Fancy Agent',
|
|
377
|
+
redirect_uris: ['http://127.0.0.1/cb'],
|
|
378
|
+
})).json()
|
|
379
|
+
|
|
380
|
+
const { verifier, challenge } = pkcePair()
|
|
381
|
+
const q = new URLSearchParams({
|
|
382
|
+
response_type: 'code', client_id: reg.client_id, redirect_uri: 'http://127.0.0.1:7788/cb',
|
|
383
|
+
code_challenge: challenge, code_challenge_method: 'S256', state: 'zz',
|
|
384
|
+
})
|
|
385
|
+
const page = await fetch(url(`/auth/authorize?${q}`))
|
|
386
|
+
assert.equal(page.status, 200)
|
|
387
|
+
assert.match(await page.text(), /to give <strong>Fancy Agent<\/strong> access/)
|
|
388
|
+
|
|
389
|
+
const signed = await fetch(url('/auth/authorize'), {
|
|
390
|
+
method: 'POST',
|
|
391
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
392
|
+
redirect: 'manual',
|
|
393
|
+
body: new URLSearchParams({
|
|
394
|
+
response_type: 'code', client_id: reg.client_id,
|
|
395
|
+
redirect_uri: 'http://127.0.0.1:7788/cb',
|
|
396
|
+
code_challenge: challenge, code_challenge_method: 'S256', state: 'zz',
|
|
397
|
+
username: 'alice', password: 'alice-pw',
|
|
398
|
+
}),
|
|
399
|
+
})
|
|
400
|
+
assert.equal(signed.status, 302)
|
|
401
|
+
const code = new URL(signed.headers.get('location')).searchParams.get('code')
|
|
402
|
+
|
|
403
|
+
const tok = await (await token({
|
|
404
|
+
grant_type: 'authorization_code', code,
|
|
405
|
+
redirect_uri: 'http://127.0.0.1:7788/cb',
|
|
406
|
+
code_verifier: verifier, client_id: reg.client_id,
|
|
407
|
+
})).json()
|
|
408
|
+
assert.equal(tok.scope, 'api:list api:update')
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('escapes a client name — it is attacker-controlled and lands on the page', async () => {
|
|
412
|
+
const reg = await (await register({
|
|
413
|
+
client_name: '<script>alert(1)</script>',
|
|
414
|
+
redirect_uris: ['http://127.0.0.1/cb'],
|
|
415
|
+
})).json()
|
|
416
|
+
const q = new URLSearchParams({
|
|
417
|
+
response_type: 'code', client_id: reg.client_id, redirect_uri: 'http://127.0.0.1/cb',
|
|
418
|
+
code_challenge: pkcePair().challenge, code_challenge_method: 'S256',
|
|
419
|
+
})
|
|
420
|
+
const html = await (await fetch(url(`/auth/authorize?${q}`))).text()
|
|
421
|
+
assert.ok(!html.includes('<script>alert(1)</script>'), 'raw script tag must not reach the page')
|
|
422
|
+
assert.match(html, /<script>/)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('gives a nameless client a neutral name rather than a blank line', async () => {
|
|
426
|
+
const body = await (await register({ redirect_uris: ['http://127.0.0.1/cb'] })).json()
|
|
427
|
+
assert.equal(body.client_name, 'Unnamed client')
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('refuses redirect URIs that are not https or loopback http', async () => {
|
|
431
|
+
for (const uri of ['http://evil.example.com/cb', 'ftp://x/cb', 'not-a-uri', 'https://x/cb#frag']) {
|
|
432
|
+
const res = await register({ client_name: 'X', redirect_uris: [uri] })
|
|
433
|
+
assert.equal(res.status, 400, `${uri} should be refused`)
|
|
434
|
+
assert.equal((await res.json()).error, 'invalid_redirect_uri')
|
|
435
|
+
}
|
|
436
|
+
const ok = await register({ client_name: 'X', redirect_uris: ['https://agent.example.com/cb'] })
|
|
437
|
+
assert.equal(ok.status, 201)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('requires a non-empty redirect_uris array', async () => {
|
|
441
|
+
assert.equal((await register({ client_name: 'X' })).status, 400)
|
|
442
|
+
assert.equal((await register({ client_name: 'X', redirect_uris: [] })).status, 400)
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('is the only way a client exists — an unregistered id is refused', async () => {
|
|
446
|
+
const res = await fetch(authorizeUrl(pkcePair().challenge, { client_id: 'never-registered' }),
|
|
447
|
+
{ redirect: 'manual' })
|
|
448
|
+
assert.equal(res.status, 400)
|
|
449
|
+
assert.equal(res.headers.get('location'), null)
|
|
450
|
+
})
|
|
451
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
redirectUriAllowed, validateRedirectUri,
|
|
6
|
+
registerDynamicClient, RegistrationError,
|
|
7
|
+
} from '../lib/clients.js'
|
|
8
|
+
|
|
9
|
+
describe('redirectUriAllowed', () => {
|
|
10
|
+
const client = { redirectUris: ['http://127.0.0.1/callback', 'https://agent.example.com/cb'] }
|
|
11
|
+
|
|
12
|
+
it('matches exactly', () => {
|
|
13
|
+
assert.equal(redirectUriAllowed(client, 'https://agent.example.com/cb'), true)
|
|
14
|
+
assert.equal(redirectUriAllowed(client, 'https://agent.example.com/other'), false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('ignores the port on loopback (RFC 8252 §7.3) — native agents bind an ephemeral one', () => {
|
|
18
|
+
assert.equal(redirectUriAllowed(client, 'http://127.0.0.1:49821/callback'), true)
|
|
19
|
+
assert.equal(redirectUriAllowed(client, 'http://127.0.0.1:1/callback'), true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('does not let the loopback exception loosen anything else', () => {
|
|
23
|
+
assert.equal(redirectUriAllowed(client, 'http://127.0.0.1:49821/evil'), false, 'path must still match')
|
|
24
|
+
assert.equal(redirectUriAllowed(client, 'https://127.0.0.1:49821/callback'), false, 'scheme must still match')
|
|
25
|
+
assert.equal(redirectUriAllowed(client, 'http://evil.example.com:49821/callback'), false)
|
|
26
|
+
assert.equal(redirectUriAllowed(client, 'https://agent.example.com:8443/cb'), false, 'non-loopback keeps its port')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('refuses junk without throwing', () => {
|
|
30
|
+
assert.equal(redirectUriAllowed(client, undefined), false)
|
|
31
|
+
assert.equal(redirectUriAllowed(client, 'not-a-uri'), false)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('validateRedirectUri', () => {
|
|
36
|
+
it('allows https anywhere and http only on loopback', () => {
|
|
37
|
+
assert.equal(validateRedirectUri('https://agent.example.com/cb'), null)
|
|
38
|
+
assert.equal(validateRedirectUri('http://127.0.0.1:1234/cb'), null)
|
|
39
|
+
assert.equal(validateRedirectUri('http://localhost/cb'), null)
|
|
40
|
+
assert.match(validateRedirectUri('http://evil.example.com/cb'), /https, except on loopback/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('refuses a fragment and an unknown scheme', () => {
|
|
44
|
+
assert.match(validateRedirectUri('https://a/cb#x'), /fragment/)
|
|
45
|
+
assert.match(validateRedirectUri('ftp://a/cb'), /is not allowed/)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('registerDynamicClient bounds', () => {
|
|
50
|
+
const fakeStore = (count = 0) => ({
|
|
51
|
+
countDynamicClients: () => count,
|
|
52
|
+
insertDynamicClient: (c) => ({ ...c, createdAt: 1 }),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('caps the number of redirect URIs', () => {
|
|
56
|
+
const many = Array.from({ length: 11 }, (_, i) => `https://a/cb${i}`)
|
|
57
|
+
assert.throws(() => registerDynamicClient({ redirectUris: many, store: fakeStore() }),
|
|
58
|
+
(e) => e instanceof RegistrationError && e.code === 'invalid_redirect_uri')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('caps the name, because it renders on the sign-in page', () => {
|
|
62
|
+
const out = registerDynamicClient({
|
|
63
|
+
name: 'x'.repeat(500), redirectUris: ['https://a/cb'], store: fakeStore(),
|
|
64
|
+
})
|
|
65
|
+
assert.equal(out.name.length, 80)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('refuses once the table is full — the durable bound, not the rate limiter', () => {
|
|
69
|
+
// The per-IP limiter lives in process memory and does not survive a
|
|
70
|
+
// restart; rows do. This is what actually bounds the table.
|
|
71
|
+
assert.throws(
|
|
72
|
+
() => registerDynamicClient({ redirectUris: ['https://a/cb'], maxClients: 10, store: fakeStore(10) }),
|
|
73
|
+
(e) => e.code === 'invalid_client_metadata',
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('mints a distinct client_id every time — RFC 7591 has no get-or-create', () => {
|
|
78
|
+
const store = fakeStore()
|
|
79
|
+
const a = registerDynamicClient({ redirectUris: ['https://a/cb'], store })
|
|
80
|
+
const b = registerDynamicClient({ redirectUris: ['https://a/cb'], store })
|
|
81
|
+
assert.notEqual(a.clientId, b.clientId)
|
|
82
|
+
})
|
|
83
|
+
})
|