mikser-io-auth 0.6.0 → 0.7.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/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
- const originOf = (req) => issuer ?? `${req.protocol}://${req.get('host')}`
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
@@ -147,6 +159,7 @@ export function auth(options = {}) {
147
159
  issuerFor: originOf,
148
160
  audienceFor: (req) => audience ?? originOf(req),
149
161
  dcr,
162
+ scopes: [...new Set(Object.values(capabilities).flat())],
150
163
  logger,
151
164
  })
152
165
 
@@ -165,7 +178,8 @@ export function auth(options = {}) {
165
178
  // rather than insert the well-known segment find it there.
166
179
  if (base && base !== '/') {
167
180
  app.get('/.well-known/oauth-authorization-server',
168
- metadataHandler({ base, issuerFor: originOf }))
181
+ metadataHandler({ base, issuerFor: originOf,
182
+ scopes: [...new Set(Object.values(capabilities).flat())] }))
169
183
  }
170
184
 
171
185
  // Codes are 60s and refresh tokens 30d; without a sweep the rows
@@ -231,7 +245,14 @@ export function auth(options = {}) {
231
245
  // one, which is rare.
232
246
  const composite = anyOf(plugin.basic(), plugin.jwt())
233
247
  plugin.verify = (req) => composite.verify(req)
234
- plugin.challenge = (req, res) => composite.challenge(req, res)
248
+ // Both forwarded WITH their arguments. Dropping either is silent: the
249
+ // endpoint still denies correctly, it just stops saying which denial it
250
+ // was — so an expiry a refresh token would have fixed reads as a fresh
251
+ // sign-in, which is the whole failure this vocabulary exists to prevent.
252
+ // `auth: identity` is the documented shape, so a gap here bypasses the
253
+ // signal in the configuration almost everyone uses.
254
+ plugin.rejectionFor = (req) => composite.rejectionFor?.(req)
255
+ plugin.challenge = (req, res, outcome) => composite.challenge(req, res, outcome)
235
256
  Object.defineProperties(plugin, {
236
257
  // A function's own `name` is non-writable, so plain assignment
237
258
  // throws in a module. defineProperty is the only way to give the
package/lib/routes.js CHANGED
@@ -12,6 +12,12 @@ import * as grants from './grants.js'
12
12
  const CODE_TTL_SEC = 60
13
13
  const REFRESH_TTL_SEC = 30 * 24 * 60 * 60
14
14
 
15
+ // RFC 6749 §3.3 / OIDC's name for "this client may hold a refresh token and use
16
+ // it without the user present". It is not one of this deployment's capabilities
17
+ // — it grants no access to anything — but it is the only standard way to TELL a
18
+ // client that unattended renewal is available to it.
19
+ const OFFLINE_ACCESS = 'offline_access'
20
+
15
21
  // The subset of an authorization request threaded through the login form's
16
22
  // hidden fields, untouched.
17
23
  function authParams(src) {
@@ -28,11 +34,16 @@ function authParams(src) {
28
34
  // under `base`, so its own copy answers one level down — where a client that
29
35
  // appends to the issuer looks, and nowhere a client following RFC 8414 does.
30
36
  // index.js mounts this at the root as well; both paths return this document.
31
- export function metadataHandler({ base, issuerFor }) {
37
+ export function metadataHandler({ base, issuerFor, scopes = [] }) {
32
38
  return (req, res) => {
33
39
  const issuer = issuerFor(req)
34
40
  res.json({
35
41
  issuer,
42
+ // RECOMMENDED by RFC 8414 §2 and load-bearing here: without it a
43
+ // client cannot discover that offline_access is available, so it
44
+ // never asks, so it never learns it may renew unattended — while a
45
+ // refresh token sits unused in every token response.
46
+ scopes_supported: [...new Set([...scopes, OFFLINE_ACCESS])],
36
47
  authorization_endpoint: `${issuer}${base}/authorize`,
37
48
  token_endpoint: `${issuer}${base}/token`,
38
49
  jwks_uri: `${issuer}${base}/jwks.json`,
@@ -47,7 +58,7 @@ export function metadataHandler({ base, issuerFor }) {
47
58
  }
48
59
 
49
60
  export function mountRoutes(router, ctx) {
50
- const { base, nameOf, ready, logoUrl, ttl, issuerFor, audienceFor, dcr, logger } = ctx
61
+ const { base, nameOf, ready, logoUrl, ttl, issuerFor, audienceFor, dcr, scopes = [], logger } = ctx
51
62
 
52
63
  const { windowMs = 60 * 60 * 1000, maxPerIp = 5, maxClients = 1000 } = dcr ?? {}
53
64
 
@@ -68,7 +79,7 @@ export function mountRoutes(router, ctx) {
68
79
  res.json(jwks({ publicJwk: ready().key.publicJwk }))
69
80
  })
70
81
 
71
- router.get('/.well-known/oauth-authorization-server', metadataHandler({ base, issuerFor }))
82
+ router.get('/.well-known/oauth-authorization-server', metadataHandler({ base, issuerFor, scopes }))
72
83
 
73
84
  // ── /authorize ───────────────────────────────────────────────────────
74
85
  //
@@ -181,6 +192,14 @@ export function mountRoutes(router, ctx) {
181
192
  body.refresh_token = grants.createRefreshToken({
182
193
  clientId, subject, ttlSec: REFRESH_TTL_SEC,
183
194
  })
195
+ // Say that unattended renewal was granted, not just hand over the
196
+ // means to do it. Per RFC 6749 §5.1 the response scope is what the
197
+ // client was actually granted; returning the capability list alone
198
+ // told a client that asked for offline_access that it had been
199
+ // REFUSED, so a conforming one would not use the refresh token it
200
+ // had just been given. Measured symptom: a fixed one-hour window
201
+ // that never renewed, and a human re-authorizing by hand.
202
+ body.scope = [...capabilities, OFFLINE_ACCESS].join(' ')
184
203
  }
185
204
  res.json(body)
186
205
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-auth",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
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",
@@ -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
- runtime.options.url = 'https://test-mikser.example'
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
- server = await new Promise(resolve => {
73
- const s = app.listen(0, () => resolve(s))
74
- })
75
- port = server.address().port
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
- assert.match(html, /Sign in to test-mikser\.example/)
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"/)
@@ -225,7 +242,13 @@ describe('POST /token — authorization_code', () => {
225
242
  const body = await res.json()
226
243
  assert.equal(body.token_type, 'Bearer')
227
244
  assert.ok(body.refresh_token)
228
- assert.equal(body.scope, 'api:list api:update')
245
+ // offline_access rides along BECAUSE a refresh token was issued. Per
246
+ // RFC 6749 §5.1 the response scope is what the client was granted, and
247
+ // handing over a refresh token while reporting a scope without
248
+ // offline_access tells a conforming client it was refused — so it does
249
+ // not renew, and the window becomes a hard one-hour cliff.
250
+ assert.equal(body.scope, 'api:list api:update offline_access')
251
+ assert.ok(body.refresh_token, 'the grant that says offline_access must actually issue one')
229
252
 
230
253
  const claims = JSON.parse(Buffer.from(body.access_token.split('.')[1], 'base64url').toString())
231
254
  assert.equal(claims.sub, 'alice')
@@ -433,7 +456,7 @@ describe('POST /register — any agent, no operator config (RFC 7591)', () => {
433
456
  redirect_uri: 'http://127.0.0.1:7788/cb',
434
457
  code_verifier: verifier, client_id: reg.client_id,
435
458
  })).json()
436
- assert.equal(tok.scope, 'api:list api:update')
459
+ assert.equal(tok.scope, 'api:list api:update offline_access')
437
460
  })
438
461
 
439
462
  it('escapes a client name — it is attacker-controlled and lands on the page', async () => {
@@ -477,3 +500,186 @@ describe('POST /register — any agent, no operator config (RFC 7591)', () => {
477
500
  assert.equal(res.headers.get('location'), null)
478
501
  })
479
502
  })
503
+
504
+ // The loop a real client runs, end to end. Rotation is covered above; this is
505
+ // the part that was never exercised — whether a client can TELL that it should
506
+ // rotate, and complete the round trip without a human.
507
+ //
508
+ // The failure this pins down: an access token expiring mid-task, between a
509
+ // finished decision and the write that would apply it. Refresh tokens were
510
+ // being issued the whole time; nothing told the client the moment to use one.
511
+ describe('an expired access token is recoverable without a human', () => {
512
+ let refreshToken, key, issuer
513
+
514
+ before(async () => {
515
+ const { verifier, challenge } = pkcePair()
516
+ const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
517
+ const code = new URL(res.headers.get('location')).searchParams.get('code')
518
+ const body = await (await token({
519
+ grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
520
+ client_id: CLIENT, code_verifier: verifier,
521
+ })).json()
522
+ refreshToken = body.refresh_token
523
+ assert.ok(refreshToken, 'the authorization_code grant must issue a refresh token')
524
+
525
+ // Mint an already-expired token for the same subject with the same
526
+ // key — the state the client reaches an hour later, without waiting
527
+ // an hour for it.
528
+ const { loadOrCreateKey } = await import('../lib/keys.js')
529
+ // The same issuer the running deployment declares — a token minted
530
+ // for any other one fails verification rather than expiring, which is
531
+ // the confusion this whole block exists to rule out.
532
+ issuer = runtime.options.url
533
+ key = await loadOrCreateKey({ keyFile: path.join(dir, 'auth.key') })
534
+ })
535
+
536
+ const mintExpired = async () => {
537
+ const { issueToken } = await import('../lib/tokens.js')
538
+ return issueToken({
539
+ key, issuer, audience: issuer, subject: 'alice',
540
+ capabilities: ['api:list', 'api:update'], ttl: '-1s',
541
+ })
542
+ }
543
+
544
+ it('the resource says WHICH failure it was, not just that there was one', async () => {
545
+ const expired = await mintExpired()
546
+ const res = await fetch(url('/resource'), { headers: { authorization: `Bearer ${expired}` } })
547
+ assert.equal(res.status, 401)
548
+ const header = res.headers.get('www-authenticate')
549
+ // The one field a client's refresh logic reads.
550
+ assert.match(header, /error="invalid_token"/)
551
+ assert.match(header, /error_description="The access token expired"/)
552
+ })
553
+
554
+ it('and does NOT say it for a request that carried no credential', async () => {
555
+ // The omission is the signal for "you have never authenticated here".
556
+ // Claiming invalid_token would send a client to refresh a token it
557
+ // does not hold.
558
+ const res = await fetch(url('/resource'))
559
+ assert.equal(res.status, 401)
560
+ assert.doesNotMatch(res.headers.get('www-authenticate') ?? '', /error=/)
561
+ })
562
+
563
+ it('completes the whole loop: expire → read the signal → exchange → retry', async () => {
564
+ const expired = await mintExpired()
565
+
566
+ // 1. The call the agent actually wanted to make.
567
+ const denied = await fetch(url('/resource'), { headers: { authorization: `Bearer ${expired}` } })
568
+ assert.equal(denied.status, 401)
569
+
570
+ // 2. A client decides to refresh ONLY because of this.
571
+ assert.match(denied.headers.get('www-authenticate'), /error="invalid_token"/)
572
+
573
+ // 3. Exchange, with no human anywhere in it.
574
+ const refreshed = await token({
575
+ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT,
576
+ })
577
+ assert.equal(refreshed.status, 200)
578
+ const body = await refreshed.json()
579
+ assert.ok(body.access_token)
580
+ refreshToken = body.refresh_token
581
+
582
+ // 4. The retry the whole exercise is for.
583
+ const retried = await fetch(url('/resource'), {
584
+ headers: { authorization: `Bearer ${body.access_token}` },
585
+ })
586
+ assert.equal(retried.status, 200)
587
+ assert.equal((await retried.json()).subject, 'alice')
588
+ })
589
+
590
+ it('tells a client NOT to refresh when the token is fine but unauthorized', async () => {
591
+ // A fresh token for the same subject is refused identically, so a
592
+ // client that reads this as an expiry loops forever.
593
+ const { verifier, challenge } = pkcePair()
594
+ const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
595
+ const code = new URL(res.headers.get('location')).searchParams.get('code')
596
+ const { access_token } = await (await token({
597
+ grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
598
+ client_id: CLIENT, code_verifier: verifier,
599
+ })).json()
600
+
601
+ const denied = await fetch(url('/resource-admin'), {
602
+ headers: { authorization: `Bearer ${access_token}` },
603
+ })
604
+ assert.equal(denied.status, 403)
605
+ const header = denied.headers.get('www-authenticate')
606
+ assert.match(header, /error="insufficient_scope"/)
607
+ assert.match(header, /scope="api:delete"/)
608
+ })
609
+ })
610
+
611
+ // A fixed one-hour window that never renewed, and a human re-authorizing by
612
+ // hand — twice in one working session, the second time between a finished
613
+ // decision and the write that would have applied it.
614
+ //
615
+ // The refresh token was there the whole time. What was missing was the sentence
616
+ // that tells a client it may use one.
617
+ describe('unattended renewal is granted out loud, not just made possible', () => {
618
+ it('advertises offline_access, so a client can discover it exists', async () => {
619
+ const meta = await (await fetch(url('/auth/.well-known/oauth-authorization-server'))).json()
620
+ assert.ok(meta.scopes_supported, 'RFC 8414 §2 RECOMMENDS scopes_supported; without it nothing is discoverable')
621
+ assert.ok(meta.scopes_supported.includes('offline_access'))
622
+ // The deployment's real capabilities are listed too, so a client can
623
+ // see what it is allowed to ask for rather than guessing.
624
+ assert.ok(meta.scopes_supported.includes('api:update'))
625
+ assert.ok(meta.grant_types_supported.includes('refresh_token'))
626
+ })
627
+
628
+ it('grants offline_access exactly when it hands over a refresh token', async () => {
629
+ const { verifier, challenge } = pkcePair()
630
+ const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
631
+ const code = new URL(res.headers.get('location')).searchParams.get('code')
632
+ const body = await (await token({
633
+ grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
634
+ client_id: CLIENT, code_verifier: verifier,
635
+ })).json()
636
+ assert.ok(body.refresh_token)
637
+ assert.ok(body.scope.split(' ').includes('offline_access'),
638
+ 'handing over a refresh token while reporting a scope without offline_access tells a '
639
+ + 'conforming client it was REFUSED, so it never renews')
640
+ })
641
+
642
+ it('does NOT claim it for a grant that issues no refresh token', async () => {
643
+ // The password grant deliberately issues none — a caller that can
644
+ // replay the password does not need one. Claiming offline_access there
645
+ // would promise renewal that cannot happen.
646
+ const body = await (await token({
647
+ grant_type: 'password', username: 'alice', password: 'alice-pw',
648
+ })).json()
649
+ assert.equal(body.refresh_token, undefined)
650
+ assert.equal(body.scope.split(' ').includes('offline_access'), false)
651
+ })
652
+
653
+ it('keeps offline_access OUT of the token claims, because it grants no access', async () => {
654
+ // It is a property of the grant, not a capability. A resource server
655
+ // checking capabilities must never see it in the list, or it becomes a
656
+ // permission nobody meant to give.
657
+ const { verifier, challenge } = pkcePair()
658
+ const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
659
+ const code = new URL(res.headers.get('location')).searchParams.get('code')
660
+ const body = await (await token({
661
+ grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
662
+ client_id: CLIENT, code_verifier: verifier,
663
+ })).json()
664
+ const claims = JSON.parse(Buffer.from(body.access_token.split('.')[1], 'base64url').toString())
665
+ assert.equal(claims.scope, 'api:list api:update')
666
+ assert.equal(String(claims.scope).includes('offline_access'), false)
667
+ })
668
+
669
+ it('keeps granting it on refresh, so renewal does not decay after one hop', async () => {
670
+ // A rotated refresh token that came back without offline_access would
671
+ // renew exactly once and then look refused.
672
+ const { verifier, challenge } = pkcePair()
673
+ const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
674
+ const code = new URL(res.headers.get('location')).searchParams.get('code')
675
+ const first = await (await token({
676
+ grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
677
+ client_id: CLIENT, code_verifier: verifier,
678
+ })).json()
679
+ const second = await (await token({
680
+ grant_type: 'refresh_token', refresh_token: first.refresh_token, client_id: CLIENT,
681
+ })).json()
682
+ assert.ok(second.refresh_token)
683
+ assert.ok(second.scope.split(' ').includes('offline_access'))
684
+ })
685
+ })