@boopkit/cap-oauth-server 0.1.3 → 0.3.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.
Files changed (81) 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 +78 -7
  15. package/dist/client-registry-service.d.ts.map +1 -1
  16. package/dist/client-registry-service.js +200 -52
  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/same-site.d.ts +54 -0
  69. package/dist/same-site.d.ts.map +1 -0
  70. package/dist/same-site.js +124 -0
  71. package/dist/same-site.js.map +1 -0
  72. package/dist/token-controller.d.ts +21 -0
  73. package/dist/token-controller.d.ts.map +1 -1
  74. package/dist/token-controller.js +42 -18
  75. package/dist/token-controller.js.map +1 -1
  76. package/package.json +2 -2
  77. package/scaffold/reference/oauth-server.ref.md +280 -13
  78. package/scaffold/src/middleware/oauth-server.ts +33 -17
  79. package/scaffold/src/scripts/prune-oauth-clients.ts +86 -0
  80. package/scaffold/src/services/oauth-server.service.ts +24 -0
  81. package/scaffold/views/oauth-consent-error.hbs +10 -3
@@ -6,9 +6,10 @@ This app is an OAuth 2.1 **authorization server** (it issues tokens on a user's
6
6
  and a **resource server** (it accepts those tokens on its own API).
7
7
  That is what lets Claude, Claude Code, ChatGPT or any MCP client act as one of your users.
8
8
 
9
- > Slices 1 and 2 have landed: the client registry, the discovery documents, the consent screen,
10
- > `POST /oauth/token`, and a resource-server guard good enough to protect a route.
11
- > Revocation, the connected-clients screen and the device flow are not here yet;
9
+ > What is here: the client registry, the discovery documents, the consent screen,
10
+ > `POST /oauth/token`, a resource-server guard good enough to protect a route,
11
+ > the connected-clients screen at `/connections`, and revocation, both from that screen and
12
+ > from the client through `POST /oauth/revoke`. The device flow is not here yet;
12
13
  > this page describes what is actually here and says plainly what is not.
13
14
 
14
15
  ## Related
@@ -30,6 +31,13 @@ The narrow, load-bearing constraint:
30
31
  The one table this capability owns is `oauth_clients`, which describes *software* rather than people.
31
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/`.
32
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
+
33
41
  ## The client registry
34
42
 
35
43
  One table, `oauth_clients`, with a `mode` discriminator rather than three tables.
@@ -48,6 +56,40 @@ Two columns carry the design:
48
56
 
49
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.
50
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
+
51
93
  ### Redirect URIs
52
94
 
53
95
  Three shapes may be registered, and no others — the three RFC 8252 names for calling a client back.
@@ -62,9 +104,19 @@ None of them may carry a fragment.
62
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.
63
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.
64
106
 
65
- 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).
66
108
  Claude Code needs it — it binds an ephemeral loopback port at connect time.
67
- 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.
68
120
 
69
121
  Both halves read one exported function, `classifyRedirectUri`, so what may be stored and what may be matched cannot drift apart.
70
122
  A client that could register and then not connect is the same defect as a list that was never validated at all.
@@ -82,6 +134,12 @@ Both are **computed from configuration**, not read off disk, and both are cached
82
134
 
83
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.
84
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
+
85
143
  The device-code grant type appears in `grant_types_supported` only when `OAUTH_DEVICE_CODE_ENABLED` is the string `"true"`.
86
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.
87
145
  The same rule governs the four endpoint URLs: every one of them, `revocation_endpoint` included, is mounted.
@@ -93,17 +151,150 @@ The same rule governs the four endpoint URLs: every one of them, `revocation_end
93
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`.
94
152
  There is no `client_secret`: this server issues only public clients.
95
153
 
96
- Because the endpoint is an unauthenticated write, it carries four controls:
154
+ Because the endpoint is an unauthenticated write, it carries six controls:
97
155
 
98
156
  1. `redirect_uris` is **required** and validated.
99
- 2. Per-IP rate limiting — `OAUTH_REGISTRATION_RATE_LIMIT` per hour, in process memory, per instance.
100
- 3. A row ceiling — `OAUTH_MAX_REGISTERED_CLIENTS`. This is the hard bound; the rate limit only makes reaching it slow.
101
- 4. `ClientRegistryService.pruneAbandonedClients()` for rows that never went anywhere. It takes an "is this in use?" predicate, which stays unimplemented until grants exist.
157
+ 2. `logo_uri`, `client_uri`, `policy_uri` and `tos_uri` must each be an absolute `https` URL or be
158
+ absent, and a non-https value is refused with `invalid_client_metadata` naming the field.
159
+ These four are the stranger-supplied values the consent screen renders — one in an `<img src>`,
160
+ three in an `<a href>` — and template escaping does not touch a scheme, so `javascript:` would
161
+ otherwise reach an `href` on the page where the user grants access. The same rule applies to
162
+ `ClientRegistryService.updateRegistration` and to a metadata document. The consent screen sanitises on the
163
+ way out as well (`safeHttpsUri`), which is what covers rows written before the rule existed; the
164
+ registration-time refusal is what tells the registrant rather than letting their privacy-policy
165
+ link silently fail to appear.
166
+ 3. Per-IP rate limiting — `OAUTH_REGISTRATION_RATE_LIMIT` per hour, in process memory, per instance.
167
+ 4. A row ceiling — `OAUTH_MAX_REGISTERED_CLIENTS`. This is the hard bound; the rate limit only makes reaching it slow.
168
+ 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).
169
+ 6. `scope` is **narrowed to `OAUTH_SCOPES_SUPPORTED`**, the way `grant_types` is.
170
+ 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`.
171
+ 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.
172
+ 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.
102
173
 
103
174
  **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.
104
175
 
105
176
  If open registration does not suit you, remove the `preRoutes` hook that mounts `oauthServerRoutes` and keep the seed script.
106
177
 
178
+ ### There is no way to edit a registration afterwards, by design
179
+
180
+ RFC 7592 defines `PUT` and `DELETE` on `/oauth/register/:client_id`, authenticated by a `registration_access_token` the registration response hands out.
181
+ **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.
182
+ So a registration is create-and-forget: a client that loses its `client_id` registers again, which costs it one unauthenticated request.
183
+
184
+ What you get instead is a library API rather than an HTTP one.
185
+ `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.
186
+ Both validate what `registerDynamic` validates, so an amendment cannot reach a value a registration could not, and both refuse a `static` row.
187
+ 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.
188
+
189
+ ### The other registration endpoint: `GET /oauth/authorize`
190
+
191
+ 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:
192
+
193
+ | Control | On `POST /oauth/register` | On first-sight CIMD resolution |
194
+ |---|---|---|
195
+ | Rate limit | `OAUTH_REGISTRATION_RATE_LIMIT` | `OAUTH_METADATA_RESOLUTION_RATE_LIMIT`, charged only when the `client_id` has no row |
196
+ | Row ceiling | `OAUTH_MAX_REGISTERED_CLIENTS` | the same ceiling — `dynamic` and `metadata_document` rows are counted together |
197
+ | Prune | `npm run oauth:prune-clients` | the same prune, over both modes |
198
+ | Where it may point | n/a — nothing is fetched | private, loopback, link-local and local-network targets are refused |
199
+
200
+ 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.
201
+
202
+ 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.
203
+
204
+ **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.
205
+
206
+ 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.
207
+
208
+ 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.
209
+
210
+ #### When resolution fails, the log and the page say different things
211
+
212
+ A resolution can fail for reasons that need different fixes — the document was unreachable, it was not
213
+ JSON, it declared a grant type this server does not support, the registry was built with no `fetch` to
214
+ reach it through, the ceiling was already full.
215
+ The page tells the person in front of it only that `the metadata document for this client_id could not
216
+ be used`;
217
+ the reason goes to the operator, in the registry's own words, through the consent router's `warn` port
218
+ (`console.warn` unless you pass one):
219
+
220
+ ```
221
+ [cap-oauth-server] consent refused: could not resolve the Client ID Metadata Document at
222
+ https://client.example/id — boop_metadata_document_unusable: metadata document at
223
+ https://client.example/id is not JSON
224
+ ```
225
+
226
+ Those two audiences do not need the same words, and until they were separated they did not get the
227
+ truth either: every one of those failures was reported to both as `unknown client_id`, a condition
228
+ that on this branch is never what happened (boop-nvbd).
229
+ Distinguishing it from a genuinely unknown `client_id` is not an enumeration oracle, because the
230
+ branch is reached only for an https `client_id` with no row — the caller learns nothing their own URL
231
+ did not already tell them.
232
+
233
+ The refusal page names a **suspended** client as suspended, with `403`, for the same kind of reason
234
+ (boop-wwxg).
235
+ Folding it into `unknown client_id` would hide nothing: a registered `client_id` is already
236
+ distinguishable from an unregistered one here, because probing either with a `redirect_uri` the client
237
+ does not own tells only the registered one that the URI is unregistered — and RFC 6749 §4.1.2.1
238
+ requires that sentence to be said on this page rather than redirected.
239
+ A `client_id` is a public identifier in any case.
240
+ What it would cost is the operator of a suspended client being told their intact registration is
241
+ unknown, which is the untrue sentence the paragraph above exists to remove.
242
+
243
+ The line is collapsed to one line before it is emitted.
244
+ The `client_id` is a query parameter and appears in it twice, so without that a caller sending `%0A`
245
+ writes a second line into your log and chooses what it says.
246
+
247
+ A failure that is **not** a registry refusal — the client store unreachable, a bug in the capability —
248
+ is answered `500` rather than turned into a refusal page, so an operator is not sent to read a request
249
+ when the answer is in the database.
250
+
251
+ ### Collecting abandoned registrations
252
+
253
+ Of the controls above, only the prune is a *collector*.
254
+ `OAUTH_MAX_REGISTERED_CLIENTS` caps the table and the rate limits make filling it slow, but neither
255
+ ever gives a row back — so a deployment that leaves the prune unscheduled eventually reaches the
256
+ ceiling and starts refusing **legitimate** registrations, because a script that walked away a year
257
+ ago is still holding a thousand slots.
258
+
259
+ The sweep is `npm run oauth:prune-clients`, which runs `src/scripts/prune-oauth-clients.ts`.
260
+ **It does not run on a timer by itself.**
261
+ Schedule it daily — Heroku Scheduler, cron, or a cap-background-jobs consumer calling the script's
262
+ exported `pruneAbandonedOAuthClients()`.
263
+ It is idempotent and reads only rows past the cutoff, so running it more often changes nothing but
264
+ the size of each batch.
265
+
266
+ It deletes a `dynamic` or `metadata_document` row when **both** are true:
267
+
268
+ 1. it was created more than thirty days ago, and
269
+ 2. nobody has that client connected.
270
+
271
+ A `static` row is never swept, however old — an operator wrote it, and the ceiling does not count it
272
+ either.
273
+
274
+ **"Connected" means a live grant, and nothing else.**
275
+ The predicate (`createClientInUsePredicate`) finds the client's users through the `"<publicId>|"`
276
+ token-key scan and then reads each one's `oauth-grants`.
277
+ It does not decide from the identity rows, for the reason
278
+ [What a revoke actually writes](#what-a-revoke-actually-writes) gives at length: an ended row does
279
+ not mean disconnected, because ordinary expiry end-dates rows too, and a live row does not mean
280
+ connected, because rotation leaves the superseded refresh row deliberately active.
281
+ Reading tokens would delete a client half your deployment uses.
282
+
283
+ The scan is sound because a grant is only ever written at redemption, in the same request that mints
284
+ the first token — a grant with no token row behind it is not a state this server can reach.
285
+
286
+ Two consequences worth knowing before you shorten the thirty days:
287
+
288
+ - **A disconnected client is collectable.** An ended grant is not in use, so once the row is old
289
+ enough it goes. `/connections` already renders a grant whose registry row has gone: it falls back
290
+ to the `client_id` the client presented and says the application is no longer registered here,
291
+ because a connection the screen does not list is a connection nobody can disconnect.
292
+ - **An unredeemed authorization code is not protected.** Codes live for a minute and the cutoff is
293
+ thirty days, so the window is a client that registered a month ago, never connected, and is
294
+ mid-flow at the moment the sweep runs. It loses that one redemption and can register again.
295
+ Closing it would mean scanning `oauth-codes`, a per-user property with no cross-user read — a full
296
+ user scan to buy a minute of protection for a client that has never completed a grant.
297
+
107
298
  ## The interactive grant, end to end
108
299
 
109
300
  A connector's whole journey through this app, and where each step lives.
@@ -133,7 +324,10 @@ Three things on that page are load-bearing rather than decorative. A redesign ha
133
324
  icon come from an unauthenticated stranger, and they are the entire evidence base for the only
134
325
  decision on the page. `.consent-icon` fixes the box's size; a 4000px logo, a 1px logo and no logo at
135
326
  all all produce the same layout. Anything that is not an absolute `https` URL falls back to a
136
- monogram.
327
+ monogram, and the same `https`-only rule governs the client's three link URIs — `client_uri`,
328
+ `policy_uri` and `tos_uri` — which are dropped rather than rendered when they fail it. The person
329
+ who follows a connector's privacy-policy link before approving is the cautious one, and a scheme a
330
+ stranger chose must not be what they land on.
137
331
 
138
332
  **The provenance line says where the client's claims came from**, and its wording differs by
139
333
  registration mode: an operator-configured client says so, a metadata-document client names the origin
@@ -188,6 +382,46 @@ Both ports say no by throwing. The login face shows one generic message whatever
188
382
  signup face shows the thrown error's message, so a validation failure somebody can act on should
189
383
  carry words worth reading.
190
384
 
385
+ ### The form defends itself against another site
386
+
387
+ `POST /oauth/authorize` mints an authorization code, and the only credential it needs is the session
388
+ cookie the browser attaches unprompted. That is the shape cross-site request forgery exploits: an
389
+ attacker registers a client with their own `redirect_uris` — dynamic registration is open — and
390
+ serves a page that auto-submits this form with `action=approve`, so a code for the victim's account
391
+ lands at the attacker's callback.
392
+
393
+ Neither of the two things that look like a defence is one. PKCE binds a code to whoever asked for it,
394
+ and here the attacker *is* the client, holding both the challenge and the verifier. `state` is the
395
+ client's CSRF token, echoed back untouched, and on this attack the attacker chose it. What was
396
+ actually holding the line was a browser default — an unlabelled cookie is treated as `SameSite=Lax`,
397
+ which is not sent on a cross-site POST — and a defence that lives entirely in the user agent is one
398
+ this server cannot assert or test.
399
+
400
+ So there are two now, and they are independent:
401
+
402
+ - **The route checks where the submission came from.** `Origin` and `Sec-Fetch-Site` are forbidden
403
+ header names, so page script cannot set or remove either and a hostile page's form post carries
404
+ them truthfully. Anything labelled as coming from elsewhere is refused with a **403 on our own
405
+ page** — never a redirect, because redirecting to the URI the attacker registered would tell them
406
+ the victim's browser got here. A request carrying neither header is accepted: it is not a browser,
407
+ and a forgery needs a browser, because a browser is the only thing that will attach somebody
408
+ else's cookie. `isSameSiteRequest` is exported if you want it on a form of your own.
409
+ - **The session cookie is labelled.** `SameSite=Lax` and, in production, `Secure` — on the cookie
410
+ the consent page's inline login writes and on both cookies cap-users' `getOrCreateUser` writes.
411
+
412
+ `Lax` rather than `Strict`, deliberately. The consent screen is *reached* by a cross-site top-level
413
+ navigation — the client sends the browser here — and `Strict` withholds the cookie on exactly that
414
+ hop, so every authorization would meet a login form for somebody who is already signed in. `Lax`
415
+ refuses the cross-site POST, which is the request the attack needs, and permits the arrival, which
416
+ is the request the flow needs. `cookieSameSite` overrides it if your deployment disagrees.
417
+
418
+ The one thing you may need to configure is `allowedOrigins`. The host the request arrived on is read
419
+ from the request, so an ordinary deployment needs nothing; the scaffold additionally names
420
+ `OAUTH_ISSUER_URL`'s origin, because that *is* this server's public origin, which is what keeps the
421
+ check right behind a reverse proxy that rewrites `Host` without setting `X-Forwarded-Host`. Add an
422
+ entry only if you deliberately serve the consent page from a second origin — every entry widens what
423
+ counts as your application.
424
+
191
425
  ### Scope descriptions are yours to write
192
426
 
193
427
  The capability transports and enforces scope strings; what `workflows:advance` *means* is your domain
@@ -204,6 +438,11 @@ Any advertised scope you have not described produces a **boot warning naming it*
204
438
  back to showing the raw string marked as machine text. A consent screen listing `workflows:advance`
205
439
  is asking somebody to approve something they cannot evaluate.
206
440
 
441
+ The list you describe is the whole list a consent screen can show, because no client can hold a scope
442
+ outside `OAUTH_SCOPES_SUPPORTED`: registration narrows to it, an explicit `scope` on an authorization
443
+ request is checked against it, and the no-`scope` fallback to the client's registered list is filtered
444
+ through it as well.
445
+
207
446
  ## Tokens
208
447
 
209
448
  Three credentials, all `<client publicId>.<random>` on the wire and all stored as
@@ -313,6 +552,15 @@ Do not mount `resolveOAuthBearer` on a router that already carries cap-users' `r
313
552
  read the same header. They are safe to have in one application because both **refuse** rather than
314
553
  falling back to the session cookie, but one router should have one door.
315
554
 
555
+ **Where your users come from is one line.** `createOAuthResourceServer` takes a token service, a
556
+ grant service, a `findUserById` function and a challenge — and nothing else. The scaffold points
557
+ `findUserById` at cap-users' `userService.findById`, which is the right answer for a boop
558
+ application, but the resolver calls it as a plain function and never reaches for a service, so an
559
+ application that resolves users through a cache, a read replica or a layer of its own replaces that
560
+ line and changes nothing else. The factory used to demand cap-users' `{ authService,
561
+ identityService, userService }` trio as well and read none of it; it no longer does (boop-9ral), so
562
+ the user layer is supplied once rather than twice.
563
+
316
564
 
317
565
  ## Disconnecting — `/connections` and soft revocation
318
566
 
@@ -427,6 +675,7 @@ would silently fail to revoke a correctly-presented token that a client mislabel
427
675
  | `OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `7776000` | 90 days, sliding on rotation. Clamped to 3600–31536000. |
