@boopkit/cap-oauth-server 0.1.4 → 0.3.1

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.
Files changed (91) hide show
  1. package/capability.json +14 -2
  2. package/dist/authorization-code-service.d.ts +40 -11
  3. package/dist/authorization-code-service.d.ts.map +1 -1
  4. package/dist/authorization-code-service.js +93 -41
  5. package/dist/authorization-code-service.js.map +1 -1
  6. package/dist/bearer.d.ts +27 -4
  7. package/dist/bearer.d.ts.map +1 -1
  8. package/dist/bearer.js +4 -4
  9. package/dist/bearer.js.map +1 -1
  10. package/dist/client-prune.d.ts +102 -0
  11. package/dist/client-prune.d.ts.map +1 -0
  12. package/dist/client-prune.js +51 -0
  13. package/dist/client-prune.js.map +1 -0
  14. package/dist/client-registry-service.d.ts +85 -8
  15. package/dist/client-registry-service.d.ts.map +1 -1
  16. package/dist/client-registry-service.js +267 -64
  17. package/dist/client-registry-service.js.map +1 -1
  18. package/dist/config.d.ts +6 -0
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +1 -0
  21. package/dist/config.js.map +1 -1
  22. package/dist/connections-controller.js +1 -1
  23. package/dist/connections-controller.js.map +1 -1
  24. package/dist/consent-controller.d.ts +80 -6
  25. package/dist/consent-controller.d.ts.map +1 -1
  26. package/dist/consent-controller.js +134 -18
  27. package/dist/consent-controller.js.map +1 -1
  28. package/dist/discovery-controller.d.ts +2 -1
  29. package/dist/discovery-controller.d.ts.map +1 -1
  30. package/dist/discovery-controller.js +14 -4
  31. package/dist/discovery-controller.js.map +1 -1
  32. package/dist/discovery.d.ts +26 -0
  33. package/dist/discovery.d.ts.map +1 -1
  34. package/dist/discovery.js +55 -1
  35. package/dist/discovery.js.map +1 -1
  36. package/dist/errors.d.ts +38 -0
  37. package/dist/errors.d.ts.map +1 -1
  38. package/dist/errors.js +59 -0
  39. package/dist/errors.js.map +1 -1
  40. package/dist/grant-service.d.ts +11 -4
  41. package/dist/grant-service.d.ts.map +1 -1
  42. package/dist/grant-service.js +6 -3
  43. package/dist/grant-service.js.map +1 -1
  44. package/dist/index.d.ts +10 -5
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +18 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/metadata-document.d.ts +22 -0
  49. package/dist/metadata-document.d.ts.map +1 -1
  50. package/dist/metadata-document.js +44 -2
  51. package/dist/metadata-document.js.map +1 -1
  52. package/dist/outbound-target.d.ts +48 -0
  53. package/dist/outbound-target.d.ts.map +1 -0
  54. package/dist/outbound-target.js +211 -0
  55. package/dist/outbound-target.js.map +1 -0
  56. package/dist/rate-limit.d.ts +53 -11
  57. package/dist/rate-limit.d.ts.map +1 -1
  58. package/dist/rate-limit.js +27 -4
  59. package/dist/rate-limit.js.map +1 -1
  60. package/dist/redirect-uri.d.ts +17 -9
  61. package/dist/redirect-uri.d.ts.map +1 -1
  62. package/dist/redirect-uri.js +26 -10
  63. package/dist/redirect-uri.js.map +1 -1
  64. package/dist/registration-controller.d.ts +10 -0
  65. package/dist/registration-controller.d.ts.map +1 -1
  66. package/dist/registration-controller.js +10 -0
  67. package/dist/registration-controller.js.map +1 -1
  68. package/dist/request-client.d.ts +7 -1
  69. package/dist/request-client.d.ts.map +1 -1
  70. package/dist/request-client.js +31 -13
  71. package/dist/request-client.js.map +1 -1
  72. package/dist/revocation-controller.d.ts +5 -0
  73. package/dist/revocation-controller.d.ts.map +1 -1
  74. package/dist/revocation-controller.js +5 -1
  75. package/dist/revocation-controller.js.map +1 -1
  76. package/dist/same-site.d.ts +54 -0
  77. package/dist/same-site.d.ts.map +1 -0
  78. package/dist/same-site.js +124 -0
  79. package/dist/same-site.js.map +1 -0
  80. package/dist/token-controller.d.ts +26 -0
  81. package/dist/token-controller.d.ts.map +1 -1
  82. package/dist/token-controller.js +44 -19
  83. package/dist/token-controller.js.map +1 -1
  84. package/dist/types.d.ts +4 -0
  85. package/dist/types.d.ts.map +1 -1
  86. package/package.json +2 -2
  87. package/scaffold/reference/oauth-server.ref.md +359 -11
  88. package/scaffold/src/middleware/oauth-server.ts +33 -17
  89. package/scaffold/src/scripts/prune-oauth-clients.ts +86 -0
  90. package/scaffold/src/services/oauth-server.service.ts +24 -0
  91. package/scaffold/views/oauth-consent-error.hbs +10 -3
@@ -31,6 +31,13 @@ The narrow, load-bearing constraint:
31
31
  The one table this capability owns is `oauth_clients`, which describes *software* rather than people.
32
32
  boop's own suite enforces the boundary mechanically: `tests/packages/cap-oauth-server-import-audit.test.ts` fails on `db.user`, `db.account`, `db.userIdentity`, `db.userProperty`, `db.accountUser` or `db.accountProperty` anywhere under the package's `src/` or `scaffold/src/`.
33
33
 
34
+ ### There is exactly one cap-users, and the manifest says so
35
+
36
+ `@boopkit/cap-users` is a **peer** dependency (`>=0.9.0 <1`), not an ordinary one.
37
+ The difference is the whole boundary: a `dependencies` entry lets npm satisfy this package with a nested second copy of cap-users whenever the declared range stops matching the one your project installed — and caret on a 0.x version pins the *minor*, so an ordinary minor bump is enough to trigger it.
38
+ Two copies fail quietly rather than loudly. Tokens this capability hashes with one copy's `hashToken` are unresolvable by the other, and every `instanceof` across the pair is a silent false negative — which is why the error guards in `user-store.ts` and `oauth-error.ts` compare error *names* instead.
39
+ Declared as a peer, the capability uses the cap-users your project already has, and an incompatible one is an install-time error you can read rather than an authentication outage your users report.
40
+
34
41
  ## The client registry
