mikser-io-auth 0.5.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.
@@ -0,0 +1,238 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+ import { execFileSync } from 'node:child_process'
7
+ import bcrypt from 'bcryptjs'
8
+
9
+ import { parseHtpasswd, parseHtgroup, verifyPassword, createIdentityStore } from '../lib/htpasswd.js'
10
+
11
+ describe('parseHtpasswd', () => {
12
+ it('reads user:hash, skipping comments and blank lines', () => {
13
+ const users = parseHtpasswd('# a comment\n\nalice:$2a$10$abc\nbob:{SHA}xyz\n')
14
+ assert.deepEqual([...users.keys()], ['alice', 'bob'])
15
+ assert.equal(users.get('alice'), '$2a$10$abc')
16
+ })
17
+
18
+ it('splits on the FIRST colon only, so a hash keeps any it contains', () => {
19
+ const users = parseHtpasswd('alice:$apr1$a:b$c\n')
20
+ assert.equal(users.get('alice'), '$apr1$a:b$c')
21
+ })
22
+
23
+ it('ignores malformed lines rather than throwing', () => {
24
+ const users = parseHtpasswd('nocolon\n:noname\nalice:hash\n')
25
+ assert.deepEqual([...users.keys()], ['alice'])
26
+ })
27
+ })
28
+
29
+ describe('parseHtgroup', () => {
30
+ it('reads "group: members", split on any whitespace', () => {
31
+ const groups = parseHtgroup('editors: alice bob\nviewers: carol\n')
32
+ assert.deepEqual([...groups.get('editors')], ['alice', 'bob'])
33
+ assert.deepEqual([...groups.get('viewers')], ['carol'])
34
+ })
35
+
36
+ it('accumulates a group repeated across lines, as Apache does', () => {
37
+ const groups = parseHtgroup('editors: alice\neditors: bob\n')
38
+ assert.deepEqual([...groups.get('editors')], ['alice', 'bob'])
39
+ })
40
+
41
+ it('tolerates an empty group', () => {
42
+ const groups = parseHtgroup('empty:\n')
43
+ assert.deepEqual([...groups.get('empty')], [])
44
+ })
45
+ })
46
+
47
+ describe('verifyPassword', () => {
48
+ it('accepts bcrypt, including the $2y$ prefix htpasswd -B emits', () => {
49
+ const hash = bcrypt.hashSync('s3cret', 10)
50
+ assert.equal(verifyPassword(hash, 's3cret'), true)
51
+ assert.equal(verifyPassword(hash, 'wrong'), false)
52
+ assert.equal(verifyPassword(hash.replace(/^\$2[ab]\$/, '$2y$'), 's3cret'), true)
53
+ })
54
+
55
+ it('matches openssl for $apr1$, empty password included', () => {
56
+ // Generated with: openssl passwd -apr1 -salt Xy3Zq8Lm <password>
57
+ assert.equal(verifyPassword('$apr1$Xy3Zq8Lm$.f38Fzaj.hL2hlw7nUzyU0', 'secret'), true)
58
+ assert.equal(verifyPassword('$apr1$Xy3Zq8Lm$S/NLPnpu5C6dtylCJZrAE.', 'password'), true)
59
+ assert.equal(verifyPassword('$apr1$Xy3Zq8Lm$6AX7I0yPbQ0CvK/8r.ZfL0', ''), true)
60
+ assert.equal(verifyPassword('$apr1$Xy3Zq8Lm$QxwZvfxeHTo8vpjS3o5fy1', 'a-much-longer-passphrase-here'), true)
61
+ assert.equal(verifyPassword('$apr1$Xy3Zq8Lm$.f38Fzaj.hL2hlw7nUzyU0', 'secre'), false)
62
+ })
63
+
64
+ it('accepts {SHA}', () => {
65
+ assert.equal(verifyPassword('{SHA}/vNB+F2HQ559kaLUZbmHHvZrXpg=', 's3cret'), true)
66
+ assert.equal(verifyPassword('{SHA}/vNB+F2HQ559kaLUZbmHHvZrXpg=', 'nope'), false)
67
+ })
68
+
69
+ it('refuses DES-crypt and MD5-crypt rather than half-supporting them', () => {
70
+ assert.equal(verifyPassword('abJnggxhB/yWI', 's3cret'), false)
71
+ assert.equal(verifyPassword('$1$salt$dGVzdA', 's3cret'), false)
72
+ })
73
+
74
+ it('never throws on junk', () => {
75
+ assert.equal(verifyPassword(null, 'x'), false)
76
+ assert.equal(verifyPassword('', 'x'), false)
77
+ assert.equal(verifyPassword('$2a$notavalidhash', 'x'), false)
78
+ assert.equal(verifyPassword('$2a$10$abc', 12345), false)
79
+ })
80
+ })
81
+
82
+ describe('createIdentityStore', () => {
83
+ let dir, usersFile, groupsFile
84
+
85
+ before(async () => {
86
+ dir = await mkdtemp(path.join(tmpdir(), 'mikser-auth-'))
87
+ usersFile = path.join(dir, 'users.htpasswd')
88
+ groupsFile = path.join(dir, 'groups.htgroup')
89
+ await writeFile(usersFile, [
90
+ `alice:${bcrypt.hashSync('alice-pw', 10)}`,
91
+ `bob:${bcrypt.hashSync('bob-pw', 10)}`,
92
+ `carol:${bcrypt.hashSync('carol-pw', 10)}`,
93
+ ].join('\n') + '\n')
94
+ await writeFile(groupsFile, 'editors: alice bob\nadmins: alice\n')
95
+ })
96
+
97
+ after(async () => { await rm(dir, { recursive: true, force: true }) })
98
+
99
+ const store = () => createIdentityStore({
100
+ usersFile, groupsFile,
101
+ groups: { editors: ['api:update', 'mcp:use'], admins: ['api:delete'] },
102
+ })
103
+
104
+ it('authenticates and unions capabilities across every group', async () => {
105
+ const p = await store().authenticate('alice', 'alice-pw')
106
+ assert.equal(p.subject, 'alice')
107
+ assert.deepEqual(p.groups.sort(), ['admins', 'editors'])
108
+ assert.deepEqual(p.capabilities.sort(), ['api:delete', 'api:update', 'mcp:use'])
109
+ })
110
+
111
+ it('gives a user only their own groups capabilities', async () => {
112
+ const p = await store().authenticate('bob', 'bob-pw')
113
+ assert.deepEqual(p.capabilities.sort(), ['api:update', 'mcp:use'])
114
+ })
115
+
116
+ it('authenticates a user in no group, with no capabilities', async () => {
117
+ const p = await store().authenticate('carol', 'carol-pw')
118
+ assert.equal(p.subject, 'carol')
119
+ assert.deepEqual(p.capabilities, [])
120
+ })
121
+
122
+ it('rejects a wrong password and an unknown user identically', async () => {
123
+ assert.equal(await store().authenticate('alice', 'nope'), null)
124
+ assert.equal(await store().authenticate('nobody', 'nope'), null)
125
+ })
126
+
127
+ it('ignores a group naming a user who does not exist', async () => {
128
+ const s = createIdentityStore({
129
+ usersFile, groupsFile,
130
+ groups: { ghosts: ['api:delete'] },
131
+ })
132
+ const p = await s.authenticate('alice', 'alice-pw')
133
+ assert.deepEqual(p.capabilities, [])
134
+ })
135
+
136
+ it('picks up an edit to the users file without a restart', async () => {
137
+ const s = store()
138
+ assert.equal(await s.authenticate('dave', 'dave-pw'), null)
139
+ await writeFile(usersFile, `dave:${bcrypt.hashSync('dave-pw', 10)}\n`, { flag: 'a' })
140
+ // mtime granularity can collapse two writes in the same millisecond.
141
+ await new Promise(r => setTimeout(r, 12))
142
+ await s.reload()
143
+ const p = await s.authenticate('dave', 'dave-pw')
144
+ assert.equal(p?.subject, 'dave')
145
+ })
146
+
147
+ it('refuses everyone when the users file is missing, without throwing', async () => {
148
+ const s = createIdentityStore({ usersFile: path.join(dir, 'nope.htpasswd') })
149
+ assert.equal(await s.authenticate('alice', 'alice-pw'), null)
150
+ })
151
+
152
+ it('works with no groups file at all, leaving the user unscoped', async () => {
153
+ // No capability map configured → null, "not capability-scoped",
154
+ // the same thing a bare static token reports. [] would mean "no
155
+ // verbs at all", which would refuse everything to everyone in the
156
+ // simplest possible setup.
157
+ const s = createIdentityStore({ usersFile })
158
+ const p = await s.authenticate('alice', 'alice-pw')
159
+ assert.equal(p.subject, 'alice')
160
+ assert.equal(p.capabilities, null)
161
+ })
162
+
163
+ it('still reports [] for an ungranted user once a capability map exists', async () => {
164
+ const s = createIdentityStore({ usersFile, groupsFile, groups: { nobody: ['api:list'] } })
165
+ const p = await s.authenticate('alice', 'alice-pw')
166
+ assert.deepEqual(p.capabilities, [])
167
+ })
168
+ })
169
+
170
+ describe('group → row scope (principal-bound scope)', () => {
171
+ let dir, usersFile, groupsFile
172
+
173
+ before(async () => {
174
+ dir = await mkdtemp(path.join(tmpdir(), 'mikser-auth-scope-'))
175
+ usersFile = path.join(dir, 'users.htpasswd')
176
+ groupsFile = path.join(dir, 'groups.htgroup')
177
+ await writeFile(usersFile, ['alice', 'bob', 'carol', 'dan'].map(
178
+ u => `${u}:${bcrypt.hashSync(`${u}-pw`, 10)}`).join('\n') + '\n')
179
+ await writeFile(groupsFile, [
180
+ 'web-editors: alice dan',
181
+ 'franchise-editors: bob dan',
182
+ 'staff: carol', // a group with capabilities but no scope
183
+ ].join('\n') + '\n')
184
+ })
185
+
186
+ after(async () => { await rm(dir, { recursive: true, force: true }) })
187
+
188
+ const store = () => createIdentityStore({
189
+ usersFile, groupsFile,
190
+ groups: {
191
+ 'web-editors': ['api:list', 'api:update'],
192
+ 'franchise-editors': ['api:list', 'api:update'],
193
+ 'staff': ['api:list'],
194
+ },
195
+ scopes: {
196
+ 'web-editors': { 'meta.href': { $regex: '^/web' } },
197
+ 'franchise-editors': { 'meta.href': { $regex: '^/franchise' } },
198
+ },
199
+ })
200
+
201
+ it('gives a single-group user that group\'s filter verbatim', async () => {
202
+ const p = await store().authenticate('alice', 'alice-pw')
203
+ assert.deepEqual(p.scope, { 'meta.href': { $regex: '^/web' } })
204
+ })
205
+
206
+ it('unions across groups with $or — more groups means MORE reach, not less', async () => {
207
+ // Intersecting would make every extra group make a user less able,
208
+ // which is never what an operator means by adding one.
209
+ const p = await store().authenticate('dan', 'dan-pw')
210
+ assert.deepEqual(p.scope, {
211
+ $or: [
212
+ { 'meta.href': { $regex: '^/web' } },
213
+ { 'meta.href': { $regex: '^/franchise' } },
214
+ ],
215
+ })
216
+ })
217
+
218
+ it('leaves a user in no scoped group unscoped — the endpoint is their only limit', async () => {
219
+ const p = await store().authenticate('carol', 'carol-pw')
220
+ assert.equal(p.scope, null)
221
+ assert.deepEqual(p.capabilities, ['api:list'])
222
+ })
223
+
224
+ it('is null when no scopes are configured at all', async () => {
225
+ const s = createIdentityStore({ usersFile, groupsFile, groups: { 'web-editors': ['api:list'] } })
226
+ assert.equal((await s.authenticate('alice', 'alice-pw')).scope, null)
227
+ })
228
+
229
+ it('picks up a group-membership edit without a restart', async () => {
230
+ const s = store()
231
+ assert.equal((await s.authenticate('carol', 'carol-pw')).scope, null)
232
+ await writeFile(groupsFile, 'web-editors: carol\n', { flag: 'a' })
233
+ await new Promise(r => setTimeout(r, 12))
234
+ await s.reload()
235
+ assert.deepEqual((await s.authenticate('carol', 'carol-pw')).scope,
236
+ { 'meta.href': { $regex: '^/web' } })
237
+ })
238
+ })
@@ -0,0 +1,164 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+ import bcrypt from 'bcryptjs'
7
+
8
+ // The point of this file: prove these verifiers satisfy the engine's
9
+ // contract (ADR-0012) by driving them through the engine's own authorize(),
10
+ // rather than asserting against a local re-implementation of the rule.
11
+ import { authorize, resolveAuth } from 'mikser-io'
12
+
13
+ import { createIdentityStore } from '../lib/htpasswd.js'
14
+ import { loadOrCreateKey } from '../lib/keys.js'
15
+ import { issueToken, createTokenVerifier } from '../lib/tokens.js'
16
+ import { basic, jwt } from '../lib/verifiers.js'
17
+
18
+ const ISSUER = 'https://cms.example.com'
19
+ const b64 = (s) => Buffer.from(s, 'utf8').toString('base64')
20
+ const req = (header, ip = '203.0.113.9') => ({ headers: header ? { authorization: header } : {}, ip })
21
+
22
+ let dir, store, key
23
+
24
+ before(async () => {
25
+ dir = await mkdtemp(path.join(tmpdir(), 'mikser-auth-s-'))
26
+ const usersFile = path.join(dir, 'users.htpasswd')
27
+ const groupsFile = path.join(dir, 'groups.htgroup')
28
+ await writeFile(usersFile, `alice:${bcrypt.hashSync('alice-pw', 10)}\n`)
29
+ await writeFile(groupsFile, 'editors: alice\n')
30
+ store = createIdentityStore({ usersFile, groupsFile, groups: { editors: ['api:update'] } })
31
+ key = await loadOrCreateKey({ keyFile: path.join(dir, 'auth.key') })
32
+ })
33
+
34
+ after(async () => { await rm(dir, { recursive: true, force: true }) })
35
+
36
+ describe('verifiers satisfy the engine seam', () => {
37
+ it('resolveAuth passes a verifier through untouched', () => {
38
+ const v = basic({ store })
39
+ assert.equal(resolveAuth(v), v)
40
+ })
41
+
42
+ it('basic: valid credentials authorize from anywhere', async () => {
43
+ const out = await authorize(req(`Basic ${b64('alice:alice-pw')}`), basic({ store }))
44
+ assert.equal(out.ok, true)
45
+ assert.deepEqual(out.principal.capabilities, ['api:update'])
46
+ })
47
+
48
+ it('basic: a wrong password is 401 even from loopback with trustLoopback on', async () => {
49
+ const out = await authorize(
50
+ req(`Basic ${b64('alice:nope')}`, '127.0.0.1'),
51
+ basic({ store }),
52
+ { trustLoopback: true },
53
+ )
54
+ assert.equal(out.ok, false)
55
+ assert.equal(out.status, 401)
56
+ assert.equal(out.reason, 'invalid')
57
+ })
58
+
59
+ it('basic: no credential is "missing", which loopback policy may still rescue', async () => {
60
+ const v = basic({ store })
61
+ assert.equal((await authorize(req(null, '127.0.0.1'), v)).reason, 'missing')
62
+ assert.equal((await authorize(req(null, '127.0.0.1'), v, { trustLoopback: true })).ok, true)
63
+ })
64
+
65
+ it('jwt: a token minted from the files authorizes, carrying its scope', async () => {
66
+ const principal = await store.authenticate('alice', 'alice-pw')
67
+ const token = await issueToken({
68
+ key, issuer: ISSUER, audience: ISSUER,
69
+ subject: principal.subject, capabilities: principal.capabilities,
70
+ })
71
+ const v = jwt({ verifyToken: createTokenVerifier({ key, issuer: ISSUER, audience: ISSUER }), issuer: ISSUER })
72
+ const out = await authorize(req(`Bearer ${token}`), v)
73
+ assert.equal(out.ok, true)
74
+ assert.equal(out.principal.subject, 'alice')
75
+ assert.deepEqual(out.principal.capabilities, ['api:update'])
76
+ })
77
+
78
+ it('jwt: an OAuth-gated surface gets no loopback bypass by default', async () => {
79
+ const v = jwt({ verifyToken: createTokenVerifier({ key, issuer: ISSUER, audience: ISSUER }), issuer: ISSUER })
80
+ const out = await authorize(req(null, '127.0.0.1'), v)
81
+ assert.equal(out.ok, false)
82
+ assert.equal(out.status, 401)
83
+ })
84
+
85
+ it('scope is never taken from the client — it comes from the files', async () => {
86
+ // A user whose group grants api:update cannot obtain api:delete by
87
+ // asking for it; the only input to the token's scope is the store.
88
+ const principal = await store.authenticate('alice', 'alice-pw')
89
+ assert.deepEqual(principal.capabilities, ['api:update'])
90
+ const token = await issueToken({
91
+ key, issuer: ISSUER, audience: ISSUER,
92
+ subject: principal.subject, capabilities: principal.capabilities,
93
+ })
94
+ const v = jwt({
95
+ verifyToken: createTokenVerifier({ key, issuer: ISSUER, audience: ISSUER }),
96
+ issuer: ISSUER,
97
+ requiredCapability: 'api:delete',
98
+ })
99
+ assert.equal((await authorize(req(`Bearer ${token}`), v)).ok, false)
100
+ })
101
+ })
102
+
103
+ describe('the plugin is itself a verifier — one line at the call site', () => {
104
+ // The config burden this removes: every gated endpoint used to have to
105
+ // pick .basic() or .jwt() when the honest answer is "whichever the
106
+ // caller has".
107
+ it('resolveAuth takes it unchanged, despite being a function', async () => {
108
+ const { auth } = await import('../index.js')
109
+ const identity = auth({})
110
+ assert.equal(typeof identity, 'function', 'still the plugin')
111
+ assert.equal(resolveAuth(identity), identity, 'and also the verifier')
112
+ })
113
+
114
+ it('reports "nothing presented" with no credential, so loopback policy still applies', async () => {
115
+ const { auth } = await import('../index.js')
116
+ assert.equal(await auth({}).verify(req(null)), null)
117
+ })
118
+
119
+ it('routes each scheme to the verifier that owns it', async () => {
120
+ // Basic and Bearer never collide: each verifier reports "not mine"
121
+ // for the other's scheme, so the composite reaches the right one
122
+ // rather than the first one.
123
+ const { basic, jwt } = await import('../lib/verifiers.js')
124
+ const composite = (await import('mikser-io')).anyOf(
125
+ basic({ store }),
126
+ jwt({ verifyToken: createTokenVerifier({ key, issuer: ISSUER, audience: ISSUER }), issuer: ISSUER }),
127
+ )
128
+ const viaBasic = await composite.verify(req(`Basic ${b64('alice:alice-pw')}`))
129
+ assert.equal(viaBasic.subject, 'alice')
130
+
131
+ const token = await issueToken({
132
+ key, issuer: ISSUER, audience: ISSUER, subject: 'alice', capabilities: ['api:update'],
133
+ })
134
+ const viaBearer = await composite.verify(req(`Bearer ${token}`))
135
+ assert.equal(viaBearer.subject, 'alice')
136
+ assert.deepEqual(viaBearer.capabilities, ['api:update'])
137
+ })
138
+ })
139
+
140
+ describe('the simplest possible setup', () => {
141
+ it('with no capability map, an authenticated user behaves like a static token', async () => {
142
+ // capabilities: null means "not capability-scoped" — the endpoint's
143
+ // own `operations` list is the only limit, exactly as for a bare
144
+ // token. Returning [] here would authenticate people and then refuse
145
+ // them everything, making the capability map effectively mandatory.
146
+ const { hasCapability } = await import('mikser-io')
147
+ const bare = createIdentityStore({ usersFile: path.join(dir, 'users.htpasswd') })
148
+ const p = await bare.authenticate('alice', 'alice-pw')
149
+ assert.equal(p.capabilities, null)
150
+ assert.equal(hasCapability(p, 'api:delete'), true)
151
+ })
152
+
153
+ it('once a capability map exists, an ungranted user is refused rather than unscoped', async () => {
154
+ const { hasCapability } = await import('mikser-io')
155
+ const scoped = createIdentityStore({
156
+ usersFile: path.join(dir, 'users.htpasswd'),
157
+ groupsFile: path.join(dir, 'groups.htgroup'),
158
+ groups: { someone_else: ['api:delete'] },
159
+ })
160
+ const p = await scoped.authenticate('alice', 'alice-pw')
161
+ assert.deepEqual(p.capabilities, [])
162
+ assert.equal(hasCapability(p, 'api:delete'), false)
163
+ })
164
+ })
@@ -0,0 +1,202 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, writeFile, rm, readFile, stat } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+ import bcrypt from 'bcryptjs'
7
+
8
+ import { createIdentityStore } from '../lib/htpasswd.js'
9
+ import { loadOrCreateKey, jwks } from '../lib/keys.js'
10
+ import { issueToken, createTokenVerifier } from '../lib/tokens.js'
11
+ import { basic, jwt } from '../lib/verifiers.js'
12
+
13
+ const req = (header) => ({ headers: header ? { authorization: header } : {} })
14
+ const b64 = (s) => Buffer.from(s, 'utf8').toString('base64')
15
+
16
+ let dir, store, key
17
+
18
+ before(async () => {
19
+ dir = await mkdtemp(path.join(tmpdir(), 'mikser-auth-v-'))
20
+ const usersFile = path.join(dir, 'users.htpasswd')
21
+ const groupsFile = path.join(dir, 'groups.htgroup')
22
+ await writeFile(usersFile, `alice:${bcrypt.hashSync('alice-pw', 10)}\n`)
23
+ await writeFile(groupsFile, 'editors: alice\n')
24
+ store = createIdentityStore({ usersFile, groupsFile, groups: { editors: ['api:update', 'mcp:use'] } })
25
+ key = await loadOrCreateKey({ keyFile: path.join(dir, 'auth.key') })
26
+ })
27
+
28
+ after(async () => { await rm(dir, { recursive: true, force: true }) })
29
+
30
+ describe('basic verifier', () => {
31
+ it('accepts valid credentials and carries the capabilities through', async () => {
32
+ const p = await basic({ store }).verify(req(`Basic ${b64('alice:alice-pw')}`))
33
+ assert.equal(p.subject, 'alice')
34
+ assert.deepEqual(p.capabilities.sort(), ['api:update', 'mcp:use'])
35
+ })
36
+
37
+ it('returns null when nothing is presented, so loopback policy can still apply', async () => {
38
+ assert.equal(await basic({ store }).verify(req(null)), null)
39
+ })
40
+
41
+ it('rejects a wrong password', async () => {
42
+ assert.equal(await basic({ store }).verify(req(`Basic ${b64('alice:nope')}`)), false)
43
+ })
44
+
45
+ it('treats a Bearer on a Basic endpoint as rejected, not as absent', async () => {
46
+ // If this returned null it would fall through to a loopback bypass —
47
+ // a presented-but-unusable credential must never do that.
48
+ assert.equal(await basic({ store }).verify(req('Bearer something')), false)
49
+ })
50
+
51
+ it('survives malformed base64 and a missing colon', async () => {
52
+ assert.equal(await basic({ store }).verify(req('Basic !!!not-base64!!!')), false)
53
+ assert.equal(await basic({ store }).verify(req(`Basic ${b64('nocolon')}`)), false)
54
+ })
55
+
56
+ it('handles a password containing a colon', async () => {
57
+ const usersFile = path.join(dir, 'colon.htpasswd')
58
+ await writeFile(usersFile, `dave:${bcrypt.hashSync('pa:ss:word', 10)}\n`)
59
+ const s = createIdentityStore({ usersFile })
60
+ assert.equal((await basic({ store: s }).verify(req(`Basic ${b64('dave:pa:ss:word')}`))).subject, 'dave')
61
+ })
62
+
63
+ it('challenges with a realm and UTF-8 charset', () => {
64
+ const res = { headers: {}, set(k, v) { this.headers[k] = v } }
65
+ basic({ store, realm: 'cms' }).challenge({}, res)
66
+ assert.equal(res.headers['WWW-Authenticate'], 'Basic realm="cms", charset="UTF-8"')
67
+ })
68
+ })
69
+
70
+ describe('key file', () => {
71
+ it('is stable across loads — a restart must not invalidate live tokens', async () => {
72
+ const keyFile = path.join(dir, 'stable.key')
73
+ const a = await loadOrCreateKey({ keyFile })
74
+ const b = await loadOrCreateKey({ keyFile })
75
+ assert.equal(a.kid, b.kid)
76
+ assert.deepEqual(a.publicJwk, b.publicJwk)
77
+ })
78
+
79
+ it('is written 0600 — it is a signing key sitting in a content folder', async () => {
80
+ const keyFile = path.join(dir, 'perms.key')
81
+ await loadOrCreateKey({ keyFile })
82
+ assert.equal((await stat(keyFile)).mode & 0o777, 0o600)
83
+ })
84
+
85
+ it('fails loudly on a corrupt key file rather than silently rotating', async () => {
86
+ const keyFile = path.join(dir, 'corrupt.key')
87
+ await writeFile(keyFile, 'not json at all')
88
+ await assert.rejects(() => loadOrCreateKey({ keyFile }), /unreadable or not JSON/)
89
+ })
90
+
91
+ it('publishes only the public half', async () => {
92
+ const doc = jwks({ publicJwk: key.publicJwk })
93
+ assert.equal(doc.keys.length, 1)
94
+ assert.equal(doc.keys[0].d, undefined)
95
+ assert.ok(doc.keys[0].kid)
96
+ })
97
+
98
+ it('refuses to publish a JWKS carrying a private component', async () => {
99
+ const stored = JSON.parse(await readFile(path.join(dir, 'auth.key'), 'utf8'))
100
+ assert.throws(() => jwks({ publicJwk: stored.privateJwk }), /private component/)
101
+ })
102
+ })
103
+
104
+ describe('jwt verifier', () => {
105
+ const issuer = 'https://cms.example.com'
106
+ const mint = (over = {}) => issueToken({
107
+ key, issuer, audience: issuer, subject: 'alice',
108
+ capabilities: ['api:update', 'mcp:use'], ...over,
109
+ })
110
+ const verifier = (over = {}) => jwt({
111
+ verifyToken: createTokenVerifier({ key, issuer, audience: issuer }),
112
+ issuer, ...over,
113
+ })
114
+
115
+ it('round-trips a token and recovers subject + capabilities', async () => {
116
+ const p = await verifier().verify(req(`Bearer ${await mint()}`))
117
+ assert.equal(p.subject, 'alice')
118
+ assert.deepEqual(p.capabilities, ['api:update', 'mcp:use'])
119
+ })
120
+
121
+ it('returns null with no header, false for a non-Bearer scheme', async () => {
122
+ assert.equal(await verifier().verify(req(null)), null)
123
+ assert.equal(await verifier().verify(req(`Basic ${b64('alice:alice-pw')}`)), false)
124
+ })
125
+
126
+ it('rejects garbage and an expired token as false, never as a throw', async () => {
127
+ assert.equal(await verifier().verify(req('Bearer not.a.jwt')), false)
128
+ const expired = await mint({ ttl: '-1s' })
129
+ assert.equal(await verifier().verify(req(`Bearer ${expired}`)), false)
130
+ })
131
+
132
+ it('rejects a token minted for a different audience', async () => {
133
+ const other = await mint({ audience: 'https://elsewhere.example.com' })
134
+ assert.equal(await verifier().verify(req(`Bearer ${other}`)), false)
135
+ })
136
+
137
+ it('rejects a token signed by a different key', async () => {
138
+ const foreign = await loadOrCreateKey({ keyFile: path.join(dir, 'foreign.key') })
139
+ const token = await issueToken({ key: foreign, issuer, audience: issuer, subject: 'mallory' })
140
+ assert.equal(await verifier().verify(req(`Bearer ${token}`)), false)
141
+ })
142
+
143
+ it('enforces requiredCapability', async () => {
144
+ const v = verifier({ requiredCapability: 'api:delete' })
145
+ assert.equal(await v.verify(req(`Bearer ${await mint()}`)), false)
146
+ const ok = await mint({ capabilities: ['api:delete'] })
147
+ assert.equal((await v.verify(req(`Bearer ${ok}`))).subject, 'alice')
148
+ })
149
+
150
+ it('advertises the issuer for RFC 9728 discovery', () => {
151
+ const v = verifier({ scopes: ['mcp:use'] })
152
+ assert.deepEqual(v.authorizationServers, [issuer])
153
+ assert.deepEqual(v.scopesSupported, ['mcp:use'])
154
+ })
155
+ })
156
+
157
+ describe('row scope survives the JWT round trip', () => {
158
+ const issuer = 'https://cms.example.com'
159
+ const ROWS = { 'meta.href': { $regex: '^/web' } }
160
+
161
+ it('travels as a private claim, not as OAuth `scope`', async () => {
162
+ const token = await issueToken({
163
+ key, issuer, audience: issuer, subject: 'alice',
164
+ capabilities: ['api:list'], scope: ROWS,
165
+ })
166
+ const [, payload] = token.split('.')
167
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString())
168
+ // OAuth's `scope` is the capability list — a client library parses it
169
+ // as one, so the row filter must not be in there.
170
+ assert.equal(claims.scope, 'api:list')
171
+ assert.deepEqual(claims.mks_scope, ROWS)
172
+ })
173
+
174
+ it('is recovered by the verifier and reaches the principal', async () => {
175
+ const token = await issueToken({
176
+ key, issuer, audience: issuer, subject: 'alice',
177
+ capabilities: ['api:list'], scope: ROWS,
178
+ })
179
+ const v = jwt({ verifyToken: createTokenVerifier({ key, issuer, audience: issuer }), issuer })
180
+ const p = await v.verify(req(`Bearer ${token}`))
181
+ assert.deepEqual(p.scope, ROWS)
182
+ })
183
+
184
+ it('is null when the user has none', async () => {
185
+ const token = await issueToken({ key, issuer, audience: issuer, subject: 'carol', capabilities: [] })
186
+ const v = jwt({ verifyToken: createTokenVerifier({ key, issuer, audience: issuer }), issuer })
187
+ assert.equal((await v.verify(req(`Bearer ${token}`))).scope, null)
188
+ })
189
+
190
+ it('cannot be widened by a client — the token is signed', async () => {
191
+ const token = await issueToken({
192
+ key, issuer, audience: issuer, subject: 'alice',
193
+ capabilities: ['api:list'], scope: ROWS,
194
+ })
195
+ const [head, payload, sig] = token.split('.')
196
+ const tampered = JSON.parse(Buffer.from(payload, 'base64url').toString())
197
+ tampered.mks_scope = {} // "show me everything"
198
+ const forged = [head, Buffer.from(JSON.stringify(tampered)).toString('base64url'), sig].join('.')
199
+ const v = jwt({ verifyToken: createTokenVerifier({ key, issuer, audience: issuer }), issuer })
200
+ assert.equal(await v.verify(req(`Bearer ${forged}`)), false)
201
+ })
202
+ })