mikser-io-auth 0.5.0 → 0.5.2

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
@@ -8,7 +8,7 @@ import { basic, jwt } from './lib/verifiers.js'
8
8
  import { redirectUriAllowed, validateRedirectUri, registerDynamicClient } from './lib/clients.js'
9
9
  import { challengeFromVerifier, verifyPkce, opaqueToken } from './lib/pkce.js'
10
10
  import { loginPage } from './lib/login-page.js'
11
- import { mountRoutes } from './lib/routes.js'
11
+ import { mountRoutes, metadataHandler } from './lib/routes.js'
12
12
  import * as grants from './lib/grants.js'
13
13
 
14
14
  export { parseHtpasswd, parseHtgroup, verifyPassword, createIdentityStore }
@@ -152,6 +152,22 @@ export function auth(options = {}) {
152
152
 
153
153
  app.use(base, router)
154
154
 
155
+ // RFC 8414 §3.1: an issuer with no path component publishes its
156
+ // metadata at <origin>/.well-known/oauth-authorization-server.
157
+ // `issuerFor` is the origin, so that is where a conforming client
158
+ // looks — including every MCP client, which is sent here by the
159
+ // resource's RFC 9728 document naming this issuer. Mounted under
160
+ // `base` alone, the document exists but at an address nothing
161
+ // following the spec will ask for, and dynamic client
162
+ // registration fails with the metadata sitting right there.
163
+ //
164
+ // The copy under `base` stays: clients that append to the issuer
165
+ // rather than insert the well-known segment find it there.
166
+ if (base && base !== '/') {
167
+ app.get('/.well-known/oauth-authorization-server',
168
+ metadataHandler({ base, issuerFor: originOf }))
169
+ }
170
+
155
171
  // Codes are 60s and refresh tokens 30d; without a sweep the rows
156
172
  // accumulate for the life of the database. Once at boot is enough
157
173
  // for a build tool — the checks that matter (expiry, single use)
package/lib/grants.js CHANGED
@@ -49,7 +49,21 @@ registerSchema('auth', `
49
49
  created_at INTEGER NOT NULL,
50
50
  last_used_at INTEGER
51
51
  );
52
- `)
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
+ })
53
67
 
54
68
  const db = () => useDatabase().handle
55
69
 
package/lib/routes.js CHANGED
@@ -19,6 +19,33 @@ function authParams(src) {
19
19
  return { response_type, client_id, redirect_uri, code_challenge, code_challenge_method, scope, state }
20
20
  }
21
21
 
22
+ // The authorization server metadata document.
23
+ //
24
+ // Exported because WHERE it is served is part of the contract, and one mount
25
+ // cannot satisfy it. `issuerFor` returns the origin, and RFC 8414 §3.1 says
26
+ // an issuer with no path component publishes at
27
+ // <origin>/.well-known/oauth-authorization-server. This router is mounted
28
+ // under `base`, so its own copy answers one level down — where a client that
29
+ // appends to the issuer looks, and nowhere a client following RFC 8414 does.
30
+ // index.js mounts this at the root as well; both paths return this document.
31
+ export function metadataHandler({ base, issuerFor }) {
32
+ return (req, res) => {
33
+ const issuer = issuerFor(req)
34
+ res.json({
35
+ issuer,
36
+ authorization_endpoint: `${issuer}${base}/authorize`,
37
+ token_endpoint: `${issuer}${base}/token`,
38
+ jwks_uri: `${issuer}${base}/jwks.json`,
39
+ response_types_supported: ['code'],
40
+ grant_types_supported: ['authorization_code', 'refresh_token', 'password'],
41
+ code_challenge_methods_supported: ['S256'],
42
+ token_endpoint_auth_methods_supported: ['none'],
43
+ id_token_signing_alg_values_supported: [ALG],
44
+ registration_endpoint: `${issuer}${base}/register`,
45
+ })
46
+ }
47
+ }
48
+
22
49
  export function mountRoutes(router, ctx) {
23
50
  const { base, nameOf, ready, logoUrl, ttl, issuerFor, audienceFor, dcr, logger } = ctx
24
51
 
@@ -41,21 +68,7 @@ export function mountRoutes(router, ctx) {
41
68
  res.json(jwks({ publicJwk: ready().key.publicJwk }))
42
69
  })
43
70
 
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
- })
71
+ router.get('/.well-known/oauth-authorization-server', metadataHandler({ base, issuerFor }))
59
72
 
60
73
  // ── /authorize ───────────────────────────────────────────────────────
61
74
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-auth",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
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
5
  "main": "index.js",
6
6
  "type": "module",
@@ -340,6 +340,34 @@ describe('discovery', () => {
340
340
  assert.ok(doc.grant_types_supported.includes('authorization_code'))
341
341
  })
342
342
 
343
+ // RFC 8414 §3.1: the metadata for an issuer with no path component lives
344
+ // at <origin>/.well-known/oauth-authorization-server. The document names
345
+ // the origin as its issuer, so serving it only under /auth makes it
346
+ // unreachable at the one address a conforming client derives — and every
347
+ // MCP client is a conforming client, sent here by the resource's RFC 9728
348
+ // document. Dynamic registration then fails with the metadata present.
349
+ it('serves the metadata at the origin, where the issuer it declares says it is', async () => {
350
+ const res = await fetch(url('/.well-known/oauth-authorization-server'))
351
+ assert.equal(res.status, 200)
352
+ const doc = await res.json()
353
+ assert.match(doc.registration_endpoint, /\/auth\/register$/)
354
+ })
355
+
356
+ it('is reachable by deriving the URL from the issuer it declares', async () => {
357
+ const doc = await (await fetch(url('/auth/.well-known/oauth-authorization-server'))).json()
358
+ // What a client actually does: take `issuer`, append the well-known
359
+ // path, fetch. If that 404s, discovery is broken however correct the
360
+ // document's contents are.
361
+ const derived = await fetch(`${doc.issuer}/.well-known/oauth-authorization-server`)
362
+ assert.equal(derived.status, 200)
363
+ })
364
+
365
+ it('serves the same document at both mounts', async () => {
366
+ const atBase = await (await fetch(url('/auth/.well-known/oauth-authorization-server'))).json()
367
+ const atRoot = await (await fetch(url('/.well-known/oauth-authorization-server'))).json()
368
+ assert.deepEqual(atRoot, atBase)
369
+ })
370
+
343
371
  it('publishes a JWKS with no private component', async () => {
344
372
  const doc = await (await fetch(url('/auth/jwks.json'))).json()
345
373
  assert.equal(doc.keys.length, 1)