35
42
 
36
43
  One table, `oauth_clients`, with a `mode` discriminator rather than three tables.
@@ -49,6 +56,40 @@ Two columns carry the design:
49
56
 
50
57
  `status` is `enabled` or `suspended`. Suspending is how you stop a compromised client, because deleting the row would strand the live tokens that carry its prefix.
51
58
 
59
+ ### Two writers creating the same client
60
+
61
+ Both paths that create a row — `registerStaticClient` and `resolveMetadataDocument` — look the client up, and then create it, with an `await` in between.
62
+ Two callers can therefore both look, both see nothing, and both write.
63
+ This is not hypothetical timing trivia: `registerStaticClient` is what a seed script calls, and two app instances booting together run it at the same moment.
64
+ `resolveMetadataDocument` has the wider window of the two, because it fetches the client's metadata document between the lookup and the write, so two authorization requests naming the same new `client_id` overlap for a whole network round-trip.
65
+
66
+ The unique index on `client_id` settles it, and the loser gets a Prisma `P2002`.
67
+ Both paths catch that and converge on the winner's row instead of failing:
68
+
69
+ - `registerStaticClient` re-runs its update against the existing row. The data it writes carries no `publicId`, so the **winner's token prefix survives** — which is the whole point, since every token this client is ever issued carries it.
70
+ - `resolveMetadataDocument` re-reads rather than writes. The row that won might be `static` or `dynamic` — somebody racing an operator's seed of the same URL — and overwriting one of those is exactly what the mode check exists to prevent.
71
+
72
+ A `P2002` on `public_id` is **not** treated as a lost race. That is a collision between two generated identifiers, which is genuinely exceptional, and it is rethrown.
73
+
74
+ **If you write a recovery like this yourself, read `isUniqueConstraintViolation` in `errors.ts` first.**
75
+ Which name Prisma puts in `meta.target` depends on the connector: PostgreSQL reports the **column** (`client_id`), because it reads the target off the constraint, while MySQL reports the **field** (`clientId`).
76
+ The predicate accepts both, and it does so because it once did not — see below.
77
+
78
+ ### What is proven against a real database, and what is not
79
+
80
+ Most tests of `ClientRegistryService` run against `tests/helpers/oauth-client-delegate.ts`, an in-memory stand-in.
81
+ `tests/integration/oauth-clients-postgres.test.ts` migrates this capability's schema fragment into a throwaway Postgres and runs the service against the generated Prisma client, covering the things that are properties of the **column** rather than of the service: that `varchar(512)` holds a 512-character `client_id` and refuses a longer one, that the JSON columns round-trip by value, that `@updatedAt` fires while `@default(now())` stays put, that both `@@index` declarations survive migration and `(mode, created_at)` can answer the prune's predicate, and that two connections racing to seed one `client_id` converge on one row.
82
+
83
+ It is gated on `E2E_SCHEMA=1`, because it installs Prisma and migrates a database, and it needs a local Postgres.
84
+
85
+ ```
86
+ E2E_SCHEMA=1 npx jest tests/integration/oauth-clients-postgres.test.ts
87
+ ```
88
+
89
+ Run it when you change the schema fragment or the mock delegate.
90
+ It has already earned its keep: a mock decides for itself what its own errors look like, and the first thing real Postgres said was that the mock had picked the wrong spelling for `meta.target`.
91
+ The recovery paths above matched a string the database never sends, and every mock test in the suite passed anyway.
92
+
52
93
  ### Redirect URIs
53
94
 
54
95
  Three shapes may be registered, and no others — the three RFC 8252 names for calling a client back.
@@ -63,9 +104,19 @@ None of them may carry a fragment.
63
104
  A private-use entry must be **hierarchical** — something after the scheme starts with `/` — and its scheme may not be one a browser already gives a meaning to.
64
105
  That refuses `urn:ietf:wg:oauth:2.0:oob`, `mailto:` and `tel:`, which carry an opaque payload rather than a location, and it refuses `javascript:`, `data:`, `vbscript:`, `view-source:`, `blob:`, `about:`, `file:`, `ws:`, `ftp:` and the privileged browser origins, which are places a browser must never be redirected to.
65
106
 
66
- Matching is **exact string match**, with **one** exception: a registered *loopback `http`* entry matches a presented URI that differs *only* in port (§7.3).
107
+ Matching is **exact string match**, with **one** exception: a registered entry whose **host** is `localhost` or `127.0.0.1` matches a presented URI that differs *only* in port (§7.3).
67
108
  Claude Code needs it — it binds an ephemeral loopback port at connect time.
68
- Nothing else is relaxed: not the scheme, not the path, not a trailing slash, not the port on a public host, and not the port on a private-use URI whose authority happens to read as `localhost`.
109
+ Nothing else is relaxed: not the path, not a trailing slash, not the port on a public host, and not the port on a private-use URI whose authority happens to read as `localhost`.
110
+ The presented URI must carry the *same* scheme and the *same* host — `localhost` and `127.0.0.1` are two different hosts here, because §7.3 relaxes the port rather than name resolution.
111
+
112
+ The exception is keyed on the host and **not** on the scheme, so a registered `https://localhost/cb` gets its port relaxed as well.
113
+ §7.3 is about loopback `http`, so an argument exists for narrowing this to `http` alone; it stays host-keyed because a loopback port is ephemeral whichever scheme terminates it, and refusing an operator who runs a local client over TLS buys nothing.
114
+ So registering `https://localhost/cb` does not buy you exact matching on the port, which is the reading an operator is most likely to arrive at on their own.
115
+
116
+ Those two hosts are the whole list: the IPv6 loopback `[::1]` is **deliberately unsupported**.
117
+ It keeps the surface to the two spellings the design enumerated, at the known cost of turning away a native app on an IPv6-only machine — a client RFC 8252 §7.3 anticipates.
118
+ The refusal is at least legible rather than silent: `validateRedirectUris` reads `http://[::1]:52731/callback` as `http` on a non-loopback host and rejects it at **registration**, so the client learns at the point it registers instead of failing later with `redirect_uri is not registered`.
119
+ An `https://[::1]/cb` entry does register — it is an ordinary absolute `https` URI — but it gets exact matching, port and all, like any other `https` host.
69
120
 
