mikser-io-auth 0.13.0 → 0.15.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/login-page.js +85 -1
- package/lib/routes.js +45 -11
- package/lib/tokens.js +6 -0
- package/lib/verifiers.js +39 -2
- package/package.json +6 -3
- package/test/authorize.test.js +66 -0
- 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/login-page.js
CHANGED
|
@@ -49,7 +49,7 @@ body{
|
|
|
49
49
|
margin:0; min-height:100vh; display:grid; place-items:center; background:var(--bg);
|
|
50
50
|
color:var(--text); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
|
51
51
|
}
|
|
52
|
-
form{
|
|
52
|
+
form,.card{
|
|
53
53
|
width:320px; padding:32px; display:flex; flex-direction:column; align-items:center; gap:10px;
|
|
54
54
|
background:var(--panel); border:1px solid var(--border);
|
|
55
55
|
border-radius:var(--radius); box-shadow:var(--shadow);
|
|
@@ -62,6 +62,12 @@ h1{font-size:17px;font-weight:700;margin:0 0 6px;color:var(--text-strong)}
|
|
|
62
62
|
width:100%; margin:0 0 2px; padding:8px 10px; border-radius:8px; font-size:13px;
|
|
63
63
|
background:#fef2f2; border:1px solid #fecaca; color:#b91c1c; text-align:center;
|
|
64
64
|
}
|
|
65
|
+
.msg{margin:0;font-size:13px;text-align:center;opacity:.85}
|
|
66
|
+
.msg code{
|
|
67
|
+
font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
68
|
+
background:#f1f5f9;border:1px solid var(--border);border-radius:4px;padding:1px 4px;
|
|
69
|
+
color:var(--text-strong);word-break:break-all;
|
|
70
|
+
}
|
|
65
71
|
/* 8px on the fields against the card's 6px is WhiteBox's own combination, not a slip. */
|
|
66
72
|
input{
|
|
67
73
|
width:100%; padding:9px 10px; border:1px solid var(--border-2); border-radius:8px;
|
|
@@ -113,4 +119,82 @@ ${notice}
|
|
|
113
119
|
</body></html>`
|
|
114
120
|
}
|
|
115
121
|
|
|
122
|
+
|
|
123
|
+
// The same page, when signing in is not the answer.
|
|
124
|
+
//
|
|
125
|
+
// A request that names a client the server has never registered cannot be
|
|
126
|
+
// fixed by a password, so it gets an explanation rather than a form. It was a
|
|
127
|
+
// bare `res.send('Unknown client_id')`: seventeen bytes of unstyled text on a
|
|
128
|
+
// white page, reached from a polished consent screen, which reads as a broken
|
|
129
|
+
// deployment rather than as a request that could not be honoured.
|
|
130
|
+
//
|
|
131
|
+
// Not a redirect back to the client, deliberately — RFC 6749 §4.1.2.1 says an
|
|
132
|
+
// unregistered client_id or an unregistered redirect_uri MUST be shown to the
|
|
133
|
+
// user rather than sent to that URI, because the URI is exactly the part that
|
|
134
|
+
// cannot be trusted yet.
|
|
135
|
+
export function messagePage({ appName, logoUrl, title, error, detail }) {
|
|
136
|
+
return `<!doctype html>
|
|
137
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
138
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
139
|
+
<title>${escapeHtml(title)}</title>
|
|
140
|
+
<style>
|
|
141
|
+
/* Values measured off WhiteBox's running console login, not copied from its
|
|
142
|
+
stylesheet fallbacks — its PrimeVue theme overrides --accent, --text and
|
|
143
|
+
--radius at runtime, so the source would have produced a page that looked
|
|
144
|
+
adjacent to it rather than identical.
|
|
145
|
+
Light only, also deliberately: WhiteBox's login stays light under
|
|
146
|
+
prefers-color-scheme: dark, so a dark block here would create exactly the
|
|
147
|
+
mismatch it looks like it prevents. */
|
|
148
|
+
:root{
|
|
149
|
+
--bg:#f1f5f9; --panel:#fff; --border:#e2e8f0; --border-2:#cbd5e1;
|
|
150
|
+
--text:#334155; --text-strong:#0f172a; --accent:#09090b;
|
|
151
|
+
--radius:6px; --shadow:0 6px 18px rgba(15,23,42,.10);
|
|
152
|
+
}
|
|
153
|
+
*{box-sizing:border-box}
|
|
154
|
+
body{
|
|
155
|
+
margin:0; min-height:100vh; display:grid; place-items:center; background:var(--bg);
|
|
156
|
+
color:var(--text); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
|
157
|
+
}
|
|
158
|
+
form,.card{
|
|
159
|
+
width:320px; padding:32px; display:flex; flex-direction:column; align-items:center; gap:10px;
|
|
160
|
+
background:var(--panel); border:1px solid var(--border);
|
|
161
|
+
border-radius:var(--radius); box-shadow:var(--shadow);
|
|
162
|
+
}
|
|
163
|
+
img{width:36px;height:36px;margin-bottom:2px}
|
|
164
|
+
h1{font-size:17px;font-weight:700;margin:0 0 6px;color:var(--text-strong)}
|
|
165
|
+
.sub{margin:-4px 0 4px;font-size:13px;text-align:center;opacity:.8}
|
|
166
|
+
.sub strong{color:var(--text-strong);font-weight:600}
|
|
167
|
+
.err{
|
|
168
|
+
width:100%; margin:0 0 2px; padding:8px 10px; border-radius:8px; font-size:13px;
|
|
169
|
+
background:#fef2f2; border:1px solid #fecaca; color:#b91c1c; text-align:center;
|
|
170
|
+
}
|
|
171
|
+
.msg{margin:0;font-size:13px;text-align:center;opacity:.85}
|
|
172
|
+
.msg code{
|
|
173
|
+
font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
174
|
+
background:#f1f5f9;border:1px solid var(--border);border-radius:4px;padding:1px 4px;
|
|
175
|
+
color:var(--text-strong);word-break:break-all;
|
|
176
|
+
}
|
|
177
|
+
/* 8px on the fields against the card's 6px is WhiteBox's own combination, not a slip. */
|
|
178
|
+
input{
|
|
179
|
+
width:100%; padding:9px 10px; border:1px solid var(--border-2); border-radius:8px;
|
|
180
|
+
font-size:14px; background:var(--panel); color:var(--text);
|
|
181
|
+
}
|
|
182
|
+
input:focus{outline:2px solid color-mix(in srgb,var(--accent) 25%,transparent);border-color:var(--accent)}
|
|
183
|
+
button{
|
|
184
|
+
width:100%; margin-top:6px; padding:9px; border:none; border-radius:8px;
|
|
185
|
+
background:var(--accent); color:#fff; font-size:14px; font-weight:500; cursor:pointer;
|
|
186
|
+
}
|
|
187
|
+
button:hover{opacity:.92}
|
|
188
|
+
button[disabled]{opacity:.65;cursor:default}
|
|
189
|
+
</style>
|
|
190
|
+
</head><body>
|
|
191
|
+
<div class="card">
|
|
192
|
+
<img src="${escapeHtml(logoUrl)}" alt="" onerror="this.remove()">
|
|
193
|
+
<h1>${escapeHtml(title)}${appName ? ` — ${escapeHtml(appName)}` : ''}</h1>
|
|
194
|
+
${error ? `<p class="err">${escapeHtml(error)}</p>` : ''}
|
|
195
|
+
${detail ? `<p class="msg">${detail}</p>` : ''}
|
|
196
|
+
</div>
|
|
197
|
+
</body></html>`
|
|
198
|
+
}
|
|
199
|
+
|
|
116
200
|
export { escapeHtml }
|
package/lib/routes.js
CHANGED
|
@@ -6,7 +6,7 @@ import { jwks, ALG } from './keys.js'
|
|
|
6
6
|
import { issueToken } from './tokens.js'
|
|
7
7
|
import { verifyPkce } from './pkce.js'
|
|
8
8
|
import { redirectUriAllowed, registerDynamicClient, RegistrationError } from './clients.js'
|
|
9
|
-
import { loginPage } from './login-page.js'
|
|
9
|
+
import { loginPage, messagePage, escapeHtml } from './login-page.js'
|
|
10
10
|
import * as grants from './grants.js'
|
|
11
11
|
|
|
12
12
|
const CODE_TTL_SEC = 60
|
|
@@ -87,12 +87,39 @@ export function mountRoutes(router, ctx) {
|
|
|
87
87
|
// error directly rather than redirecting. Redirecting to an unvalidated
|
|
88
88
|
// URI is itself the vulnerability — an open redirect through the
|
|
89
89
|
// authorization endpoint.
|
|
90
|
-
|
|
90
|
+
// Shown, not redirected. RFC 6749 §4.1.2.1: when the client_id or the
|
|
91
|
+
// redirect_uri is not registered, the error goes to the person rather than
|
|
92
|
+
// to that URI — the URI being the part that cannot be trusted yet.
|
|
93
|
+
function refuse(req, res, { error, detail }) {
|
|
94
|
+
res.status(400).type('html').send(messagePage({
|
|
95
|
+
appName: nameOf(req), logoUrl, title: 'Cannot authorise', error, detail,
|
|
96
|
+
}))
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function resolveClient(params, res, req) {
|
|
91
101
|
const client = params.client_id ? await grants.getDynamicClient(params.client_id) : null
|
|
92
|
-
if (!client) {
|
|
102
|
+
if (!client) {
|
|
103
|
+
// Almost always a client_id typed in by hand. Connectors register
|
|
104
|
+
// themselves at /auth/register and are handed one; a value that
|
|
105
|
+
// looks like a person — an email, a username — is a sign the field
|
|
106
|
+
// was filled in where it should have been left empty.
|
|
107
|
+
return refuse(req, res, {
|
|
108
|
+
error: 'Unknown client_id',
|
|
109
|
+
detail: params.client_id
|
|
110
|
+
? `No application is registered as <code>${escapeHtml(params.client_id)}</code>. `
|
|
111
|
+
+ 'Connectors register themselves and are issued an ID, so leave the client ID '
|
|
112
|
+
+ 'field empty and let it do that. Your username and password are asked for on '
|
|
113
|
+
+ 'the next screen, not here.'
|
|
114
|
+
: 'The request did not name a client.',
|
|
115
|
+
})
|
|
116
|
+
}
|
|
93
117
|
if (!redirectUriAllowed(client, params.redirect_uri)) {
|
|
94
|
-
|
|
95
|
-
|
|
118
|
+
return refuse(req, res, {
|
|
119
|
+
error: 'redirect_uri is not registered for this client',
|
|
120
|
+
detail: `<code>${escapeHtml(params.redirect_uri ?? '')}</code> is not one of the `
|
|
121
|
+
+ 'addresses this application registered, so sending you back to it would not be safe.',
|
|
122
|
+
})
|
|
96
123
|
}
|
|
97
124
|
return client
|
|
98
125
|
}
|
|
@@ -119,7 +146,7 @@ export function mountRoutes(router, ctx) {
|
|
|
119
146
|
|
|
120
147
|
router.get('/authorize', async (req, res) => {
|
|
121
148
|
const params = authParams(req.query)
|
|
122
|
-
const client = await resolveClient(params, res)
|
|
149
|
+
const client = await resolveClient(params, res, req)
|
|
123
150
|
if (!client) return
|
|
124
151
|
if (!checkRequest(params, res)) return
|
|
125
152
|
res.type('html').send(loginPage({ params, client, appName: nameOf(req), logoUrl }))
|
|
@@ -127,7 +154,7 @@ export function mountRoutes(router, ctx) {
|
|
|
127
154
|
|
|
128
155
|
router.post('/authorize', async (req, res) => {
|
|
129
156
|
const params = authParams(req.body)
|
|
130
|
-
const client = await resolveClient(params, res)
|
|
157
|
+
const client = await resolveClient(params, res, req)
|
|
131
158
|
if (!client) return
|
|
132
159
|
if (!checkRequest(params, res)) return
|
|
133
160
|
|
|
@@ -168,19 +195,26 @@ export function mountRoutes(router, ctx) {
|
|
|
168
195
|
// ── /token ───────────────────────────────────────────────────────────
|
|
169
196
|
//
|
|
170
197
|
// The ONE place a token's capabilities and row scope are decided, always
|
|
171
|
-
// recomputed from the identity files.
|
|
172
|
-
//
|
|
173
|
-
//
|
|
198
|
+
// recomputed from the identity files. Nothing a client sends can influence
|
|
199
|
+
// what goes in here.
|
|
200
|
+
//
|
|
201
|
+
// The token also carries a STAMP of the identity it was minted from. A
|
|
202
|
+
// gate still reads capabilities from the token — no per-request file read
|
|
203
|
+
// — but it compares that stamp against the current one, so a user who has
|
|
204
|
+
// since been deleted, moved group, or had their group narrowed stops being
|
|
205
|
+
// accepted immediately rather than when the token ages out. Only the users
|
|
206
|
+
// whose own identity changed are affected.
|
|
174
207
|
async function issueTokens(res, { clientId, subject, withRefresh }) {
|
|
175
208
|
const { store, key } = ready()
|
|
176
209
|
const capabilities = await store.capabilitiesOf(subject)
|
|
177
210
|
const scope = await store.scopeOf(subject)
|
|
178
211
|
const roles = await store.groupsOf(subject)
|
|
212
|
+
const stamp = await store.stampOf(subject)
|
|
179
213
|
const issuer = issuerFor(res.req)
|
|
180
214
|
|
|
181
215
|
const accessToken = await issueToken({
|
|
182
216
|
key, issuer, audience: audienceFor(res.req),
|
|
183
|
-
subject, capabilities, scope, roles, ttl,
|
|
217
|
+
subject, capabilities, scope, roles, ttl, stamp,
|
|
184
218
|
})
|
|
185
219
|
|
|
186
220
|
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { explainRefusal, actingRole, runtime } from 'mikser-io'
|
|
2
|
+
import { hasCapability } from 'mikser-io'
|
|
2
3
|
// The two verifiers, both implementing the ADR-0012 contract:
|
|
3
4
|
//
|
|
4
5
|
// { name, verify(req) → null | false | { subject, capabilities }, challenge? }
|
|
@@ -75,7 +76,8 @@ export function basic({ store, realm = 'mikser', logger } = {}) {
|
|
|
75
76
|
// discovery fields are what make an MCP client able to log in unattended:
|
|
76
77
|
// mikser-io-mcp reads them to publish RFC 9728 metadata and to point its
|
|
77
78
|
// 401 challenge at that document.
|
|
78
|
-
export function jwt({ verifyToken, issuer, audience, resource, scopes = [], requiredCapability, logger
|
|
79
|
+
export function jwt({ verifyToken, issuer, audience, resource, scopes = [], requiredCapability, logger,
|
|
80
|
+
currentStamp } = {}) {
|
|
79
81
|
if (!verifyToken) throw new Error('jwt({ verifyToken }) requires a token verifier')
|
|
80
82
|
|
|
81
83
|
// Why the last verify() on THIS request said no.
|
|
@@ -134,7 +136,42 @@ export function jwt({ verifyToken, issuer, audience, resource, scopes = [], requ
|
|
|
134
136
|
}, err.code ?? err.message)
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
|
|
139
|
+
// Has the identity behind this token changed since it was minted?
|
|
140
|
+
//
|
|
141
|
+
// The signature only proves the server issued it; it says nothing
|
|
142
|
+
// about whether the user still exists or still holds what the
|
|
143
|
+
// token claims. Without this a deleted user keeps working until
|
|
144
|
+
// the token expires — an hour by default.
|
|
145
|
+
//
|
|
146
|
+
// 401, not 403: the correct client response is to authenticate
|
|
147
|
+
// again, which will either mint a token with the new capabilities
|
|
148
|
+
// or refuse. 403 would tell a client to give up on a subject that
|
|
149
|
+
// may simply have moved group.
|
|
150
|
+
if (currentStamp && principal.stamp) {
|
|
151
|
+
let now
|
|
152
|
+
try {
|
|
153
|
+
now = await currentStamp(principal.subject)
|
|
154
|
+
} catch (err) {
|
|
155
|
+
// A store that cannot answer must not fail open. It also
|
|
156
|
+
// must not fail every request silently.
|
|
157
|
+
logger?.error?.({ code: 'auth-stamp-unavailable' },
|
|
158
|
+
'auth: could not check whether %s is still current — refusing (%s)',
|
|
159
|
+
principal.subject, err.message)
|
|
160
|
+
return reject(req, { status: 401, code: 'invalid_token',
|
|
161
|
+
description: 'The access token could not be verified against current identity' })
|
|
162
|
+
}
|
|
163
|
+
if (now !== principal.stamp) {
|
|
164
|
+
return reject(req, {
|
|
165
|
+
status: 401,
|
|
166
|
+
code: 'invalid_token',
|
|
167
|
+
description: now === null
|
|
168
|
+
? 'The account this token was issued for no longer exists'
|
|
169
|
+
: 'Access for this account changed — authenticate again',
|
|
170
|
+
}, 'stamp mismatch')
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (requiredCapability && !hasCapability(principal, requiredCapability)) {
|
|
138
175
|
// 403, not 401: the token is perfectly good and a fresh one
|
|
139
176
|
// for the same subject would be refused identically. A client
|
|
140
177
|
// that reads this as an expiry refreshes in a loop.
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-auth",
|
|
3
|
-
"version": "0.
|
|
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
|
|
3
|
+
"version": "0.15.0",
|
|
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 \u2014 api, mcp, forms.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"allowScripts": {
|
|
8
|
+
"better-sqlite3": true
|
|
9
|
+
},
|
|
7
10
|
"scripts": {
|
|
8
11
|
"test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
|
|
9
12
|
},
|
|
@@ -34,6 +37,6 @@
|
|
|
34
37
|
},
|
|
35
38
|
"devDependencies": {
|
|
36
39
|
"express": "^5.2.1",
|
|
37
|
-
"mikser-io": "
|
|
40
|
+
"mikser-io": "^10.0.0"
|
|
38
41
|
}
|
|
39
42
|
}
|
package/test/authorize.test.js
CHANGED
|
@@ -685,3 +685,69 @@ describe('unattended renewal is granted out loud, not just made possible', () =>
|
|
|
685
685
|
assert.ok(second.scope.split(' ').includes('offline_access'))
|
|
686
686
|
})
|
|
687
687
|
})
|
|
688
|
+
|
|
689
|
+
// A request that cannot be honoured still lands on a page, not on bare text.
|
|
690
|
+
//
|
|
691
|
+
// `res.send('Unknown client_id')` is seventeen bytes of unstyled text on a
|
|
692
|
+
// white background, reached from a polished consent screen. It reads as a
|
|
693
|
+
// broken deployment rather than as a request that could not be served, and it
|
|
694
|
+
// says nothing about what to do — which matters most here, because the
|
|
695
|
+
// commonest cause is a client ID typed into a field that should have been left
|
|
696
|
+
// empty: connectors register themselves at /auth/register and are issued one.
|
|
697
|
+
//
|
|
698
|
+
// Not a redirect, deliberately. RFC 6749 §4.1.2.1: an unregistered client_id
|
|
699
|
+
// or redirect_uri is shown to the person rather than sent to that URI, the URI
|
|
700
|
+
// being the part that cannot be trusted yet.
|
|
701
|
+
describe('a request that cannot be authorised', () => {
|
|
702
|
+
const looksLikeTheLoginPage = (html) => {
|
|
703
|
+
// Same shell, same tokens, same card — asserted rather than eyeballed,
|
|
704
|
+
// because "make it look like the login page" is only true while both
|
|
705
|
+
// are rendered from the one style block.
|
|
706
|
+
assert.match(html, /<!doctype html>/i)
|
|
707
|
+
assert.match(html, /--accent:#09090b/, 'shares the login page palette')
|
|
708
|
+
assert.match(html, /width:320px/, 'shares the card geometry')
|
|
709
|
+
assert.match(html, /class="err"/, 'shares the error treatment')
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
it('renders an unknown client_id as a page, naming what was sent', async () => {
|
|
713
|
+
const res = await fetch(authorizeUrl(pkcePair().challenge, { client_id: 'dk@almero.bg' }))
|
|
714
|
+
assert.equal(res.status, 400)
|
|
715
|
+
assert.match(res.headers.get('content-type') ?? '', /text\/html/)
|
|
716
|
+
const html = await res.text()
|
|
717
|
+
looksLikeTheLoginPage(html)
|
|
718
|
+
assert.match(html, /Unknown client_id/)
|
|
719
|
+
assert.match(html, /dk@almero\.bg/, 'says which id was not found')
|
|
720
|
+
assert.match(html, /leave the client ID/i, 'and what to do instead')
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
it('does not redirect an unregistered client back to its redirect_uri', async () => {
|
|
724
|
+
// The URI is the untrusted part; bouncing an error to it hands an
|
|
725
|
+
// attacker a redirector.
|
|
726
|
+
const res = await fetch(authorizeUrl(pkcePair().challenge, {
|
|
727
|
+
client_id: 'nope', redirect_uri: 'https://example.invalid/cb',
|
|
728
|
+
}), { redirect: 'manual' })
|
|
729
|
+
assert.equal(res.status, 400)
|
|
730
|
+
assert.equal(res.headers.get('location'), null)
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
it('renders an unregistered redirect_uri as a page too', async () => {
|
|
734
|
+
const res = await fetch(authorizeUrl(pkcePair().challenge, {
|
|
735
|
+
redirect_uri: 'https://example.invalid/somewhere-else',
|
|
736
|
+
}))
|
|
737
|
+
assert.equal(res.status, 400)
|
|
738
|
+
const html = await res.text()
|
|
739
|
+
looksLikeTheLoginPage(html)
|
|
740
|
+
assert.match(html, /redirect_uri is not registered/)
|
|
741
|
+
assert.match(html, /example\.invalid/)
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
it('escapes what it echoes back', async () => {
|
|
745
|
+
// Everything on this page came from the query string.
|
|
746
|
+
const res = await fetch(authorizeUrl(pkcePair().challenge, {
|
|
747
|
+
client_id: '<script>alert(1)</script>',
|
|
748
|
+
}))
|
|
749
|
+
const html = await res.text()
|
|
750
|
+
assert.doesNotMatch(html, /<script>alert\(1\)<\/script>/)
|
|
751
|
+
assert.match(html, /<script>/)
|
|
752
|
+
})
|
|
753
|
+
})
|
|
@@ -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
|
+
})
|