@boopkit/cap-oauth-server 0.1.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 (124) hide show
  1. package/capability.json +120 -0
  2. package/dist/authorization-code-service.d.ts +105 -0
  3. package/dist/authorization-code-service.d.ts.map +1 -0
  4. package/dist/authorization-code-service.js +142 -0
  5. package/dist/authorization-code-service.js.map +1 -0
  6. package/dist/bearer.d.ts +146 -0
  7. package/dist/bearer.d.ts.map +1 -0
  8. package/dist/bearer.js +150 -0
  9. package/dist/bearer.js.map +1 -0
  10. package/dist/body-field.d.ts +24 -0
  11. package/dist/body-field.d.ts.map +1 -0
  12. package/dist/body-field.js +43 -0
  13. package/dist/body-field.js.map +1 -0
  14. package/dist/client-registry-service.d.ts +120 -0
  15. package/dist/client-registry-service.d.ts.map +1 -0
  16. package/dist/client-registry-service.js +450 -0
  17. package/dist/client-registry-service.js.map +1 -0
  18. package/dist/config.d.ts +82 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +157 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/connections-controller.d.ts +106 -0
  23. package/dist/connections-controller.d.ts.map +1 -0
  24. package/dist/connections-controller.js +161 -0
  25. package/dist/connections-controller.js.map +1 -0
  26. package/dist/consent-controller.d.ts +197 -0
  27. package/dist/consent-controller.d.ts.map +1 -0
  28. package/dist/consent-controller.js +532 -0
  29. package/dist/consent-controller.js.map +1 -0
  30. package/dist/discovery-controller.d.ts +16 -0
  31. package/dist/discovery-controller.d.ts.map +1 -0
  32. package/dist/discovery-controller.js +52 -0
  33. package/dist/discovery-controller.js.map +1 -0
  34. package/dist/discovery.d.ts +95 -0
  35. package/dist/discovery.d.ts.map +1 -0
  36. package/dist/discovery.js +101 -0
  37. package/dist/discovery.js.map +1 -0
  38. package/dist/errors.d.ts +26 -0
  39. package/dist/errors.d.ts.map +1 -0
  40. package/dist/errors.js +41 -0
  41. package/dist/errors.js.map +1 -0
  42. package/dist/express.d.ts +66 -0
  43. package/dist/express.d.ts.map +1 -0
  44. package/dist/express.js +3 -0
  45. package/dist/express.js.map +1 -0
  46. package/dist/grant-service.d.ts +102 -0
  47. package/dist/grant-service.d.ts.map +1 -0
  48. package/dist/grant-service.js +137 -0
  49. package/dist/grant-service.js.map +1 -0
  50. package/dist/index.d.ts +67 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +148 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/metadata-document.d.ts +60 -0
  55. package/dist/metadata-document.d.ts.map +1 -0
  56. package/dist/metadata-document.js +148 -0
  57. package/dist/metadata-document.js.map +1 -0
  58. package/dist/oauth-error.d.ts +45 -0
  59. package/dist/oauth-error.d.ts.map +1 -0
  60. package/dist/oauth-error.js +51 -0
  61. package/dist/oauth-error.js.map +1 -0
  62. package/dist/pkce.d.ts +29 -0
  63. package/dist/pkce.d.ts.map +1 -0
  64. package/dist/pkce.js +65 -0
  65. package/dist/pkce.js.map +1 -0
  66. package/dist/property-array.d.ts +60 -0
  67. package/dist/property-array.d.ts.map +1 -0
  68. package/dist/property-array.js +31 -0
  69. package/dist/property-array.js.map +1 -0
  70. package/dist/public-id.d.ts +35 -0
  71. package/dist/public-id.d.ts.map +1 -0
  72. package/dist/public-id.js +76 -0
  73. package/dist/public-id.js.map +1 -0
  74. package/dist/rate-limit.d.ts +56 -0
  75. package/dist/rate-limit.d.ts.map +1 -0
  76. package/dist/rate-limit.js +55 -0
  77. package/dist/rate-limit.js.map +1 -0
  78. package/dist/redirect-uri.d.ts +89 -0
  79. package/dist/redirect-uri.d.ts.map +1 -0
  80. package/dist/redirect-uri.js +249 -0
  81. package/dist/redirect-uri.js.map +1 -0
  82. package/dist/registration-controller.d.ts +62 -0
  83. package/dist/registration-controller.d.ts.map +1 -0
  84. package/dist/registration-controller.js +79 -0
  85. package/dist/registration-controller.js.map +1 -0
  86. package/dist/request-client.d.ts +19 -0
  87. package/dist/request-client.d.ts.map +1 -0
  88. package/dist/request-client.js +32 -0
  89. package/dist/request-client.js.map +1 -0
  90. package/dist/revocation-controller.d.ts +28 -0
  91. package/dist/revocation-controller.d.ts.map +1 -0
  92. package/dist/revocation-controller.js +176 -0
  93. package/dist/revocation-controller.js.map +1 -0
  94. package/dist/revocation-service.d.ts +113 -0
  95. package/dist/revocation-service.d.ts.map +1 -0
  96. package/dist/revocation-service.js +121 -0
  97. package/dist/revocation-service.js.map +1 -0
  98. package/dist/token-controller.d.ts +79 -0
  99. package/dist/token-controller.d.ts.map +1 -0
  100. package/dist/token-controller.js +207 -0
  101. package/dist/token-controller.js.map +1 -0
  102. package/dist/token-service.d.ts +175 -0
  103. package/dist/token-service.d.ts.map +1 -0
  104. package/dist/token-service.js +251 -0
  105. package/dist/token-service.js.map +1 -0
  106. package/dist/types.d.ts +130 -0
  107. package/dist/types.d.ts.map +1 -0
  108. package/dist/types.js +13 -0
  109. package/dist/types.js.map +1 -0
  110. package/dist/user-store.d.ts +110 -0
  111. package/dist/user-store.d.ts.map +1 -0
  112. package/dist/user-store.js +40 -0
  113. package/dist/user-store.js.map +1 -0
  114. package/package.json +37 -0
  115. package/scaffold/prisma/schema/oauth-clients.prisma +80 -0
  116. package/scaffold/reference/oauth-server.ref.md +478 -0
  117. package/scaffold/src/middleware/oauth-server.ts +227 -0
  118. package/scaffold/src/scripts/seed-oauth-clients.ts +72 -0
  119. package/scaffold/src/services/oauth-server.service.ts +101 -0
  120. package/scaffold/styles/oauth-server.css +345 -0
  121. package/scaffold/views/oauth-connected-clients.hbs +98 -0
  122. package/scaffold/views/oauth-consent-error.hbs +22 -0
  123. package/scaffold/views/oauth-consent.hbs +138 -0
  124. package/scaffold/views/oauth-disconnect-confirm.hbs +57 -0