70
121
  Both halves read one exported function, `classifyRedirectUri`, so what may be stored and what may be matched cannot drift apart.
71
122
  A client that could register and then not connect is the same defect as a list that was never validated at all.
@@ -83,6 +134,12 @@ Both are **computed from configuration**, not read off disk, and both are cached
83
134
 
84
135
  `resource` is `OAUTH_RESOURCE_URL`, and it is **not** the issuer: it must equal the URL clients actually connect to, exactly as they enter it.
85
136
 
137
+ The protected-resource document is served at **two** paths whenever that resource is not at the root.
138
+ RFC 9728 §3.1 puts it at the well-known segment inserted between the host and the resource's own path — a resource at `https://app/api/mcp` publishes at `https://app/.well-known/oauth-protected-resource/api/mcp` — and that is where clients ask first.
139
+ The bare `/.well-known/oauth-protected-resource` stays beside it, because it is where a client that took a 404 from the specified location falls back.
140
+ Both paths are fixed at boot from `OAUTH_RESOURCE_URL`; neither is built from anything a caller sends, and a resource at the root gets the bare path alone.
141
+ So does a resource whose path Express would read as a route pattern rather than as a literal — one containing `:`, `(`, `+`, `[` or `*` — since an app that refuses to boot over a discovery document's address is the worse trade.
142
+
86
143
  The device-code grant type appears in `grant_types_supported` only when `OAUTH_DEVICE_CODE_ENABLED` is the string `"true"`.
87
144
  When the flag is off the endpoints are not mounted *and* the grant type is absent — advertising a grant whose endpoint answers 404 is worse than not advertising it, because a client that read the metadata treats the 404 as a server fault.
88
145
  The same rule governs the four endpoint URLs: every one of them, `revocation_endpoint` included, is mounted.
