mikser-io-auth 0.5.2 → 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/README.md CHANGED
@@ -220,10 +220,36 @@ Authorization codes (60s, single-use) and refresh tokens (30d, rotated on
220
220
  every use) live in the engine's sqlite (ADR-0009), under `mikser_auth_*`.
221
221
  Identity stays in files; this is session bookkeeping.
222
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.
223
+ The engine wipes that database when its schema stamp or config checksum
224
+ changes an upgrade, or any deploy that edits `mikser.config.js`. These
225
+ tables are registered `durable: true`, so the wipe drops table by table and
226
+ keeps them: a registered client and its refresh token exist only because a
227
+ human completed a sign-in once, and they are not something the working folder
228
+ can rebuild.
229
+
230
+ ### When an access token expires
231
+
232
+ Access tokens are short (`ttl`, default `1h`) and refresh tokens are long, so
233
+ a client is expected to notice the expiry and exchange quietly. It can only do
234
+ that if the resource server *says* which failure it hit, and the vocabulary is
235
+ RFC 6750 §3.1:
236
+
237
+ | Situation | Status | `WWW-Authenticate` | What a client should do |
238
+ | --- | --- | --- | --- |
239
+ | no credential presented | 401 | no `error` — the omission is the signal | start a sign-in |
240
+ | access token expired | 401 | `error="invalid_token"`, `error_description="The access token expired"` | exchange the refresh token, retry |
241
+ | token malformed, wrong audience, wrong key | 401 | `error="invalid_token"` | sign in again; refreshing will not help |
242
+ | token valid, subject lacks the capability | 403 | `error="insufficient_scope"`, `scope="<capability>"` | do NOT refresh — a fresh token is refused identically |
243
+
244
+ All four used to be one byte-identical 401. A client cannot tell "your token
245
+ went stale" from "you have never authenticated here" in that state, so it does
246
+ the safe thing and starts a whole new authorization flow — a human, a browser,
247
+ mid-task, with a perfectly good refresh token in hand.
248
+
249
+ The verifier reports which one it hit through `rejectionFor(req)`, an optional
250
+ method on the engine's ADR-0012 verifier contract. It can only *narrow* a
251
+ denial that has already happened; there is nothing it can return that turns a
252
+ rejection into an acceptance.
227
253
 
228
254
  ## Not implemented
229
255
 
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
@@ -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
- plugin.challenge = (req, res) => composite.challenge(req, res)
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/lib/verifiers.js CHANGED
@@ -5,6 +5,23 @@
5
5
  // null = no credential presented (loopback may still apply)
6
6
  // false = presented and rejected (never falls back to anything)
7
7
 
8
+ // Build a Bearer challenge, appending the RFC 6750 §3.1 error parameters
9
+ // when the request actually presented something.
10
+ //
11
+ // Omission is meaningful and is why `outcome` is consulted rather than
12
+ // always writing an error: a challenge to a request that carried NO
13
+ // credential must not claim the token was invalid — that is how a client
14
+ // distinguishes "sign in" from "your token went stale".
15
+ export function challengeHeader({ params = {}, outcome } = {}) {
16
+ const parts = Object.entries(params).map(([k, v]) => `${k}="${v}"`)
17
+ if (outcome?.code) {
18
+ parts.push(`error="${outcome.code}"`)
19
+ if (outcome.description) parts.push(`error_description="${outcome.description}"`)
20
+ if (outcome.scope) parts.push(`scope="${outcome.scope}"`)
21
+ }
22
+ return parts.length ? `Bearer, ${parts.join(', ')}` : 'Bearer'
23
+ }
24
+
8
25
  // HTTP Basic against the htpasswd file. Browser-native, no flow, no tokens —
9
26
  // the right tool for the api/forms/decap surfaces, where the caller is a
10
27
  // person with a browser or a script with curl. Not for MCP: an MCP client
