mikser-io-auth 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/index.js +20 -5
- package/lib/grants.js +18 -0
- package/lib/keys.js +58 -0
- package/package.json +3 -3
- package/test/signing-key.test.js +105 -0
package/README.md
CHANGED
|
@@ -131,8 +131,33 @@ lazily; plugin order doesn't matter, and forgetting to add `identity` to
|
|
|
131
131
|
auth.key {"kid":…,"privateJwk":…} generated on first run, 0600
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
`auth.key` is ONE key for the whole deployment, not one per user — every token
|
|
135
|
+
for every subject is signed with it. It is written `0600` on first run and
|
|
136
|
+
never rewritten.
|
|
137
|
+
|
|
138
|
+
**Back it up.** Nothing can reproduce it. If it goes missing, the next start
|
|
139
|
+
generates a new one and everyone who was signed in has to authorise again — and
|
|
140
|
+
mikser says so rather than leaving you to infer it from a wave of 401s: the
|
|
141
|
+
`kid` is recorded in the durable store, and one that no longer matches raises
|
|
142
|
+
the `auth-signing-key-changed` fault, which appears in `mikser_ping`. Restoring
|
|
143
|
+
the file from backup is what brings those sessions back; leaving it means every
|
|
144
|
+
client re-registers.
|
|
145
|
+
|
|
146
|
+
Whether to commit it is **your call, and mikser does not touch your
|
|
147
|
+
`.gitignore`**. In a private repo, committing it is a legitimate backup for the
|
|
148
|
+
one file nothing else can reproduce, and it is written once so it adds no churn.
|
|
149
|
+
Against that, git history does not forget: if the repo's audience ever widens,
|
|
150
|
+
rotating the key does not remove the old one from history. Weigh those for your
|
|
151
|
+
own setup — the engine is in no position to.
|
|
152
|
+
|
|
153
|
+
(The engine does gitignore its own `mikser.data.sqlite`, for a different reason
|
|
154
|
+
that holds regardless: that file is rewritten on every write, so committing it
|
|
155
|
+
means a binary diff and a conflict every time.)
|
|
156
|
+
|
|
157
|
+
Keep it a file. A single instance is safer with the key on disk, where it never
|
|
158
|
+
leaves the box. Only a multi-instance deployment behind a load balancer needs it
|
|
159
|
+
shared — instances signing with different keys reject each other's tokens, which
|
|
160
|
+
from a client looks exactly like random expiry.
|
|
136
161
|
|
|
137
162
|
### Endpoints
|
|
138
163
|
|
package/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import { registerRoute, anyOf } from 'mikser-io'
|
|
2
|
+
import { registerRoute, anyOf, useDurableDatabase, provideService } from 'mikser-io'
|
|
3
3
|
|
|
4
4
|
import { createIdentityStore, parseHtpasswd, parseHtgroup, verifyPassword } from './lib/htpasswd.js'
|
|
5
|
-
import { loadOrCreateKey, jwks, ALG } from './lib/keys.js'
|
|
5
|
+
import { loadOrCreateKey, checkKeyContinuity, jwks, ALG } from './lib/keys.js'
|
|
6
6
|
import { issueToken, createTokenVerifier } from './lib/tokens.js'
|
|
7
7
|
import { basic, jwt } from './lib/verifiers.js'
|
|
8
8
|
import { redirectUriAllowed, validateRedirectUri, registerDynamicClient } from './lib/clients.js'
|
|
@@ -86,16 +86,31 @@ export function auth(options = {}) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
const plugin = ({ runtime, onLoad, onLoaded, useLogger }) => {
|
|
89
|
+
// Deliberately NOT in onLoad below.
|
|
90
|
+
//
|
|
91
|
+
// The durable store opens at onLoaded, so this check ran against no
|
|
92
|
+
// database at all and returned null — which the call site ignored.
|
|
93
|
+
// A guard against a silent failure that fails silently itself is
|
|
94
|
+
// worse than none: it reads as covered.
|
|
95
|
+
onLoaded(async () => {
|
|
96
|
+
if (!state?.key?.kid) return
|
|
97
|
+
await checkKeyContinuity({ kid: state.key.kid, db: useDurableDatabase(), logger: state.logger })
|
|
98
|
+
})
|
|
99
|
+
|
|
89
100
|
onLoad(async () => {
|
|
90
101
|
const logger = useLogger()
|
|
91
|
-
//
|
|
102
|
+
// Offered so any transport can say which role is acting and
|
|
92
103
|
// which others exist. The catalogue is not secret — naming the
|
|
93
104
|
// role that could do something is what makes a handoff possible,
|
|
94
105
|
// and it reveals nothing about how to obtain one.
|
|
95
|
-
|
|
106
|
+
//
|
|
107
|
+
// A service rather than runtime.options.roles: mikser-io-mcp is
|
|
108
|
+
// the consumer, and it should not have to know this package
|
|
109
|
+
// exists to ask what roles there are.
|
|
110
|
+
provideService('roles', {
|
|
96
111
|
catalogue: capabilities,
|
|
97
112
|
summaries: roleSummaries,
|
|
98
|
-
}
|
|
113
|
+
}, { plugin: 'mikser-io-auth' })
|
|
99
114
|
|
|
100
115
|
const workingFolder = runtime.options.workingFolder
|
|
101
116
|
const resolve = (f) => (path.isAbsolute(f) ? f : path.join(workingFolder, f))
|
package/lib/grants.js
CHANGED
|
@@ -56,6 +56,24 @@ registerMigrations('auth', [
|
|
|
56
56
|
})
|
|
57
57
|
},
|
|
58
58
|
},
|
|
59
|
+
{
|
|
60
|
+
// Which signing key was in force. A history rather than one row: when
|
|
61
|
+
// it changes, the useful sentence names both, and "was X, now Y" is
|
|
62
|
+
// what tells an operator whether they replaced it on purpose.
|
|
63
|
+
//
|
|
64
|
+
// Appended as its own migration rather than edited into 001 — a
|
|
65
|
+
// migration that has run is permanent, and changing it would mean it
|
|
66
|
+
// never runs against the databases that already applied it.
|
|
67
|
+
name: '002-signing-key',
|
|
68
|
+
up: async (knex) => {
|
|
69
|
+
await knex.schema.createTable('mikser_auth_signing_key', (table) => {
|
|
70
|
+
table.string('kid').primary()
|
|
71
|
+
table.string('alg').notNullable()
|
|
72
|
+
table.bigInteger('recorded_at').notNullable()
|
|
73
|
+
table.index(['recorded_at'], 'idx_mikser_auth_signing_key_recorded')
|
|
74
|
+
})
|
|
75
|
+
},
|
|
76
|
+
},
|
|
59
77
|
])
|
|
60
78
|
|
|
61
79
|
const db = () => useDurableDatabase()
|
package/lib/keys.js
CHANGED
|
@@ -11,6 +11,18 @@ import { generateKeyPair, exportJWK, importJWK, calculateJwkThumbprint } from 'j
|
|
|
11
11
|
// survive a restart: regenerating on boot silently invalidates every token
|
|
12
12
|
// the moment the process cycles, which looks exactly like an intermittent
|
|
13
13
|
// auth bug and is miserable to diagnose.
|
|
14
|
+
//
|
|
15
|
+
// Deliberately NOT added to .gitignore, unlike the engine's durable database.
|
|
16
|
+
// That file is rewritten on every write, so committing it means a binary diff
|
|
17
|
+
// and a conflict every time — a reason that holds whatever it contains. This
|
|
18
|
+
// one is written once and never again, so there is no such reason, and what
|
|
19
|
+
// would be left is a guess about the operator's threat model that the engine
|
|
20
|
+
// is in no position to make. Committing it to a private repo is a legitimate
|
|
21
|
+
// choice and a real backup; keeping it out is also legitimate. What is not
|
|
22
|
+
// legitimate is mikser editing a .gitignore to enforce either.
|
|
23
|
+
//
|
|
24
|
+
// Losing it is the failure that actually happens — a rebuilt container, an
|
|
25
|
+
// `rm -rf` — and checkKeyContinuity below is what makes that sayable.
|
|
14
26
|
export const ALG = 'ES256'
|
|
15
27
|
|
|
16
28
|
export async function loadOrCreateKey({ keyFile, logger }) {
|
|
@@ -51,6 +63,52 @@ export async function loadOrCreateKey({ keyFile, logger }) {
|
|
|
51
63
|
return { privateKey, publicKey, publicJwk, kid }
|
|
52
64
|
}
|
|
53
65
|
|
|
66
|
+
// Has the signing key changed since last time?
|
|
67
|
+
//
|
|
68
|
+
// Creating a key logs a warning either way, and on a first run that is routine
|
|
69
|
+
// — so the alarming case and the normal one produce the same line, and the
|
|
70
|
+
// difference only shows up later as every agent being asked to authorise
|
|
71
|
+
// again. That is the shape of failure this whole codebase keeps finding: two
|
|
72
|
+
// states, one output.
|
|
73
|
+
//
|
|
74
|
+
// The kid is recorded in the durable store, which survives the cache wipe that
|
|
75
|
+
// the key file survives, so the two stay in step. A recorded kid that no
|
|
76
|
+
// longer matches means the file was replaced or lost — and every access and
|
|
77
|
+
// refresh token ever issued under the old one is now unverifiable.
|
|
78
|
+
//
|
|
79
|
+
// Returns null when there is nothing to compare against, which is not the same
|
|
80
|
+
// as "unchanged" and must not be reported as such.
|
|
81
|
+
export async function checkKeyContinuity({ kid, db, logger }) {
|
|
82
|
+
if (!db || !kid) return null
|
|
83
|
+
try {
|
|
84
|
+
const [previous] = await db(SIGNING_KEY_TABLE).orderBy('recorded_at', 'desc').limit(1)
|
|
85
|
+
|
|
86
|
+
if (!previous) {
|
|
87
|
+
// First run, or the first boot after this check was added. Either
|
|
88
|
+
// way there is no prior claim to contradict.
|
|
89
|
+
await db(SIGNING_KEY_TABLE).insert({ kid, alg: ALG, recorded_at: Date.now() })
|
|
90
|
+
return { recorded: true }
|
|
91
|
+
}
|
|
92
|
+
if (previous.kid === kid) return { unchanged: true }
|
|
93
|
+
|
|
94
|
+
await db(SIGNING_KEY_TABLE).insert({ kid, alg: ALG, recorded_at: Date.now() })
|
|
95
|
+
logger?.error?.({ code: 'auth-signing-key-changed' },
|
|
96
|
+
'The signing key changed (was %s, now %s). Every access and refresh token issued under the old key '
|
|
97
|
+
+ 'is unverifiable, so everyone who was signed in must authorise again. If %s was not replaced on '
|
|
98
|
+
+ 'purpose it was LOST — restoring it from backup is what brings those sessions back; leaving it '
|
|
99
|
+
+ 'means every client re-registers.',
|
|
100
|
+
previous.kid, kid, 'auth.key')
|
|
101
|
+
return { changed: true, previous: previous.kid }
|
|
102
|
+
} catch (err) {
|
|
103
|
+
logger?.error?.({ code: 'auth-signing-key-changed' },
|
|
104
|
+
'Could not check whether the signing key changed: %s. A silently replaced key looks exactly like '
|
|
105
|
+
+ 'tokens expiring at random, so this check not running is worth knowing about.', err.message)
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const SIGNING_KEY_TABLE = 'mikser_auth_signing_key'
|
|
111
|
+
|
|
54
112
|
// The JWKS document an OAuth client fetches to verify our tokens. Public
|
|
55
113
|
// half only — if a private component ever appears here, that is the whole
|
|
56
114
|
// system compromised, so it is asserted rather than trusted.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.13.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 — api, mcp, forms.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"homepage": "https://github.com/almero-digital-marketing/mikser-io-auth#readme",
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"mikser-io": "^
|
|
29
|
+
"mikser-io": "^10.0.0"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"bcryptjs": "^3.0.0",
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The signing key: kept out of the repo, and never replaced quietly.
|
|
2
|
+
//
|
|
3
|
+
// It is ONE key for the whole deployment — every token for every user is
|
|
4
|
+
// signed with it. Losing it invalidates all of them at once; leaking it lets
|
|
5
|
+
// anyone mint one for any subject. Both failures are silent by default, which
|
|
6
|
+
// is what these two guards are for.
|
|
7
|
+
|
|
8
|
+
import { describe, it, beforeEach, afterEach } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { mkdtemp, rm, mkdir, readFile, writeFile, stat } from 'node:fs/promises'
|
|
11
|
+
import { existsSync } from 'node:fs'
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import knexFactory from 'knex'
|
|
15
|
+
|
|
16
|
+
import { runMigrations } from 'mikser-io'
|
|
17
|
+
import { loadOrCreateKey, checkKeyContinuity } from '../lib/keys.js'
|
|
18
|
+
import '../lib/grants.js' // registers the auth migrations
|
|
19
|
+
|
|
20
|
+
let dir, db
|
|
21
|
+
const quiet = { warn() {}, info() {}, notice() {}, error() {} }
|
|
22
|
+
const loud = () => { const seen = []; return { warn() {}, info() {}, notice() {}, error: (o) => seen.push(o), seen } }
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
dir = await mkdtemp(path.join(tmpdir(), 'mikser-key-'))
|
|
26
|
+
db = knexFactory({ client: 'better-sqlite3', connection: { filename: path.join(dir, 'd.sqlite') }, useNullAsDefault: true })
|
|
27
|
+
await runMigrations(db, quiet)
|
|
28
|
+
})
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await db.destroy()
|
|
31
|
+
await rm(dir, { recursive: true, force: true })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const keyFile = () => path.join(dir, 'auth.key')
|
|
35
|
+
const gitignore = () => path.join(dir, '.gitignore')
|
|
36
|
+
|
|
37
|
+
describe('writing the key', () => {
|
|
38
|
+
it('writes it 0600', async () => {
|
|
39
|
+
await loadOrCreateKey({ keyFile: keyFile(), logger: quiet })
|
|
40
|
+
assert.equal((await stat(keyFile())).mode & 0o777, 0o600)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('leaves .gitignore alone', async () => {
|
|
44
|
+
// Whether this file is committed is the operator's call: it is written
|
|
45
|
+
// once, so there is no churn argument, and a private repo is a real
|
|
46
|
+
// backup for the one thing nothing else can reproduce. The engine
|
|
47
|
+
// ignores its own database because that one is rewritten constantly —
|
|
48
|
+
// a reason that does not transfer.
|
|
49
|
+
await mkdir(path.join(dir, '.git'), { recursive: true })
|
|
50
|
+
await loadOrCreateKey({ keyFile: keyFile(), logger: quiet })
|
|
51
|
+
assert.equal(existsSync(gitignore()), false)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('noticing that the key changed', () => {
|
|
56
|
+
it('records the kid on a first run without complaining', async () => {
|
|
57
|
+
const key = await loadOrCreateKey({ keyFile: keyFile(), logger: quiet })
|
|
58
|
+
const logger = loud()
|
|
59
|
+
const result = await checkKeyContinuity({ kid: key.kid, db, logger })
|
|
60
|
+
assert.deepEqual(result, { recorded: true })
|
|
61
|
+
assert.deepEqual(logger.seen, [], 'a first run is routine and must be silent')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('says nothing on a restart with the same key', async () => {
|
|
65
|
+
const key = await loadOrCreateKey({ keyFile: keyFile(), logger: quiet })
|
|
66
|
+
await checkKeyContinuity({ kid: key.kid, db, logger: quiet })
|
|
67
|
+
const logger = loud()
|
|
68
|
+
assert.deepEqual(await checkKeyContinuity({ kid: key.kid, db, logger }), { unchanged: true })
|
|
69
|
+
assert.deepEqual(logger.seen, [])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('raises a fault when the key was replaced', async () => {
|
|
73
|
+
// The case that matters: auth.key deleted, regenerated on the next
|
|
74
|
+
// boot. Every token anyone holds is now unverifiable, and the creation
|
|
75
|
+
// warning alone reads exactly like a first run.
|
|
76
|
+
const first = await loadOrCreateKey({ keyFile: keyFile(), logger: quiet })
|
|
77
|
+
await checkKeyContinuity({ kid: first.kid, db, logger: quiet })
|
|
78
|
+
|
|
79
|
+
await rm(keyFile())
|
|
80
|
+
const second = await loadOrCreateKey({ keyFile: keyFile(), logger: quiet })
|
|
81
|
+
assert.notEqual(second.kid, first.kid, 'a regenerated key is a different key')
|
|
82
|
+
|
|
83
|
+
const logger = loud()
|
|
84
|
+
const result = await checkKeyContinuity({ kid: second.kid, db, logger })
|
|
85
|
+
assert.equal(result.changed, true)
|
|
86
|
+
assert.equal(result.previous, first.kid)
|
|
87
|
+
assert.equal(logger.seen[0]?.code, 'auth-signing-key-changed',
|
|
88
|
+
'it has to be a fault, or ping cannot say why everyone was signed out')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('reports null rather than "unchanged" with no store to compare against', async () => {
|
|
92
|
+
// Absence of a comparison is not evidence the key is the same, and
|
|
93
|
+
// returning `unchanged` here would be inventing the reassurance.
|
|
94
|
+
const key = await loadOrCreateKey({ keyFile: keyFile(), logger: quiet })
|
|
95
|
+
assert.equal(await checkKeyContinuity({ kid: key.kid, db: null, logger: quiet }), null)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('is a fault when the check itself cannot run', async () => {
|
|
99
|
+
const key = await loadOrCreateKey({ keyFile: keyFile(), logger: quiet })
|
|
100
|
+
await db.schema.dropTable('mikser_auth_signing_key')
|
|
101
|
+
const logger = loud()
|
|
102
|
+
assert.equal(await checkKeyContinuity({ kid: key.kid, db, logger }), null)
|
|
103
|
+
assert.equal(logger.seen[0]?.code, 'auth-signing-key-changed')
|
|
104
|
+
})
|
|
105
|
+
})
|