@@ -92,19 +149,204 @@ The same rule governs the four endpoint URLs: every one of them, `revocation_end
92
149
  `POST /oauth/register` — RFC 7591 open registration. Unauthenticated by design; that is what the Claude ecosystem expects.
93
150
 
94
151
  A valid request returns **201** with `client_id`, `client_id_issued_at`, the echoed metadata, `grant_types`, `response_types`, `token_endpoint_auth_method` and `scope`.
95
- There is no `client_secret`: this server issues only public clients.
152
+ There is no `client_secret`: this server issues only public clients, and `token_endpoint_auth_method` in the response is always `none`, whatever the request declared.
153
+ [Which authentication method a client may declare](#which-authentication-method-a-client-may-declare) says what is accepted and what is refused.
96
154
 
97
- Because the endpoint is an unauthenticated write, it carries four controls:
155
+ Because the endpoint is an unauthenticated write, it carries six controls:
98
156
 
99
157
  1. `redirect_uris` is **required** and validated.
100
- 2. Per-IP rate limiting — `OAUTH_REGISTRATION_RATE_LIMIT` per hour, in process memory, per instance.
101
- 3. A row ceiling — `OAUTH_MAX_REGISTERED_CLIENTS`. This is the hard bound; the rate limit only makes reaching it slow.
102
- 4. `ClientRegistryService.pruneAbandonedClients()` for rows that never went anywhere. It takes an "is this in use?" predicate, which stays unimplemented until grants exist.
158
+ 2. `logo_uri`, `client_uri`, `policy_uri` and `tos_uri` must each be an absolute `https` URL or be
159
+ absent, and a non-https value is refused with `invalid_client_metadata` naming the field.
160
+ These four are the stranger-supplied values the consent screen renders — one in an `<img src>`,
161
+ three in an `<a href>` — and template escaping does not touch a scheme, so `javascript:` would
162
+ otherwise reach an `href` on the page where the user grants access. The same rule applies to
163
+ `ClientRegistryService.updateRegistration` and to a metadata document. The consent screen sanitises on the
164
+ way out as well (`safeHttpsUri`), which is what covers rows written before the rule existed; the
165
+ registration-time refusal is what tells the registrant rather than letting their privacy-policy
166
+ link silently fail to appear.
167
+ 3. Per-IP rate limiting — `OAUTH_REGISTRATION_RATE_LIMIT` per hour, in process memory, per instance.
168
+ 4. A row ceiling — `OAUTH_MAX_REGISTERED_CLIENTS`. This is the hard bound; the rate limit only makes reaching it slow.
169
+ 5. A scheduled prune for rows that never went anywhere — `npm run oauth:prune-clients`, backed by `ClientRegistryService.pruneAbandonedClients()`. See [Collecting abandoned registrations](#collecting-abandoned-registrations).
170
+ 6. `scope` is **narrowed to `OAUTH_SCOPES_SUPPORTED`**, the way `grant_types` is.
171
+ A registration naming `profile admin` is stored as `profile` and the 201 says so; one naming only scopes you do not advertise is refused with `invalid_client_metadata`.
172
+ Storing it verbatim would make `scopes_supported` in your discovery document untrue and put a word nothing describes in front of a user on the consent screen.
173
+ The same ceiling applies to a metadata document, whose owner writes it unsupervised, and to the no-`scope` path at `/oauth/authorize`, which falls back to the client's registered list.
103
174
 
104
175
  **A `static` row cannot be modified or deleted through this surface.** That is a check in the service, not a convention: `/oauth/register` has no caller to authenticate, so a client you vouched for must not be rewritable by a stranger.
105
176
 
106
177
  If open registration does not suit you, remove the `preRoutes` hook that mounts `oauthServerRoutes` and keep the seed script.
107
178
 
179
+ ### Which authentication method a client may declare
180
+
181
+ This server does not authenticate a client at the token endpoint.
182
+ A `client_id` that names an enabled row is the whole check, and PKCE is what protects the code exchange.
183
+ So every row is stored with `token_endpoint_auth_method: "none"`, and what a client declares decides only whether it is registered at all.
184
+ The rule is the same for a `POST /oauth/register` body and for a Client ID Metadata Document.
185
+
186
+ | The client declares | And its `token_endpoint_auth_methods_supported` | What happens |
187
+ |---|---|---|
188
+ | nothing, or `none` | anything | Registered as a public client. |
189
+ | any other method, such as `private_key_jwt` or `client_secret_basic` | lists `none` | Registered as a public client. The declared method is not stored and is never performed. |
190
+ | any other method | is absent, or does not list `none` | Refused with `invalid_client_metadata`. The message names the declared method and the methods the client listed. |
191
+
192
+ The middle row is what lets ChatGPT connect.
193
+ Its metadata document declares `private_key_jwt` and lists `["none", "private_key_jwt"]` as the methods it supports, so it prefers a signed assertion and can work without one.
194
+ RFC 7591 §3.2.1 lets a server replace a requested metadata value with one it supports, which is the same reading that narrows `grant_types` and `scope`.
195
+ The client is told what it got: the discovery document advertises only `none`, and a registration response echoes `token_endpoint_auth_method: "none"`.
196
+
197
+ The last row stays a refusal on purpose.
198
+ A client that declares `private_key_jwt` and does not offer `none` expects the token endpoint to check its signature.
199
+ This server would not, so registering it would give that client weaker protection than it asked for without telling it.
200
+ On the metadata-document path the refusal reaches the operator through the consent router's `warn` port, like any other document the registry will not use.
201
+
202
+ #### When a registered client stops offering `none`
203
+
204
+ A metadata document is fetched again once the registry's copy is more than 24 hours old, and the rule above is applied to the new document too.
205
+ If the new document cannot be fetched, or is refused for any other reason, `resolveMetadataDocument` returns the row it already has and reports the failure through `onMetadataRefreshFailed`.
206
+ A client whose document is briefly unreachable, or briefly wrong, should not break consent for the people already using it.
207
+
208
+ One refusal is handled differently.
209
+ If the document was fetched and now declares a method without offering `none`, `resolveMetadataDocument` throws the same `invalid_client_metadata` a new client with that document would get, and does not call `onMetadataRefreshFailed`.
210
+ The client has withdrawn the thing its registration rested on, and serving the old row would give it the weaker protection the last row of the table refuses.
211
+ A client that arrived yesterday and one that arrives today get the same answer.
212
+
213
+ The refusal writes nothing.
214
+ The row is still `enabled`, and its copy of the document is still stale, so the next call fetches the document again and a client that puts `none` back is served again without anyone stepping in.
215
+ Because the row is untouched, the token endpoint and the resource server, which look a client up by its row and never read its document, go on serving the connections that client already has.
216
+ If you want those stopped as well, [`suspend`](#there-is-no-way-to-edit-a-registration-afterwards-by-design) the client.
217
+
218
+ Only the authentication method is treated this way.
219
+ Refusing the cached row for every validation failure would turn a typo in a client's document, or a rule of this server's that turns out to be too strict, into an outage for everyone connected through that client.
220
+
221
+ **The shipped `/oauth/authorize` route does not reach any of this yet.**
222
+ It looks a client up by its row first and calls `resolveMetadataDocument` only when there is none, so a metadata-document client that already has a row is never fetched again through the routes (boop-uep7).
223
+ Until that changes, the paragraphs above describe `resolveMetadataDocument` for an app that calls it directly, and a registered client's row stays as it was on first sight.
224
+
225
+ `private_key_jwt` itself is not implemented: nothing fetches a client's `jwks_uri`, and nothing verifies a `client_assertion`.
226
+ See [What is not here yet](#what-is-not-here-yet).
227
+
228
+ ### There is no way to edit a registration afterwards, by design
229
+
230
+ RFC 7592 defines `PUT` and `DELETE` on `/oauth/register/:client_id`, authenticated by a `registration_access_token` the registration response hands out.
231
+ **None of that is here, and none of it is planned** — see [What is not here yet](#what-is-not-here-yet) for the reasoning.
232
+ So a registration is create-and-forget: a client that loses its `client_id` registers again, which costs it one unauthenticated request.
233
+
234
+ What you get instead is a library API rather than an HTTP one.
235
+ `ClientRegistryService.updateRegistration(clientId, metadata)` and `.deleteRegistration(clientId)` are exported for **your** admin screens and support scripts, where the caller is an operator you have already authenticated.
236
+ Both validate what `registerDynamic` validates, so an amendment cannot reach a value a registration could not, and both refuse a `static` row.
237
+ Prefer `suspend` over `deleteRegistration` for a client that may still have tokens outstanding: deleting the row drops the `publicId` every one of those tokens carries as its prefix.
238
+
239
+ ### The other registration endpoint: `GET /oauth/authorize`
240
+
241
+ A Client ID Metadata Document client registers itself **by arriving**. An https `client_id` with no row yet is fetched at its own URL and written, on an unauthenticated GET — so the consent endpoint is a registration endpoint too, and it carries the same controls under different names:
242
+
243
+ | Control | On `POST /oauth/register` | On first-sight CIMD resolution |
244
+ |---|---|---|
245
+ | Rate limit | `OAUTH_REGISTRATION_RATE_LIMIT` | `OAUTH_METADATA_RESOLUTION_RATE_LIMIT`, charged only when the `client_id` has no row |
246
+ | Row ceiling | `OAUTH_MAX_REGISTERED_CLIENTS` | the same ceiling — `dynamic` and `metadata_document` rows are counted together |
247
+ | Prune | `npm run oauth:prune-clients` | the same prune, over both modes |
248
+ | Where it may point | n/a — nothing is fetched | private, loopback, link-local and local-network targets are refused |
249
+
250
+ Only `static` rows are outside all four: an operator wrote them, and letting them fill the ceiling would let your own seed script lock registration shut.
251
+
252
+ Both limiters key on `req.ip`. If you run behind a proxy or a CDN, set `trust proxy` on the app — without it `req.ip` is the proxy, every caller shares one bucket, and the limiter stops nobody in particular while rationing everybody.
253
+
254
+ **The last row is the one to understand.** The URL fetched is a string an unauthenticated caller supplied, so without a guard the consent endpoint is a blind SSRF probe of whatever network your app runs in — the document body is discarded on a failure, but existence and latency are both readable off the response time, and `169.254.169.254` is where your cloud keeps its instance credentials. Every literal private, loopback, link-local and multicast address is refused, as are `localhost`, `.local`, `.internal` and `.home.arpa`, and the check runs again on every redirect hop.
255
+
256
+ A *name* that merely points somewhere private needs DNS, which the capability will not do on its own. The scaffold wires `dns.promises.lookup` as `metadataResolveHost`, and every address a name resolves to is then run through the same table. Keep it. What remains uncovered is DNS rebinding — a name re-resolved between the check and the connection — which needs a transport that connects to an address this code chose, and `fetch` does not offer one.
257
+
258
+ The ceiling is checked **before** the fetch, not before the write: a caller past it must not still be able to make your server issue one outbound request per attempt. A client that already has a row is served past the ceiling, including its lazy re-fetch, so filling the table does not break every existing connection at once.
259
+ That re-fetch is the registry's; the route does not yet trigger one for a client it already knows, as [When a registered client stops offering `none`](#when-a-registered-client-stops-offering-none) explains.
260
+
261
+ #### When resolution fails, the log and the page say different things
262
+
263
+ A resolution can fail for reasons that need different fixes — the document was unreachable, it was not
264
+ JSON, it declared a grant type this server does not support, it declared an authentication method
265
+ without offering `none`, the registry was built with no `fetch` to reach it through, the ceiling was
266
+ already full.
267
+ The page tells the person in front of it only that `the metadata document for this client_id could not
268
+ be used`;
269
+ the reason goes to the operator, in the registry's own words, through the consent router's `warn` port
270
+ (`console.warn` unless you pass one):
271
+
272
+ ```
273
+ [cap-oauth-server] consent refused: could not resolve the Client ID Metadata Document at
274
+ https://client.example/id — boop_metadata_document_unusable: metadata document at
275
+ https://client.example/id is not JSON
276
+ ```
277
+
278
+ Those two audiences do not need the same words, and until they were separated they did not get the
279
+ truth either: every one of those failures was reported to both as `unknown client_id`, a condition
280
+ that on this branch is never what happened (boop-nvbd).
281
+ Distinguishing it from a genuinely unknown `client_id` is not an enumeration oracle, because the
282
+ branch is reached only for an https `client_id` with no row — the caller learns nothing their own URL
283
+ did not already tell them.
284
+
285
+ The refusal page names a **suspended** client as suspended, with `403`, for the same kind of reason
286
+ (boop-wwxg).
287
+ Folding it into `unknown client_id` would hide nothing: a registered `client_id` is already
288
+ distinguishable from an unregistered one here, because probing either with a `redirect_uri` the client
289
+ does not own tells only the registered one that the URI is unregistered — and RFC 6749 §4.1.2.1
290
+ requires that sentence to be said on this page rather than redirected.
291
+ A `client_id` is a public identifier in any case.
292
+ What it would cost is the operator of a suspended client being told their intact registration is
293
+ unknown, which is the untrue sentence the paragraph above exists to remove.
294
+
295
+ The line is collapsed to one line before it is emitted.
296
+ The `client_id` is a query parameter and appears in it twice, so without that a caller sending `%0A`
297
+ writes a second line into your log and chooses what it says.
298
+
299
+ A failure that is **not** a registry refusal — the client store unreachable, a bug in the capability —
300
+ is answered `500` rather than turned into a refusal page, so an operator is not sent to read a request
301
+ when the answer is in the database.
302
+
303
+ ### Collecting abandoned registrations
304
+
305
+ Of the controls above, only the prune is a *collector*.
306
+ `OAUTH_MAX_REGISTERED_CLIENTS` caps the table and the rate limits make filling it slow, but neither
307
+ ever gives a row back — so a deployment that leaves the prune unscheduled eventually reaches the
308
+ ceiling and starts refusing **legitimate** registrations, because a script that walked away a year
309
+ ago is still holding a thousand slots.
310
+
311
+ The sweep is `npm run oauth:prune-clients`, which runs `src/scripts/prune-oauth-clients.ts`.
312
+ **It does not run on a timer by itself.**
313
+ Schedule it daily — Heroku Scheduler, cron, or a cap-background-jobs consumer calling the script's
314
+ exported `pruneAbandonedOAuthClients()`.
315
+ It is idempotent and reads only rows past the cutoff, so running it more often changes nothing but
316
+ the size of each batch.
317
+
318
+ It deletes a `dynamic` or `metadata_document` row when **both** are true:
319
+
320
+ 1. it was created more than thirty days ago, and
321
+ 2. nobody has that client connected.
322
+
323
+ A `static` row is never swept, however old — an operator wrote it, and the ceiling does not count it
324
+ either.
325
+
326
+ **"Connected" means a live grant, and nothing else.**
327
+ The predicate (`createClientInUsePredicate`) finds the client's users through the `"<publicId>|"`
328
+ token-key scan and then reads each one's `oauth-grants`.
329
+ It does not decide from the identity rows, for the reason
330
+ [What a revoke actually writes](#what-a-revoke-actually-writes) gives at length: an ended row does
331
+ not mean disconnected, because ordinary expiry end-dates rows too, and a live row does not mean
332
+ connected, because rotation leaves the superseded refresh row deliberately active.
333
+ Reading tokens would delete a client half your deployment uses.
334
+
335
+ The scan is sound because a grant is only ever written at redemption, in the same request that mints
336
+ the first token — a grant with no token row behind it is not a state this server can reach.
337
+
338
+ Two consequences worth knowing before you shorten the thirty days:
339
+
340
+ - **A disconnected client is collectable.** An ended grant is not in use, so once the row is old
341
+ enough it goes. `/connections` already renders a grant whose registry row has gone: it falls back
342
+ to the `client_id` the client presented and says the application is no longer registered here,
343
+ because a connection the screen does not list is a connection nobody can disconnect.
344
+ - **An unredeemed authorization code is not protected.** Codes live for a minute and the cutoff is
345
+ thirty days, so the window is a client that registered a month ago, never connected, and is
346
+ mid-flow at the moment the sweep runs. It loses that one redemption and can register again.
347
+ Closing it would mean scanning `oauth-codes`, a per-user property with no cross-user read — a full
348
+ user scan to buy a minute of protection for a client that has never completed a grant.
349
+
108
350
  ## The interactive grant, end to end
109
351
 
110
352
  A connector's whole journey through this app, and where each step lives.
@@ -134,7 +376,10 @@ Three things on that page are load-bearing rather than decorative. A redesign ha
134
376
  icon come from an unauthenticated stranger, and they are the entire evidence base for the only
135
377
  decision on the page. `.consent-icon` fixes the box's size; a 4000px logo, a 1px logo and no logo at
136
378
  all all produce the same layout. Anything that is not an absolute `https` URL falls back to a
137
- monogram.
379
+ monogram, and the same `https`-only rule governs the client's three link URIs — `client_uri`,
380
+ `policy_uri` and `tos_uri` — which are dropped rather than rendered when they fail it. The person
381
+ who follows a connector's privacy-policy link before approving is the cautious one, and a scheme a
382
+ stranger chose must not be what they land on.
138
383
 
139
384
  **The provenance line says where the client's claims came from**, and its wording differs by
140
385
  registration mode: an operator-configured client says so, a metadata-document client names the origin
@@ -189,6 +434,46 @@ Both ports say no by throwing. The login face shows one generic message whatever
189
434
  signup face shows the thrown error's message, so a validation failure somebody can act on should
190
435
  carry words worth reading.
191
436
 
437
+ ### The form defends itself against another site
438
+
439
+ `POST /oauth/authorize` mints an authorization code, and the only credential it needs is the session
440
+ cookie the browser attaches unprompted. That is the shape cross-site request forgery exploits: an
441
+ attacker registers a client with their own `redirect_uris` — dynamic registration is open — and
442
+ serves a page that auto-submits this form with `action=approve`, so a code for the victim's account
443
+ lands at the attacker's callback.
444
+
445
+ Neither of the two things that look like a defence is one. PKCE binds a code to whoever asked for it,
446
+ and here the attacker *is* the client, holding both the challenge and the verifier. `state` is the
447
+ client's CSRF token, echoed back untouched, and on this attack the attacker chose it. What was
448
+ actually holding the line was a browser default — an unlabelled cookie is treated as `SameSite=Lax`,
449
+ which is not sent on a cross-site POST — and a defence that lives entirely in the user agent is one
450
+ this server cannot assert or test.
451
+
452
+ So there are two now, and they are independent:
453
+
454
+ - **The route checks where the submission came from.** `Origin` and `Sec-Fetch-Site` are forbidden
455
+ header names, so page script cannot set or remove either and a hostile page's form post carries
456
+ them truthfully. Anything labelled as coming from elsewhere is refused with a **403 on our own
457
+ page** — never a redirect, because redirecting to the URI the attacker registered would tell them
458
+ the victim's browser got here. A request carrying neither header is accepted: it is not a browser,
459
+ and a forgery needs a browser, because a browser is the only thing that will attach somebody
460
+ else's cookie. `isSameSiteRequest` is exported if you want it on a form of your own.
461
+ - **The session cookie is labelled.** `SameSite=Lax` and, in production, `Secure` — on the cookie
462
+ the consent page's inline login writes and on both cookies cap-users' `getOrCreateUser` writes.
463
+
464
+ `Lax` rather than `Strict`, deliberately. The consent screen is *reached* by a cross-site top-level
465
+ navigation — the client sends the browser here — and `Strict` withholds the cookie on exactly that
466
+ hop, so every authorization would meet a login form for somebody who is already signed in. `Lax`
467
+ refuses the cross-site POST, which is the request the attack needs, and permits the arrival, which
468
+ is the request the flow needs. `cookieSameSite` overrides it if your deployment disagrees.
469
+
470
+ The one thing you may need to configure is `allowedOrigins`. The host the request arrived on is read
471
+ from the request, so an ordinary deployment needs nothing; the scaffold additionally names
472
+ `OAUTH_ISSUER_URL`'s origin, because that *is* this server's public origin, which is what keeps the
473
+ check right behind a reverse proxy that rewrites `Host` without setting `X-Forwarded-Host`. Add an
474
+ entry only if you deliberately serve the consent page from a second origin — every entry widens what
475
+ counts as your application.
476
+
192
477
  ### Scope descriptions are yours to write
193
478
 
194
479
  The capability transports and enforces scope strings; what `workflows:advance` *means* is your domain
@@ -205,6 +490,11 @@ Any advertised scope you have not described produces a **boot warning naming it*
205
490
  back to showing the raw string marked as machine text. A consent screen listing `workflows:advance`
206
491
  is asking somebody to approve something they cannot evaluate.
207
492
 
493
+ The list you describe is the whole list a consent screen can show, because no client can hold a scope
494
+ outside `OAUTH_SCOPES_SUPPORTED`: registration narrows to it, an explicit `scope` on an authorization
495
+ request is checked against it, and the no-`scope` fallback to the client's registered list is filtered
496
+ through it as well.
497
+
208
498
  ## Tokens
209
499
 
210
500
  Three credentials, all `<client publicId>.<random>` on the wire and all stored as
@@ -216,6 +506,28 @@ Three credentials, all `<client publicId>.<random>` on the wire and all stored a
216
506
  | Access token | `oauth-access` | `OAUTH_ACCESS_TOKEN_TTL_SECONDS` (900) |
217
507
  | Refresh token | `oauth-refresh` | `OAUTH_REFRESH_TOKEN_TTL_SECONDS` (7776000), sliding |
218
508
 
509
+ ### How the token endpoint identifies the client
510
+
511
+ `POST /oauth/token` and `POST /oauth/revoke` read `client_id` from the request body and nothing else.
512
+ A request with no `client_id` is answered `400 invalid_request`, and one whose `client_id` names no registered client, or a suspended one, is answered `401 invalid_client`.
513
+
514
+ A request may carry `client_assertion` and `client_assertion_type` beside a valid `client_id`, as a client that declared `private_key_jwt` might send.
515
+ Both fields are ignored: the assertion is not verified, and the request is not refused for carrying it.
516
+
517
+ A client that sends an assertion *in place of* `client_id` is refused, because nothing here reads an identity out of an assertion.
518
+ Whether ChatGPT does that has not been observed.
519
+ Until its document was accepted it was turned away at consent, so it had never reached this endpoint.
520
+ To make that first attempt diagnosable, a request refused because the client could not be identified writes one line through the router's `warn` option (`console.warn` unless you pass one), naming which of the three client-authentication fields the request carried:
521
+
522
+ ```
523
+ [cap-oauth-server] POST /oauth/token refused: no client_id in the request. The request carried
524
+ client_assertion, client_assertion_type.
525
+ ```
526
+
527
+ The line gives field names and never values.
528
+ An assertion is a signed credential, and a `client_id` is a string the caller chose.
529
+ A refusal for a suspended client is not logged this way, because that client was identified.
530
+
219
531
  ### `endedAt` is a tombstone, not an expiry
220
532
 
221
533
  Worth knowing before you read the code and conclude something is missing. cap-users'
@@ -314,6 +626,15 @@ Do not mount `resolveOAuthBearer` on a router that already carries cap-users' `r
314
626
  read the same header. They are safe to have in one application because both **refuse** rather than