428
676
  | `OAUTH_MAX_REGISTERED_CLIENTS` | `1000` | |
429
677
  | `OAUTH_REGISTRATION_RATE_LIMIT` | `10` | Per hour, per address. |
678
+ | `OAUTH_METADATA_RESOLUTION_RATE_LIMIT` | `10` | Per hour, per address. Charged only for a metadata-document `client_id` with no row yet. |
430
679
  | `OAUTH_DEVICE_CODE_ENABLED` | `false` | Not implemented yet; named so the flag has one home. |
431
680
  | `OAUTH_DOCUMENTATION_URL` | — | Echoed into both documents. |
432
681
 
@@ -449,6 +698,7 @@ The refresh token is long and *sliding*: each rotation mints a token with a fres
449
698
  | `src/services/oauth-server.service.ts` | Binds `db.oauthClient` and the environment to the capability's services |
450
699
  | `src/middleware/oauth-server.ts` | Builds the routers the server hooks mount, plus `resolveOAuthBearer` and `requireOAuth` |
451
700
  | `src/scripts/seed-oauth-clients.ts` | The static clients this deployment trusts. Edit this. |
701
+ | `src/scripts/prune-oauth-clients.ts` | The abandoned-registration sweep. Schedule it. |
452
702
  | `views/oauth-consent.hbs` | The consent screen, in all three variants |
