mikser-io-auth 0.6.0 → 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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-auth",
3
- "version": "0.6.0",
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
+ })