mikser-io-auth 0.6.1 → 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
@@ -159,6 +159,7 @@ export function auth(options = {}) {
159
159
  issuerFor: originOf,
160
160
  audienceFor: (req) => audience ?? originOf(req),
161
161
  dcr,
162
+ scopes: [...new Set(Object.values(capabilities).flat())],
162
163
  logger,
163
164
  })
164
165
 
@@ -177,7 +178,8 @@ export function auth(options = {}) {
177
178
  // rather than insert the well-known segment find it there.
178
179
  if (base && base !== '/') {
179
180
  app.get('/.well-known/oauth-authorization-server',
180
- metadataHandler({ base, issuerFor: originOf }))
181
+ metadataHandler({ base, issuerFor: originOf,
182
+ scopes: [...new Set(Object.values(capabilities).flat())] }))
181
183
  }
182
184
 
183
185
  // Codes are 60s and refresh tokens 30d; without a sweep the rows
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.1",
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",
@@ -242,7 +242,13 @@ describe('POST /token — authorization_code', () => {
242
242
  const body = await res.json()
243
243
  assert.equal(body.token_type, 'Bearer')
244
244
  assert.ok(body.refresh_token)
245
- 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')
246
252
 
247
253
  const claims = JSON.parse(Buffer.from(body.access_token.split('.')[1], 'base64url').toString())
248
254
  assert.equal(claims.sub, 'alice')
@@ -450,7 +456,7 @@ describe('POST /register — any agent, no operator config (RFC 7591)', () => {
450
456
  redirect_uri: 'http://127.0.0.1:7788/cb',
451
457
  code_verifier: verifier, client_id: reg.client_id,
452
458
  })).json()
453
- assert.equal(tok.scope, 'api:list api:update')
459
+ assert.equal(tok.scope, 'api:list api:update offline_access')
454
460
  })
455
461
 
456
462
  it('escapes a client name — it is attacker-controlled and lands on the page', async () => {
@@ -601,3 +607,79 @@ describe('an expired access token is recoverable without a human', () => {
601
607
  assert.match(header, /scope="api:delete"/)
602
608
  })
603
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
+ })