315
627
  falling back to the session cookie, but one router should have one door.
316
628
 
629
+ **Where your users come from is one line.** `createOAuthResourceServer` takes a token service, a
630
+ grant service, a `findUserById` function and a challenge — and nothing else. The scaffold points
631
+ `findUserById` at cap-users' `userService.findById`, which is the right answer for a boop
632
+ application, but the resolver calls it as a plain function and never reaches for a service, so an
633
+ application that resolves users through a cache, a read replica or a layer of its own replaces that
634
+ line and changes nothing else. The factory used to demand cap-users' `{ authService,
635
+ identityService, userService }` trio as well and read none of it; it no longer does (boop-9ral), so
636
+ the user layer is supplied once rather than twice.
637
+
317
638
 
318
639
  ## Disconnecting — `/connections` and soft revocation
319
640
 
@@ -413,6 +734,8 @@ somebody debug — an empty 200 is the feature.
413
734
  The only refusals are about the request itself: `400 invalid_request` for a missing `token` or
414
735
  `client_id`, `400 unsupported_token_type` for a `token_type_hint` naming a species this server does
415
736
  not issue, and `401 invalid_client` for a client that is unknown or suspended.
737
+ A request refused because the client could not be identified is logged as the token endpoint logs
738
+ it; see [How the token endpoint identifies the client](#how-the-token-endpoint-identifies-the-client).
416
739
 
417
740
  The hint is advisory. Both token domains are searched, hint first, because a server that trusted it
418
741
  would silently fail to revoke a correctly-presented token that a client mislabelled.
@@ -428,6 +751,7 @@ would silently fail to revoke a correctly-presented token that a client mislabel
428
751
  | `OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `7776000` | 90 days, sliding on rotation. Clamped to 3600–31536000. |
