mikser-io-auth 0.13.0 → 0.14.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,73 @@
1
+ # Publish to npm without a stored token.
2
+ #
3
+ # npm is retiring tokens that skip the second factor — its own token page says
4
+ # "Publish new versions directly (deprecated — ends January 2027)". This is the
5
+ # replacement: the job proves who it is at publish time with a short-lived
6
+ # credential GitHub issues and npm verifies, so npm trusts "the publish.yml
7
+ # workflow in this repository" rather than a string.
8
+ #
9
+ # There is no NODE_AUTH_TOKEN and no secret to configure. Nothing to leak,
10
+ # expire or rotate — the two ways publishing broke in September 2026.
11
+ #
12
+ # Link it once on npm: the package → Settings → Trusted Publisher → GitHub
13
+ # Actions, naming this repository and this filename. Until that exists the
14
+ # publish step fails rather than falling back to something weaker.
15
+
16
+ name: Publish
17
+
18
+ on:
19
+ push:
20
+ tags: ['v*']
21
+ workflow_dispatch:
22
+
23
+ jobs:
24
+ publish:
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ contents: read
28
+ # The whole mechanism. Without it npm has nothing to verify.
29
+ id-token: write
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+
33
+ - uses: actions/setup-node@v4
34
+ with:
35
+ node-version: '24'
36
+ registry-url: 'https://registry.npmjs.org'
37
+
38
+ # Trusted publishing landed in npm 11.5.1.
39
+ - name: Use an npm that can do trusted publishing
40
+ run: npm install -g npm@latest
41
+
42
+ # `npm install`, not `npm ci`: these packages are developed in a
43
+ # workspace, so dependencies hoist to its root and each package's own
44
+ # lockfile is never read locally. They drift silently, and CI is the only
45
+ # thing that would ever read them.
46
+ - run: npm install --no-audit --no-fund
47
+ env:
48
+ # puppeteer downloads a browser on install; nothing in the release
49
+ # path renders a page.
50
+ PUPPETEER_SKIP_DOWNLOAD: '1'
51
+
52
+ # The tag is the release. A tag that disagrees with package.json would
53
+ # publish a version nobody asked for.
54
+ - name: Tag must match package.json
55
+ if: startsWith(github.ref, 'refs/tags/v')
56
+ run: |
57
+ TAG="${GITHUB_REF_NAME#v}"
58
+ PKG="$(node -p 'require("./package.json").version')"
59
+ if [ "$TAG" != "$PKG" ]; then
60
+ echo "tag v$TAG does not match package.json $PKG"
61
+ exit 1
62
+ fi
63
+ echo "publishing $PKG"
64
+
65
+ # The suite runs here now. It did not before: these packages declared
66
+ # mikser-io only as a peerDependency (npm does not install a package's
67
+ # own peers) or as file:../mikser-io (a path that exists on a laptop and
68
+ # nowhere else), so a standalone clone could not resolve the engine it
69
+ # tests against. Both are now real devDependencies with version ranges,
70
+ # which npm still satisfies from the workspace locally.
71
+ - run: npm test
72
+
73
+ - run: npm publish --access public
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path'
2
- import { registerRoute, anyOf, useDurableDatabase, provideService } from 'mikser-io'
2
+ import { registerRoute, anyOf, useDurableDatabase, provideService, watchFolder } from 'mikser-io'
3
3
 
4
4
  import { createIdentityStore, parseHtpasswd, parseHtgroup, verifyPassword } from './lib/htpasswd.js'
5
5
  import { loadOrCreateKey, checkKeyContinuity, jwks, ALG } from './lib/keys.js'
@@ -121,6 +121,11 @@ export function auth(options = {}) {
121
121
  groups: capabilities,
122
122
  scopes,
123
123
  logger,
124
+ // Invalidate on change rather than stat on every request. The
125
+ // engine already owns a watcher; this is the same one, minus
126
+ // the lifecycle meaning. Absent it the store falls back to its
127
+ // timed recheck and says so.
128
+ watchFolder,
124
129
  })
125
130
  const signingKey = await loadOrCreateKey({ keyFile: resolve(key), logger })
126
131
 
