mikser-io-auth 0.10.0 → 0.11.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/index.js CHANGED
@@ -28,7 +28,7 @@ export { grants }
28
28
  * store can only be opened once the working folder is known:
29
29
  *
30
30
  * const identity = auth({
31
- * capabilities: { editors: ['api:update', 'mcp:use'] },
31
+ * capabilities: { editors: ['api:update', 'drive:documents:write'] },
32
32
  * issuer: 'https://cms.example.com',
33
33
  * })
34
34
  *
@@ -200,7 +200,7 @@ export function auth(options = {}) {
200
200
  // for a build tool — the checks that matter (expiry, single use)
201
201
  // are enforced on read, not by the sweep.
202
202
  try {
203
- const swept = grants.sweepExpired()
203
+ const swept = await grants.sweepExpired()
204
204
  if (swept.codes || swept.refresh) {
205
205
  logger?.debug?.('auth: swept %d expired code(s), %d refresh token(s)',
206
206
  swept.codes, swept.refresh)
@@ -211,7 +211,7 @@ export function auth(options = {}) {
211
211
  // signed in with are dropped; a config-declared client is
212
212
  // never touched, because it lives in config, not this table.
213
213
  if (pruneClientsAfterDays) {
214
- const pruned = grants.pruneUnusedClients({
214
+ const pruned = await grants.pruneUnusedClients({
215
215
  olderThanMs: pruneClientsAfterDays * 24 * 60 * 60 * 1000,
216
216
  })
217
217
  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,106 @@
1
- import { registerSchema, useDatabase } from 'mikser-io'
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, which is exactly what the engine's sqlite substrate is for
7
- // (ADR-0009).
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
- // One consequence worth knowing: the engine wipes this database when its
13
- // schema stamp changes, so upgrading mikser signs everyone out. Codes live
14
- // 60 seconds so they are irrelevant; refresh tokens are the real cost, and
15
- // re-authenticating after an engine upgrade is a fair price for not
16
- // inventing a second persistence story.
17
- registerSchema('auth', `
18
- CREATE TABLE IF NOT EXISTS mikser_auth_codes (
19
- code TEXT PRIMARY KEY,
20
- client_id TEXT NOT NULL,
21
- subject TEXT NOT NULL,
22
- redirect_uri TEXT NOT NULL,
23
- code_challenge TEXT NOT NULL,
24
- scope TEXT,
25
- expires_at INTEGER NOT NULL,
26
- used_at INTEGER
27
- );
28
- CREATE INDEX IF NOT EXISTS idx_mikser_auth_codes_expiry
29
- ON mikser_auth_codes (expires_at);
30
-
31
- CREATE TABLE IF NOT EXISTS mikser_auth_refresh (
32
- token TEXT PRIMARY KEY,
33
- client_id TEXT NOT NULL,
34
- subject TEXT NOT NULL,
35
- expires_at INTEGER NOT NULL,
36
- revoked_at INTEGER
37
- );
38
- CREATE INDEX IF NOT EXISTS idx_mikser_auth_refresh_subject
39
- ON mikser_auth_refresh (subject);
40
-
41
- -- Self-registered clients (RFC 7591). Config-declared clients are NOT
42
- -- here: they live in config, are not prunable, and always win a lookup.
43
- -- Keeping the two apart is what makes pruning safe — an operator's
44
- -- client that has not been used yet is not garbage.
45
- CREATE TABLE IF NOT EXISTS mikser_auth_clients (
46
- client_id TEXT PRIMARY KEY,
47
- name TEXT NOT NULL,
48
- redirect_uris TEXT NOT NULL,
49
- created_at INTEGER NOT NULL,
50
- last_used_at INTEGER
51
- );
52
- `, {
53
- // Durable: these tables are not derived from anything on disk.
54
- //
55
- // The engine wipes its database whenever the schema version or the
56
- // config checksum changes — an upgrade, or any deploy that edits
57
- // mikser.config.js. That is correct for a cache the files can rebuild,
58
- // and wrong here: a registered OAuth client and its refresh token exist
59
- // only because a human completed a sign-in once. Losing them logs every
60
- // connected agent out, and the operator's first sign of it is being
61
- // asked to authorize again after an unrelated deploy.
62
- //
63
- // Codes are 60s and swept anyway; they ride along because they share the
64
- // schema, and a stale one is rejected on expiry rather than trusted.
65
- durable: true,
66
- })
67
-
68
- const db = () => useDatabase().handle
69
-
70
- export function createCode({ clientId, subject, redirectUri, codeChallenge, scope, ttlSec = 60 }) {
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
+
61
+ const db = () => useDurableDatabase()
62
+
63
+ export async function createCode({ clientId, subject, redirectUri, codeChallenge, scope, ttlSec = 60 }) {
71
64
  const code = opaqueToken()
72
- db().prepare(`
73
- INSERT INTO mikser_auth_codes
74
- (code, client_id, subject, redirect_uri, code_challenge, scope, expires_at)
75
- VALUES (?, ?, ?, ?, ?, ?, ?)
76
- `).run(code, clientId, subject, redirectUri, codeChallenge, scope ?? '', Date.now() + ttlSec * 1000)
65
+ await db()('mikser_auth_codes').insert({
66
+ code, client_id: clientId, subject, redirect_uri: redirectUri,
67
+ code_challenge: codeChallenge, scope: scope ?? '', expires_at: Date.now() + ttlSec * 1000,
68
+ })
77
69
  return code
78
70
  }
79
71
 
80
- export function getCode(code) {
81
- return db().prepare('SELECT * FROM mikser_auth_codes WHERE code = ?').get(code)
72
+ export async function getCode(code) {
73
+ return db()('mikser_auth_codes').where({ code }).first()
82
74
  }
83
75
 
84
76
  // Single-use, enforced by the UPDATE's own WHERE rather than by a read
85
77
  // followed by a write: two simultaneous redemptions of the same code both
86
78
  // pass a prior SELECT, and only one can win this.
87
- export function redeemCode(code) {
88
- const result = db()
89
- .prepare('UPDATE mikser_auth_codes SET used_at = ? WHERE code = ? AND used_at IS NULL')
90
- .run(Date.now(), code)
91
- return result.changes === 1
79
+ export async function redeemCode(code) {
80
+ const changed = await db()('mikser_auth_codes')
81
+ .where({ code }).whereNull('used_at').update({ used_at: Date.now() })
82
+ return changed === 1
92
83
  }
93
84
 
94
- export function createRefreshToken({ clientId, subject, ttlSec }) {
85
+ export async function createRefreshToken({ clientId, subject, ttlSec }) {
95
86
  const token = opaqueToken()
96
- db().prepare(`
97
- INSERT INTO mikser_auth_refresh (token, client_id, subject, expires_at)
98
- VALUES (?, ?, ?, ?)
99
- `).run(token, clientId, subject, Date.now() + ttlSec * 1000)
87
+ await db()('mikser_auth_refresh').insert({
88
+ token, client_id: clientId, subject, expires_at: Date.now() + ttlSec * 1000,
89
+ })
100
90
  return token
101
91
  }
102
92
 
103
- export function getRefreshToken(token) {
104
- return db().prepare('SELECT * FROM mikser_auth_refresh WHERE token = ?').get(token)
93
+ export async function getRefreshToken(token) {
94
+ return db()('mikser_auth_refresh').where({ token }).first()
105
95
  }
106
96
 
107
97
  // Same race-safe shape as redeemCode. Rotation revokes BEFORE minting the
108
98
  // replacement, so losing the race means creating nothing at all rather than
109
99
  // leaving a valid token nobody holds.
110
- export function revokeRefreshToken(token) {
111
- const result = db()
112
- .prepare('UPDATE mikser_auth_refresh SET revoked_at = ? WHERE token = ? AND revoked_at IS NULL')
113
- .run(Date.now(), token)
114
- return result.changes === 1
100
+ export async function revokeRefreshToken(token) {
101
+ const changed = await db()('mikser_auth_refresh')
102
+ .where({ token }).whereNull('revoked_at').update({ revoked_at: Date.now() })
103
+ return changed === 1
115
104
  }
116
105
 
117
106
  // Everything a subject holds, for a sign-out-everywhere. Also what an
@@ -119,36 +108,35 @@ export function revokeRefreshToken(token) {
119
108
  // except that revoking here is immediate, where an htpasswd edit only
120
109
  // stops the NEXT login and leaves live access tokens valid until they
121
110
  // expire.
122
- export function revokeAllForSubject(subject) {
123
- return db()
124
- .prepare('UPDATE mikser_auth_refresh SET revoked_at = ? WHERE subject = ? AND revoked_at IS NULL')
125
- .run(Date.now(), subject).changes
111
+ export async function revokeAllForSubject(subject) {
112
+ return db()('mikser_auth_refresh')
113
+ .where({ subject }).whereNull('revoked_at').update({ revoked_at: Date.now() })
126
114
  }
127
115
 
128
- export function sweepExpired() {
116
+ export async function sweepExpired() {
129
117
  const now = Date.now()
130
- const codes = db().prepare('DELETE FROM mikser_auth_codes WHERE expires_at < ?').run(now - 60_000).changes
131
- const refresh = db().prepare('DELETE FROM mikser_auth_refresh WHERE expires_at < ?').run(now).changes
118
+ const codes = await db()('mikser_auth_codes').where('expires_at', '<', now - 60_000).delete()
119
+ const refresh = await db()('mikser_auth_refresh').where('expires_at', '<', now).delete()
132
120
  return { codes, refresh }
133
121
  }
134
122
 
135
123
  // ── self-registered clients ─────────────────────────────────────────────
136
124
 
137
- export function countDynamicClients() {
138
- return db().prepare('SELECT COUNT(*) AS n FROM mikser_auth_clients').get().n
125
+ export async function countDynamicClients() {
126
+ const [{ n }] = await db()('mikser_auth_clients').count({ n: '*' })
127
+ return Number(n)
139
128
  }
140
129
 
141
- export function insertDynamicClient({ clientId, name, redirectUris }) {
130
+ export async function insertDynamicClient({ clientId, name, redirectUris }) {
142
131
  const createdAt = Date.now()
143
- db().prepare(`
144
- INSERT INTO mikser_auth_clients (client_id, name, redirect_uris, created_at)
145
- VALUES (?, ?, ?, ?)
146
- `).run(clientId, name, JSON.stringify(redirectUris), createdAt)
132
+ await db()('mikser_auth_clients').insert({
133
+ client_id: clientId, name, redirect_uris: JSON.stringify(redirectUris), created_at: createdAt,
134
+ })
147
135
  return { clientId, name, redirectUris, createdAt }
148
136
  }
149
137
 
150
- export function getDynamicClient(clientId) {
151
- const row = db().prepare('SELECT * FROM mikser_auth_clients WHERE client_id = ?').get(clientId)
138
+ export async function getDynamicClient(clientId) {
139
+ const row = await db()('mikser_auth_clients').where({ client_id: clientId }).first()
152
140
  if (!row) return null
153
141
  return {
154
142
  clientId: row.client_id,
@@ -160,17 +148,14 @@ export function getDynamicClient(clientId) {
160
148
  }
161
149
  }
162
150
 
163
- export function touchClient(clientId) {
164
- db().prepare('UPDATE mikser_auth_clients SET last_used_at = ? WHERE client_id = ?')
165
- .run(Date.now(), clientId)
151
+ export async function touchClient(clientId) {
152
+ await db()('mikser_auth_clients').where({ client_id: clientId }).update({ last_used_at: Date.now() })
166
153
  }
167
154
 
168
155
  // DCR has no "get or create" — every registration mints a NEW client_id, so
169
156
  // a reinstall, a cleared cache or a second machine each leave another row
170
157
  // behind. Prune the ones nobody ever signed in with.
171
- export function pruneUnusedClients({ olderThanMs }) {
172
- return db().prepare(`
173
- DELETE FROM mikser_auth_clients
174
- WHERE last_used_at IS NULL AND created_at < ?
175
- `).run(Date.now() - olderThanMs).changes
158
+ export async function pruneUnusedClients({ olderThanMs }) {
159
+ return db()('mikser_auth_clients')
160
+ .whereNull('last_used_at').where('created_at', '<', Date.now() - olderThanMs).delete()
176
161
  }
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.10.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.",
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.",
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.50.0"
29
+ "mikser-io": "^9.57.0"
30
30
  },
31
31
  "dependencies": {
32
32
  "bcryptjs": "^3.0.0",
@@ -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
  })
@@ -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.throws(() => registerDynamicClient({ redirectUris: many, store: fakeStore() }),
58
- (e) => e instanceof RegistrationError && e.code === 'invalid_redirect_uri')
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.throws(
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
  })