mikser-io-auth 0.5.2 → 0.6.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 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/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.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",
@@ -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', () => {