mikser-io-auth 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +21 -2
- package/package.json +1 -1
- package/test/authorize.test.js +130 -6
package/index.js
CHANGED
|
@@ -123,7 +123,19 @@ export function auth(options = {}) {
|
|
|
123
123
|
router.use(express.urlencoded({ extended: false, limit: '8kb' }))
|
|
124
124
|
router.use(express.json({ limit: '8kb' }))
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
// The deployment's identity, and it must be the SAME string the
|
|
127
|
+
// verifier was built with — a token minted for one issuer and
|
|
128
|
+
// checked against another fails verification, which from the
|
|
129
|
+
// outside is indistinguishable from an expired token. The
|
|
130
|
+
// verifier is pinned to `issuer ?? runtime.options.url` at load,
|
|
131
|
+
// so this resolves in that order too and only falls back to the
|
|
132
|
+
// request when neither is configured (a dev box with no --url).
|
|
133
|
+
//
|
|
134
|
+
// RFC 8414 §2 wants a stable issuer besides: clients cache the
|
|
135
|
+
// metadata document keyed by it, so deriving it per-request makes
|
|
136
|
+
// one deployment look like several.
|
|
137
|
+
const originOf = (req) =>
|
|
138
|
+
issuer ?? runtime.options.url ?? `${req.protocol}://${req.get('host')}`
|
|
127
139
|
|
|
128
140
|
// What the sign-in page calls this deployment. Not a config
|
|
129
141
|
// option: mikser already knows its external URL, and failing
|
|
@@ -231,7 +243,14 @@ export function auth(options = {}) {
|
|
|
231
243
|
// one, which is rare.
|
|
232
244
|
const composite = anyOf(plugin.basic(), plugin.jwt())
|
|
233
245
|
plugin.verify = (req) => composite.verify(req)
|
|
234
|
-
|
|
246
|
+
// Both forwarded WITH their arguments. Dropping either is silent: the
|
|
247
|
+
// endpoint still denies correctly, it just stops saying which denial it
|
|
248
|
+
// was — so an expiry a refresh token would have fixed reads as a fresh
|
|
249
|
+
// sign-in, which is the whole failure this vocabulary exists to prevent.
|
|
250
|
+
// `auth: identity` is the documented shape, so a gap here bypasses the
|
|
251
|
+
// signal in the configuration almost everyone uses.
|
|
252
|
+
plugin.rejectionFor = (req) => composite.rejectionFor?.(req)
|
|
253
|
+
plugin.challenge = (req, res, outcome) => composite.challenge(req, res, outcome)
|
|
235
254
|
Object.defineProperties(plugin, {
|
|
236
255
|
// A function's own `name` is non-writable, so plain assignment
|
|
237
256
|
// throws in a module. defineProperty is the only way to give the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-auth",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Authentication for mikser-io: an OAuth 2.1 authorization server (self-registering clients, authorization code + PKCE, refresh rotation) and HTTP Basic / JWT verifiers over Apache-format htpasswd and htgroup files in the working folder. Implements the ADR-0012 verifier contract, so it plugs in wherever a static token does — api, mcp, forms.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
package/test/authorize.test.js
CHANGED
|
@@ -51,7 +51,19 @@ before(async () => {
|
|
|
51
51
|
for (const hook of runtime.hooks.initialize) await hook()
|
|
52
52
|
for (const hook of runtime.hooks.loaded) await hook()
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// Listen FIRST, then declare that origin as the deployment's URL.
|
|
55
|
+
//
|
|
56
|
+
// A deployment whose `url` is not where it actually serves is not a
|
|
57
|
+
// configuration anyone runs, and pretending otherwise hid a real
|
|
58
|
+
// asymmetry: the token minter derived issuer/audience from the request
|
|
59
|
+
// while the verifier was pinned to `runtime.options.url`. With the two
|
|
60
|
+
// disagreeing, every minted token failed verification — which from a
|
|
61
|
+
// client looks exactly like expiry.
|
|
62
|
+
server = await new Promise(resolve => {
|
|
63
|
+
const s = app.listen(0, () => resolve(s))
|
|
64
|
+
})
|
|
65
|
+
port = server.address().port
|
|
66
|
+
runtime.options.url = `http://127.0.0.1:${port}`
|
|
55
67
|
|
|
56
68
|
const plugin = auth({
|
|
57
69
|
capabilities: { editors: ['api:list', 'api:update'] },
|
|
@@ -69,10 +81,13 @@ before(async () => {
|
|
|
69
81
|
for (const cb of load) await cb()
|
|
70
82
|
for (const cb of loaded) await cb()
|
|
71
83
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
84
|
+
// A protected resource on the same app, gated by the plugin ITSELF —
|
|
85
|
+
// `auth: identity`, which is the documented shape. Gating with
|
|
86
|
+
// identity.jwt() instead would test a path most deployments do not take.
|
|
87
|
+
const { requireAuth } = await import('mikser-io')
|
|
88
|
+
app.get('/resource', requireAuth(plugin), (req, res) => res.json({ subject: req.principal.subject }))
|
|
89
|
+
app.get('/resource-admin', requireAuth(plugin.jwt({ requiredCapability: 'api:delete' })),
|
|
90
|
+
(req, res) => res.json({ ok: true }))
|
|
76
91
|
|
|
77
92
|
const reg = await fetch(`http://127.0.0.1:${port}/auth/register`, {
|
|
78
93
|
method: 'POST',
|
|
@@ -128,7 +143,9 @@ describe('GET /authorize — the login page', () => {
|
|
|
128
143
|
assert.equal(res.status, 200)
|
|
129
144
|
assert.match(res.headers.get('content-type'), /text\/html/)
|
|
130
145
|
const html = await res.text()
|
|
131
|
-
|
|
146
|
+
// The deployment is named by its own declared URL's host, which the
|
|
147
|
+
// fixture now serves at rather than merely claiming.
|
|
148
|
+
assert.match(html, new RegExp(`Sign in to ${new URL(runtime.options.url).host.replace('.', '\\.')}`))
|
|
132
149
|
assert.match(html, /to give <strong>Test Client<\/strong> access/)
|
|
133
150
|
assert.match(html, /name="username"[^>]*autocomplete="username"/)
|
|
134
151
|
assert.match(html, /autocomplete="current-password"/)
|
|
@@ -477,3 +494,110 @@ describe('POST /register — any agent, no operator config (RFC 7591)', () => {
|
|
|
477
494
|
assert.equal(res.headers.get('location'), null)
|
|
478
495
|
})
|
|
479
496
|
})
|
|
497
|
+
|
|
498
|
+
// The loop a real client runs, end to end. Rotation is covered above; this is
|
|
499
|
+
// the part that was never exercised — whether a client can TELL that it should
|
|
500
|
+
// rotate, and complete the round trip without a human.
|
|
501
|
+
//
|
|
502
|
+
// The failure this pins down: an access token expiring mid-task, between a
|
|
503
|
+
// finished decision and the write that would apply it. Refresh tokens were
|
|
504
|
+
// being issued the whole time; nothing told the client the moment to use one.
|
|
505
|
+
describe('an expired access token is recoverable without a human', () => {
|
|
506
|
+
let refreshToken, key, issuer
|
|
507
|
+
|
|
508
|
+
before(async () => {
|
|
509
|
+
const { verifier, challenge } = pkcePair()
|
|
510
|
+
const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
|
|
511
|
+
const code = new URL(res.headers.get('location')).searchParams.get('code')
|
|
512
|
+
const body = await (await token({
|
|
513
|
+
grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
|
|
514
|
+
client_id: CLIENT, code_verifier: verifier,
|
|
515
|
+
})).json()
|
|
516
|
+
refreshToken = body.refresh_token
|
|
517
|
+
assert.ok(refreshToken, 'the authorization_code grant must issue a refresh token')
|
|
518
|
+
|
|
519
|
+
// Mint an already-expired token for the same subject with the same
|
|
520
|
+
// key — the state the client reaches an hour later, without waiting
|
|
521
|
+
// an hour for it.
|
|
522
|
+
const { loadOrCreateKey } = await import('../lib/keys.js')
|
|
523
|
+
// The same issuer the running deployment declares — a token minted
|
|
524
|
+
// for any other one fails verification rather than expiring, which is
|
|
525
|
+
// the confusion this whole block exists to rule out.
|
|
526
|
+
issuer = runtime.options.url
|
|
527
|
+
key = await loadOrCreateKey({ keyFile: path.join(dir, 'auth.key') })
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
const mintExpired = async () => {
|
|
531
|
+
const { issueToken } = await import('../lib/tokens.js')
|
|
532
|
+
return issueToken({
|
|
533
|
+
key, issuer, audience: issuer, subject: 'alice',
|
|
534
|
+
capabilities: ['api:list', 'api:update'], ttl: '-1s',
|
|
535
|
+
})
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
it('the resource says WHICH failure it was, not just that there was one', async () => {
|
|
539
|
+
const expired = await mintExpired()
|
|
540
|
+
const res = await fetch(url('/resource'), { headers: { authorization: `Bearer ${expired}` } })
|
|
541
|
+
assert.equal(res.status, 401)
|
|
542
|
+
const header = res.headers.get('www-authenticate')
|
|
543
|
+
// The one field a client's refresh logic reads.
|
|
544
|
+
assert.match(header, /error="invalid_token"/)
|
|
545
|
+
assert.match(header, /error_description="The access token expired"/)
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
it('and does NOT say it for a request that carried no credential', async () => {
|
|
549
|
+
// The omission is the signal for "you have never authenticated here".
|
|
550
|
+
// Claiming invalid_token would send a client to refresh a token it
|
|
551
|
+
// does not hold.
|
|
552
|
+
const res = await fetch(url('/resource'))
|
|
553
|
+
assert.equal(res.status, 401)
|
|
554
|
+
assert.doesNotMatch(res.headers.get('www-authenticate') ?? '', /error=/)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('completes the whole loop: expire → read the signal → exchange → retry', async () => {
|
|
558
|
+
const expired = await mintExpired()
|
|
559
|
+
|
|
560
|
+
// 1. The call the agent actually wanted to make.
|
|
561
|
+
const denied = await fetch(url('/resource'), { headers: { authorization: `Bearer ${expired}` } })
|
|
562
|
+
assert.equal(denied.status, 401)
|
|
563
|
+
|
|
564
|
+
// 2. A client decides to refresh ONLY because of this.
|
|
565
|
+
assert.match(denied.headers.get('www-authenticate'), /error="invalid_token"/)
|
|
566
|
+
|
|
567
|
+
// 3. Exchange, with no human anywhere in it.
|
|
568
|
+
const refreshed = await token({
|
|
569
|
+
grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT,
|
|
570
|
+
})
|
|
571
|
+
assert.equal(refreshed.status, 200)
|
|
572
|
+
const body = await refreshed.json()
|
|
573
|
+
assert.ok(body.access_token)
|
|
574
|
+
refreshToken = body.refresh_token
|
|
575
|
+
|
|
576
|
+
// 4. The retry the whole exercise is for.
|
|
577
|
+
const retried = await fetch(url('/resource'), {
|
|
578
|
+
headers: { authorization: `Bearer ${body.access_token}` },
|
|
579
|
+
})
|
|
580
|
+
assert.equal(retried.status, 200)
|
|
581
|
+
assert.equal((await retried.json()).subject, 'alice')
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
it('tells a client NOT to refresh when the token is fine but unauthorized', async () => {
|
|
585
|
+
// A fresh token for the same subject is refused identically, so a
|
|
586
|
+
// client that reads this as an expiry loops forever.
|
|
587
|
+
const { verifier, challenge } = pkcePair()
|
|
588
|
+
const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
|
|
589
|
+
const code = new URL(res.headers.get('location')).searchParams.get('code')
|
|
590
|
+
const { access_token } = await (await token({
|
|
591
|
+
grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
|
|
592
|
+
client_id: CLIENT, code_verifier: verifier,
|
|
593
|
+
})).json()
|
|
594
|
+
|
|
595
|
+
const denied = await fetch(url('/resource-admin'), {
|
|
596
|
+
headers: { authorization: `Bearer ${access_token}` },
|
|
597
|
+
})
|
|
598
|
+
assert.equal(denied.status, 403)
|
|
599
|
+
const header = denied.headers.get('www-authenticate')
|
|
600
|
+
assert.match(header, /error="insufficient_scope"/)
|
|
601
|
+
assert.match(header, /scope="api:delete"/)
|
|
602
|
+
})
|
|
603
|
+
})
|