mikser-io-auth 0.10.1 → 0.12.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 +15 -4
- package/lib/clients.js +2 -2
- package/lib/grants.js +114 -111
- package/lib/keys.js +58 -0
- package/lib/routes.js +15 -15
- package/package.json +3 -3
- package/test/authorize.test.js +3 -1
- package/test/clients.test.js +19 -19
- 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 } 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,6 +86,17 @@ 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
|
// Published so any transport can say which role is acting and
|
|
@@ -200,7 +211,7 @@ export function auth(options = {}) {
|
|
|
200
211
|
// for a build tool — the checks that matter (expiry, single use)
|
|
201
212
|
// are enforced on read, not by the sweep.
|
|
202
213
|
try {
|
|
203
|
-
const swept = grants.sweepExpired()
|
|
214
|
+
const swept = await grants.sweepExpired()
|
|
204
215
|
if (swept.codes || swept.refresh) {
|
|
205
216
|
logger?.debug?.('auth: swept %d expired code(s), %d refresh token(s)',
|
|
206
217
|
swept.codes, swept.refresh)
|
|
@@ -211,7 +222,7 @@ export function auth(options = {}) {
|
|
|
211
222
|
// signed in with are dropped; a config-declared client is
|
|
212
223
|
// never touched, because it lives in config, not this table.
|
|
213
224
|
if (pruneClientsAfterDays) {
|
|
214
|
-
const pruned = grants.pruneUnusedClients({
|
|
225
|
+
const pruned = await grants.pruneUnusedClients({
|
|
215
226
|
olderThanMs: pruneClientsAfterDays * 24 * 60 * 60 * 1000,
|
|
216
227
|
})
|
|
217
228
|
if (pruned) logger?.info?.('auth: pruned %d unused client registration(s)', pruned)
|
package/lib/clients.js
CHANGED
|
@@ -82,7 +82,7 @@ export function validateRedirectUri(value) {
|
|
|
82
82
|
// What is at risk is table volume, not access. `maxClients` bounds it in the
|
|
83
83
|
// durable place — a rate limiter lives in process memory and does not survive
|
|
84
84
|
// a restart, while rows do.
|
|
85
|
-
export function registerDynamicClient({ name, redirectUris, maxClients, store }) {
|
|
85
|
+
export async function registerDynamicClient({ name, redirectUris, maxClients, store }) {
|
|
86
86
|
if (!Array.isArray(redirectUris) || !redirectUris.length) {
|
|
87
87
|
throw new RegistrationError('invalid_redirect_uri', 'redirect_uris must be a non-empty array')
|
|
88
88
|
}
|
|
@@ -96,7 +96,7 @@ export function registerDynamicClient({ name, redirectUris, maxClients, store })
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
if (maxClients != null && store.countDynamicClients() >= maxClients) {
|
|
99
|
+
if (maxClients != null && await store.countDynamicClients() >= maxClients) {
|
|
100
100
|
throw new RegistrationError('invalid_client_metadata',
|
|
101
101
|
'this server is not accepting new client registrations right now')
|
|
102
102
|
}
|
package/lib/grants.js
CHANGED
|
@@ -1,117 +1,124 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { registerMigrations, useDurableDatabase } from 'mikser-io'
|
|
2
2
|
import { opaqueToken } from './pkce.js'
|
|
3
3
|
|
|
4
4
|
// Authorization codes and refresh tokens — the only server-side state this
|
|
5
5
|
// package keeps. Identity stays in files (ADR-0012); this is session
|
|
6
|
-
// bookkeeping
|
|
7
|
-
//
|
|
6
|
+
// bookkeeping.
|
|
7
|
+
//
|
|
8
|
+
// DURABLE, which since 9.56 means a database of its own rather than a flag on
|
|
9
|
+
// a table in the cache. A registered OAuth client and its refresh token exist
|
|
10
|
+
// only because a human completed a sign-in once, and no file can reproduce
|
|
11
|
+
// them — so they live in `mikser.data.sqlite`, which the cache wipe cannot
|
|
12
|
+
// reach and `--clear` does not touch. Upgrading mikser no longer signs
|
|
13
|
+
// everyone out.
|
|
14
|
+
//
|
|
15
|
+
// Migrations rather than an idempotent CREATE, because a durable table is the
|
|
16
|
+
// only kind that is never recreated: `CREATE TABLE IF NOT EXISTS` can never
|
|
17
|
+
// give it a column it has grown, and it goes stale silently.
|
|
8
18
|
//
|
|
9
19
|
// Table prefix follows the cross-repo convention: `mikser-io-auth` →
|
|
10
20
|
// `mikser_auth_*`.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
21
|
+
registerMigrations('auth', [
|
|
22
|
+
{
|
|
23
|
+
name: '001-grants',
|
|
24
|
+
up: async (knex) => {
|
|
25
|
+
await knex.schema.createTable('mikser_auth_codes', (table) => {
|
|
26
|
+
table.string('code').primary()
|
|
27
|
+
table.string('client_id').notNullable()
|
|
28
|
+
table.string('subject').notNullable()
|
|
29
|
+
table.text('redirect_uri').notNullable()
|
|
30
|
+
table.text('code_challenge').notNullable()
|
|
31
|
+
table.text('scope').notNullable().defaultTo('')
|
|
32
|
+
table.bigInteger('expires_at').notNullable()
|
|
33
|
+
table.bigInteger('used_at')
|
|
34
|
+
table.index(['expires_at'], 'idx_mikser_auth_codes_expiry')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
await knex.schema.createTable('mikser_auth_refresh', (table) => {
|
|
38
|
+
table.string('token').primary()
|
|
39
|
+
table.string('client_id').notNullable()
|
|
40
|
+
table.string('subject').notNullable()
|
|
41
|
+
table.bigInteger('expires_at').notNullable()
|
|
42
|
+
table.bigInteger('revoked_at')
|
|
43
|
+
table.index(['subject'], 'idx_mikser_auth_refresh_subject')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Self-registered clients (RFC 7591). Config-declared clients are
|
|
47
|
+
// NOT here: they live in config, are not prunable, and always win
|
|
48
|
+
// a lookup. Keeping the two apart is what makes pruning safe — an
|
|
49
|
+
// operator's client that has not been used yet is not garbage.
|
|
50
|
+
await knex.schema.createTable('mikser_auth_clients', (table) => {
|
|
51
|
+
table.string('client_id').primary()
|
|
52
|
+
table.string('name').notNullable()
|
|
53
|
+
table.text('redirect_uris').notNullable()
|
|
54
|
+
table.bigInteger('created_at').notNullable()
|
|
55
|
+
table.bigInteger('last_used_at')
|
|
56
|
+
})
|
|
57
|
+
},
|
|
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
|
+
},
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
const db = () => useDurableDatabase()
|
|
80
|
+
|
|
81
|
+
export async function createCode({ clientId, subject, redirectUri, codeChallenge, scope, ttlSec = 60 }) {
|
|
71
82
|
const code = opaqueToken()
|
|
72
|
-
db().
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
`).run(code, clientId, subject, redirectUri, codeChallenge, scope ?? '', Date.now() + ttlSec * 1000)
|
|
83
|
+
await db()('mikser_auth_codes').insert({
|
|
84
|
+
code, client_id: clientId, subject, redirect_uri: redirectUri,
|
|
85
|
+
code_challenge: codeChallenge, scope: scope ?? '', expires_at: Date.now() + ttlSec * 1000,
|
|
86
|
+
})
|
|
77
87
|
return code
|
|
78
88
|
}
|
|
79
89
|
|
|
80
|
-
export function getCode(code) {
|
|
81
|
-
return db()
|
|
90
|
+
export async function getCode(code) {
|
|
91
|
+
return db()('mikser_auth_codes').where({ code }).first()
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
// Single-use, enforced by the UPDATE's own WHERE rather than by a read
|
|
85
95
|
// followed by a write: two simultaneous redemptions of the same code both
|
|
86
96
|
// pass a prior SELECT, and only one can win this.
|
|
87
|
-
export function redeemCode(code) {
|
|
88
|
-
const
|
|
89
|
-
.
|
|
90
|
-
|
|
91
|
-
return result.changes === 1
|
|
97
|
+
export async function redeemCode(code) {
|
|
98
|
+
const changed = await db()('mikser_auth_codes')
|
|
99
|
+
.where({ code }).whereNull('used_at').update({ used_at: Date.now() })
|
|
100
|
+
return changed === 1
|
|
92
101
|
}
|
|
93
102
|
|
|
94
|
-
export function createRefreshToken({ clientId, subject, ttlSec }) {
|
|
103
|
+
export async function createRefreshToken({ clientId, subject, ttlSec }) {
|
|
95
104
|
const token = opaqueToken()
|
|
96
|
-
db().
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
`).run(token, clientId, subject, Date.now() + ttlSec * 1000)
|
|
105
|
+
await db()('mikser_auth_refresh').insert({
|
|
106
|
+
token, client_id: clientId, subject, expires_at: Date.now() + ttlSec * 1000,
|
|
107
|
+
})
|
|
100
108
|
return token
|
|
101
109
|
}
|
|
102
110
|
|
|
103
|
-
export function getRefreshToken(token) {
|
|
104
|
-
return db()
|
|
111
|
+
export async function getRefreshToken(token) {
|
|
112
|
+
return db()('mikser_auth_refresh').where({ token }).first()
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
// Same race-safe shape as redeemCode. Rotation revokes BEFORE minting the
|
|
108
116
|
// replacement, so losing the race means creating nothing at all rather than
|
|
109
117
|
// leaving a valid token nobody holds.
|
|
110
|
-
export function revokeRefreshToken(token) {
|
|
111
|
-
const
|
|
112
|
-
.
|
|
113
|
-
|
|
114
|
-
return result.changes === 1
|
|
118
|
+
export async function revokeRefreshToken(token) {
|
|
119
|
+
const changed = await db()('mikser_auth_refresh')
|
|
120
|
+
.where({ token }).whereNull('revoked_at').update({ revoked_at: Date.now() })
|
|
121
|
+
return changed === 1
|
|
115
122
|
}
|
|
116
123
|
|
|
117
124
|
// Everything a subject holds, for a sign-out-everywhere. Also what an
|
|
@@ -119,36 +126,35 @@ export function revokeRefreshToken(token) {
|
|
|
119
126
|
// except that revoking here is immediate, where an htpasswd edit only
|
|
120
127
|
// stops the NEXT login and leaves live access tokens valid until they
|
|
121
128
|
// expire.
|
|
122
|
-
export function revokeAllForSubject(subject) {
|
|
123
|
-
return db()
|
|
124
|
-
.
|
|
125
|
-
.run(Date.now(), subject).changes
|
|
129
|
+
export async function revokeAllForSubject(subject) {
|
|
130
|
+
return db()('mikser_auth_refresh')
|
|
131
|
+
.where({ subject }).whereNull('revoked_at').update({ revoked_at: Date.now() })
|
|
126
132
|
}
|
|
127
133
|
|
|
128
|
-
export function sweepExpired() {
|
|
134
|
+
export async function sweepExpired() {
|
|
129
135
|
const now = Date.now()
|
|
130
|
-
const codes = db().
|
|
131
|
-
const refresh = db().
|
|
136
|
+
const codes = await db()('mikser_auth_codes').where('expires_at', '<', now - 60_000).delete()
|
|
137
|
+
const refresh = await db()('mikser_auth_refresh').where('expires_at', '<', now).delete()
|
|
132
138
|
return { codes, refresh }
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
// ── self-registered clients ─────────────────────────────────────────────
|
|
136
142
|
|
|
137
|
-
export function countDynamicClients() {
|
|
138
|
-
|
|
143
|
+
export async function countDynamicClients() {
|
|
144
|
+
const [{ n }] = await db()('mikser_auth_clients').count({ n: '*' })
|
|
145
|
+
return Number(n)
|
|
139
146
|
}
|
|
140
147
|
|
|
141
|
-
export function insertDynamicClient({ clientId, name, redirectUris }) {
|
|
148
|
+
export async function insertDynamicClient({ clientId, name, redirectUris }) {
|
|
142
149
|
const createdAt = Date.now()
|
|
143
|
-
db().
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
`).run(clientId, name, JSON.stringify(redirectUris), createdAt)
|
|
150
|
+
await db()('mikser_auth_clients').insert({
|
|
151
|
+
client_id: clientId, name, redirect_uris: JSON.stringify(redirectUris), created_at: createdAt,
|
|
152
|
+
})
|
|
147
153
|
return { clientId, name, redirectUris, createdAt }
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
export function getDynamicClient(clientId) {
|
|
151
|
-
const row = db()
|
|
156
|
+
export async function getDynamicClient(clientId) {
|
|
157
|
+
const row = await db()('mikser_auth_clients').where({ client_id: clientId }).first()
|
|
152
158
|
if (!row) return null
|
|
153
159
|
return {
|
|
154
160
|
clientId: row.client_id,
|
|
@@ -160,17 +166,14 @@ export function getDynamicClient(clientId) {
|
|
|
160
166
|
}
|
|
161
167
|
}
|
|
162
168
|
|
|
163
|
-
export function touchClient(clientId) {
|
|
164
|
-
db()
|
|
165
|
-
.run(Date.now(), clientId)
|
|
169
|
+
export async function touchClient(clientId) {
|
|
170
|
+
await db()('mikser_auth_clients').where({ client_id: clientId }).update({ last_used_at: Date.now() })
|
|
166
171
|
}
|
|
167
172
|
|
|
168
173
|
// DCR has no "get or create" — every registration mints a NEW client_id, so
|
|
169
174
|
// a reinstall, a cleared cache or a second machine each leave another row
|
|
170
175
|
// behind. Prune the ones nobody ever signed in with.
|
|
171
|
-
export function pruneUnusedClients({ olderThanMs }) {
|
|
172
|
-
return db()
|
|
173
|
-
|
|
174
|
-
WHERE last_used_at IS NULL AND created_at < ?
|
|
175
|
-
`).run(Date.now() - olderThanMs).changes
|
|
176
|
+
export async function pruneUnusedClients({ olderThanMs }) {
|
|
177
|
+
return db()('mikser_auth_clients')
|
|
178
|
+
.whereNull('last_used_at').where('created_at', '<', Date.now() - olderThanMs).delete()
|
|
176
179
|
}
|
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/lib/routes.js
CHANGED
|
@@ -75,7 +75,7 @@ export function mountRoutes(router, ctx) {
|
|
|
75
75
|
})
|
|
76
76
|
|
|
77
77
|
// ── discovery ────────────────────────────────────────────────────────
|
|
78
|
-
router.get('/jwks.json', (req, res) => {
|
|
78
|
+
router.get('/jwks.json', async (req, res) => {
|
|
79
79
|
res.json(jwks({ publicJwk: ready().key.publicJwk }))
|
|
80
80
|
})
|
|
81
81
|
|
|
@@ -87,8 +87,8 @@ 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
|
-
function resolveClient(params, res) {
|
|
91
|
-
const client = params.client_id ? grants.getDynamicClient(params.client_id) : null
|
|
90
|
+
async function resolveClient(params, res) {
|
|
91
|
+
const client = params.client_id ? await grants.getDynamicClient(params.client_id) : null
|
|
92
92
|
if (!client) { res.status(400).send('Unknown client_id'); return null }
|
|
93
93
|
if (!redirectUriAllowed(client, params.redirect_uri)) {
|
|
94
94
|
res.status(400).send('redirect_uri is not registered for this client')
|
|
@@ -117,9 +117,9 @@ export function mountRoutes(router, ctx) {
|
|
|
117
117
|
return true
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
router.get('/authorize', (req, res) => {
|
|
120
|
+
router.get('/authorize', async (req, res) => {
|
|
121
121
|
const params = authParams(req.query)
|
|
122
|
-
const client = resolveClient(params, res)
|
|
122
|
+
const client = await resolveClient(params, res)
|
|
123
123
|
if (!client) return
|
|
124
124
|
if (!checkRequest(params, res)) return
|
|
125
125
|
res.type('html').send(loginPage({ params, client, appName: nameOf(req), logoUrl }))
|
|
@@ -127,7 +127,7 @@ export function mountRoutes(router, ctx) {
|
|
|
127
127
|
|
|
128
128
|
router.post('/authorize', async (req, res) => {
|
|
129
129
|
const params = authParams(req.body)
|
|
130
|
-
const client = resolveClient(params, res)
|
|
130
|
+
const client = await resolveClient(params, res)
|
|
131
131
|
if (!client) return
|
|
132
132
|
if (!checkRequest(params, res)) return
|
|
133
133
|
|
|
@@ -149,14 +149,14 @@ export function mountRoutes(router, ctx) {
|
|
|
149
149
|
// params.scope is what the CLIENT asked for. It is recorded and never
|
|
150
150
|
// trusted: the token's real scope is recomputed from the files at
|
|
151
151
|
// issue time, so a forged request cannot mint itself more access.
|
|
152
|
-
const code = grants.createCode({
|
|
152
|
+
const code = await grants.createCode({
|
|
153
153
|
clientId: client.clientId, subject: principal.subject,
|
|
154
154
|
redirectUri: params.redirect_uri, codeChallenge: params.code_challenge,
|
|
155
155
|
scope: params.scope, ttlSec: CODE_TTL_SEC,
|
|
156
156
|
})
|
|
157
157
|
// Marks the registration as live, so pruning can tell a client
|
|
158
158
|
// somebody actually uses from one left behind by a reinstall.
|
|
159
|
-
grants.touchClient(client.clientId)
|
|
159
|
+
await grants.touchClient(client.clientId)
|
|
160
160
|
logger?.info?.('auth: authorization granted to %j for %j', client.clientId, principal.subject)
|
|
161
161
|
|
|
162
162
|
const url = new URL(params.redirect_uri)
|
|
@@ -190,7 +190,7 @@ export function mountRoutes(router, ctx) {
|
|
|
190
190
|
scope: capabilities.join(' '),
|
|
191
191
|
}
|
|
192
192
|
if (withRefresh) {
|
|
193
|
-
body.refresh_token = grants.createRefreshToken({
|
|
193
|
+
body.refresh_token = await grants.createRefreshToken({
|
|
194
194
|
clientId, subject, ttlSec: REFRESH_TTL_SEC,
|
|
195
195
|
})
|
|
196
196
|
// Say that unattended renewal was granted, not just hand over the
|
|
@@ -207,7 +207,7 @@ export function mountRoutes(router, ctx) {
|
|
|
207
207
|
|
|
208
208
|
async function authorizationCodeGrant(req, res) {
|
|
209
209
|
const { code, redirect_uri: redirectUri, code_verifier: verifier, client_id: clientId } = req.body
|
|
210
|
-
const row = code && grants.getCode(code)
|
|
210
|
+
const row = code && await grants.getCode(code)
|
|
211
211
|
if (!row) return res.status(400).json({ error: 'invalid_grant' })
|
|
212
212
|
if (row.used_at || row.expires_at < Date.now()) {
|
|
213
213
|
return res.status(400).json({ error: 'invalid_grant' })
|
|
@@ -223,14 +223,14 @@ export function mountRoutes(router, ctx) {
|
|
|
223
223
|
}
|
|
224
224
|
// Single-use, decided by the UPDATE itself — two simultaneous
|
|
225
225
|
// redemptions both pass every check above, and only one wins here.
|
|
226
|
-
if (!grants.redeemCode(code)) return res.status(400).json({ error: 'invalid_grant' })
|
|
226
|
+
if (!await grants.redeemCode(code)) return res.status(400).json({ error: 'invalid_grant' })
|
|
227
227
|
|
|
228
228
|
return issueTokens(res, { clientId: row.client_id, subject: row.subject, withRefresh: true })
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
async function refreshGrant(req, res) {
|
|
232
232
|
const { refresh_token: token, client_id: clientId } = req.body
|
|
233
|
-
const row = token && grants.getRefreshToken(token)
|
|
233
|
+
const row = token && await grants.getRefreshToken(token)
|
|
234
234
|
if (!row) return res.status(400).json({ error: 'invalid_grant' })
|
|
235
235
|
if (row.revoked_at || row.expires_at < Date.now()) {
|
|
236
236
|
return res.status(400).json({ error: 'invalid_grant' })
|
|
@@ -240,7 +240,7 @@ export function mountRoutes(router, ctx) {
|
|
|
240
240
|
// Revoke BEFORE minting the replacement: losing this race means
|
|
241
241
|
// having created nothing, rather than leaving a valid token that
|
|
242
242
|
// nobody holds.
|
|
243
|
-
if (!grants.revokeRefreshToken(token)) return res.status(400).json({ error: 'invalid_grant' })
|
|
243
|
+
if (!await grants.revokeRefreshToken(token)) return res.status(400).json({ error: 'invalid_grant' })
|
|
244
244
|
|
|
245
245
|
// Recomputed from the files, not carried over from the old token —
|
|
246
246
|
// this is what makes an htgroup edit take effect on the next refresh
|
|
@@ -290,7 +290,7 @@ export function mountRoutes(router, ctx) {
|
|
|
290
290
|
{
|
|
291
291
|
const recent = new Map() // ip -> timestamps[]
|
|
292
292
|
|
|
293
|
-
router.post('/register', (req, res) => {
|
|
293
|
+
router.post('/register', async (req, res) => {
|
|
294
294
|
const now = Date.now()
|
|
295
295
|
const ip = req.ip || 'unknown'
|
|
296
296
|
const hits = (recent.get(ip) || []).filter(t => now - t < windowMs)
|
|
@@ -313,7 +313,7 @@ export function mountRoutes(router, ctx) {
|
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
try {
|
|
316
|
-
const row = registerDynamicClient({
|
|
316
|
+
const row = await registerDynamicClient({
|
|
317
317
|
name: req.body?.client_name,
|
|
318
318
|
redirectUris: req.body?.redirect_uris,
|
|
319
319
|
maxClients,
|
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.12.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
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.
|
|
29
|
+
"mikser-io": "^9.57.0"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"bcryptjs": "^3.0.0",
|
package/test/authorize.test.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path'
|
|
|
6
6
|
import { createHash, randomBytes } from 'node:crypto'
|
|
7
7
|
import bcrypt from 'bcryptjs'
|
|
8
8
|
|
|
9
|
-
import { runtime } from 'mikser-io'
|
|
9
|
+
import { runtime , closeDurableDatabase } from 'mikser-io'
|
|
10
10
|
import { auth } from '../index.js'
|
|
11
11
|
|
|
12
12
|
// Boot the plugin against a REAL express app and a REAL mikser database, by
|
|
@@ -98,6 +98,8 @@ before(async () => {
|
|
|
98
98
|
})
|
|
99
99
|
|
|
100
100
|
after(async () => {
|
|
101
|
+
// knex holds a pool reaper timer; without this the runner never exits.
|
|
102
|
+
await closeDurableDatabase()
|
|
101
103
|
await new Promise(r => server?.close(r))
|
|
102
104
|
await rm(dir, { recursive: true, force: true })
|
|
103
105
|
})
|
package/test/clients.test.js
CHANGED
|
@@ -6,78 +6,78 @@ import {
|
|
|
6
6
|
registerDynamicClient, RegistrationError,
|
|
7
7
|
} from '../lib/clients.js'
|
|
8
8
|
|
|
9
|
-
describe('redirectUriAllowed', () => {
|
|
9
|
+
describe('redirectUriAllowed', async () => {
|
|
10
10
|
const client = { redirectUris: ['http://127.0.0.1/callback', 'https://agent.example.com/cb'] }
|
|
11
11
|
|
|
12
|
-
it('matches exactly', () => {
|
|
12
|
+
it('matches exactly', async () => {
|
|
13
13
|
assert.equal(redirectUriAllowed(client, 'https://agent.example.com/cb'), true)
|
|
14
14
|
assert.equal(redirectUriAllowed(client, 'https://agent.example.com/other'), false)
|
|
15
15
|
})
|
|
16
16
|
|
|
17
|
-
it('ignores the port on loopback (RFC 8252 §7.3) — native agents bind an ephemeral one', () => {
|
|
17
|
+
it('ignores the port on loopback (RFC 8252 §7.3) — native agents bind an ephemeral one', async () => {
|
|
18
18
|
assert.equal(redirectUriAllowed(client, 'http://127.0.0.1:49821/callback'), true)
|
|
19
19
|
assert.equal(redirectUriAllowed(client, 'http://127.0.0.1:1/callback'), true)
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
it('does not let the loopback exception loosen anything else', () => {
|
|
22
|
+
it('does not let the loopback exception loosen anything else', async () => {
|
|
23
23
|
assert.equal(redirectUriAllowed(client, 'http://127.0.0.1:49821/evil'), false, 'path must still match')
|
|
24
24
|
assert.equal(redirectUriAllowed(client, 'https://127.0.0.1:49821/callback'), false, 'scheme must still match')
|
|
25
25
|
assert.equal(redirectUriAllowed(client, 'http://evil.example.com:49821/callback'), false)
|
|
26
26
|
assert.equal(redirectUriAllowed(client, 'https://agent.example.com:8443/cb'), false, 'non-loopback keeps its port')
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
-
it('refuses junk without throwing', () => {
|
|
29
|
+
it('refuses junk without throwing', async () => {
|
|
30
30
|
assert.equal(redirectUriAllowed(client, undefined), false)
|
|
31
31
|
assert.equal(redirectUriAllowed(client, 'not-a-uri'), false)
|
|
32
32
|
})
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
-
describe('validateRedirectUri', () => {
|
|
36
|
-
it('allows https anywhere and http only on loopback', () => {
|
|
35
|
+
describe('validateRedirectUri', async () => {
|
|
36
|
+
it('allows https anywhere and http only on loopback', async () => {
|
|
37
37
|
assert.equal(validateRedirectUri('https://agent.example.com/cb'), null)
|
|
38
38
|
assert.equal(validateRedirectUri('http://127.0.0.1:1234/cb'), null)
|
|
39
39
|
assert.equal(validateRedirectUri('http://localhost/cb'), null)
|
|
40
40
|
assert.match(validateRedirectUri('http://evil.example.com/cb'), /https, except on loopback/)
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
-
it('refuses a fragment and an unknown scheme', () => {
|
|
43
|
+
it('refuses a fragment and an unknown scheme', async () => {
|
|
44
44
|
assert.match(validateRedirectUri('https://a/cb#x'), /fragment/)
|
|
45
45
|
assert.match(validateRedirectUri('ftp://a/cb'), /is not allowed/)
|
|
46
46
|
})
|
|
47
47
|
})
|
|
48
48
|
|
|
49
|
-
describe('registerDynamicClient bounds', () => {
|
|
49
|
+
describe('registerDynamicClient bounds', async () => {
|
|
50
50
|
const fakeStore = (count = 0) => ({
|
|
51
51
|
countDynamicClients: () => count,
|
|
52
52
|
insertDynamicClient: (c) => ({ ...c, createdAt: 1 }),
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
-
it('caps the number of redirect URIs', () => {
|
|
55
|
+
it('caps the number of redirect URIs', async () => {
|
|
56
56
|
const many = Array.from({ length: 11 }, (_, i) => `https://a/cb${i}`)
|
|
57
|
-
assert.
|
|
58
|
-
|
|
57
|
+
await assert.rejects(() => registerDynamicClient({ redirectUris: many, store: fakeStore() }),
|
|
58
|
+
(e) => e instanceof RegistrationError && e.code === 'invalid_redirect_uri')
|
|
59
59
|
})
|
|
60
60
|
|
|
61
|
-
it('caps the name, because it renders on the sign-in page', () => {
|
|
62
|
-
const out = registerDynamicClient({
|
|
61
|
+
it('caps the name, because it renders on the sign-in page', async () => {
|
|
62
|
+
const out = await registerDynamicClient({
|
|
63
63
|
name: 'x'.repeat(500), redirectUris: ['https://a/cb'], store: fakeStore(),
|
|
64
64
|
})
|
|
65
65
|
assert.equal(out.name.length, 80)
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
-
it('refuses once the table is full — the durable bound, not the rate limiter', () => {
|
|
68
|
+
it('refuses once the table is full — the durable bound, not the rate limiter', async () => {
|
|
69
69
|
// The per-IP limiter lives in process memory and does not survive a
|
|
70
70
|
// restart; rows do. This is what actually bounds the table.
|
|
71
|
-
assert.
|
|
71
|
+
await assert.rejects(
|
|
72
72
|
() => registerDynamicClient({ redirectUris: ['https://a/cb'], maxClients: 10, store: fakeStore(10) }),
|
|
73
73
|
(e) => e.code === 'invalid_client_metadata',
|
|
74
74
|
)
|
|
75
75
|
})
|
|
76
76
|
|
|
77
|
-
it('mints a distinct client_id every time — RFC 7591 has no get-or-create', () => {
|
|
77
|
+
it('mints a distinct client_id every time — RFC 7591 has no get-or-create', async () => {
|
|
78
78
|
const store = fakeStore()
|
|
79
|
-
const a = registerDynamicClient({ redirectUris: ['https://a/cb'], store })
|
|
80
|
-
const b = registerDynamicClient({ redirectUris: ['https://a/cb'], store })
|
|
79
|
+
const a = await registerDynamicClient({ redirectUris: ['https://a/cb'], store })
|
|
80
|
+
const b = await registerDynamicClient({ redirectUris: ['https://a/cb'], store })
|
|
81
81
|
assert.notEqual(a.clientId, b.clientId)
|
|
82
82
|
})
|
|
83
83
|
})
|
|
@@ -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
|
+
})
|