453
703
  | `views/oauth-consent-error.hbs` | Shown when the request named no usable client or redirect URI |
454
704
  | `views/oauth-connected-clients.hbs` | `/connections` — what this user has connected, and what they once had |
@@ -464,7 +714,10 @@ The refresh token is long and *sliding*: each rotation mints a token with a fres
464
714
  - `generateClientPublicId()`, `isValidClientPublicId(value)`
465
715
  - `buildAuthorizationServerMetadata(config)`, `buildProtectedResourceMetadata(config)`
466
716
  - `createDiscoveryRouter(config)`, `createRegistrationRouter(options)`, and the handler-set factories behind both
467
- - `createRegistrationRateLimiter(options)`
717
+ - `createFixedWindowRateLimiter(options)` — the per-IP counter both unauthenticated write paths share; `createRegistrationRateLimiter` is the same factory under its original name
718
+ - `blockedHostReason(hostname)`, `isBlockedAddress(value)` — where a caller-chosen URL may point
719
+ - `STRANGER_CREATED_MODES` — the two modes the ceiling and the prune count
720
+ - `createClientPruneJob(options)` — the scheduled abandoned-registration sweep; `createClientInUsePredicate(options)` is the "is anybody still connected?" half on its own
468
721
  - `readServerConfig(env)` — the environment, parsed, clamped and defaulted
