mikser-io-auth 0.5.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 +249 -0
- package/assets/logo.svg +10 -0
- package/index.js +232 -0
- package/lib/clients.js +117 -0
- package/lib/grants.js +162 -0
- package/lib/htpasswd.js +253 -0
- package/lib/keys.js +64 -0
- package/lib/login-page.js +116 -0
- package/lib/pkce.js +23 -0
- package/lib/routes.js +314 -0
- package/lib/tokens.js +46 -0
- package/lib/verifiers.js +100 -0
- package/package.json +39 -0
- package/test/authorize.test.js +451 -0
- package/test/clients.test.js +83 -0
- package/test/identity.test.js +238 -0
- package/test/seam.test.js +164 -0
- package/test/verifiers.test.js +202 -0
package/lib/routes.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
import { jwks, ALG } from './keys.js'
|
|
6
|
+
import { issueToken } from './tokens.js'
|
|
7
|
+
import { verifyPkce } from './pkce.js'
|
|
8
|
+
import { redirectUriAllowed, registerDynamicClient, RegistrationError } from './clients.js'
|
|
9
|
+
import { loginPage } from './login-page.js'
|
|
10
|
+
import * as grants from './grants.js'
|
|
11
|
+
|
|
12
|
+
const CODE_TTL_SEC = 60
|
|
13
|
+
const REFRESH_TTL_SEC = 30 * 24 * 60 * 60
|
|
14
|
+
|
|
15
|
+
// The subset of an authorization request threaded through the login form's
|
|
16
|
+
// hidden fields, untouched.
|
|
17
|
+
function authParams(src) {
|
|
18
|
+
const { response_type, client_id, redirect_uri, code_challenge, code_challenge_method, scope, state } = src
|
|
19
|
+
return { response_type, client_id, redirect_uri, code_challenge, code_challenge_method, scope, state }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function mountRoutes(router, ctx) {
|
|
23
|
+
const { base, nameOf, ready, logoUrl, ttl, issuerFor, audienceFor, dcr, logger } = ctx
|
|
24
|
+
|
|
25
|
+
const { windowMs = 60 * 60 * 1000, maxPerIp = 5, maxClients = 1000 } = dcr ?? {}
|
|
26
|
+
|
|
27
|
+
// ── the mark ─────────────────────────────────────────────────────────
|
|
28
|
+
const logoFile = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'logo.svg')
|
|
29
|
+
let logoCache = null
|
|
30
|
+
router.get('/logo.svg', async (req, res) => {
|
|
31
|
+
try {
|
|
32
|
+
logoCache ??= await readFile(logoFile, 'utf8')
|
|
33
|
+
res.type('image/svg+xml').set('Cache-Control', 'public, max-age=3600').send(logoCache)
|
|
34
|
+
} catch {
|
|
35
|
+
res.status(404).end() // the page's onerror drops the <img>
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// ── discovery ────────────────────────────────────────────────────────
|
|
40
|
+
router.get('/jwks.json', (req, res) => {
|
|
41
|
+
res.json(jwks({ publicJwk: ready().key.publicJwk }))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
router.get('/.well-known/oauth-authorization-server', (req, res) => {
|
|
45
|
+
const issuer = issuerFor(req)
|
|
46
|
+
res.json({
|
|
47
|
+
issuer,
|
|
48
|
+
authorization_endpoint: `${issuer}${base}/authorize`,
|
|
49
|
+
token_endpoint: `${issuer}${base}/token`,
|
|
50
|
+
jwks_uri: `${issuer}${base}/jwks.json`,
|
|
51
|
+
response_types_supported: ['code'],
|
|
52
|
+
grant_types_supported: ['authorization_code', 'refresh_token', 'password'],
|
|
53
|
+
code_challenge_methods_supported: ['S256'],
|
|
54
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
55
|
+
id_token_signing_alg_values_supported: [ALG],
|
|
56
|
+
registration_endpoint: `${issuer}${base}/register`,
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ── /authorize ───────────────────────────────────────────────────────
|
|
61
|
+
//
|
|
62
|
+
// Validate client_id and redirect_uri BEFORE anything else, and render an
|
|
63
|
+
// error directly rather than redirecting. Redirecting to an unvalidated
|
|
64
|
+
// URI is itself the vulnerability — an open redirect through the
|
|
65
|
+
// authorization endpoint.
|
|
66
|
+
function resolveClient(params, res) {
|
|
67
|
+
const client = params.client_id ? grants.getDynamicClient(params.client_id) : null
|
|
68
|
+
if (!client) { res.status(400).send('Unknown client_id'); return null }
|
|
69
|
+
if (!redirectUriAllowed(client, params.redirect_uri)) {
|
|
70
|
+
res.status(400).send('redirect_uri is not registered for this client')
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
return client
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function redirectWithError(res, redirectUri, state, error, description) {
|
|
77
|
+
const url = new URL(redirectUri)
|
|
78
|
+
url.searchParams.set('error', error)
|
|
79
|
+
if (description) url.searchParams.set('error_description', description)
|
|
80
|
+
if (state != null) url.searchParams.set('state', state)
|
|
81
|
+
res.redirect(302, url.toString())
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkRequest(params, res) {
|
|
85
|
+
if (params.response_type !== 'code') {
|
|
86
|
+
redirectWithError(res, params.redirect_uri, params.state, 'unsupported_response_type')
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
if (params.code_challenge_method !== 'S256' || !params.code_challenge) {
|
|
90
|
+
redirectWithError(res, params.redirect_uri, params.state, 'invalid_request', 'PKCE (S256) is required')
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
router.get('/authorize', (req, res) => {
|
|
97
|
+
const params = authParams(req.query)
|
|
98
|
+
const client = resolveClient(params, res)
|
|
99
|
+
if (!client) return
|
|
100
|
+
if (!checkRequest(params, res)) return
|
|
101
|
+
res.type('html').send(loginPage({ params, client, appName: nameOf(req), logoUrl }))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
router.post('/authorize', async (req, res) => {
|
|
105
|
+
const params = authParams(req.body)
|
|
106
|
+
const client = resolveClient(params, res)
|
|
107
|
+
if (!client) return
|
|
108
|
+
if (!checkRequest(params, res)) return
|
|
109
|
+
|
|
110
|
+
const { store } = ready()
|
|
111
|
+
const principal = await store.authenticate(req.body.username, req.body.password)
|
|
112
|
+
if (!principal) {
|
|
113
|
+
logger?.warn?.('auth: sign-in refused for %j (ip=%s)', req.body.username, req.ip)
|
|
114
|
+
// Re-render rather than redirect. WhiteBox redirects an
|
|
115
|
+
// access_denied back to the client so its own branded form can
|
|
116
|
+
// show the message; mikser's form IS this page, and bouncing the
|
|
117
|
+
// browser out to the client just to be sent back loses what the
|
|
118
|
+
// person typed and reads like a crash.
|
|
119
|
+
return res.status(401).type('html').send(loginPage({
|
|
120
|
+
params, client, appName: nameOf(req), logoUrl,
|
|
121
|
+
error: 'Incorrect username or password',
|
|
122
|
+
}))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// params.scope is what the CLIENT asked for. It is recorded and never
|
|
126
|
+
// trusted: the token's real scope is recomputed from the files at
|
|
127
|
+
// issue time, so a forged request cannot mint itself more access.
|
|
128
|
+
const code = grants.createCode({
|
|
129
|
+
clientId: client.clientId, subject: principal.subject,
|
|
130
|
+
redirectUri: params.redirect_uri, codeChallenge: params.code_challenge,
|
|
131
|
+
scope: params.scope, ttlSec: CODE_TTL_SEC,
|
|
132
|
+
})
|
|
133
|
+
// Marks the registration as live, so pruning can tell a client
|
|
134
|
+
// somebody actually uses from one left behind by a reinstall.
|
|
135
|
+
grants.touchClient(client.clientId)
|
|
136
|
+
logger?.info?.('auth: authorization granted to %j for %j', client.clientId, principal.subject)
|
|
137
|
+
|
|
138
|
+
const url = new URL(params.redirect_uri)
|
|
139
|
+
url.searchParams.set('code', code)
|
|
140
|
+
if (params.state != null) url.searchParams.set('state', params.state)
|
|
141
|
+
res.redirect(302, url.toString())
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ── /token ───────────────────────────────────────────────────────────
|
|
145
|
+
//
|
|
146
|
+
// The ONE place a token's capabilities and row scope are decided, always
|
|
147
|
+
// recomputed from the identity files. Every gate downstream trusts the
|
|
148
|
+
// token alone with no per-request re-read, which is safe only because
|
|
149
|
+
// nothing a client sends can influence what goes in here.
|
|
150
|
+
async function issueTokens(res, { clientId, subject, withRefresh }) {
|
|
151
|
+
const { store, key } = ready()
|
|
152
|
+
const capabilities = await store.capabilitiesOf(subject)
|
|
153
|
+
const scope = await store.scopeOf(subject)
|
|
154
|
+
const issuer = issuerFor(res.req)
|
|
155
|
+
|
|
156
|
+
const accessToken = await issueToken({
|
|
157
|
+
key, issuer, audience: audienceFor(res.req),
|
|
158
|
+
subject, capabilities, scope, ttl,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const body = {
|
|
162
|
+
access_token: accessToken,
|
|
163
|
+
token_type: 'Bearer',
|
|
164
|
+
expires_in: typeof ttl === 'number' ? ttl : 3600,
|
|
165
|
+
scope: capabilities.join(' '),
|
|
166
|
+
}
|
|
167
|
+
if (withRefresh) {
|
|
168
|
+
body.refresh_token = grants.createRefreshToken({
|
|
169
|
+
clientId, subject, ttlSec: REFRESH_TTL_SEC,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
res.json(body)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function authorizationCodeGrant(req, res) {
|
|
176
|
+
const { code, redirect_uri: redirectUri, code_verifier: verifier, client_id: clientId } = req.body
|
|
177
|
+
const row = code && grants.getCode(code)
|
|
178
|
+
if (!row) return res.status(400).json({ error: 'invalid_grant' })
|
|
179
|
+
if (row.used_at || row.expires_at < Date.now()) {
|
|
180
|
+
return res.status(400).json({ error: 'invalid_grant' })
|
|
181
|
+
}
|
|
182
|
+
// Both must match what /authorize was called with (RFC 6749 §4.1.3):
|
|
183
|
+
// a code minted for one client or redirect cannot be redeemed against
|
|
184
|
+
// another.
|
|
185
|
+
if (row.client_id !== clientId || row.redirect_uri !== redirectUri) {
|
|
186
|
+
return res.status(400).json({ error: 'invalid_grant' })
|
|
187
|
+
}
|
|
188
|
+
if (!verifyPkce(verifier, row.code_challenge)) {
|
|
189
|
+
return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' })
|
|
190
|
+
}
|
|
191
|
+
// Single-use, decided by the UPDATE itself — two simultaneous
|
|
192
|
+
// redemptions both pass every check above, and only one wins here.
|
|
193
|
+
if (!grants.redeemCode(code)) return res.status(400).json({ error: 'invalid_grant' })
|
|
194
|
+
|
|
195
|
+
return issueTokens(res, { clientId: row.client_id, subject: row.subject, withRefresh: true })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function refreshGrant(req, res) {
|
|
199
|
+
const { refresh_token: token, client_id: clientId } = req.body
|
|
200
|
+
const row = token && grants.getRefreshToken(token)
|
|
201
|
+
if (!row) return res.status(400).json({ error: 'invalid_grant' })
|
|
202
|
+
if (row.revoked_at || row.expires_at < Date.now()) {
|
|
203
|
+
return res.status(400).json({ error: 'invalid_grant' })
|
|
204
|
+
}
|
|
205
|
+
if (row.client_id !== clientId) return res.status(400).json({ error: 'invalid_grant' })
|
|
206
|
+
|
|
207
|
+
// Revoke BEFORE minting the replacement: losing this race means
|
|
208
|
+
// having created nothing, rather than leaving a valid token that
|
|
209
|
+
// nobody holds.
|
|
210
|
+
if (!grants.revokeRefreshToken(token)) return res.status(400).json({ error: 'invalid_grant' })
|
|
211
|
+
|
|
212
|
+
// Recomputed from the files, not carried over from the old token —
|
|
213
|
+
// this is what makes an htgroup edit take effect on the next refresh
|
|
214
|
+
// rather than only on the next full sign-in.
|
|
215
|
+
return issueTokens(res, { clientId: row.client_id, subject: row.subject, withRefresh: true })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Credentials straight to a token: for a script or a CLI, where there is
|
|
219
|
+
// no browser to open and no callback to receive. No refresh token — a
|
|
220
|
+
// caller that can replay the password does not need one.
|
|
221
|
+
async function passwordGrant(req, res) {
|
|
222
|
+
const { store } = ready()
|
|
223
|
+
let username = req.body?.username
|
|
224
|
+
let password = req.body?.password
|
|
225
|
+
const header = req.get('authorization')
|
|
226
|
+
if (!username && header?.startsWith('Basic ')) {
|
|
227
|
+
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
|
|
228
|
+
const i = decoded.indexOf(':')
|
|
229
|
+
if (i >= 0) { username = decoded.slice(0, i); password = decoded.slice(i + 1) }
|
|
230
|
+
}
|
|
231
|
+
if (!username || typeof password !== 'string') {
|
|
232
|
+
return res.status(400).json({ error: 'invalid_request' })
|
|
233
|
+
}
|
|
234
|
+
const principal = await store.authenticate(username, password)
|
|
235
|
+
if (!principal) {
|
|
236
|
+
logger?.warn?.('auth: token request refused for %j (ip=%s)', username, req.ip)
|
|
237
|
+
res.set('WWW-Authenticate', `Basic realm="${ctx.realm}", charset="UTF-8"`)
|
|
238
|
+
return res.status(401).json({ error: 'invalid_grant' })
|
|
239
|
+
}
|
|
240
|
+
return issueTokens(res, { clientId: req.body?.client_id ?? 'password', subject: principal.subject, withRefresh: false })
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
router.post('/token', async (req, res) => {
|
|
244
|
+
const grantType = req.body?.grant_type
|
|
245
|
+
if (grantType === 'authorization_code') return authorizationCodeGrant(req, res)
|
|
246
|
+
if (grantType === 'refresh_token') return refreshGrant(req, res)
|
|
247
|
+
if (grantType === 'password' || !grantType) return passwordGrant(req, res)
|
|
248
|
+
return res.status(400).json({ error: 'unsupported_grant_type' })
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// ── Dynamic Client Registration (RFC 7591) ───────────────────────────
|
|
252
|
+
//
|
|
253
|
+
// The only way a client exists. There is no operator-maintained list,
|
|
254
|
+
// because a list makes the set of agents that can connect equal to the
|
|
255
|
+
// set somebody wrote down — and an agent whose UI takes a URL and
|
|
256
|
+
// nothing else has no field to type a client_id into anyway.
|
|
257
|
+
{
|
|
258
|
+
const recent = new Map() // ip -> timestamps[]
|
|
259
|
+
|
|
260
|
+
router.post('/register', (req, res) => {
|
|
261
|
+
const now = Date.now()
|
|
262
|
+
const ip = req.ip || 'unknown'
|
|
263
|
+
const hits = (recent.get(ip) || []).filter(t => now - t < windowMs)
|
|
264
|
+
if (hits.length >= maxPerIp) {
|
|
265
|
+
logger?.warn?.('auth: registration rate-limited (ip=%s)', ip)
|
|
266
|
+
return res.status(429).json({
|
|
267
|
+
error: 'invalid_client_metadata',
|
|
268
|
+
error_description: 'too many registrations from this address — try again later',
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
// Counts every REQUEST, not every success — a rejected attempt
|
|
272
|
+
// still spends budget. Fail-closed for an unauthenticated
|
|
273
|
+
// endpoint: otherwise an invalid payload repeats for free. The
|
|
274
|
+
// cost is that a client with broken metadata locks itself out
|
|
275
|
+
// until the window rolls, which is recoverable and logged.
|
|
276
|
+
hits.push(now)
|
|
277
|
+
recent.set(ip, hits)
|
|
278
|
+
if (recent.size > 10_000) {
|
|
279
|
+
for (const [k, v] of recent) if (!v.some(t => now - t < windowMs)) recent.delete(k)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const row = registerDynamicClient({
|
|
284
|
+
name: req.body?.client_name,
|
|
285
|
+
redirectUris: req.body?.redirect_uris,
|
|
286
|
+
maxClients,
|
|
287
|
+
store: grants,
|
|
288
|
+
})
|
|
289
|
+
logger?.info?.('auth: client self-registered — %j (%s) from %s',
|
|
290
|
+
row.name, row.clientId, ip)
|
|
291
|
+
// RFC 7591 §3.2.1: 201, echoing the metadata AS REGISTERED,
|
|
292
|
+
// which may differ from what was sent (a missing name became
|
|
293
|
+
// a placeholder) so a client can see what it actually got.
|
|
294
|
+
res.status(201).json({
|
|
295
|
+
client_id: row.clientId,
|
|
296
|
+
client_id_issued_at: Math.floor(row.createdAt / 1000),
|
|
297
|
+
client_name: row.name,
|
|
298
|
+
redirect_uris: row.redirectUris,
|
|
299
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
300
|
+
response_types: ['code'],
|
|
301
|
+
// Stated in the response itself: every client here is
|
|
302
|
+
// public, and PKCE is what proves possession.
|
|
303
|
+
token_endpoint_auth_method: 'none',
|
|
304
|
+
})
|
|
305
|
+
} catch (err) {
|
|
306
|
+
if (err instanceof RegistrationError) {
|
|
307
|
+
return res.status(400).json({ error: err.code, error_description: err.message })
|
|
308
|
+
}
|
|
309
|
+
logger?.error?.('auth: registration failed — %s', err.message)
|
|
310
|
+
res.status(500).json({ error: 'invalid_client_metadata' })
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
}
|
package/lib/tokens.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify, createLocalJWKSet } from 'jose'
|
|
2
|
+
import { ALG } from './keys.js'
|
|
3
|
+
|
|
4
|
+
// Access tokens are JWTs signed with the working folder's key.
|
|
5
|
+
//
|
|
6
|
+
// The load-bearing invariant, inherited from WhiteBox: `scope` is ALWAYS
|
|
7
|
+
// computed here from the identity files, never taken from whatever a client
|
|
8
|
+
// asked for at /authorize. Enforcement downstream is scope-only with no
|
|
9
|
+
// per-request re-read, which is safe precisely because a client cannot
|
|
10
|
+
// influence what goes in.
|
|
11
|
+
export async function issueToken({ key, issuer, audience, subject, capabilities = [], scope = null, ttl = '1h' }) {
|
|
12
|
+
// `scope` is already taken: in OAuth it is the space-separated capability
|
|
13
|
+
// list, and a client library will parse it as one. The row filter travels
|
|
14
|
+
// as a private claim so the two never collide. It is signed, so a client
|
|
15
|
+
// cannot widen its own reach by editing it.
|
|
16
|
+
return new SignJWT({
|
|
17
|
+
scope: capabilities.join(' '),
|
|
18
|
+
...(scope ? { mks_scope: scope } : {}),
|
|
19
|
+
})
|
|
20
|
+
.setProtectedHeader({ alg: ALG, kid: key.kid })
|
|
21
|
+
.setIssuedAt()
|
|
22
|
+
.setIssuer(issuer)
|
|
23
|
+
.setAudience(audience)
|
|
24
|
+
.setSubject(subject)
|
|
25
|
+
.setExpirationTime(ttl)
|
|
26
|
+
.sign(key.privateKey)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Verify a token minted by this server. `audience` is checked because a
|
|
30
|
+
// token issued for one endpoint must not be replayable against another.
|
|
31
|
+
export function createTokenVerifier({ key, issuer, audience }) {
|
|
32
|
+
const keySet = createLocalJWKSet({ keys: [key.publicJwk] })
|
|
33
|
+
return async function verifyToken(token) {
|
|
34
|
+
const { payload } = await jwtVerify(token, keySet, {
|
|
35
|
+
issuer,
|
|
36
|
+
audience,
|
|
37
|
+
algorithms: [ALG],
|
|
38
|
+
})
|
|
39
|
+
return {
|
|
40
|
+
subject: payload.sub,
|
|
41
|
+
capabilities: payload.scope ? payload.scope.split(' ') : [],
|
|
42
|
+
scope: payload.mks_scope ?? null,
|
|
43
|
+
claims: payload,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/lib/verifiers.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The two verifiers, both implementing the ADR-0012 contract:
|
|
2
|
+
//
|
|
3
|
+
// { name, verify(req) → null | false | { subject, capabilities }, challenge? }
|
|
4
|
+
//
|
|
5
|
+
// null = no credential presented (loopback may still apply)
|
|
6
|
+
// false = presented and rejected (never falls back to anything)
|
|
7
|
+
|
|
8
|
+
// HTTP Basic against the htpasswd file. Browser-native, no flow, no tokens —
|
|
9
|
+
// the right tool for the api/forms/decap surfaces, where the caller is a
|
|
10
|
+
// person with a browser or a script with curl. Not for MCP: an MCP client
|
|
11
|
+
// expects Bearer and a discovery document.
|
|
12
|
+
export function basic({ store, realm = 'mikser', logger } = {}) {
|
|
13
|
+
if (!store) throw new Error('basic({ store }) requires an identity store')
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
name: 'basic',
|
|
17
|
+
|
|
18
|
+
async verify(req) {
|
|
19
|
+
const header = req.headers?.authorization ?? req.get?.('authorization')
|
|
20
|
+
if (!header) return null
|
|
21
|
+
|
|
22
|
+
const [scheme, encoded] = header.split(' ')
|
|
23
|
+
// A Bearer on a Basic-only endpoint is a presented credential we
|
|
24
|
+
// cannot accept — false, not null. Treating it as "nothing
|
|
25
|
+
// presented" would let it fall through to a loopback bypass.
|
|
26
|
+
if (!/^basic$/i.test(scheme ?? '') || !encoded) return false
|
|
27
|
+
|
|
28
|
+
let decoded
|
|
29
|
+
try {
|
|
30
|
+
decoded = Buffer.from(encoded, 'base64').toString('utf8')
|
|
31
|
+
} catch {
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const i = decoded.indexOf(':')
|
|
36
|
+
if (i < 0) return false
|
|
37
|
+
const username = decoded.slice(0, i)
|
|
38
|
+
const password = decoded.slice(i + 1)
|
|
39
|
+
|
|
40
|
+
const principal = await store.authenticate(username, password)
|
|
41
|
+
if (!principal) {
|
|
42
|
+
logger?.debug?.('auth: basic rejected for %j', username)
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
return principal
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
challenge(req, res) {
|
|
49
|
+
// charset="UTF-8" per RFC 7617 §2.1 — without it a browser may
|
|
50
|
+
// send latin-1 for a non-ASCII password and the hash won't match.
|
|
51
|
+
res.set('WWW-Authenticate', `Basic realm="${realm}", charset="UTF-8"`)
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Bearer JWT, for MCP and any other client that runs an OAuth flow. The
|
|
57
|
+
// discovery fields are what make an MCP client able to log in unattended:
|
|
58
|
+
// mikser-io-mcp reads them to publish RFC 9728 metadata and to point its
|
|
59
|
+
// 401 challenge at that document.
|
|
60
|
+
export function jwt({ verifyToken, issuer, audience, resource, scopes = [], requiredCapability, logger } = {}) {
|
|
61
|
+
if (!verifyToken) throw new Error('jwt({ verifyToken }) requires a token verifier')
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
name: 'jwt',
|
|
65
|
+
authorizationServers: [issuer],
|
|
66
|
+
resource,
|
|
67
|
+
scopesSupported: scopes,
|
|
68
|
+
|
|
69
|
+
async verify(req) {
|
|
70
|
+
const header = req.headers?.authorization ?? req.get?.('authorization')
|
|
71
|
+
if (!header) return null
|
|
72
|
+
|
|
73
|
+
const match = /^Bearer\s+(.+)$/i.exec(header)
|
|
74
|
+
if (!match) return false
|
|
75
|
+
|
|
76
|
+
let principal
|
|
77
|
+
try {
|
|
78
|
+
principal = await verifyToken(match[1])
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// An expired or malformed token is a rejection, not an error:
|
|
81
|
+
// failing loudly here would turn a routine token expiry into
|
|
82
|
+
// a 500 and mask it from the client's refresh logic.
|
|
83
|
+
logger?.debug?.('auth: jwt rejected — %s', err.code ?? err.message)
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (requiredCapability && !principal.capabilities.includes(requiredCapability)) {
|
|
88
|
+
logger?.debug?.('auth: %j lacks %j', principal.subject, requiredCapability)
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
return principal
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// Only used when a surface has no better idea; mikser-io-mcp
|
|
95
|
+
// overrides this with a resource_metadata pointer of its own.
|
|
96
|
+
challenge(req, res) {
|
|
97
|
+
res.set('WWW-Authenticate', `Bearer${issuer ? `, authorization_uri="${issuer}"` : ''}`)
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mikser-io-auth",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Authentication for mikser-io: an OAuth 2.1 authorization server (self-registering clients, authorization code + PKCE, refresh rotation) and HTTP Basic / JWT verifiers over Apache-format htpasswd and htgroup files in the working folder. Implements the ADR-0012 verifier contract, so it plugs in wherever a static token does — api, mcp, forms.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/almero-digital-marketing/mikser-io-auth.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"mikser",
|
|
16
|
+
"mikser-io",
|
|
17
|
+
"auth",
|
|
18
|
+
"oauth",
|
|
19
|
+
"htpasswd",
|
|
20
|
+
"jwt"
|
|
21
|
+
],
|
|
22
|
+
"author": "",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/almero-digital-marketing/mikser-io-auth/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/almero-digital-marketing/mikser-io-auth#readme",
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"mikser-io": "^9.4.0"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"bcryptjs": "^3.0.0",
|
|
33
|
+
"jose": "^5.9.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"express": "^5.2.1",
|
|
37
|
+
"mikser-io": "file:../mikser-io"
|
|
38
|
+
}
|
|
39
|
+
}
|