mikser-io-auth 0.7.0 → 0.8.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
@@ -227,6 +227,44 @@ keeps them: a registered client and its refresh token exist only because a
227
227
  human completed a sign-in once, and they are not something the working folder
228
228
  can rebuild.
229
229
 
230
+ ### Minted tokens
231
+
232
+ `auth()` publishes a minting surface at `runtime.options.auth`, so another
233
+ plugin can hand out a credential **narrower than the caller's own** without
234
+ importing this package:
235
+
236
+ ```js
237
+ runtime.options.auth.mint({
238
+ subject: principal.subject,
239
+ capabilities: principal.capabilities, // what they already hold
240
+ request: ['webdav:media', 'webdav:media:write'],
241
+ ttlSec: 300,
242
+ purpose: 'webdav:media (write)',
243
+ })
244
+ ```
245
+
246
+ The scopes are the **intersection** of `request` and `capabilities`. This can
247
+ only ever narrow: there is no argument that widens anyone's reach, and asking
248
+ for a scope the caller does not hold is refused with the missing one named
249
+ rather than trimmed silently. A caller whose `capabilities` are `null` — a
250
+ static token, not capability-scoped — is refused outright, because it cannot
251
+ delegate what it cannot enumerate.
252
+
253
+ A minted token carries a **`jti`** and is recorded in `mikser_auth_minted`, which
254
+ makes it revokable before it expires — the one thing an ordinary JWT is not, and
255
+ the thing that matters for a credential handed to a machine that logs its own
256
+ output. `revokeMinted(jti)` kills one; `listMinted(subject)` is the audit view.
257
+ Every mint is logged with subject, purpose, scopes and ttl.
258
+
259
+ Verification **fails closed**: a token with a `jti` whose row is missing, or
260
+ whose checker was never wired, is rejected. Losing the record must revoke, never
261
+ un-revoke. Session tokens carry no `jti`, so they never touch this table and
262
+ gain no new failure mode.
263
+
264
+ Minted tokens are **not refreshable**. Expiry is the revocation mechanism, so
265
+ they are deliberately short and a caller mints again rather than renewing one
266
+ that has been sitting in a transcript.
267
+
230
268
  ### When an access token expires
231
269
 
232
270
  Access tokens are short (`ttl`, default `1h`) and refresh tokens are long, so
package/index.js CHANGED
@@ -81,7 +81,20 @@ export function auth(options = {}) {
81
81
  reload: () => ready().store.reload(),
82
82
  }
83
83
 