469
722
  - `createTokenService(options)` — minting, parsing, the expiry rule and the lazy sweep
470
723
  - `createGrantService(options)` — `read`, `write`, `list`, `findActive`, `findById`, `approve`, `setRefreshKey`, `end`
@@ -473,10 +726,10 @@ The refresh token is long and *sliding*: each rotation mints a token with a fres
473
726
  - `createConnectionsRouter(options)` — `/connections` and its disconnect confirmation page
474
727
  - `createAuthorizationCodeService(options)` — `issue`, `redeem`, `list`
475
728
  - `createConsentRouter(options)`, `createTokenRouter(options)`, and the handler-set factories behind both
476
- - `createOAuthResourceServer(deps, options)` — `resolveBearer` and `requireOAuth`, sharing one challenge
729
+ - `createOAuthResourceServer(options)` — `resolveBearer` and `requireOAuth`, sharing one challenge
477
730
  - `requireOAuth(...scopes)` and `missingScopes(granted, required)` on their own, for a route that already has a resolver in front of it
478
731
  - `verifyCodeChallenge`, `deriveCodeChallenge`, `isValidCodeVerifier`, `isValidCodeChallenge`
479
- - `warnUndescribedScopes`, `provenanceFor`, `safeLogoUri`, `monogramFor`, `toScopeViews`
732
+ - `warnUndescribedScopes`, `provenanceFor`, `safeHttpsUri`, `monogramFor`, `toScopeViews`
480
733
  - `ACCOUNT_COVERAGE_STATEMENT`, `REVOKED_BY_USER`, `REVOKED_BY_CLIENT`, `REVOKED_CLIENT_WIDE`