@@ -60,6 +77,22 @@ export function basic({ store, realm = 'mikser', logger } = {}) {
60
77
  export function jwt({ verifyToken, issuer, audience, resource, scopes = [], requiredCapability, logger } = {}) {
61
78
  if (!verifyToken) throw new Error('jwt({ verifyToken }) requires a token verifier')
62
79
 
80
+ // Why the last verify() on THIS request said no.
81
+ //
82
+ // The ADR-0012 contract is three-valued on purpose and `false` is all of
83
+ // it — but "expired" and "you are not allowed" are opposite instructions
84
+ // to a client, and only this function ever looked at the token. Kept on
85
+ // the request rather than in the closure because a verifier instance is
86
+ // shared across every concurrent request and a module-level slot would
87
+ // hand one request's reason to another.
88
+ const REASON = Symbol.for('mikser-io-auth.rejection')
89
+
90
+ const reject = (req, rejection, detail) => {
91
+ if (req) req[REASON] = rejection
92
+ logger?.debug?.('auth: jwt rejected — %s%s', rejection.code, detail ? ` (${detail})` : '')
93
+ return false
94
+ }
95
+
63
96
  return {
64
97
  name: 'jwt',
65
98
  authorizationServers: [issuer],
@@ -69,9 +102,13 @@ export function jwt({ verifyToken, issuer, audience, resource, scopes = [], requ
69
102
  async verify(req) {
70
103
  const header = req.headers?.authorization ?? req.get?.('authorization')
71
104
  if (!header) return null
105
+ if (req) delete req[REASON]
72
106
 
73
107
  const match = /^Bearer\s+(.+)$/i.exec(header)
74
- if (!match) return false
108
+ if (!match) {
109
+ return reject(req, { status: 401, code: 'invalid_token',
110
+ description: 'Authorization header is not a Bearer token' })
111
+ }
75
112
 
76
113
  let principal
77
114
  try {
@@ -80,21 +117,52 @@ export function jwt({ verifyToken, issuer, audience, resource, scopes = [], requ
80
117
  // An expired or malformed token is a rejection, not an error:
81
118
  // failing loudly here would turn a routine token expiry into
82
119
  // a 500 and mask it from the client's refresh logic.
83
- logger?.debug?.('auth: jwt rejected — %s', err.code ?? err.message)
84
- return false
120
+ //
121
+ // jose throws ERR_JWT_EXPIRED for the one case a client can
122
+ // fix by itself, so it is named. Everything else is a token
123
+ // this server will never accept, and saying "expired" there
124
+ // would send a client to burn its refresh token for nothing.
125
+ const expired = err.code === 'ERR_JWT_EXPIRED'
126
+ return reject(req, {
127
+ status: 401,
128
+ code: 'invalid_token',
129
+ description: expired
130
+ ? 'The access token expired'
131
+ : 'The access token is not valid',
132
+ expired,
133
+ }, err.code ?? err.message)
85
134
  }
86
135
 
87
136
  if (requiredCapability && !principal.capabilities.includes(requiredCapability)) {
88
- logger?.debug?.('auth: %j lacks %j', principal.subject, requiredCapability)
89
- return false
137
+ // 403, not 401: the token is perfectly good and a fresh one
138
+ // for the same subject would be refused identically. A client
139
+ // that reads this as an expiry refreshes in a loop.
140
+ return reject(req, {
141
+ status: 403,
142
+ code: 'insufficient_scope',
143
+ description: `This token does not carry ${requiredCapability}`,
144
+ scope: requiredCapability,
145
+ }, `${principal.subject} lacks ${requiredCapability}`)
90
146
  }
91
147
  return principal
92
148
  },
93
149
 
150
+ rejectionFor(req) {
151
+ return req?.[REASON]
152
+ },
153
+
94
154
  // Only used when a surface has no better idea; mikser-io-mcp
95
155
  // overrides this with a resource_metadata pointer of its own.
96
- challenge(req, res) {
97
- res.set('WWW-Authenticate', `Bearer${issuer ? `, authorization_uri="${issuer}"` : ''}`)
156
+ //
157
+ // `error` is the field a client's refresh logic reads (RFC 6750
158
+ // §3.1). Without it every denial looks the same from outside, and an
159
+ // expiry that a refresh token would have fixed silently becomes a
160
+ // fresh authorization flow — a human, a browser, mid-task.
161
+ challenge(req, res, outcome) {
162
+ res.set('WWW-Authenticate', challengeHeader({
163
+ params: issuer ? { authorization_uri: issuer } : {},
164
+ outcome,
165
+ }))
98
166
  },
99
167
  }
100
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-auth",
3
- "version": "0.5.2",
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",
@@ -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"/)
@@ -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
+ })
@@ -152,6 +152,86 @@ describe('jwt verifier', () => {
152
152
  assert.deepEqual(v.authorizationServers, [issuer])
153
153
  assert.deepEqual(v.scopesSupported, ['mcp:use'])
154
154
  })
155
+
156
+ // An expired token and a request that never carried one both come back
157
+ // `false` — the ADR-0012 contract is three-valued and that is on purpose.
158
+ // But they are OPPOSITE instructions to a client: one is fixed silently
159
+ // with a refresh token, the other needs a human and a browser. Told apart
160
+ // through rejectionFor, which only ever refines a denial.
161
+ describe('rejectionFor tells the client what to do next', () => {
162
+ it('names an expired token as expired', async () => {
163
+ const v = verifier()
164
+ const r = req(`Bearer ${await mint({ ttl: '-1s' })}`)
165
+ assert.equal(await v.verify(r), false)
166
+ const rejection = v.rejectionFor(r)
167
+ assert.equal(rejection.status, 401)
168
+ assert.equal(rejection.code, 'invalid_token')
169
+ assert.equal(rejection.expired, true)
170
+ })
171
+
172
+ it('does NOT claim expiry for a token this server will never accept', async () => {
173
+ // A client that reads "expired" here burns its refresh token on a
174
+ // request that fails identically afterwards.
175
+ const v = verifier()
176
+ for (const header of ['Bearer not.a.jwt', `Bearer ${await mint({ audience: 'https://elsewhere' })}`]) {
177
+ const r = req(header)
178
+ assert.equal(await v.verify(r), false)
179
+ assert.equal(v.rejectionFor(r).code, 'invalid_token')
180
+ assert.notEqual(v.rejectionFor(r).expired, true)
181
+ }
182
+ })
183
+
184
+ it('answers 403 insufficient_scope when the token is good but unauthorized', async () => {
185
+ const v = verifier({ requiredCapability: 'api:delete' })
186
+ const r = req(`Bearer ${await mint()}`)
187
+ assert.equal(await v.verify(r), false)
188
+ const rejection = v.rejectionFor(r)
189
+ // Refreshing cannot fix this, and a client that tries loops.
190
+ assert.equal(rejection.status, 403)
191
+ assert.equal(rejection.code, 'insufficient_scope')
192
+ assert.equal(rejection.scope, 'api:delete')
193
+ })
194
+
195
+ it('leaves no rejection behind when the token is accepted', async () => {
196
+ const v = verifier()
197
+ const r = req(`Bearer ${await mint()}`)
198
+ assert.ok(await v.verify(r))
199
+ assert.equal(v.rejectionFor(r), undefined)
200
+ })
201
+
202
+ it('does not leak one request\'s reason to another', async () => {
203
+ // The verifier instance is shared across concurrent requests; a
204
+ // reason kept in its closure would answer for the wrong caller.
205
+ const v = verifier()
206
+ const bad = req(`Bearer ${await mint({ ttl: '-1s' })}`)
207
+ const good = req(`Bearer ${await mint()}`)
208
+ await v.verify(bad)
209
+ await v.verify(good)
210
+ assert.equal(v.rejectionFor(good), undefined)
211
+ assert.equal(v.rejectionFor(bad).expired, true)
212
+ })
213
+
214
+ it('carries the reason into the WWW-Authenticate header', async () => {
215
+ const v = verifier()
216
+ const r = req(`Bearer ${await mint({ ttl: '-1s' })}`)
217
+ await v.verify(r)
218
+ const res = { headers: {}, set(k, val) { this.headers[k] = val } }
219
+ v.challenge(r, res, { code: 'invalid_token', description: 'The access token expired' })
220
+ const header = res.headers['WWW-Authenticate']
221
+ assert.match(header, /error="invalid_token"/)
222
+ assert.match(header, /error_description="The access token expired"/)
223
+ })
224
+
225
+ it('omits error entirely when nothing was presented', async () => {
226
+ // The omission IS the signal for "you have never authenticated
227
+ // here" — writing invalid_token would send a client to refresh a
228
+ // token it does not have.
229
+ const v = verifier()
230
+ const res = { headers: {}, set(k, val) { this.headers[k] = val } }
231
+ v.challenge(req(null), res, { reason: 'missing' })
232
+ assert.doesNotMatch(res.headers['WWW-Authenticate'], /error=/)
233
+ })
234
+ })
155
235
  })
156
236
 
157
237
  describe('row scope survives the JWT round trip', () => {