@@ -0,0 +1,478 @@
1
+ # OAuth server
2
+
3
+ What exists now, after `boop add oauth-server`.
4
+
5
+ This app is an OAuth 2.1 **authorization server** (it issues tokens on a user's behalf)
6
+ and a **resource server** (it accepts those tokens on its own API).
7
+ That is what lets Claude, Claude Code, ChatGPT or any MCP client act as one of your users.
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;
12
+ > this page describes what is actually here and says plainly what is not.
13
+
14
+ ## Related
15
+
16
+ - Skill: `boop-services-services`, `boop-services-controllers` — the service and controller patterns this capability follows.
17
+ - Capability: `users-and-accounts` — the sole authority on users, identities and sessions. This capability owns no user data.
18
+ - Capability: `oauth-client` — the mirror image: signing *your* users in with Google or Microsoft. The two are independent.
19
+
20
+ ## The identity boundary
21
+
22
+ The narrow, load-bearing constraint:
23
+ **anything user-, account- or identity-shaped goes through cap-users' public surface, and nothing reaches around it.**
24
+
25
+ - Tokens are minted with cap-users' `generateToken`, stored hashed with `hashToken`, and resolved through `IdentityService` under OAuth-specific identity domains.
26
+ - A token *is* a `UserIdentity` row, so bearer resolution and cookie resolution are the same query and yield the same `req.user`.
27
+ - The consent gate is the existing session middleware.
28
+ - No new user or account concepts, and **no direct Prisma access to the user tables**.
29
+
30
+ The one table this capability owns is `oauth_clients`, which describes *software* rather than people.
31
+ 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
+ ## The client registry
34
+
35
+ One table, `oauth_clients`, with a `mode` discriminator rather than three tables.
36
+ A mode says **where a client's authoritative metadata lives**, and almost nothing else.
37
+
38
+ | Mode | How a row appears | Who owns the metadata |
39
+ |---|---|---|
40
+ | `dynamic` | `POST /oauth/register`, unauthenticated (RFC 7591) | We do, after registration |
41
+ | `metadata_document` | First time a `client_id` that is an `https` URL is seen | The client, at that URL |
42
+ | `static` | `npm run oauth:seed-clients` | You do |
43
+
44
+ Two columns carry the design:
45
+
46
+ - **`publicId`** — twelve lowercase base36 characters we assign in **every** mode. This is the **token prefix**: tokens are shaped `<publicId>.<random>` and the identity key is `<publicId>|<sha256>`. It is never the raw `client_id`, because a metadata-document `client_id` is a URL containing `/`, `:` and `.`, all of which break one of those two splits.
47
+ - **`clientId`** — what the client actually sends. Equal to `publicId` for `dynamic` and `static`; the metadata-document URL otherwise.
48
+
49
+ `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
+
51
+ ### Redirect URIs
52
+
53
+ Three shapes may be registered, and no others — the three RFC 8252 names for calling a client back.
54
+ None of them may carry a fragment.
55
+
56
+ | Shape | Example | Why |
57
+ |---|---|---|
58
+ | Absolute `https` | `https://claude.ai/api/mcp/auth_callback` | §7.2. A hosted connector. |
59
+ | `http` on `localhost` or `127.0.0.1` | `http://127.0.0.1/callback` | §7.3. A native app cannot terminate TLS on loopback. |
60
+ | A private-use scheme | `claude://oauth/callback`, `com.example.app:/oauth2redirect/cb` | §7.1. How a desktop or mobile app is handed the response by its operating system. |
61
+
62
+ 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
+ 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
+
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).
66
+ 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`.
68
+
69
+ Both halves read one exported function, `classifyRedirectUri`, so what may be stored and what may be matched cannot drift apart.
70
+ A client that could register and then not connect is the same defect as a list that was never validated at all.
71
+
72
+ ## Discovery
73
+
74
+ Two documents, mounted **outside `/api`** and reachable with **no authentication** — a client reads them before it holds any credential.
75
+
76
+ | Path | Contents |
77
+ |---|---|
78
+ | `GET /.well-known/oauth-authorization-server` | RFC 8414: issuer, the four endpoint URLs, `code_challenge_methods_supported: ["S256"]`, `token_endpoint_auth_methods_supported: ["none"]`, `client_id_metadata_document_supported: true` |
79
+ | `GET /.well-known/oauth-protected-resource` | RFC 9728: `resource`, `authorization_servers`, `scopes_supported`, `bearer_methods_supported: ["header"]` |
80
+
81
+ Both are **computed from configuration**, not read off disk, and both are cached for an hour (`Cache-Control: public, max-age=3600`).
82
+
83
+ `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
+
85
+ The device-code grant type appears in `grant_types_supported` only when `OAUTH_DEVICE_CODE_ENABLED` is the string `"true"`.
86
+ 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
+ The same rule governs the four endpoint URLs: every one of them, `revocation_endpoint` included, is mounted.
88
+
89
+ ## Client registration
90
+
91
+ `POST /oauth/register` — RFC 7591 open registration. Unauthenticated by design; that is what the Claude ecosystem expects.
92
+
93
+ 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
+ There is no `client_secret`: this server issues only public clients.
95
+
96
+ Because the endpoint is an unauthenticated write, it carries four controls:
97
+
98
+ 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.
102
+
103
+ **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
+
105
+ If open registration does not suit you, remove the `preRoutes` hook that mounts `oauthServerRoutes` and keep the seed script.
106
+
107
+ ## The interactive grant, end to end
108
+
109
+ A connector's whole journey through this app, and where each step lives.
110
+
111
+ | Step | What happens | Where |
112
+ |---|---|---|
113
+ | 1 | The client calls your protected route with no token and gets **401** with `WWW-Authenticate: Bearer resource_metadata="…"` | `resolveOAuthBearer` / `requireOAuth` |
114
+ | 2 | It follows that pointer to `/.well-known/oauth-protected-resource`, then to `/.well-known/oauth-authorization-server` | `oauthDiscoveryRoutes` |
115
+ | 3 | It sends the user to `GET /oauth/authorize` with a PKCE `S256` challenge | the consent screen |
116
+ | 4 | The user signs in (inline, on that page) and approves | `POST /oauth/authorize` |
117
+ | 5 | We redirect back with a `code` and the client's original `state` | |
118
+ | 6 | The client POSTs the code to `/oauth/token` with its `code_verifier` | `POST /oauth/token` |
119
+ | 7 | It gets an access token, a refresh token, and calls your route again | |
120
+
121
+ `tests/packages/cap-oauth-server-flow.test.ts` in boop walks exactly that, end to end, reading each
122
+ step's values out of the previous step's real response.
123
+
124
+ ### The consent screen
125
+
126
+ One page with three faces — an already-signed-in user, an inline login, an inline signup — because an
127
+ authorization request is a long query string and round-tripping it through cap-users' `?next=` would
128
+ mean encoding a URL into a redirect target. The request stays on the page in hidden fields.
129
+
130
+ Three things on that page are load-bearing rather than decorative. A redesign has to keep all three.
131
+
132
+ **The icon sits in a fixed box.** Under open dynamic registration the client's name, description and
133
+ icon come from an unauthenticated stranger, and they are the entire evidence base for the only
134
+ decision on the page. `.consent-icon` fixes the box's size; a 4000px logo, a 1px logo and no logo at
135
+ all all produce the same layout. Anything that is not an absolute `https` URL falls back to a
136
+ monogram.
137
+
138
+ **The provenance line says where the client's claims came from**, and its wording differs by
139
+ registration mode: an operator-configured client says so, a metadata-document client names the origin
140
+ its document was published at, and a self-registered one says that nothing about it has been
141
+ verified. Without that line a stranger's self-assertion is displayed exactly like something you
142
+ vouched for, which makes the page a phishing surface.
143
+
144
+ **The coverage statement comes before the Approve button.** The grant is keyed on the *user*, so a
145
+ client reaches every account that user reaches, now and in future. Somebody must not discover that
146
+ afterwards.
147
+
148
+ Cancel is a protocol response rather than a navigation: it redirects to the registered URI with
149
+ `error=access_denied` and the original `state`, so the waiting client is told rather than left to
150
+ time out. The target is derived from the registry and never read from the form.
151
+
152
+ **Signing in on that page is your decision.** `createConsentRouter` takes `signIn` and `register` as
153
+ required ports, the way it takes `requireSignIn` for `/connections` — the capability never forms its
154
+ own opinion about what signing in means here. The scaffold passes cap-users straight through:
155
+
156
+ ```ts
157
+ signIn: ({ email, password, sessionToken }) =>
158
+ UserService.authService.login(email, password, sessionToken),
159
+ register: (registration) => UserService.authService.signup(registration),
160
+ ```
161
+
162
+ Replace either one when your application's own answer differs. The case that produced the ports:
163
+ `authService.login` resolves credentials through a **proven `email` identity**, so an application
164
+ that mints that identity later than signup — after a verification link is redeemed, say — has a
165
+ login form that admits somebody this page would refuse. Two doors disagreeing about one password
166
+ cannot be explained on the page, because cap-users returns a single generic failure on purpose:
167
+ saying "this account has no password yet" would confirm the account exists. Pass the function your
168
+ own login controller calls, and the doors agree.
169
+
170
+ Both ports say no by throwing. The login face shows one generic message whatever the reason; the
171
+ signup face shows the thrown error's message, so a validation failure somebody can act on should
172
+ carry words worth reading.
173
+
174
+ ### Scope descriptions are yours to write
175
+
176
+ The capability transports and enforces scope strings; what `workflows:advance` *means* is your domain
177
+ knowledge. Publish the words on `app.locals`:
178
+
179
+ ```ts
180
+ app.locals.oauthScopeDescriptions = {
181
+ profile: 'See your name and email address',
182
+ offline_access: 'Stay connected when you are not using the app',
183
+ };
184
+ ```
185
+
186
+ Any advertised scope you have not described produces a **boot warning naming it**, and the page falls
187
+ back to showing the raw string marked as machine text. A consent screen listing `workflows:advance`
188
+ is asking somebody to approve something they cannot evaluate.
189
+
190
+ ## Tokens
191
+
192
+ Three credentials, all `<client publicId>.<random>` on the wire and all stored as
193
+ `"<publicId>|<sha256>"` in a `user_identities` row. The raw value is never stored anywhere.
194
+
195
+ | Credential | Domain | Lifetime |
196
+ |---|---|---|
197
+ | Authorization code | `oauth-code` | 120 seconds, single use, **not configurable** |
198
+ | Access token | `oauth-access` | `OAUTH_ACCESS_TOKEN_TTL_SECONDS` (900) |
199
+ | Refresh token | `oauth-refresh` | `OAUTH_REFRESH_TOKEN_TTL_SECONDS` (7776000), sliding |
200
+
201
+ ### `endedAt` is a tombstone, not an expiry
202
+
203
+ Worth knowing before you read the code and conclude something is missing. cap-users'
204
+ `findByDomainAndKey` is active-only, so a *future* `endedAt` would read as already ended, and
205
+ `endDate` takes no timestamp. A token's lifetime therefore has no home on the row, and is computed:
206
+ **a token is expired when `createdAt + ttl < now`**.
207
+
208
+ Because that comparison is derived from configuration it is retroactive — lengthening a TTL would
209
+ otherwise resurrect tokens that had already expired. So the resolver **writes `endedAt` the first
210
+ time it reads an expired token**, and the row is then permanently dead whatever the configuration
211
+ does later. That write is a correctness requirement, not an optimisation; do not remove it.
212
+
213
+ The authorization code is not governed by this rule: its `oauth-codes` entry carries an explicit
214
+ `expires_at`, and that field is the authority.
215
+
216
+ ### Refresh rotation, and what reuse means
217
+
218
+ Every refresh returns a new refresh token, and the grant records which one is current. Presenting an
219
+ **already-rotated** refresh token is treated as evidence of theft — the legitimate client holds the
220
+ newer one — so the whole grant is revoked: every `oauth-access` and `oauth-refresh` row is
221
+ end-dated, the grant is end-dated with `reason: "refresh_reuse"`, and the answer is
222
+ `400 invalid_grant`. The honest client's tokens die too, which is deliberate: after a theft there is
223
+ no way to tell which of the two holders is real.
224
+
225
+ A rotated token's identity row is deliberately left *active* rather than end-dated. That is what
226
+ makes reuse detectable: resolution is the only thing that yields a user id, and without one there is
227
+ no way to reach the grant at all. The old row grants nothing, because its key no longer matches
228
+ `grant.refresh_key`.
229
+
230
+ ## Grants — what was approved
231
+
232
+ `oauth-grants`, a user property, one entry per client:
233
+
234
+ ```jsonc
235
+ {
236
+ "grant_id": "…", "client_id": "…", "client_public_id": "…",
237
+ "scopes": ["profile", "offline_access"],
238
+ "resource": null, "refresh_key": "<publicId>|<sha256>",
239
+ "created_at": "…", "updated_at": "…", "ended_at": null, "ended_reason": null
240
+ }
241
+ ```
242
+
243
+ **The grant is the scope authority at request time and the token carries no scope copy.** The guard
244
+ reads the grant on every request, so re-consenting with a narrower list takes effect on the very next
245
+ call with no revocation involved. Re-consent **replaces** the scope set rather than unioning it: the
246
+ screen showed a specific list and the user approved that list.
247
+
248
+ **No credential goes in here.** Only `credentials` is on cap-users' sensitive-property list, so this
249
+ property and `oauth-codes` are readable by default. Client ids, scopes and timestamps belong there;
250
+ token values, raw codes and PKCE verifiers do not. `refresh_key` is a SHA-256 digest, which is the
251
+ same value the identity row holds.
252
+
253
+ ### Both properties are written whole, under `expectedRevision`
254
+
255
+ Every write to `oauth-grants` and `oauth-codes` reads the entire array, changes it, and writes it
256
+ back quoting the revision it read. It will look wasteful next to cap-users' `addAuthenticationMethod`,
257
+ which updates one entry — and reaching for that is the bug in boop-zj8: it *replaces* an entry rather
258
+ than merging it, and silently destroys fields the write did not name. Follow `setAuthorization`.
259
+
260
+ The revision check is what makes a concurrent consent and revoke collide **detectably**. Without it
261
+ one of the two would simply vanish and nothing anywhere would say so.
262
+
263
+ ## Protecting your own routes
264
+
265
+ ```ts
266
+ import { resolveOAuthBearer, requireOAuth } from './middleware/oauth-server';
267
+
268
+ app.use('/api/mcp', resolveOAuthBearer, requireOAuth('profile'), mcpRoutes);
269
+ ```
270
+
271
+ `src/middleware/oauth-server.ts` already mounts one route this way — `GET /api/oauth/whoami`, which
272
+ answers with the user a token resolved to and the scopes its grant currently carries. It is there so
273
+ the guard has a real call site and so you have something to curl with a fresh token while checking a
274
+ deployment. Delete it once you have a real protected surface, or keep it; it grants a connector
275
+ nothing it did not already have by holding the token.
276
+
277
+ `resolveOAuthBearer` puts the user on `req.user` and the grant on `req.oauthGrant`, and labels the
278
+ request `req.authStrategy = 'oauth'`. That label is why an OAuth access token **fails**
279
+ cap-users' `requireApiKey`, whose whole purpose is to demand an independently revocable key.
280
+
281
+ A request with no `Authorization` header passes *through* the resolver untouched, so it can be
282
+ mounted on a router that also serves anonymous traffic; gating is `requireOAuth`'s job.
283
+
284
+ `requireOAuth` answers RFC 6750: **401** with the resolver's `WWW-Authenticate` challenge when there
285
+ is no usable token, and **403** with `error="insufficient_scope"` and a `scope` parameter naming what
286
+ the route requires when the token is valid but the grant is short. The challenge is not configured
287
+ twice — the resolver writes it onto the request and the gate reads it back, so the two halves cannot
288
+ disagree about where a client should look next. A gate mounted with no resolver in front of it
289
+ refuses everything with a bare `Bearer`, which is the visible symptom of that mistake.
290
+
291
+ **The grant is the authority, not the token.** Scopes are read off `oauth-grants` on every request,
292
+ so a consent narrowed a moment ago binds a token minted under the wider set, with no revocation
293
+ involved.
294
+
295
+ Do not mount `resolveOAuthBearer` on a router that already carries cap-users' `resolveBearer` — both
296
+ read the same header. They are safe to have in one application because both **refuse** rather than
297
+ falling back to the session cookie, but one router should have one door.
298
+
299
+
300
+ ## Disconnecting — `/connections` and soft revocation
301
+
302
+ `/connections` is mounted for you, gated by cap-users' `requireAuth`. It lists what this user has
303
+ connected, states what a connected application actually reaches, and sends every disconnect through a
304
+ confirmation page. **What it needs from you is a link**, from wherever your account settings live —
305
+ nothing else points at it.
306
+
307
+ Three things on that screen are load-bearing rather than layout, and a redesign has to keep all three.
308
+
309
+ **The account-coverage statement.** A grant is keyed on the *user*, so a connected application reaches
310
+ every account that user reaches, present and future. Somebody reading a list of client names will
311
+ otherwise assume a connector reaches only the account they were looking at when they approved it. The
312
+ consent screen says this before the Approve button; this screen says it above the list.
313
+
314
+ **The "previously connected" section.** Revocation is soft, and the audit trail is why. A screen that
315
+ showed only live grants would hide the ended ones from the person who wants them: the user who just
316
+ disconnected something and wants to see that it stuck, and the user working out what a connector did
317
+ last month. It is a quiet secondary section, because a disconnected client is history rather than
318
+ inventory.
319
+
320
+ **A dedicated confirmation page, not an inline delete.** `GET /connections/:grantId/disconnect`
321
+ renders and changes nothing; only the `POST` revokes. Disconnecting is not undoable and its
322
+ consequence — an agent that stops mid-conversation, and the whole flow to walk again — is not visible
323
+ from a row in a list. A `GET` that revoked would also fire on a link prefetch or a restored tab.
324
+
325
+ ### What a revoke actually writes
326
+
327
+ ```ts
328
+ await revocationService.revokeGrant(userId, grantId, 'user_revoked');
329
+ ```
330
+
331
+ The grant record is end-dated with a reason **first**, then every live `oauth-access` and
332
+ `oauth-refresh` row for that client is end-dated too. Both writes lock the client out on their own —
333
+ the guard needs a live grant *and* a live token row — so ending the grant first makes the lockout one
334
+ write, and a token sweep that fails halfway is safe to retry against a client that is already refused.
335
+
336
+ Nothing is deleted. **The rows that stay behind are the point**, and they are what distinguishes a
337
+ revoked client from one that never connected: `findAllByUser(userId, { includeEnded: true })` returns
338
+ the ended rows for the first and nothing at all for the second.
339
+
340
+ But do not read revocation *off* those rows, in either direction. An ended row does not mean revoked —
341
+ expiry end-dates a row on the first read past its deadline. A live row does not mean connected —
342
+ rotation leaves the superseded refresh row deliberately active, so presenting it again reads as reuse
343
+ rather than as a typo. **The signal is `grant.ended_at` and `grant.ended_reason`**, which is what the
344
+ screen and the guard both read.
345
+
346
+ ### The compromised-client case
347
+
348
+ ```ts
349
+ await revocationService.revokeClient(clientPublicId, 'client_revoked');
350
+ ```
351
+
352
+ A client and nothing else — it finds its own users. A token's identity key is
353
+ `"<publicId>|<hash>"`, so everybody that client ever reached is a left-anchored prefix away, and
354
+ cap-users' `findAllByDomainAndKeyPrefix(domain, keyPrefix, opts)` runs that scan behind the
355
+ `IdentityStore` port. The boundary at the top of this document is unchanged: the query is still
356
+ cap-users', by prefix exactly as it is by user id.
357
+
358
+ Every one of those users' grants for that client ends, and no other client's does — the match is on
359
+ the whole `client_public_id`, never a prefix of it. The result carries one entry per connection
360
+ actually broken, so its length is how many people were disconnected rather than how many the scan
361
+ turned up.
362
+
363
+ Two details worth knowing before you read the code:
364
+
365
+ - **The scan includes end-dated rows.** An idle connection's access and refresh rows are both swept
366
+ by the expiry rule while its grant stays perfectly alive, so a live-rows-only enumeration would
367
+ report that nobody uses a client half your users have connected.
368
+ - **A malformed public id throws** rather than sweeping nothing. During an incident, a typo that
369
+ reads as "no users are affected" is the worst available answer. Pass the twelve-character
370
+ `publicId` from the client registry, not the `client_id` URL.
371
+
372
+ ### The client-driven case — `POST /oauth/revoke`
373
+
374
+ RFC 7009's endpoint, mounted for you on the same router as `/oauth/token` and needing no session.
375
+ It is the third of the three ways a connection ends, and the only one a piece of software rather than
376
+ a person starts: a connector hands a token back because the user disconnected on *its* side.
377
+ It takes `token` and `client_id`, as a form or as JSON, plus an optional `token_type_hint`.
378
+
379
+ **Which token ends what is the decision to know about, and the two halves are deliberately
380
+ asymmetric.**
381
+
382
+ - **A refresh token ends the whole grant**, recorded with the reason `client_requested`. RFC 7009
383
+ §2.1 only says a server SHOULD do this; here the grant is the unit of connection, and a connector
384
+ calling this on user-disconnect wants the connection gone rather than one credential of it.
385
+ - **An access token ends only its own row**, and §2.1's MAY is declined. Rotation does not end-date
386
+ the superseded *access* row, so the access token from before a refresh stays live until its TTL. A
387
+ tidy client that hands that old token back right after refreshing is doing exactly what this
388
+ endpoint is for — and under the MAY it would destroy the connection it had just renewed.
389
+
390
+ **Every token gets `200` with an empty body** (§2.2): revoked, already invalid, expired, never
391
+ issued, or belonging to a different client. There is no way to report that last one without
392
+ confirming the token exists, so it takes the same answer as the rest. Do not add a body here to help
393
+ somebody debug — an empty 200 is the feature.
394
+
395
+ The only refusals are about the request itself: `400 invalid_request` for a missing `token` or
396
+ `client_id`, `400 unsupported_token_type` for a `token_type_hint` naming a species this server does
397
+ not issue, and `401 invalid_client` for a client that is unknown or suspended.
398
+
399
+ The hint is advisory. Both token domains are searched, hint first, because a server that trusted it
400
+ would silently fail to revoke a correctly-presented token that a client mislabelled.
401
+
402
+ ## Configuration
403
+
404
+ | Variable | Default | Notes |
405
+ |---|---|---|
406
+ | `OAUTH_ISSUER_URL` | — | Required at boot. https, or localhost for development. |
407
+ | `OAUTH_RESOURCE_URL` | the issuer | The protected surface, as clients enter it. |
408
+ | `OAUTH_SCOPES_SUPPORTED` | `profile email offline_access` | Without `offline_access` no client can request a refresh token. |
409
+ | `OAUTH_ACCESS_TOKEN_TTL_SECONDS` | `900` | Clamped to 60–3600, with a boot warning. |
410
+ | `OAUTH_REFRESH_TOKEN_TTL_SECONDS` | `7776000` | 90 days, sliding on rotation. Clamped to 3600–31536000. |
411
+ | `OAUTH_MAX_REGISTERED_CLIENTS` | `1000` | |
412
+ | `OAUTH_REGISTRATION_RATE_LIMIT` | `10` | Per hour, per address. |
413
+ | `OAUTH_DEVICE_CODE_ENABLED` | `false` | Not implemented yet; named so the flag has one home. |
414
+ | `OAUTH_DOCUMENTATION_URL` | — | Echoed into both documents. |
415
+
416
+ All of it is read **once at boot**, so a running process has one answer and a clamp warns once.
417
+
418
+ ### Why token lifetimes are what they are
419
+
420
+ A short access token is usually argued for on the grounds that revocation is otherwise slow.
421
+ **That argument does not apply here.**
422
+ Every request performs an active-only identity lookup, and a revoke writes `endedAt`, so revocation takes effect on the client's very next request whatever the stated lifetime.
423
+ The only pressure on the access token's lifetime is blast radius if one leaks, which is why fifteen minutes is comfortable.
424
+
425
+ The refresh token is long and *sliding*: each rotation mints a token with a fresh 90 days, so a connector in daily use never expires while one abandoned for a quarter does.
426
+
427
+ ## Key files
428
+
429
+ | File | What it is |
430
+ |---|---|
431
+ | `prisma/schema/oauth-clients.prisma` | The `oauth_clients` table |
432
+ | `src/services/oauth-server.service.ts` | Binds `db.oauthClient` and the environment to the capability's services |
433
+ | `src/middleware/oauth-server.ts` | Builds the routers the server hooks mount, plus `resolveOAuthBearer` and `requireOAuth` |
434
+ | `src/scripts/seed-oauth-clients.ts` | The static clients this deployment trusts. Edit this. |
435
+ | `views/oauth-consent.hbs` | The consent screen, in all three variants |
436
+ | `views/oauth-consent-error.hbs` | Shown when the request named no usable client or redirect URI |
437
+ | `views/oauth-connected-clients.hbs` | `/connections` — what this user has connected, and what they once had |
438
+ | `views/oauth-disconnect-confirm.hbs` | The confirmation page a disconnect goes through |
439
+ | `styles/oauth-server.css` | The `consent-*` and `connection-*` classes. Add it to your stylesheet entry point. |
440
+
441
+ ## Package exports
442
+
443
+ `@boopkit/cap-oauth-server` provides:
444
+
445
+ - `createClientRegistryService(delegate, options)` — `registerDynamic`, `resolveMetadataDocument`, `registerStaticClient`, `findByClientId`, `findByPublicId`, `suspend`, `unsuspend`, `updateRegistration`, `deleteRegistration`, `pruneAbandonedClients`
446
+ - `matchRedirectUri(registered, presented)` and `matchesAnyRedirectUri(list, presented)`
447
+ - `generateClientPublicId()`, `isValidClientPublicId(value)`
448
+ - `buildAuthorizationServerMetadata(config)`, `buildProtectedResourceMetadata(config)`
449
+ - `createDiscoveryRouter(config)`, `createRegistrationRouter(options)`, and the handler-set factories behind both
450
+ - `createRegistrationRateLimiter(options)`
451
+ - `readServerConfig(env)` — the environment, parsed, clamped and defaulted
452
+ - `createTokenService(options)` — minting, parsing, the expiry rule and the lazy sweep
453
+ - `createGrantService(options)` — `read`, `write`, `list`, `findActive`, `findById`, `approve`, `setRefreshKey`, `end`
454
+ - `createRevocationService(options)` — `revokeGrant`, `revokeClientForUser`, `revokeClient`
455
+ - `createRevocationRouter(options)` — `POST /oauth/revoke`, and `createRevocationHandlers` behind it
456
+ - `createConnectionsRouter(options)` — `/connections` and its disconnect confirmation page
457
+ - `createAuthorizationCodeService(options)` — `issue`, `redeem`, `list`
458
+ - `createConsentRouter(options)`, `createTokenRouter(options)`, and the handler-set factories behind both
459
+ - `createOAuthResourceServer(deps, options)` — `resolveBearer` and `requireOAuth`, sharing one challenge
460
+ - `requireOAuth(...scopes)` and `missingScopes(granted, required)` on their own, for a route that already has a resolver in front of it
461
+ - `verifyCodeChallenge`, `deriveCodeChallenge`, `isValidCodeVerifier`, `isValidCodeChallenge`
462
+ - `warnUndescribedScopes`, `provenanceFor`, `safeLogoUri`, `monogramFor`, `toScopeViews`
463
+ - `ACCOUNT_COVERAGE_STATEMENT`, `REVOKED_BY_USER`, `REVOKED_BY_CLIENT`, `REVOKED_CLIENT_WIDE`
464
+ - `ClientRegistryError` / `isClientRegistryError`, `OAuthError` / `isOAuthError`, `isStaleWriteError`
465
+
466
+ ## What is not here yet
467
+
468
+ Named so nobody debugs their absence:
469
+
470
+ - **Per-token audience binding.** A `resource` indicator is accepted, carried through the
471
+ authorization code and recorded on the grant, but no token carries an audience and nothing enforces
472
+ one.
473
+ - **The device-code endpoints.** `OAUTH_DEVICE_CODE_ENABLED` names the flag; nothing reads it beyond
474
+ keeping the grant type out of the discovery document.
475
+
476
+ Everything the discovery document advertises is mounted. That is the rule the device grant type is
477
+ held to by `OAUTH_DEVICE_CODE_ENABLED`, and it is why `revocation_endpoint` now has an endpoint
478
+ behind it rather than being taken back out of the document.