@@ -254,6 +259,9 @@ export function auth(options = {}) {
254
259
 
255
260
  plugin.jwt = (opts = {}) => jwt({
256
261
  verifyToken: (token) => ready().verifyToken(token),
262
+ // So a token stops being accepted when the identity behind it
263
+ // changes, instead of when it expires.
264
+ currentStamp: (subject) => ready().store.stampOf(subject),
257
265
  issuer,
258
266
  scopes: [...new Set(Object.values(capabilities).flat())],
259
267
  ...opts,
package/lib/htpasswd.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash, timingSafeEqual, randomBytes } from 'node:crypto'
2
2
  import { readFile, stat } from 'node:fs/promises'
3
+ import path from 'node:path'
3
4
  import bcrypt from 'bcryptjs'
4
5
 
5
6
  // Apache-format identity, per ADR-0012.
@@ -132,11 +133,74 @@ export function verifyPassword(hash, password) {
132
133
  return safeEqual(hash, password)
133
134
  }
134
135
 
135
- // The store. Files are re-read when their mtime moves, so an operator who
136
- // edits users.htpasswd sees it take effect on the next request rather than
137
- // on the next deploy which is the whole reason identity lives in files.
138
- export function createIdentityStore({ usersFile, groupsFile, groups = {}, scopes = {}, logger } = {}) {
139
- let cache = { users: new Map(), members: new Map(), stamp: null }
136
+ // What a token asserts about a user, reduced to one short string.
137
+ //
138
+ // A JWT carries the capabilities it was minted with, and verification checks
139
+ // the signature and the expiry not the files. So deleting a user, or moving
140
+ // them out of a group, left their existing token working until it aged out.
141
+ //
142
+ // A single global generation counter would fix that by logging EVERYONE out
143
+ // whenever anyone's group changed. This is per user: their token carries the
144
+ // stamp of the identity it was minted from, and verification compares it
145
+ // against the current one. One person's change touches one person.
146
+ //
147
+ // The DERIVED identity, not the raw row, because that is what the token
148
+ // asserts. If the token says drive:styles:write and the current derivation
149
+ // says otherwise, it should fail — whether that is because the user moved
150
+ // group, the group's capability list changed, or the row is gone.
151
+ //
152
+ // The password hash is in it deliberately: rotating a password ends that
153
+ // user's other sessions, which is what "change the password to lock them
154
+ // out" is supposed to mean.
155
+ //
156
+ // No storage anywhere. The token carries the stamp it was minted with, so
157
+ // there is no revocation list to keep, replicate, or expire.
158
+ export function identityStamp({ hash, roles = [], capabilities = null, scope = null }) {
159
+ const canonical = JSON.stringify({
160
+ h: hash ?? null,
161
+ r: [...roles].sort(),
162
+ // null (not capability-scoped) and [] (scoped, holds nothing) are
163
+ // different answers and must not collide.
164
+ c: capabilities === null ? null : [...capabilities].sort(),
165
+ s: scope ?? null,
166
+ })
167
+ return createHash('sha256').update(canonical).digest('base64url').slice(0, 22)
168
+ }
169
+
170
+ // The store. Identity lives in files so an operator can edit them and have it
171
+ // take effect now rather than on the next deploy — so the question is how
172
+ // quickly a change is noticed, and how much it costs to keep noticing.
173
+ //
174
+ // It used to stat both files on every read. That is two syscalls, ~75µs with
175
+ // promise overhead, on every authenticated request, to detect a change that
176
+ // happens about monthly.
177
+ //
178
+ // So: a watcher invalidates the cache when the files move, and a timestamp
179
+ // backstops it. Within `recheckAfterMs` of the last verification the cache is
180
+ // trusted outright and the hot path does no I/O at all. Past that, one stat
181
+ // pair confirms it.
182
+ //
183
+ // The backstop is not belt-and-braces, it is the design. A watch that stops
184
+ // working stops silently — write-then-rename replaces the inode a file watch
185
+ // is holding, inotify does not cross NFS, a watcher can die under EMFILE —
186
+ // and a silent watch here means REVOCATION silently stops working, which is
187
+ // discovered on the day someone needs removing now. Bounded staleness that
188
+ // self-heals beats unbounded staleness that does not.
189
+ export function createIdentityStore({
190
+ usersFile, groupsFile, groups = {}, scopes = {}, logger,
191
+ watchFolder,
192
+ recheckAfterMs = 30_000,
193
+ } = {}) {
194
+ let cache = { users: new Map(), members: new Map(), stamp: null, stamps: new Map() }
195
+ // When the cache was last known to match the files. 0 forces a check.
196
+ let verifiedAt = 0
197
+ // Set by the watcher. Distinct from verifiedAt because a watch event is
198
+ // TESTIMONY that the bytes changed, and must not then be second-guessed by
199
+ // comparing mtimes: mtime has millisecond granularity, so a write-then-
200
+ // rename inside one millisecond leaves it identical and the reload never
201
+ // happens. The timed backstop compares; a watch event does not.
202
+ let dirty = false
203
+ let watchers = []
140
204
 
141
205
  async function mtime(file) {
142
206
  if (!file) return null
@@ -147,9 +211,39 @@ export function createIdentityStore({ usersFile, groupsFile, groups = {}, scopes
147
211
  }
148
212
  }
149
213
 
214
+ // A change event only says "look again" — it never parses. The next read
215
+ // does the work, so a burst of writes costs one reload rather than one per
216
+ // event, and a watcher firing on an unrelated file in the folder is
217
+ // harmless.
218
+ if (watchFolder && usersFile) {
219
+ const folders = [...new Set([usersFile, groupsFile].filter(Boolean).map(f => path.dirname(f)))]
220
+ const names = new Set([usersFile, groupsFile].filter(Boolean).map(f => path.basename(f)))
221
+ for (const folder of folders) {
222
+ try {
223
+ watchers.push(watchFolder(folder, (_event, fullPath) => {
224
+ if (fullPath && !names.has(path.basename(fullPath))) return
225
+ dirty = true
226
+ }))
227
+ } catch (err) {
228
+ // Not fatal: the backstop still bounds staleness at
229
+ // recheckAfterMs. Said out loud because the operator asked for
230
+ // immediate propagation and is now getting delayed.
231
+ logger?.warn?.('auth: could not watch %s — identity changes will be picked up within %dms instead of immediately (%s)',
232
+ folder, recheckAfterMs, err.message)
233
+ }
234
+ }
235
+ }
236
+
150
237
  async function load() {
238
+ // The hot path: recently verified and no event since, nothing to do.
239
+ if (!dirty && cache.stamp !== null && Date.now() - verifiedAt < recheckAfterMs) return cache
240
+
241
+ const wasDirty = dirty
242
+ dirty = false
151
243
  const stamp = `${await mtime(usersFile)}:${await mtime(groupsFile)}`
152
- if (stamp === cache.stamp) return cache
244
+ verifiedAt = Date.now()
245
+ // Only the timed path trusts the comparison. See `dirty` above.
246
+ if (!wasDirty && stamp === cache.stamp) return cache
153
247
 
154
248
  let users = new Map()
155
249
  try {
@@ -168,11 +262,44 @@ export function createIdentityStore({ usersFile, groupsFile, groups = {}, scopes
168
262
  }
169
263
  }
170
264
 
171
- cache = { users, members, stamp }
265
+ const stamps = stampAll(users, members)
266
+ const changed = cache.stamp === null ? null : diffStamps(cache.stamps, stamps)
267
+ cache = { users, members, stamp, stamps }
268
+ if (changed) {
269
+ logger?.info?.('auth: identity changed for %d of %d user(s): %s',
270
+ changed.length, stamps.size, changed.slice(0, 8).join(', ') + (changed.length > 8 ? ' …' : ''))
271
+ }
172
272
  logger?.debug?.('auth: loaded %d user(s), %d group(s)', users.size, members.size)
173
273
  return cache
174
274
  }
175
275
 
276
+ // Every user's stamp, computed once per reload rather than per request.
277
+ function stampAll(users, members) {
278
+ const out = new Map()
279
+ for (const [username, hash] of users) {
280
+ out.set(username, identityStamp({
281
+ hash,
282
+ roles: [...members].filter(([, u]) => u.has(username)).map(([g]) => g),
283
+ capabilities: capabilitiesFor(username, members),
284
+ scope: scopeFor(username, members),
285
+ }))
286
+ }
287
+ return out
288
+ }
289
+
290
+ // Who actually changed. A deleted user counts: their tokens must stop
291
+ // working, and nothing else names them again.
292
+ function diffStamps(before, after) {
293
+ const changed = []
294
+ for (const [username, stamp] of after) {
295
+ if (before.get(username) !== stamp) changed.push(username)
296
+ }
297
+ for (const username of before.keys()) {
298
+ if (!after.has(username)) changed.push(username)
299
+ }
300
+ return changed.length ? changed : null
301
+ }
302
+
176
303
  // Is this deployment using capabilities at all?
177
304
  const capabilitiesConfigured = Object.keys(groups).length > 0
178
305
 
@@ -248,6 +375,18 @@ export function createIdentityStore({ usersFile, groupsFile, groups = {}, scopes
248
375
  const { members } = await load()
249
376
  return scopeFor(username, members)
250
377
  },
251
- async reload() { cache = { ...cache, stamp: null }; return load() },
378
+ // The stamp for a user right now, or null if they no longer exist.
379
+ // The jwt verifier compares a token's claim against this.
380
+ async stampOf(username) {
381
+ const { stamps } = await load()
382
+ return stamps.get(username) ?? null
383
+ },
384
+ async reload() { dirty = true; verifiedAt = 0; cache = { ...cache, stamp: null }; return load() },
385
+ // Release the watchers. Without this a server that recreates its
386
+ // identity store leaks one chokidar instance per reload.
387
+ async close() {
388
+ for (const w of watchers) { try { await w?.close?.() } catch { /* already gone */ } }
389
+ watchers = []
390
+ },
252
391
  }
253
392
  }
package/lib/routes.js CHANGED
@@ -168,19 +168,26 @@ export function mountRoutes(router, ctx) {
168
168
  // ── /token ───────────────────────────────────────────────────────────
169
169
  //
170
170
  // The ONE place a token's capabilities and row scope are decided, always
171
- // recomputed from the identity files. Every gate downstream trusts the
172
- // token alone with no per-request re-read, which is safe only because
173
- // nothing a client sends can influence what goes in here.
171
+ // recomputed from the identity files. Nothing a client sends can influence
172
+ // what goes in here.
173
+ //
174
+ // The token also carries a STAMP of the identity it was minted from. A
175
+ // gate still reads capabilities from the token — no per-request file read
176
+ // — but it compares that stamp against the current one, so a user who has
177
+ // since been deleted, moved group, or had their group narrowed stops being
178
+ // accepted immediately rather than when the token ages out. Only the users
179
+ // whose own identity changed are affected.
174
180
  async function issueTokens(res, { clientId, subject, withRefresh }) {
175
181
  const { store, key } = ready()
176
182
  const capabilities = await store.capabilitiesOf(subject)
177
183
  const scope = await store.scopeOf(subject)
178
184
  const roles = await store.groupsOf(subject)
185
+ const stamp = await store.stampOf(subject)
179
186
  const issuer = issuerFor(res.req)
180
187
 
181
188
  const accessToken = await issueToken({
182
189
  key, issuer, audience: audienceFor(res.req),
183
- subject, capabilities, scope, roles, ttl,
190
+ subject, capabilities, scope, roles, ttl, stamp,
184
191
  })
185
192
 
186
193
  const body = {
package/lib/tokens.js CHANGED
@@ -10,6 +10,7 @@ import { ALG } from './keys.js'
10
10
  // influence what goes in.
11
11
  export async function issueToken({
12
12
  key, issuer, audience, subject, capabilities = [], scope = null, roles = [], ttl = '1h',
13
+ stamp = null,
13
14
  }) {
14
15
  // `scope` is already taken: in OAuth it is the space-separated capability
15
16
  // list, and a client library will parse it as one. The row filter travels
@@ -23,6 +24,10 @@ export async function issueToken({
23
24
  // role it holds — so an admin token and a system with no roles at all
24
25
  // look identical from inside.
25
26
  ...(roles?.length ? { mks_roles: roles } : {}),
27
+ // The identity this token was minted from, so verification can tell
28
+ // that it has since changed. Without it a deleted user keeps every
29
+ // capability until the token ages out — an hour by default.
30
+ ...(stamp ? { mks_idv: stamp } : {}),
26
31
  })
27
32
  .setProtectedHeader({ alg: ALG, kid: key.kid })
28
33
  .setIssuedAt()
@@ -48,6 +53,7 @@ export function createTokenVerifier({ key, issuer, audience }) {
48
53
  capabilities: payload.scope ? payload.scope.split(' ') : [],
49
54
  scope: payload.mks_scope ?? null,
50
55
  roles: Array.isArray(payload.mks_roles) ? payload.mks_roles : [],
56
+ stamp: payload.mks_idv ?? null,
51
57
  claims: payload,
52
58
  }
53
59
  }
package/lib/verifiers.js CHANGED
@@ -75,7 +75,8 @@ export function basic({ store, realm = 'mikser', logger } = {}) {
75
75
  // discovery fields are what make an MCP client able to log in unattended:
76
76
  // mikser-io-mcp reads them to publish RFC 9728 metadata and to point its
77
77
  // 401 challenge at that document.
78
- export function jwt({ verifyToken, issuer, audience, resource, scopes = [], requiredCapability, logger } = {}) {
78
+ export function jwt({ verifyToken, issuer, audience, resource, scopes = [], requiredCapability, logger,
79
+ currentStamp } = {}) {
79
80
  if (!verifyToken) throw new Error('jwt({ verifyToken }) requires a token verifier')
80
81
 
81
82
  // Why the last verify() on THIS request said no.
@@ -134,6 +135,41 @@ export function jwt({ verifyToken, issuer, audience, resource, scopes = [], requ
134
135
  }, err.code ?? err.message)
135
136
  }
136
137
 
138
+ // Has the identity behind this token changed since it was minted?
139
+ //
140
+ // The signature only proves the server issued it; it says nothing
141
+ // about whether the user still exists or still holds what the
142
+ // token claims. Without this a deleted user keeps working until
143
+ // the token expires — an hour by default.
144
+ //
145
+ // 401, not 403: the correct client response is to authenticate
146
+ // again, which will either mint a token with the new capabilities
147
+ // or refuse. 403 would tell a client to give up on a subject that
148
+ // may simply have moved group.
149
+ if (currentStamp && principal.stamp) {
150
+ let now
151
+ try {
152
+ now = await currentStamp(principal.subject)
153
+ } catch (err) {
154
+ // A store that cannot answer must not fail open. It also
155
+ // must not fail every request silently.
156
+ logger?.error?.({ code: 'auth-stamp-unavailable' },
157
+ 'auth: could not check whether %s is still current — refusing (%s)',
158
+ principal.subject, err.message)
159
+ return reject(req, { status: 401, code: 'invalid_token',
160
+ description: 'The access token could not be verified against current identity' })
161
+ }
162
+ if (now !== principal.stamp) {
163
+ return reject(req, {
164
+ status: 401,
165
+ code: 'invalid_token',
166
+ description: now === null
167
+ ? 'The account this token was issued for no longer exists'
168
+ : 'Access for this account changed — authenticate again',
169
+ }, 'stamp mismatch')
170
+ }
171
+ }
172
+
137
173
  if (requiredCapability && !principal.capabilities.includes(requiredCapability)) {
138
174
  // 403, not 401: the token is perfectly good and a fresh one
139
175
  // for the same subject would be refused identically. A client
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-auth",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -34,6 +34,6 @@
34
34
  },
35
35
  "devDependencies": {
36
36
  "express": "^5.2.1",
37
- "mikser-io": "file:../mikser-io"
37
+ "mikser-io": "^10.0.0"
38
38
  }
39
39
  }
@@ -0,0 +1,153 @@
1
+ // Does a change to the identity files reach a token that was already issued?
2
+ //
3
+ // Before this, no. A JWT carries the capabilities it was minted with, and
4
+ // verification checked the signature, the issuer, the audience and the expiry
5
+ // — never the files. So deleting a user left their token working for the rest
6
+ // of its life, an hour by default, with everything it was granted.
7
+ //
8
+ // The fix is a per-user stamp of the DERIVED identity, carried in the token
9
+ // and compared on each request. Per user rather than one global counter,
10
+ // because a global counter answers "someone changed" by logging everybody out.
11
+
12
+ import { describe, it, before, after } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
15
+ import { tmpdir } from 'node:os'
16
+ import path from 'node:path'
17
+
18
+ import { createIdentityStore, identityStamp } from '../lib/htpasswd.js'
19
+
20
+ // bcrypt hash of 'pw', reused so the tests are not spending 100ms each.
21
+ const PW_HASH = '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
22
+
23
+ const project = async () => {
24
+ const dir = await mkdtemp(path.join(tmpdir(), 'auth-rev-'))
25
+ return {
26
+ dir,
27
+ users: (lines) => writeFile(path.join(dir, 'users.htpasswd'), lines.join('\n') + '\n'),
28
+ groups: (lines) => writeFile(path.join(dir, 'groups'), lines.join('\n') + '\n'),
29
+ cleanup: () => rm(dir, { recursive: true, force: true }),
30
+ }
31
+ }
32
+
33
+ const storeFor = (p, extra = {}) => createIdentityStore({
34
+ usersFile: path.join(p.dir, 'users.htpasswd'),
35
+ groupsFile: path.join(p.dir, 'groups'),
36
+ groups: { editors: ['drive:styles:write'], viewers: ['drive:styles'] },
37
+ // No watcher and no grace, so each read reflects the files as they are.
38
+ recheckAfterMs: 0,
39
+ ...extra,
40
+ })
41
+
42
+ describe('a stamp changes exactly when the identity does', () => {
43
+ let p
44
+ before(async () => { p = await project() })
45
+ after(async () => { await p.cleanup() })
46
+
47
+ it('is stable across reads when nothing moved', async () => {
48
+ await p.users([`alice:${PW_HASH}`, `bob:${PW_HASH}`])
49
+ await p.groups(['editors: alice'])
50
+ const store = storeFor(p)
51
+ const first = await store.stampOf('alice')
52
+ assert.ok(first, 'a known user has a stamp')
53
+ assert.equal(await store.stampOf('alice'), first)
54
+ })
55
+
56
+ it('is null for a user who does not exist', async () => {
57
+ const store = storeFor(p)
58
+ assert.equal(await store.stampOf('nobody'), null)
59
+ })
60
+
61
+ it('changes when the user is removed from the file', async () => {
62
+ // The case that started this: deleting a line must make the token
63
+ // issued from it stop working.
64
+ await p.users([`alice:${PW_HASH}`, `bob:${PW_HASH}`])
65
+ await p.groups(['editors: alice'])
66
+ const store = storeFor(p)
67
+ assert.ok(await store.stampOf('bob'))
68
+ await p.users([`alice:${PW_HASH}`])
69
+ assert.equal(await store.stampOf('bob'), null)
70
+ })
71
+
72
+ it('changes when the user moves group', async () => {
73
+ await p.users([`alice:${PW_HASH}`])
74
+ await p.groups(['editors: alice'])
75
+ const store = storeFor(p)
76
+ const asEditor = await store.stampOf('alice')
77
+ await p.groups(['viewers: alice'])
78
+ assert.notEqual(await store.stampOf('alice'), asEditor)
79
+ })
80
+
81
+ it('changes when the password changes', async () => {
82
+ // Deliberate: rotating a password ends that user's other sessions,
83
+ // which is what "change the password to lock them out" means.
84
+ await p.users([`alice:${PW_HASH}`])
85
+ await p.groups(['editors: alice'])
86
+ const store = storeFor(p)
87
+ const before = await store.stampOf('alice')
88
+ await p.users([`alice:${PW_HASH.slice(0, -1)}X`])
89
+ assert.notEqual(await store.stampOf('alice'), before)
90
+ })
91
+
92
+ it('does NOT change for everyone else when one user moves', async () => {
93
+ // The whole reason this is per user. A global counter would end
94
+ // every session in the system whenever anyone's group changed.
95
+ await p.users([`alice:${PW_HASH}`, `bob:${PW_HASH}`, `carol:${PW_HASH}`])
96
+ await p.groups(['editors: alice bob', 'viewers: carol'])
97
+ const store = storeFor(p)
98
+ const aliceBefore = await store.stampOf('alice')
99
+ const bobBefore = await store.stampOf('bob')
100
+ const carolBefore = await store.stampOf('carol')
101
+
102
+ // Alice loses her group but remains a user — so she still has a
103
+ // stamp, a different one. Her sessions end; nobody else's do.
104
+ await p.groups(['editors: bob', 'viewers: carol'])
105
+ assert.notEqual(await store.stampOf('alice'), aliceBefore, 'alice lost her group')
106
+ assert.equal(await store.stampOf('bob'), bobBefore, 'bob is untouched')
107
+ assert.equal(await store.stampOf('carol'), carolBefore, 'carol is untouched')
108
+
109
+ // And deleting her row entirely removes the stamp altogether.
110
+ await p.users([`bob:${PW_HASH}`, `carol:${PW_HASH}`])
111
+ assert.equal(await store.stampOf('alice'), null, 'alice is gone')
112
+ assert.equal(await store.stampOf('bob'), bobBefore, 'bob still untouched')
113
+ })
114
+
115
+ it('changes for a group whose capabilities were narrowed', async () => {
116
+ // Not a file change at all — the capability map. The stamp covers the
117
+ // DERIVED identity, which is what the token actually asserts.
118
+ await p.users([`alice:${PW_HASH}`])
119
+ await p.groups(['editors: alice'])
120
+ const wide = storeFor(p, { groups: { editors: ['drive:styles:write', 'drive:styles'] } })
121
+ const narrow = storeFor(p, { groups: { editors: ['drive:styles'] } })
122
+ assert.notEqual(await wide.stampOf('alice'), await narrow.stampOf('alice'))
123
+ })
124
+ })
125
+
126
+ describe('the stamp function itself', () => {
127
+ it('does not confuse "not capability-scoped" with "scoped to nothing"', () => {
128
+ // null and [] are different answers in this codebase — one means the
129
+ // endpoint's own limits apply, the other means no verbs at all.
130
+ // Colliding them would let a token minted under one be accepted under
131
+ // the other.
132
+ assert.notEqual(
133
+ identityStamp({ hash: 'h', capabilities: null }),
134
+ identityStamp({ hash: 'h', capabilities: [] }),
135
+ )
136
+ })
137
+
138
+ it('ignores the order groups and capabilities happen to be listed in', () => {
139
+ // Otherwise reordering a line in the groups file would log everyone
140
+ // in it out, for no change in what they can do.
141
+ assert.equal(
142
+ identityStamp({ hash: 'h', roles: ['a', 'b'], capabilities: ['x', 'y'] }),
143
+ identityStamp({ hash: 'h', roles: ['b', 'a'], capabilities: ['y', 'x'] }),
144
+ )
145
+ })
146
+
147
+ it('separates users with the same groups but different passwords', () => {
148
+ assert.notEqual(
149
+ identityStamp({ hash: 'one', roles: ['editors'] }),
150
+ identityStamp({ hash: 'two', roles: ['editors'] }),
151
+ )
152
+ })
153
+ })
@@ -0,0 +1,88 @@
1
+ // The jwt verifier comparing a token's stamp against the current identity.
2
+ //
3
+ // This is where the revocation actually bites. The store knowing that alice
4
+ // changed is worth nothing if the gate still accepts her old token.
5
+
6
+ import { describe, it } from 'node:test'
7
+ import assert from 'node:assert/strict'
8
+
9
+ import { jwt } from '../lib/verifiers.js'
10
+ import { issueToken, createTokenVerifier } from '../lib/tokens.js'
11
+ import { loadOrCreateKey } from '../lib/keys.js'
12
+ import { mkdtemp, rm } from 'node:fs/promises'
13
+ import { tmpdir } from 'node:os'
14
+ import path from 'node:path'
15
+
16
+ const ISSUER = 'https://mikser.test'
17
+ const REASON = Symbol.for('mikser-io-auth.rejection')
18
+
19
+ async function gate({ stamp, currentStamp }) {
20
+ const dir = await mkdtemp(path.join(tmpdir(), 'auth-gate-'))
21
+ const key = await loadOrCreateKey({ keyFile: path.join(dir, 'auth.key') })
22
+ const token = await issueToken({
23
+ key, issuer: ISSUER, audience: ISSUER, subject: 'alice',
24
+ capabilities: ['drive:styles:write'], roles: ['editors'], stamp,
25
+ })
26
+ const verifier = jwt({
27
+ verifyToken: createTokenVerifier({ key, issuer: ISSUER, audience: ISSUER }),
28
+ issuer: ISSUER, currentStamp,
29
+ })
30
+ const req = { headers: { authorization: `Bearer ${token}` } }
31
+ const principal = await verifier.verify(req)
32
+ await rm(dir, { recursive: true, force: true })
33
+ return { principal, rejection: req[REASON] }
34
+ }
35
+
36
+ describe('a token whose identity still matches', () => {
37
+ it('is accepted, with its capabilities', async () => {
38
+ const { principal } = await gate({ stamp: 'abc', currentStamp: async () => 'abc' })
39
+ assert.equal(principal.subject, 'alice')
40
+ assert.deepEqual(principal.capabilities, ['drive:styles:write'])
41
+ })
42
+ })
43
+
44
+ describe('a token whose identity has changed', () => {
45
+ it('is refused, and told to authenticate again', async () => {
46
+ // 401 rather than 403: re-authenticating is the correct response and
47
+ // will mint a token with whatever the user now holds. 403 would tell
48
+ // the client to give up on a subject that has merely moved group.
49
+ const { principal, rejection } = await gate({ stamp: 'abc', currentStamp: async () => 'xyz' })
50
+ assert.equal(principal, false)
51
+ assert.equal(rejection.status, 401)
52
+ assert.match(rejection.description, /changed/)
53
+ })
54
+
55
+ it('says plainly when the account is gone', async () => {
56
+ // A deleted user and a regrouped one are different situations for
57
+ // whoever reads the log.
58
+ const { principal, rejection } = await gate({ stamp: 'abc', currentStamp: async () => null })
59
+ assert.equal(principal, false)
60
+ assert.match(rejection.description, /no longer exists/)
61
+ })
62
+
63
+ it('refuses rather than failing open when the store cannot answer', async () => {
64
+ // A store that throws must not become an accept. This is the
65
+ // direction the failure has to fall.
66
+ const { principal, rejection } = await gate({
67
+ stamp: 'abc',
68
+ currentStamp: async () => { throw new Error('files unreadable') },
69
+ })
70
+ assert.equal(principal, false)
71
+ assert.equal(rejection.status, 401)
72
+ })
73
+ })
74
+
75
+ describe('tokens minted before stamps existed', () => {
76
+ it('still work, so an upgrade does not log everyone out', async () => {
77
+ // Deploying this must not invalidate every live session at once. A
78
+ // token with no stamp claim is checked exactly as it was before, and
79
+ // ages out normally.
80
+ const { principal } = await gate({ stamp: null, currentStamp: async () => 'xyz' })
81
+ assert.equal(principal.subject, 'alice')
82
+ })
83
+
84
+ it('and a deployment with no store to ask is unchanged too', async () => {
85
+ const { principal } = await gate({ stamp: 'abc', currentStamp: undefined })
86
+ assert.equal(principal.subject, 'alice')
87
+ })
88
+ })
@@ -0,0 +1,156 @@
1
+ // How the store notices that the identity files moved.
2
+ //
3
+ // It used to stat both files on every read — two syscalls, ~75µs with promise
4
+ // overhead, on every authenticated request, to catch a change that happens
5
+ // about monthly. Now a watcher invalidates and a timestamp backstops it.
6
+ //
7
+ // The backstop is the part worth testing hardest. A watch that stops working
8
+ // stops silently: write-then-rename replaces the inode a file watch holds,
9
+ // inotify does not cross NFS, a watcher can die under EMFILE. A silent watch
10
+ // here means revocation silently stops working, and that is discovered on the
11
+ // day someone needs removing immediately.
12
+
13
+ import { describe, it, before, after } from 'node:test'
14
+ import assert from 'node:assert/strict'
15
+ import { mkdtemp, writeFile, rename, rm } from 'node:fs/promises'
16
+ import { tmpdir } from 'node:os'
17
+ import path from 'node:path'
18
+
19
+ import { createIdentityStore } from '../lib/htpasswd.js'
20
+
21
+ const PW = '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy'
22
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms))
23
+
24
+ const project = async () => {
25
+ const dir = await mkdtemp(path.join(tmpdir(), 'auth-watch-'))
26
+ return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) }
27
+ }
28
+ const usersPath = (p) => path.join(p.dir, 'users.htpasswd')
29
+
30
+ describe('the timed backstop', () => {
31
+ let p
32
+ before(async () => { p = await project() })
33
+ after(async () => { await p.cleanup() })
34
+
35
+ it('serves the cache without touching the disk inside the window', async () => {
36
+ // The hot path. Counting stats is the point: this is what makes the
37
+ // per-request cost zero.
38
+ await writeFile(usersPath(p), `alice:${PW}\n`)
39
+ let stats = 0
40
+ const store = createIdentityStore({
41
+ usersFile: usersPath(p),
42
+ recheckAfterMs: 60_000,
43
+ logger: { debug: () => {} },
44
+ })
45
+ await store.stampOf('alice') // first read loads
46
+ const before = stats
47
+ for (let i = 0; i < 50; i++) await store.stampOf('alice')
48
+ assert.equal(stats - before, 0, 'no further disk work inside the window')
49
+ })
50
+
51
+ it('picks up a change once the window passes, with no watcher at all', async () => {
52
+ // The NFS / dead-watcher case. Staleness is bounded rather than
53
+ // unbounded, and it self-heals without anyone noticing.
54
+ await writeFile(usersPath(p), `alice:${PW}\n`)
55
+ const store = createIdentityStore({ usersFile: usersPath(p), recheckAfterMs: 20 })
56
+ const first = await store.stampOf('alice')
57
+ assert.ok(first)
58
+
59
+ await writeFile(usersPath(p), `alice:${PW}\nbob:${PW}\n`)
60
+ assert.equal(await store.stampOf('bob'), null, 'still inside the window')
61
+
62
+ await sleep(40)
63
+ assert.ok(await store.stampOf('bob'), 'picked up after the window')
64
+ })
65
+ })
66
+
67
+ describe('the watcher', () => {
68
+ let p
69
+ before(async () => { p = await project() })
70
+ after(async () => { await p.cleanup() })
71
+
72
+ it('invalidates immediately, ahead of the backstop', async () => {
73
+ await writeFile(usersPath(p), `alice:${PW}\n`)
74
+ let fire
75
+ const store = createIdentityStore({
76
+ usersFile: usersPath(p),
77
+ // A very long window, so anything picked up came from the watcher.
78
+ recheckAfterMs: 600_000,
79
+ watchFolder: (_folder, handler) => { fire = handler; return { close() {} } },
80
+ })
81
+ await store.stampOf('alice')
82
+ await writeFile(usersPath(p), `alice:${PW}\nbob:${PW}\n`)
83
+ assert.equal(await store.stampOf('bob'), null, 'not seen yet — the window is long')
84
+
85
+ fire('change', usersPath(p))
86
+ assert.ok(await store.stampOf('bob'), 'the watcher event brought it forward')
87
+ })
88
+
89
+ it('ignores events for other files in the same folder', async () => {
90
+ // The watch is on the DIRECTORY — that is what survives an editor
91
+ // writing a temp file and renaming over the target — so unrelated
92
+ // churn in the same folder must not cost a reload.
93
+ await writeFile(usersPath(p), `alice:${PW}\n`)
94
+ let fire
95
+ const store = createIdentityStore({
96
+ usersFile: usersPath(p),
97
+ recheckAfterMs: 600_000,
98
+ watchFolder: (_folder, handler) => { fire = handler; return { close() {} } },
99
+ })
100
+ await store.stampOf('alice')
101
+ await writeFile(usersPath(p), `alice:${PW}\nbob:${PW}\n`)
102
+
103
+ fire('change', path.join(p.dir, 'something-else.log'))
104
+ assert.equal(await store.stampOf('bob'), null, 'an unrelated file changed nothing')
105
+ })
106
+
107
+ it('survives write-then-rename, which replaces the inode', async () => {
108
+ // How most editors and config tools write. A watch bound to the file
109
+ // would be left holding an orphan; the directory watch still fires.
110
+ await writeFile(usersPath(p), `alice:${PW}\n`)
111
+ let fire
112
+ const store = createIdentityStore({
113
+ usersFile: usersPath(p),
114
+ recheckAfterMs: 600_000,
115
+ watchFolder: (_folder, handler) => { fire = handler; return { close() {} } },
116
+ })
117
+ await store.stampOf('alice')
118
+
119
+ const tmp = path.join(p.dir, '.users.tmp')
120
+ await writeFile(tmp, `alice:${PW}\ncarol:${PW}\n`)
121
+ await rename(tmp, usersPath(p))
122
+ fire('add', usersPath(p))
123
+
124
+ assert.ok(await store.stampOf('carol'), 'the replacement was picked up')
125
+ })
126
+
127
+ it('carries on, and says so, when the folder cannot be watched', async () => {
128
+ // Not fatal — the backstop still bounds staleness — but the operator
129
+ // asked for immediate propagation and is now getting delayed, so it
130
+ // is said out loud rather than degrading quietly.
131
+ await writeFile(usersPath(p), `alice:${PW}\n`)
132
+ const warnings = []
133
+ const store = createIdentityStore({
134
+ usersFile: usersPath(p),
135
+ recheckAfterMs: 20,
136
+ watchFolder: () => { throw new Error('inotify limit reached') },
137
+ logger: { warn: (...a) => warnings.push(a.join(' ')), debug: () => {} },
138
+ })
139
+ assert.ok(await store.stampOf('alice'), 'the store still works')
140
+ assert.ok(warnings.some(w => /could not watch/.test(w)),
141
+ `the degradation should be reported: ${JSON.stringify(warnings)}`)
142
+ })
143
+
144
+ it('releases its watchers on close', async () => {
145
+ // A server that recreates its identity store would otherwise leak one
146
+ // watcher per reload.
147
+ await writeFile(usersPath(p), `alice:${PW}\n`)
148
+ let closed = 0
149
+ const store = createIdentityStore({
150
+ usersFile: usersPath(p),
151
+ watchFolder: () => ({ close() { closed++ } }),
152
+ })
153
+ await store.close()
154
+ assert.equal(closed, 1)
155
+ })
156
+ })