429
752
  | `OAUTH_MAX_REGISTERED_CLIENTS` | `1000` | |
430
753
  | `OAUTH_REGISTRATION_RATE_LIMIT` | `10` | Per hour, per address. |
754
+ | `OAUTH_METADATA_RESOLUTION_RATE_LIMIT` | `10` | Per hour, per address. Charged only for a metadata-document `client_id` with no row yet. |
431
755
  | `OAUTH_DEVICE_CODE_ENABLED` | `false` | Not implemented yet; named so the flag has one home. |
432
756
  | `OAUTH_DOCUMENTATION_URL` | — | Echoed into both documents. |
433
757
 
@@ -450,6 +774,7 @@ The refresh token is long and *sliding*: each rotation mints a token with a fres
450
774
  | `src/services/oauth-server.service.ts` | Binds `db.oauthClient` and the environment to the capability's services |
451
775
  | `src/middleware/oauth-server.ts` | Builds the routers the server hooks mount, plus `resolveOAuthBearer` and `requireOAuth` |
452
776
  | `src/scripts/seed-oauth-clients.ts` | The static clients this deployment trusts. Edit this. |
777
+ | `src/scripts/prune-oauth-clients.ts` | The abandoned-registration sweep. Schedule it. |
453
778
  | `views/oauth-consent.hbs` | The consent screen, in all three variants |
