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 +38 -0
- package/index.js +68 -0
- package/lib/grants.js +66 -1
- package/lib/tokens.js +20 -4
- package/package.json +1 -1
- package/test/authorize.test.js +124 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
package/test/authorize.test.js
CHANGED
|
@@ -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
|
+
})
|