481
734
  - `ClientRegistryError` / `isClientRegistryError`, `OAuthError` / `isOAuthError`, `isStaleWriteError`
482
735
 
@@ -490,6 +743,20 @@ Named so nobody debugs their absence:
490
743
  - **The device-code endpoints.** `OAUTH_DEVICE_CODE_ENABLED` names the flag; nothing reads it beyond
491
744
  keeping the grant type out of the discovery document.
492
745
 
746
+ And one that is decided rather than pending — nothing is waiting on a later slice to add it:
747
+
748
+ - **RFC 7592 client management.** `PUT`/`DELETE /oauth/register/:client_id` are not mounted, the 201
749
+ carries no `registration_access_token` or `registration_client_uri`, and the discovery document
750
+ advertises no management surface. Authenticating those endpoints means a third credential kind,
751
+ with its own hashing, rotation and revocation story, bought so that a client can amend a
752
+ registration it can re-create with one unauthenticated `POST`; nothing in the Claude ecosystem
753
+ checklist asks for it. The endpoints being *absent* is what makes them safe — mounted without that
754
+ credential, they would let any caller rewrite any dynamic client's redirect URIs, which is the hole
755
+ the six registration controls above exist to close. Reversing this means shipping the routes, the
756
+ token and the response fields in one change, never the fields alone.
757
+ `ClientRegistryService.updateRegistration` and `.deleteRegistration` are the operator-side
758
+ replacement; see [There is no way to edit a registration afterwards, by design](#there-is-no-way-to-edit-a-registration-afterwards-by-design).
759
+
493
760
  Everything the discovery document advertises is mounted. That is the rule the device grant type is
494
761
  held to by `OAUTH_DEVICE_CODE_ENABLED`, and it is why `revocation_endpoint` now has an endpoint
495
762
  behind it rather than being taken back out of the document.
@@ -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.
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Garbage-collect abandoned OAuth client registrations.
3
+ *
4
+ * Run with `npm run oauth:prune-clients`. **Schedule it** — see below.
5
+ *
6
+ * ## What it deletes, and what stops it
7
+ *
8
+ * `POST /oauth/register` and the Client ID Metadata Document path both write a
9
+ * registry row for a caller nobody authenticated. Most of those rows are real
10
+ * connectors. Some are a script that walked away, and those never go anywhere:
11
+ * no consent, no grant, no token. After thirty days they are garbage, and this
12
+ * is what collects them.
13
+ *
14
+ * A row is deleted only when **nobody has that client connected** — that is,
15
+ * when no user's `oauth-grants` holds a live entry naming its `publicId`. The
16
+ * capability owns that question (`createClientInUsePredicate`), because the row
17
+ * that matters is the one carrying the `publicId` every live token for that
18
+ * client has as its prefix.
19
+ *
20
+ * Rows an operator wrote with `npm run oauth:seed-clients` are never touched,
21
+ * however old they are. Only the two unauthenticated modes are swept.
22
+ *
23
+ * ## Scheduling it
24
+ *
25
+ * Nothing here runs on a timer by itself, and an unscheduled prune is the same
26
+ * as no prune: `OAUTH_MAX_REGISTERED_CLIENTS` then becomes the only bound on
27
+ * registry growth, and a deployment that reaches it starts refusing legitimate
28
+ * registrations. Pick one:
29
+ *
30
+ * - **Heroku Scheduler** (or your platform's equivalent): run
31
+ * `npm run oauth:prune-clients` daily.
32
+ * - **cron**, on a host you control:
33
+ * `0 4 * * * cd /srv/app && npm run oauth:prune-clients`
34
+ * - **cap-background-jobs**, if this project has it: import
35
+ * `pruneAbandonedOAuthClients` from a job controller's `onMessage` and give
36
+ * the queue a daily entry in `src/scheduler.ts`. That is the right home once
37
+ * the sweep is one of several recurring jobs.
38
+ *
39
+ * Daily is a suggestion, not a requirement. The sweep is idempotent and reads
40
+ * only rows older than the cutoff, so running it more often changes nothing but
41
+ * the size of each batch.
42
+ */
43
+
44
+ import { createClientPruneJob } from '@boopkit/cap-oauth-server';
45
+
46
+ import {
47
+ clientRegistryService,
48
+ grantService,
49
+ tokenService,
50
+ } from '../services/oauth-server.service';
51
+
52
+ /**
53
+ * How long an unconnected registration survives. Thirty days, per the plan.
54
+ *
55
+ * Shorten it if open registration is being abused; do not shorten it below the
56
+ * time a person plausibly takes between installing a connector and approving
57
+ * it, because that gap is exactly what this measures.
58
+ */
59
+ const ABANDONED_AFTER_DAYS = 30;
60
+
61
+ const pruneJob = createClientPruneJob({
62
+ clientRegistry: clientRegistryService,
63
+ grantService,
64
+ tokenService,
65
+ ttlMs: ABANDONED_AFTER_DAYS * 24 * 60 * 60 * 1000,
66
+ });
67
+
68
+ /** One sweep. Exported so a background-jobs consumer can call it directly. */
69
+ export async function pruneAbandonedOAuthClients(): Promise<string[]> {
70
+ const { before, removed } = await pruneJob.run();
71
+ console.log(
72
+ `[oauth-server] pruned ${removed.length} abandoned client registration(s) ` +
73
+ `created before ${before.toISOString()}`,
74
+ );
75
+ for (const clientId of removed) console.log(` removed ${clientId}`);
76
+ return removed;
77
+ }
78
+
79
+ if (require.main === module) {
80
+ pruneAbandonedOAuthClients()
81
+ .then(() => process.exit(0))
82
+ .catch((err) => {
83
+ console.error('Pruning abandoned OAuth clients failed:', err);
84
+ process.exit(1);
85
+ });
86
+ }
@@ -20,6 +20,8 @@
20
20
  * `oauth-grants` and `oauth-codes` the only two properties it ever reaches.
21
21
  */
22
22
 
23
+ import { promises as dns } from 'node:dns';
24
+
23
25
  import db from '../db';
24
26
  import UserService from './users.service';
25
27
  import {
@@ -43,7 +45,29 @@ export const clientRegistryService = createClientRegistryService(db.oauthClient,
43
45
  // is the seam boop's own tests drive; here it is the real network.
44
46
  fetch: globalThis.fetch as unknown as FetchLike,
45
47
  maxRegisteredClients: oauthServerConfig.maxRegisteredClients,
48
+ // Where a metadata document may live. A Client ID Metadata Document is
49
+ // fetched at a URL an unauthenticated caller chose, so without this the
50
+ // consent endpoint is an SSRF probe of whatever network this app runs in —
51
+ // the body is discarded, but existence and latency are readable off the
52
+ // response time. The capability refuses every literal private, loopback and
53
+ // link-local target on its own; this resolver is what extends that to a name
54
+ // like `db.internal.example` that merely *points* somewhere private.
55
+ //
56
+ // It lives here rather than in the package because a capability has no
57
+ // business importing `node:dns`. Swap it for your own if you run a resolver
58
+ // with a different view of the network.
59
+ metadataResolveHost: async (hostname) => {
60
+ const records = await dns.lookup(hostname, { all: true });
61
+ return records.map((record) => record.address);
62
+ },
63
+ // Two different questions, answered from one list here: what a client is
64
+ // granted when it asks for nothing, and what it may ask for at all. Narrow
65
+ // `defaultScopes` if you want a smaller default grant — but leave
66
+ // `scopesSupported` as the advertised list, because it is the ceiling that
67
+ // keeps an unauthenticated registrant from writing itself a scope this
68
+ // server's discovery document says it does not have.
46
69
  defaultScopes: oauthServerConfig.scopesSupported,
70
+ scopesSupported: oauthServerConfig.scopesSupported,
47
71
  onMetadataRefreshFailed: (clientId, err) => {
48
72
  // A stale document that could not be refreshed is served from cache, so the
49
73
  // user's consent still works — but the failure is not silent, because a
@@ -6,9 +6,16 @@
6
6
  redirect: sending an error to an unverified redirect URI is the open redirect.
7
7
  So the person is told here instead, on our own page.
8
8
 
9
- The message names what was wrong with the request and nothing about the
10
- registry — a page that distinguished "no such client" from "that client is
11
- suspended" would be an enumeration oracle for whoever is probing.
9
+ The message names what was wrong, including when what was wrong is that the
10
+ client has been suspended. That distinction is deliberate. It is not the
11
+ enumeration oracle it looks like: a registered `client_id` is already
12
+ distinguishable from an unregistered one here — probe either with a
13
+ `redirect_uri` the client does not own and only the registered one is told the
14
+ URI is unregistered, which is a sentence RFC 6749 requires this page to say —
15
+ and a `client_id` is a public identifier regardless, carried in the
16
+ authorization URL of every user the client has ever sent. What naming the
17
+ suspension buys is a suspended client's operator reading the right column
18
+ instead of hunting a registration that is perfectly intact (boop-wwxg).
12
19
  --}}
13
20
  <div class="consent-page">
14
21
  <section class="consent-card consent-card--error">