84
+ // Captured on first invocation so the surfaces defined below — which are
85
+ // built at config time, before any hook runs — can reach the engine.
86
+ let engine = null
87
+
84
88
  const plugin = ({ runtime, onLoad, onLoaded, useLogger }) => {
89
+ engine = runtime
90
+ // Published here rather than after the factory, because `runtime` is
91
+ // only in scope once the plugin has been invoked. mikser-io-webdav
92
+ // mints through this without importing this package.
93
+ runtime.options.auth = {
94
+ mint: (...args) => plugin.mint(...args),
95
+ revokeMinted: (jti) => plugin.revokeMinted(jti),
96
+ listMinted: (subject) => plugin.listMinted(subject),
97
+ }
85
98
  onLoad(async () => {
86
99
  const logger = useLogger()
87
100
  const workingFolder = runtime.options.workingFolder
@@ -103,6 +116,10 @@ export function auth(options = {}) {
103
116
  key: signingKey,
104
117
  issuer: issuer ?? runtime.options.url,
105
118
  audience: audience ?? runtime.options.url,
119
+ // Minted tokens are revokable; session tokens carry no jti
120
+ // and never reach this, so it costs a lookup only for the
121
+ // credentials that need one.
122
+ mintedTokenUsable: grants.mintedTokenUsable,
106
123
  }),
107
124
  logger,
108
125
  }
@@ -233,6 +250,57 @@ export function auth(options = {}) {
233
250
 
234
251
  plugin.store = lazyStore
235
252
 
253
+ // Mint a token narrower than the caller's own, for one job.
254
+ //
255
+ // Everything about this is deliberately small. The scopes are whatever the
256
+ // caller asks for INTERSECTED with what they already hold — this can never
257
+ // widen anyone's reach, only narrow it, which is the property that makes it
258
+ // safe to expose to an agent at all. It is short-lived, it is revokable by
259
+ // jti, and it is not refreshable: expiry IS the revocation mechanism, so a
260
+ // caller whose transfer outlives it mints another rather than renewing one
261
+ // that has been sitting in a transcript.
262
+ //
263
+ // Returns `{ token, jti, scopes, expiresAt, ttl }`, or throws with the
264
+ // missing scope named when the caller is asking for more than it holds.
265
+ plugin.mint = async ({ subject, capabilities: held, request, ttlSec, purpose, audience: aud }) => {
266
+ const { key } = ready()
267
+ const wanted = [...new Set(request ?? [])]
268
+ if (!wanted.length) throw new Error('mint: no scopes requested')
269
+ // `capabilities: null` means "not capability-scoped" — a static token.
270
+ // Such a caller cannot delegate what it cannot enumerate, so refuse
271
+ // rather than mint something unbounded.
272
+ if (!Array.isArray(held)) {
273
+ throw new Error('mint: the caller holds no enumerable capabilities, so nothing can be delegated from them')
274
+ }
275
+ const missing = wanted.filter(scope => !held.includes(scope))
276
+ if (missing.length) {
277
+ const err = new Error(`mint refused: you do not hold ${missing.join(', ')}`)
278
+ err.missing = missing
279
+ throw err
280
+ }
281
+ const jti = opaqueToken()
282
+ const token = await issueToken({
283
+ key,
284
+ issuer: issuer ?? engine?.options?.url,
285
+ audience: aud ?? audience ?? engine?.options?.url,
286
+ subject,
287
+ capabilities: wanted,
288
+ ttl: `${ttlSec}s`,
289
+ jti,
290
+ })
291
+ const { expiresAt } = grants.recordMintedToken({ jti, subject, purpose, scopes: wanted, ttlSec })
292
+ // Every mint is logged: who, what for, how wide, how long. A
293
+ // credential handed to a machine that no one can account for later is
294
+ // the thing that makes short expiry necessary in the first place.
295
+ engine?.engine?.logger?.info?.('auth: minted %ds token for %j — %s [%s]',
296
+ ttlSec, subject, purpose ?? 'unspecified', wanted.join(' '))
297
+ return { token, jti, scopes: wanted, ttl: ttlSec, expiresAt: new Date(expiresAt).toISOString() }
298
+ }
299
+
300
+ plugin.revokeMinted = (jti) => grants.revokeMintedToken(jti)
301
+ plugin.listMinted = (subject) => grants.listMintedTokens(subject)
302
+
303
+
236
304
  // The plugin IS a verifier, accepting either credential:
237
305
  //
238
306
  // api({ endpoints: { admin: { auth: identity } } })
package/lib/grants.js CHANGED
@@ -38,6 +38,27 @@ registerSchema('auth', `
38
38
  CREATE INDEX IF NOT EXISTS idx_mikser_auth_refresh_subject
39
39
  ON mikser_auth_refresh (subject);
40
40
 
41
+ -- Short-lived tokens minted for one narrow job — a WebDAV upload, say —
42
+ -- rather than for a session. A JWT is normally unrevokable before its
43
+ -- expiry, which is exactly the property you do not want in a credential
44
+ -- handed to something that logs its own output. So a minted token carries
45
+ -- a jti and is only usable while its row here says so.
46
+ --
47
+ -- The verifier FAILS CLOSED on a missing row: no row, not usable. That
48
+ -- makes this table load-bearing for validity rather than merely advisory,
49
+ -- which is the safe direction — losing it revokes, it never un-revokes.
50
+ CREATE TABLE IF NOT EXISTS mikser_auth_minted (
51
+ jti TEXT PRIMARY KEY,
52
+ subject TEXT NOT NULL,
53
+ purpose TEXT,
54
+ scopes TEXT NOT NULL,
55
+ created_at INTEGER NOT NULL,
56
+ expires_at INTEGER NOT NULL,
57
+ revoked_at INTEGER
58
+ );
59
+ CREATE INDEX IF NOT EXISTS idx_mikser_auth_minted_subject
60
+ ON mikser_auth_minted (subject);
61
+
41
62
  -- Self-registered clients (RFC 7591). Config-declared clients are NOT
42
63
  -- here: they live in config, are not prunable, and always win a lookup.
43
64
  -- Keeping the two apart is what makes pruning safe — an operator's
@@ -129,7 +150,51 @@ export function sweepExpired() {
129
150
  const now = Date.now()
130
151
  const codes = db().prepare('DELETE FROM mikser_auth_codes WHERE expires_at < ?').run(now - 60_000).changes
131
152
  const refresh = db().prepare('DELETE FROM mikser_auth_refresh WHERE expires_at < ?').run(now).changes
132
- return { codes, refresh }
153
+ // A minted row past its expiry can no longer authorise anything — the JWT
154
+ // has expired on its own — so keeping it buys nothing. Swept a minute late
155
+ // so a token expiring mid-request is rejected by the clock rather than by
156
+ // a missing row, which reads as revoked and is a different answer.
157
+ const minted = db().prepare('DELETE FROM mikser_auth_minted WHERE expires_at < ?').run(now - 60_000).changes
158
+ return { codes, refresh, minted }
159
+ }
160
+
161
+ // ── minted tokens ───────────────────────────────────────────────────────
162
+
163
+ export function recordMintedToken({ jti, subject, purpose, scopes, ttlSec }) {
164
+ const now = Date.now()
165
+ db().prepare(`
166
+ INSERT INTO mikser_auth_minted (jti, subject, purpose, scopes, created_at, expires_at)
167
+ VALUES (?, ?, ?, ?, ?, ?)
168
+ `).run(jti, subject, purpose ?? null, scopes.join(' '), now, now + ttlSec * 1000)
169
+ return { jti, expiresAt: now + ttlSec * 1000 }
170
+ }
171
+
172
+ // Is this jti still usable? Missing means NO — see the schema comment.
173
+ export function mintedTokenUsable(jti) {
174
+ const row = db().prepare('SELECT revoked_at, expires_at FROM mikser_auth_minted WHERE jti = ?').get(jti)
175
+ if (!row) return false
176
+ if (row.revoked_at) return false
177
+ return row.expires_at > Date.now()
178
+ }
179
+
180
+ export function revokeMintedToken(jti) {
181
+ return db()
182
+ .prepare('UPDATE mikser_auth_minted SET revoked_at = ? WHERE jti = ? AND revoked_at IS NULL')
183
+ .run(Date.now(), jti).changes > 0
184
+ }
185
+
186
+ export function listMintedTokens(subject) {
187
+ const rows = subject
188
+ ? db().prepare('SELECT * FROM mikser_auth_minted WHERE subject = ? ORDER BY created_at DESC').all(subject)
189
+ : db().prepare('SELECT * FROM mikser_auth_minted ORDER BY created_at DESC').all()
190
+ return rows.map(r => ({
191
+ jti: r.jti, subject: r.subject, purpose: r.purpose,
192
+ scopes: r.scopes.split(' '),
193
+ createdAt: new Date(r.created_at).toISOString(),
194
+ expiresAt: new Date(r.expires_at).toISOString(),
195
+ revokedAt: r.revoked_at ? new Date(r.revoked_at).toISOString() : null,
196
+ usable: !r.revoked_at && r.expires_at > Date.now(),
197
+ }))
133
198
  }
134
199
 
135
200
  // ── self-registered clients ─────────────────────────────────────────────
package/lib/tokens.js CHANGED
@@ -8,12 +8,12 @@ import { ALG } from './keys.js'
8
8
  // asked for at /authorize. Enforcement downstream is scope-only with no
9
9
  // per-request re-read, which is safe precisely because a client cannot
10
10
  // influence what goes in.
11
- export async function issueToken({ key, issuer, audience, subject, capabilities = [], scope = null, ttl = '1h' }) {
11
+ export async function issueToken({ key, issuer, audience, subject, capabilities = [], scope = null, ttl = '1h', jti = null }) {
12
12
  // `scope` is already taken: in OAuth it is the space-separated capability
13
13
  // list, and a client library will parse it as one. The row filter travels
14
14
  // as a private claim so the two never collide. It is signed, so a client
15
15
  // cannot widen its own reach by editing it.
16
- return new SignJWT({
16
+ const signer = new SignJWT({
17
17
  scope: capabilities.join(' '),
18
18
  ...(scope ? { mks_scope: scope } : {}),
19
19
  })
@@ -23,12 +23,17 @@ export async function issueToken({ key, issuer, audience, subject, capabilities
23
23
  .setAudience(audience)
24
24
  .setSubject(subject)
25
25
  .setExpirationTime(ttl)
26
- .sign(key.privateKey)
26
+ // A jti makes the token REVOKABLE. A session token has none and is
27
+ // unrevokable before expiry, which is fine for an hour a human owns; a
28
+ // token minted for a machine and returned in output that gets logged needs
29
+ // to be killable before its clock runs out.
30
+ if (jti) signer.setJti(jti)
31
+ return signer.sign(key.privateKey)
27
32
  }
28
33
 
29
34
  // Verify a token minted by this server. `audience` is checked because a
30
35
  // token issued for one endpoint must not be replayable against another.
31
- export function createTokenVerifier({ key, issuer, audience }) {
36
+ export function createTokenVerifier({ key, issuer, audience, mintedTokenUsable }) {
32
37
  const keySet = createLocalJWKSet({ keys: [key.publicJwk] })
33
38
  return async function verifyToken(token) {
34
39
  const { payload } = await jwtVerify(token, keySet, {
@@ -36,6 +41,17 @@ export function createTokenVerifier({ key, issuer, audience }) {
36
41
  audience,
37
42
  algorithms: [ALG],
38
43
  })
44
+ // A token carrying a jti was minted for one narrow job and is only
45
+ // usable while the server still says so. FAIL CLOSED: no checker
46
+ // wired, or no row, means not usable — losing the record must revoke,
47
+ // never un-revoke.
48
+ if (payload.jti) {
49
+ if (typeof mintedTokenUsable !== 'function' || !mintedTokenUsable(payload.jti)) {
50
+ const err = new Error('This token has been revoked or is no longer on record')
51
+ err.code = 'ERR_JWT_REVOKED'
52
+ throw err
53
+ }
54
+ }
39
55
  return {
40
56
  subject: payload.sub,
41
57
  capabilities: payload.scope ? payload.scope.split(' ') : [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-auth",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -683,3 +683,127 @@ describe('unattended renewal is granted out loud, not just made possible', () =>
683
683
  assert.ok(second.scope.split(' ').includes('offline_access'))
684
684
  })
685
685
  })
686
+
687
+ // A token narrower than the caller's own, for one job.
688
+ //
689
+ // The property that makes this safe to expose to an agent at all: it can only
690
+ // ever NARROW. There is no argument that widens anyone's reach, and asking for
691
+ // more than you hold is refused rather than trimmed silently.
692
+ describe('minted tokens', () => {
693
+ let identity
694
+ before(async () => {
695
+ // The plugin object is the verifier and carries the minting surface.
696
+ identity = runtime.options.auth
697
+ assert.ok(identity?.mint, 'the auth plugin must publish a minting surface')
698
+ })
699
+
700
+ const claimsOf = (token) =>
701
+ JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString())
702
+
703
+ it('mints only what the caller already holds', async () => {
704
+ const m = await identity.mint({
705
+ subject: 'alice', capabilities: ['api:list', 'api:update'],
706
+ request: ['api:list'], ttlSec: 60, purpose: 'test',
707
+ })
708
+ assert.deepEqual(claimsOf(m.token).scope.split(' '), ['api:list'])
709
+ assert.ok(m.jti, 'a minted token must carry a jti, or it cannot be revoked')
710
+ })
711
+
712
+ it('refuses to mint a scope the caller lacks, and names it', async () => {
713
+ await assert.rejects(
714
+ () => identity.mint({
715
+ subject: 'alice', capabilities: ['api:list'],
716
+ request: ['api:list', 'api:delete'], ttlSec: 60,
717
+ }),
718
+ (err) => {
719
+ assert.match(err.message, /api:delete/)
720
+ assert.deepEqual(err.missing, ['api:delete'])
721
+ return true
722
+ })
723
+ })
724
+
725
+ it('refuses a caller whose capabilities are not enumerable', async () => {
726
+ // `capabilities: null` is a static token — not capability-scoped. It
727
+ // cannot delegate what it cannot enumerate, and minting something
728
+ // unbounded from it would be the opposite of the point.
729
+ await assert.rejects(
730
+ () => identity.mint({ subject: 'x', capabilities: null, request: ['api:list'], ttlSec: 60 }),
731
+ /no enumerable capabilities/)
732
+ })
733
+
734
+ it('works against a real gated resource, and STOPS the moment it is revoked', async () => {
735
+ // Through the actual gate, not through the verifier in isolation: what
736
+ // matters is whether a request carrying it gets in.
737
+ const m = await identity.mint({
738
+ subject: 'alice', capabilities: ['api:list'], request: ['api:list'], ttlSec: 300,
739
+ })
740
+ const hit = () => fetch(url('/resource'), { headers: { authorization: `Bearer ${m.token}` } })
741
+
742
+ assert.equal((await hit()).status, 200, 'a fresh minted token must be accepted')
743
+
744
+ // A JWT is normally unrevokable before expiry, which is exactly the
745
+ // property you do not want in a credential handed to something that
746
+ // logs its own output.
747
+ assert.equal(identity.revokeMinted(m.jti), true)
748
+ assert.equal((await hit()).status, 401, 'revocation must take effect immediately')
749
+ assert.equal(identity.revokeMinted(m.jti), false, 'revoking twice reports nothing changed')
750
+ })
751
+
752
+ it('FAILS CLOSED when the record is gone', async () => {
753
+ // Losing the table must revoke, never un-revoke. If a missing row read
754
+ // as "fine", wiping the cache would silently un-revoke every token
755
+ // anyone had killed.
756
+ const { createTokenVerifier } = await import('../lib/tokens.js')
757
+ const { loadOrCreateKey } = await import('../lib/keys.js')
758
+ const { issueToken } = await import('../lib/tokens.js')
759
+ const key = await loadOrCreateKey({ keyFile: path.join(dir, 'auth.key') })
760
+ const orphan = await issueToken({
761
+ key, issuer: runtime.options.url, audience: runtime.options.url,
762
+ subject: 'alice', capabilities: ['api:list'], ttl: '300s', jti: 'never-recorded',
763
+ })
764
+ const verify = createTokenVerifier({
765
+ key, issuer: runtime.options.url, audience: runtime.options.url,
766
+ mintedTokenUsable: () => false,
767
+ })
768
+ await assert.rejects(() => verify(orphan), /revoked or is no longer on record/)
769
+ })
770
+
771
+ it('fails closed when no checker is wired at all', async () => {
772
+ const { createTokenVerifier, issueToken } = await import('../lib/tokens.js')
773
+ const { loadOrCreateKey } = await import('../lib/keys.js')
774
+ const key = await loadOrCreateKey({ keyFile: path.join(dir, 'auth.key') })
775
+ const token = await issueToken({
776
+ key, issuer: runtime.options.url, audience: runtime.options.url,
777
+ subject: 'alice', capabilities: ['api:list'], ttl: '300s', jti: 'anything',
778
+ })
779
+ // No mintedTokenUsable passed — a deployment that forgot to wire it
780
+ // must not accept revokable tokens as if they were unrevokable.
781
+ const verify = createTokenVerifier({ key, issuer: runtime.options.url, audience: runtime.options.url })
782
+ await assert.rejects(() => verify(token), /revoked or is no longer on record/)
783
+ })
784
+
785
+ it('leaves a SESSION token alone — no jti, no lookup, no new failure mode', async () => {
786
+ // Adding revocation must not make ordinary sign-in depend on a table.
787
+ const { verifier, challenge } = pkcePair()
788
+ const res = await signIn(challenge, { username: 'alice', password: 'alice-pw' })
789
+ const code = new URL(res.headers.get('location')).searchParams.get('code')
790
+ const { access_token } = await (await token({
791
+ grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
792
+ client_id: CLIENT, code_verifier: verifier,
793
+ })).json()
794
+ assert.equal(claimsOf(access_token).jti, undefined, 'a session token carries no jti')
795
+ const res2 = await fetch(url('/resource'), { headers: { authorization: `Bearer ${access_token}` } })
796
+ assert.equal(res2.status, 200)
797
+ })
798
+
799
+ it('lists what has been minted, for an operator to audit or revoke', async () => {
800
+ const m = await identity.mint({
801
+ subject: 'carol', capabilities: ['api:list'], request: ['api:list'],
802
+ ttlSec: 60, purpose: 'audit-probe',
803
+ })
804
+ const listed = identity.listMinted('carol').find(x => x.jti === m.jti)
805
+ assert.equal(listed.purpose, 'audit-probe')
806
+ assert.deepEqual(listed.scopes, ['api:list'])
807
+ assert.equal(listed.usable, true)
808
+ })
809
+ })