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.
- package/.github/workflows/publish.yml +73 -0
- package/index.js +9 -1
- package/lib/htpasswd.js +147 -8
- package/lib/routes.js +11 -4
- package/lib/tokens.js +6 -0
- package/lib/verifiers.js +37 -1
- package/package.json +2 -2
- package/test/revocation.test.js +153 -0
- package/test/stamp-gate.test.js +88 -0
- package/test/store-watch.test.js +156 -0
|
@@ -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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
172
|
-
//
|
|
173
|
-
//
|
|
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.
|
|
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": "
|
|
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
|
+
})
|