454
779
  | `views/oauth-consent-error.hbs` | Shown when the request named no usable client or redirect URI |
455
780
  | `views/oauth-connected-clients.hbs` | `/connections` — what this user has connected, and what they once had |
@@ -465,7 +790,10 @@ The refresh token is long and *sliding*: each rotation mints a token with a fres
465
790
  - `generateClientPublicId()`, `isValidClientPublicId(value)`
466
791
  - `buildAuthorizationServerMetadata(config)`, `buildProtectedResourceMetadata(config)`
467
792
  - `createDiscoveryRouter(config)`, `createRegistrationRouter(options)`, and the handler-set factories behind both
468
- - `createRegistrationRateLimiter(options)`
793
+ - `createFixedWindowRateLimiter(options)` — the per-IP counter both unauthenticated write paths share; `createRegistrationRateLimiter` is the same factory under its original name
794
+ - `blockedHostReason(hostname)`, `isBlockedAddress(value)` — where a caller-chosen URL may point
795
+ - `STRANGER_CREATED_MODES` — the two modes the ceiling and the prune count
796
+ - `createClientPruneJob(options)` — the scheduled abandoned-registration sweep; `createClientInUsePredicate(options)` is the "is anybody still connected?" half on its own
469
797
  - `readServerConfig(env)` — the environment, parsed, clamped and defaulted
470
798
  - `createTokenService(options)` — minting, parsing, the expiry rule and the lazy sweep
471
799
  - `createGrantService(options)` — `read`, `write`, `list`, `findActive`, `findById`, `approve`, `setRefreshKey`, `end`
@@ -474,10 +802,10 @@ The refresh token is long and *sliding*: each rotation mints a token with a fres
474
802
  - `createConnectionsRouter(options)` — `/connections` and its disconnect confirmation page
475
803
  - `createAuthorizationCodeService(options)` — `issue`, `redeem`, `list`
476
804
  - `createConsentRouter(options)`, `createTokenRouter(options)`, and the handler-set factories behind both
477
- - `createOAuthResourceServer(deps, options)` — `resolveBearer` and `requireOAuth`, sharing one challenge
805
+ - `createOAuthResourceServer(options)` — `resolveBearer` and `requireOAuth`, sharing one challenge
478
806
  - `requireOAuth(...scopes)` and `missingScopes(granted, required)` on their own, for a route that already has a resolver in front of it
479
807
  - `verifyCodeChallenge`, `deriveCodeChallenge`, `isValidCodeVerifier`, `isValidCodeChallenge`
