@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.
- package/capability.json +14 -2
- package/dist/authorization-code-service.d.ts +40 -11
- package/dist/authorization-code-service.d.ts.map +1 -1
- package/dist/authorization-code-service.js +93 -41
- package/dist/authorization-code-service.js.map +1 -1
- package/dist/bearer.d.ts +27 -4
- package/dist/bearer.d.ts.map +1 -1
- package/dist/bearer.js +4 -4
- package/dist/bearer.js.map +1 -1
- package/dist/client-prune.d.ts +102 -0
- package/dist/client-prune.d.ts.map +1 -0
- package/dist/client-prune.js +51 -0
- package/dist/client-prune.js.map +1 -0
- package/dist/client-registry-service.d.ts +78 -7
- package/dist/client-registry-service.d.ts.map +1 -1
- package/dist/client-registry-service.js +200 -52
- package/dist/client-registry-service.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/connections-controller.js +1 -1
- package/dist/connections-controller.js.map +1 -1
- package/dist/consent-controller.d.ts +80 -6
- package/dist/consent-controller.d.ts.map +1 -1
- package/dist/consent-controller.js +134 -18
- package/dist/consent-controller.js.map +1 -1
- package/dist/discovery-controller.d.ts +2 -1
- package/dist/discovery-controller.d.ts.map +1 -1
- package/dist/discovery-controller.js +14 -4
- package/dist/discovery-controller.js.map +1 -1
- package/dist/discovery.d.ts +26 -0
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +55 -1
- package/dist/discovery.js.map +1 -1
- package/dist/errors.d.ts +38 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +59 -0
- package/dist/errors.js.map +1 -1
- package/dist/grant-service.d.ts +11 -4
- package/dist/grant-service.d.ts.map +1 -1
- package/dist/grant-service.js +6 -3
- package/dist/grant-service.js.map +1 -1
- package/dist/index.d.ts +10 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -3
- package/dist/index.js.map +1 -1
- package/dist/metadata-document.d.ts +22 -0
- package/dist/metadata-document.d.ts.map +1 -1
- package/dist/metadata-document.js +44 -2
- package/dist/metadata-document.js.map +1 -1
- package/dist/outbound-target.d.ts +48 -0
- package/dist/outbound-target.d.ts.map +1 -0
- package/dist/outbound-target.js +211 -0
- package/dist/outbound-target.js.map +1 -0
- package/dist/rate-limit.d.ts +53 -11
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +27 -4
- package/dist/rate-limit.js.map +1 -1
- package/dist/redirect-uri.d.ts +17 -9
- package/dist/redirect-uri.d.ts.map +1 -1
- package/dist/redirect-uri.js +26 -10
- package/dist/redirect-uri.js.map +1 -1
- package/dist/registration-controller.d.ts +10 -0
- package/dist/registration-controller.d.ts.map +1 -1
- package/dist/registration-controller.js +10 -0
- package/dist/registration-controller.js.map +1 -1
- package/dist/same-site.d.ts +54 -0
- package/dist/same-site.d.ts.map +1 -0
- package/dist/same-site.js +124 -0
- package/dist/same-site.js.map +1 -0
- package/dist/token-controller.d.ts +21 -0
- package/dist/token-controller.d.ts.map +1 -1
- package/dist/token-controller.js +42 -18
- package/dist/token-controller.js.map +1 -1
- package/package.json +2 -2
- package/scaffold/reference/oauth-server.ref.md +280 -13
- package/scaffold/src/middleware/oauth-server.ts +33 -17
- package/scaffold/src/scripts/prune-oauth-clients.ts +86 -0
- package/scaffold/src/services/oauth-server.service.ts +24 -0
- 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
|
-
>
|
|
10
|
-
> `POST /oauth/token`,
|
|
11
|
-
>
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
- `
|
|
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(
|
|
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`, `
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
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">
|