@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.
- package/capability.json +120 -0
- package/dist/authorization-code-service.d.ts +105 -0
- package/dist/authorization-code-service.d.ts.map +1 -0
- package/dist/authorization-code-service.js +142 -0
- package/dist/authorization-code-service.js.map +1 -0
- package/dist/bearer.d.ts +146 -0
- package/dist/bearer.d.ts.map +1 -0
- package/dist/bearer.js +150 -0
- package/dist/bearer.js.map +1 -0
- package/dist/body-field.d.ts +24 -0
- package/dist/body-field.d.ts.map +1 -0
- package/dist/body-field.js +43 -0
- package/dist/body-field.js.map +1 -0
- package/dist/client-registry-service.d.ts +120 -0
- package/dist/client-registry-service.d.ts.map +1 -0
- package/dist/client-registry-service.js +450 -0
- package/dist/client-registry-service.js.map +1 -0
- package/dist/config.d.ts +82 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +157 -0
- package/dist/config.js.map +1 -0
- package/dist/connections-controller.d.ts +106 -0
- package/dist/connections-controller.d.ts.map +1 -0
- package/dist/connections-controller.js +161 -0
- package/dist/connections-controller.js.map +1 -0
- package/dist/consent-controller.d.ts +197 -0
- package/dist/consent-controller.d.ts.map +1 -0
- package/dist/consent-controller.js +532 -0
- package/dist/consent-controller.js.map +1 -0
- package/dist/discovery-controller.d.ts +16 -0
- package/dist/discovery-controller.d.ts.map +1 -0
- package/dist/discovery-controller.js +52 -0
- package/dist/discovery-controller.js.map +1 -0
- package/dist/discovery.d.ts +95 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +101 -0
- package/dist/discovery.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +41 -0
- package/dist/errors.js.map +1 -0
- package/dist/express.d.ts +66 -0
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +3 -0
- package/dist/express.js.map +1 -0
- package/dist/grant-service.d.ts +102 -0
- package/dist/grant-service.d.ts.map +1 -0
- package/dist/grant-service.js +137 -0
- package/dist/grant-service.js.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/metadata-document.d.ts +60 -0
- package/dist/metadata-document.d.ts.map +1 -0
- package/dist/metadata-document.js +148 -0
- package/dist/metadata-document.js.map +1 -0
- package/dist/oauth-error.d.ts +45 -0
- package/dist/oauth-error.d.ts.map +1 -0
- package/dist/oauth-error.js +51 -0
- package/dist/oauth-error.js.map +1 -0
- package/dist/pkce.d.ts +29 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +65 -0
- package/dist/pkce.js.map +1 -0
- package/dist/property-array.d.ts +60 -0
- package/dist/property-array.d.ts.map +1 -0
- package/dist/property-array.js +31 -0
- package/dist/property-array.js.map +1 -0
- package/dist/public-id.d.ts +35 -0
- package/dist/public-id.d.ts.map +1 -0
- package/dist/public-id.js +76 -0
- package/dist/public-id.js.map +1 -0
- package/dist/rate-limit.d.ts +56 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +55 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/redirect-uri.d.ts +89 -0
- package/dist/redirect-uri.d.ts.map +1 -0
- package/dist/redirect-uri.js +249 -0
- package/dist/redirect-uri.js.map +1 -0
- package/dist/registration-controller.d.ts +62 -0
- package/dist/registration-controller.d.ts.map +1 -0
- package/dist/registration-controller.js +79 -0
- package/dist/registration-controller.js.map +1 -0
- package/dist/request-client.d.ts +19 -0
- package/dist/request-client.d.ts.map +1 -0
- package/dist/request-client.js +32 -0
- package/dist/request-client.js.map +1 -0
- package/dist/revocation-controller.d.ts +28 -0
- package/dist/revocation-controller.d.ts.map +1 -0
- package/dist/revocation-controller.js +176 -0
- package/dist/revocation-controller.js.map +1 -0
- package/dist/revocation-service.d.ts +113 -0
- package/dist/revocation-service.d.ts.map +1 -0
- package/dist/revocation-service.js +121 -0
- package/dist/revocation-service.js.map +1 -0
- package/dist/token-controller.d.ts +79 -0
- package/dist/token-controller.d.ts.map +1 -0
- package/dist/token-controller.js +207 -0
- package/dist/token-controller.js.map +1 -0
- package/dist/token-service.d.ts +175 -0
- package/dist/token-service.d.ts.map +1 -0
- package/dist/token-service.js +251 -0
- package/dist/token-service.js.map +1 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/dist/user-store.d.ts +110 -0
- package/dist/user-store.d.ts.map +1 -0
- package/dist/user-store.js +40 -0
- package/dist/user-store.js.map +1 -0
- package/package.json +37 -0
- package/scaffold/prisma/schema/oauth-clients.prisma +80 -0
- package/scaffold/reference/oauth-server.ref.md +478 -0
- package/scaffold/src/middleware/oauth-server.ts +227 -0
- package/scaffold/src/scripts/seed-oauth-clients.ts +72 -0
- package/scaffold/src/services/oauth-server.service.ts +101 -0
- package/scaffold/styles/oauth-server.css +345 -0
- package/scaffold/views/oauth-connected-clients.hbs +98 -0
- package/scaffold/views/oauth-consent-error.hbs +22 -0
- package/scaffold/views/oauth-consent.hbs +138 -0
- 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.
|