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 +30 -4
- package/lib/verifiers.js +75 -7
- package/package.json +1 -1
- package/test/verifiers.test.js +80 -0
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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)
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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.
|
|
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",
|
package/test/verifiers.test.js
CHANGED
|
@@ -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', () => {
|