480
- - `warnUndescribedScopes`, `provenanceFor`, `safeLogoUri`, `monogramFor`, `toScopeViews`
808
+ - `warnUndescribedScopes`, `provenanceFor`, `safeHttpsUri`, `monogramFor`, `toScopeViews`
481
809
  - `ACCOUNT_COVERAGE_STATEMENT`, `REVOKED_BY_USER`, `REVOKED_BY_CLIENT`, `REVOKED_CLIENT_WIDE`
482
810
  - `ClientRegistryError` / `isClientRegistryError`, `OAuthError` / `isOAuthError`, `isStaleWriteError`
483
811
 
@@ -490,6 +818,26 @@ Named so nobody debugs their absence:
490
818
  one.
491
819
  - **The device-code endpoints.** `OAUTH_DEVICE_CODE_ENABLED` names the flag; nothing reads it beyond
492
820
  keeping the grant type out of the discovery document.
821
+ - **`private_key_jwt`, or any other client authentication.** A client that declares one is
822
+ registered as a public client when it also offers `none`, and refused otherwise; see
823
+ [Which authentication method a client may declare](#which-authentication-method-a-client-may-declare).
824
+ Implementing it means fetching the client's `jwks_uri` through the same guarded fetch as the
825
+ metadata document, verifying a signed `client_assertion` at the token and revocation endpoints,
826
+ and advertising the method in the discovery document.
827
+
828
+ And one that is decided rather than pending — nothing is waiting on a later slice to add it:
829
+
830
+ - **RFC 7592 client management.** `PUT`/`DELETE /oauth/register/:client_id` are not mounted, the 201
831
+ carries no `registration_access_token` or `registration_client_uri`, and the discovery document
832
+ advertises no management surface. Authenticating those endpoints means a third credential kind,
833
+ with its own hashing, rotation and revocation story, bought so that a client can amend a
834
+ registration it can re-create with one unauthenticated `POST`; nothing in the Claude ecosystem
835
+ checklist asks for it. The endpoints being *absent* is what makes them safe — mounted without that
836
+ credential, they would let any caller rewrite any dynamic client's redirect URIs, which is the hole
837
+ the six registration controls above exist to close. Reversing this means shipping the routes, the
838
+ token and the response fields in one change, never the fields alone.
839
+ `ClientRegistryService.updateRegistration` and `.deleteRegistration` are the operator-side
840
+ replacement; see [There is no way to edit a registration afterwards, by design](#there-is-no-way-to-edit-a-registration-afterwards-by-design).
493
841
 
494
842
  Everything the discovery document advertises is mounted. That is the rule the device grant type is
495
843
  held to by `OAUTH_DEVICE_CODE_ENABLED`, and it is why `revocation_endpoint` now has an endpoint
@@ -112,6 +112,27 @@ oauthServerRoutes.use(
112
112
  UserService.authService.login(email, password, sessionToken),
113
113
  register: (registration) => UserService.authService.signup(registration),
114
114
  scopesSupported: config.scopesSupported,
115
+ // The other unauthenticated write. An https `client_id` with no row yet is
116
+ // fetched and registered right here, so this path carries the same per-IP
117
+ // limit `/oauth/register` does — charged only when a request is about to
118
+ // resolve a client this server has never seen, so an ordinary visitor to an
119
+ // already-known connector never spends any of it.
120
+ metadataRateLimit: {
121
+ limit: config.metadataResolutionRateLimit,
122
+ windowMs: config.registrationRateWindowMs,
123
+ },
124
+ // `POST /oauth/authorize` refuses a submission that came from another
125
+ // site: it mints an authorization code on the strength of a cookie the
126
+ // browser attaches by itself, which is the shape cross-site request
127
+ // forgery exploits, and neither PKCE nor `state` covers it because on that
128
+ // attack the attacker is the client. The host the request arrived on is
129
+ // read from the request, so an ordinary deployment needs nothing here.
130
+ //
131
+ // Your issuer URL is named as well, because it *is* this server's public
132
+ // origin — that is what makes the check survive a reverse proxy that
133
+ // rewrites `Host` without setting `X-Forwarded-Host`. Add another entry
134
+ // only if you deliberately serve the consent page from a second origin.
135
+ allowedOrigins: [new URL(config.issuer).origin],
115
136
  // Describe every scope you advertise, in your app's composition root:
116
137
  // app.locals.oauthScopeDescriptions = { profile: 'See your name and…' };
117
138
  scopeDescriptions: undefined,
@@ -181,23 +202,18 @@ oauthServerRoutes.use(
181
202
  * the `resource_metadata` pointer — and Claude does not honour that header on a
182
203
  * 200, which makes the 401 the only place it can arrive.
183
204
  */
184
- const { resolveBearer: resolveOAuthBearer, requireOAuth } =
185
- createOAuthResourceServer(
186
- {
187
- authService: UserService.authService,
188
- identityService: UserService.identityService,
189
- userService: UserService.userService,
190
- },
191
- {
192
- tokenService,
193
- grantService,
194
- findUserById: (userId) => UserService.userService.findById(userId),
195
- // RFC 9728: point an unauthenticated caller at the document that tells it
196
- // where to go. `requireOAuth` sends this same value, because the resolver
197
- // hands it forward on the request rather than it being configured twice.
198
- challenge: `Bearer resource_metadata="${config.issuer}/.well-known/oauth-protected-resource"`,
199
- },
200
- );
205
+ const { resolveBearer: resolveOAuthBearer, requireOAuth } = createOAuthResourceServer({
206
+ tokenService,
207
+ grantService,
208
+ // The one user read the OAuth path makes. It is a function rather than a
209
+ // service, so an application that resolves users some other way — a cache, a
210
+ // read replica, a service of its own — swaps this line and nothing else.
211
+ findUserById: (userId) => UserService.userService.findById(userId),
212
+ // RFC 9728: point an unauthenticated caller at the document that tells it
213
+ // where to go. `requireOAuth` sends this same value, because the resolver
214
+ // hands it forward on the request rather than it being configured twice.
215
+ challenge: `Bearer resource_metadata="${config.issuer}/.well-known/oauth-protected-resource"`,
216
+ });
201
217
 
202
218
  /**
203
219
  * `GET /api/oauth/whoami` — the smallest protected resource there is.