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/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
+ }
@@ -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
+ }