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 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
- Keep `auth.key` out of version control losing it invalidates every issued
135
- token; leaking it lets anyone mint one.
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
- // Published so any transport can say which role is acting and
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
- runtime.options.roles = {
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.11.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.",
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": "^9.57.0"
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
+ })