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 +3 -3
- package/lib/clients.js +2 -2
- package/lib/grants.js +96 -111
- 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/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', '
|
|
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 {
|
|
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
|
-
// 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().
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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()
|
|
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
|
|
89
|
-
.
|
|
90
|
-
|
|
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().
|
|
97
|
-
|
|
98
|
-
|
|
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()
|
|
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
|
|
112
|
-
.
|
|
113
|
-
|
|
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
|
-
.
|
|
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().
|
|
131
|
-
const refresh = db().
|
|
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
|
-
|
|
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().
|
|
144
|
-
|
|
145
|
-
|
|
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()
|
|
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()
|
|
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()
|
|
173
|
-
|
|
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.
|
|
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.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.
|
|
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
|
})
|