@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,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @boopkit/cap-oauth-server — routes and middleware, pre-wired from
|
|
3
|
+
* OAuthServerService.
|
|
4
|
+
*
|
|
5
|
+
* Three mounts, and they are deliberately separate.
|
|
6
|
+
*
|
|
7
|
+
* **`oauthDiscoveryRoutes`** carries the two `.well-known` documents. They
|
|
8
|
+
* mount **outside `/api`** and are reachable with **no authentication** — they
|
|
9
|
+
* are what a client reads before it holds any credential at all, so a 401 here
|
|
10
|
+
* is a connect failure the client cannot recover from. They need no session,
|
|
11
|
+
* which is why the hook mounts them early.
|
|
12
|
+
*
|
|
13
|
+
* **`oauthServerRoutes`** carries everything that needs the request context
|
|
14
|
+
* cap-users builds. It mounts at `preRoutes` order 54, after cap-users'
|
|
15
|
+
* `addAuthContext` at 16 — the consent screen reads `res.locals.auth` to decide
|
|
16
|
+
* whether it is showing a consent card, a login form or a signup form. The
|
|
17
|
+
* back-channel endpoints — `POST /oauth/register`, `POST /oauth/token` and
|
|
18
|
+
* `POST /oauth/revoke` — need no session at all, since no browser is behind
|
|
19
|
+
* them, but they live here so the OAuth surface is one mount rather than four
|
|
20
|
+
* that have to be kept in step.
|
|
21
|
+
* The `/connections` screen is on this mount too, gated by cap-users'
|
|
22
|
+
* `requireAuth`: it is an ordinary page of the application, met by a person in a
|
|
23
|
+
* browser rather than by a connector.
|
|
24
|
+
*
|
|
25
|
+
* **`resolveOAuthBearer`** is the resource-server half and is **not** mounted
|
|
26
|
+
* by a hook. It goes on the routers an application actually wants to expose to
|
|
27
|
+
* a connector, which is a decision only that application can make. Mounting it
|
|
28
|
+
* app-wide would put an OAuth door on every route in the site.
|
|
29
|
+
*
|
|
30
|
+
* ## Two bearer doors on one header, and why that is safe here
|
|
31
|
+
*
|
|
32
|
+
* cap-users' `resolveBearer` reads the same `Authorization` header for API
|
|
33
|
+
* keys. Both are built from the same `createBearerMiddleware`, and — the part
|
|
34
|
+
* that matters — **both refuse rather than fall through** to the session
|
|
35
|
+
* cookie. The shape the spec warns against is a silent fallback, which lets a
|
|
36
|
+
* request carrying a revoked credential succeed on the strength of a cookie the
|
|
37
|
+
* caller was not presenting. Neither of these does that. Do not mount both on
|
|
38
|
+
* one router: pick the door that router's callers use.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
createConnectionsRouter,
|
|
43
|
+
createConsentRouter,
|
|
44
|
+
createDiscoveryRouter,
|
|
45
|
+
createOAuthResourceServer,
|
|
46
|
+
createRegistrationRouter,
|
|
47
|
+
createRevocationRouter,
|
|
48
|
+
createTokenRouter,
|
|
49
|
+
} from '@boopkit/cap-oauth-server';
|
|
50
|
+
import { Router, type Request, type Response } from 'express';
|
|
51
|
+
|
|
52
|
+
import { requireAuth } from './auth';
|
|
53
|
+
import OAuthServerService from '../services/oauth-server.service';
|
|
54
|
+
import UserService from '../services/users.service';
|
|
55
|
+
|
|
56
|
+
const {
|
|
57
|
+
config,
|
|
58
|
+
clientRegistryService,
|
|
59
|
+
tokenService,
|
|
60
|
+
grantService,
|
|
61
|
+
authorizationCodeService,
|
|
62
|
+
revocationService,
|
|
63
|
+
} = OAuthServerService;
|
|
64
|
+
|
|
65
|
+
/** `/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource`. */
|
|
66
|
+
const oauthDiscoveryRoutes = createDiscoveryRouter(config);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The browser-facing and back-channel OAuth surface.
|
|
70
|
+
*
|
|
71
|
+
* Scope descriptions are read from `app.locals.oauthScopeDescriptions` at render
|
|
72
|
+
* time, so an application that populates them after this module is imported —
|
|
73
|
+
* which is every boop application, since this mount is injected above the
|
|
74
|
+
* application's own `app.locals` assignments — still works. The warning naming
|
|
75
|
+
* any advertised scope you have not described reads the same value on the first
|
|
76
|
+
* request the router serves, so it tells you the truth about your own wiring
|
|
77
|
+
* rather than about an argument the scaffold does not pass. That warning is the
|
|
78
|
+
* only thing standing between a developer and a consent page that asks a user to
|
|
79
|
+
* approve `workflows:advance`.
|
|
80
|
+
*
|
|
81
|
+
* Pass `scopeDescriptions` below only for words you want as a default;
|
|
82
|
+
* `app.locals` overrides it key by key.
|
|
83
|
+
*/
|
|
84
|
+
const oauthServerRoutes = Router();
|
|
85
|
+
|
|
86
|
+
oauthServerRoutes.use(
|
|
87
|
+
createRegistrationRouter({
|
|
88
|
+
clientRegistry: clientRegistryService,
|
|
89
|
+
rateLimit: {
|
|
90
|
+
limit: config.registrationRateLimit,
|
|
91
|
+
windowMs: config.registrationRateWindowMs,
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
oauthServerRoutes.use(
|
|
97
|
+
createConsentRouter({
|
|
98
|
+
clientRegistry: clientRegistryService,
|
|
99
|
+
authorizationCodeService,
|
|
100
|
+
// How somebody signs in on this page is your decision, not the
|
|
101
|
+
// capability's — the same way `requireSignIn` below is. These two adapters
|
|
102
|
+
// are cap-users straight through, which is right for an application whose
|
|
103
|
+
// login page calls `authService.login` too.
|
|
104
|
+
//
|
|
105
|
+
// If yours does not — the common reason is minting the `email` identity
|
|
106
|
+
// later than signup, so `authService.login` cannot find a person your own
|
|
107
|
+
// login form admits — pass the function your login controller calls here
|
|
108
|
+
// instead. Two doors that disagree about one password cannot be explained
|
|
109
|
+
// to the person standing in front of them: cap-users returns one generic
|
|
110
|
+
// failure on purpose, so that saying more would confirm the account exists.
|
|
111
|
+
signIn: ({ email, password, sessionToken }) =>
|
|
112
|
+
UserService.authService.login(email, password, sessionToken),
|
|
113
|
+
register: (registration) => UserService.authService.signup(registration),
|
|
114
|
+
scopesSupported: config.scopesSupported,
|
|
115
|
+
// Describe every scope you advertise, in your app's composition root:
|
|
116
|
+
// app.locals.oauthScopeDescriptions = { profile: 'See your name and…' };
|
|
117
|
+
scopeDescriptions: undefined,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
oauthServerRoutes.use(
|
|
122
|
+
createTokenRouter({
|
|
123
|
+
clientRegistry: clientRegistryService,
|
|
124
|
+
tokenService,
|
|
125
|
+
grantService,
|
|
126
|
+
authorizationCodeService,
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `POST /oauth/revoke` — RFC 7009, where a connector hands a token back.
|
|
132
|
+
*
|
|
133
|
+
* The third of the three ways a connection ends, and the only one a piece of
|
|
134
|
+
* software rather than a person starts. It is mounted because the discovery
|
|
135
|
+
* document advertises `revocation_endpoint`, and this capability's rule is that
|
|
136
|
+
* advertising an endpoint which answers 404 is a lie a client cannot interpret.
|
|
137
|
+
*
|
|
138
|
+
* A refresh token presented here ends the whole grant; an access token ends only
|
|
139
|
+
* itself. The controller's header argues both, and the second one is the
|
|
140
|
+
* surprising half.
|
|
141
|
+
*/
|
|
142
|
+
oauthServerRoutes.use(
|
|
143
|
+
createRevocationRouter({
|
|
144
|
+
clientRegistry: clientRegistryService,
|
|
145
|
+
tokenService,
|
|
146
|
+
revocationService,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `/connections` — where a person sees what they have connected and disconnects
|
|
152
|
+
* one. An ordinary page of the application, gated by cap-users' `requireAuth`
|
|
153
|
+
* like every other page, and linked to from wherever your account settings live.
|
|
154
|
+
*
|
|
155
|
+
* `requireAuth` is injected rather than reached for, so this capability never
|
|
156
|
+
* forms its own opinion about what "signed in" means. Change `basePath` if
|
|
157
|
+
* `/connections` collides with a route you already have; every link on both
|
|
158
|
+
* pages is built from it.
|
|
159
|
+
*/
|
|
160
|
+
oauthServerRoutes.use(
|
|
161
|
+
createConnectionsRouter({
|
|
162
|
+
grantService,
|
|
163
|
+
revocationService,
|
|
164
|
+
clientRegistry: clientRegistryService,
|
|
165
|
+
requireSignIn: requireAuth(),
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve `Authorization: Bearer <oauth access token>` onto `req.user`, and the
|
|
171
|
+
* grant behind it onto `req.oauthGrant`. Labels the request `'oauth'`, which is
|
|
172
|
+
* what makes it fail `requireApiKey` by construction.
|
|
173
|
+
*
|
|
174
|
+
* Mount it on the router you are protecting, ahead of `requireOAuth`:
|
|
175
|
+
*
|
|
176
|
+
* app.use('/api/mcp', resolveOAuthBearer, requireOAuth('profile'), mcpRoutes);
|
|
177
|
+
*
|
|
178
|
+
* Both come out of one factory so they cannot be given different
|
|
179
|
+
* `WWW-Authenticate` challenges. A caller with a bad token is refused by the
|
|
180
|
+
* resolver and a caller with no token at all by the gate, so both have to carry
|
|
181
|
+
* the `resource_metadata` pointer — and Claude does not honour that header on a
|
|
182
|
+
* 200, which makes the 401 the only place it can arrive.
|
|
183
|
+
*/
|
|
184
|
+
const { resolveBearer: resolveOAuthBearer, requireOAuth } =
|
|
185
|
+
createOAuthResourceServer(
|
|
186
|
+
{
|
|
187
|
+
authService: UserService.authService,
|
|
188
|
+
identityService: UserService.identityService,
|
|
189
|
+
userService: UserService.userService,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
tokenService,
|
|
193
|
+
grantService,
|
|
194
|
+
findUserById: (userId) => UserService.userService.findById(userId),
|
|
195
|
+
// RFC 9728: point an unauthenticated caller at the document that tells it
|
|
196
|
+
// where to go. `requireOAuth` sends this same value, because the resolver
|
|
197
|
+
// hands it forward on the request rather than it being configured twice.
|
|
198
|
+
challenge: `Bearer resource_metadata="${config.issuer}/.well-known/oauth-protected-resource"`,
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `GET /api/oauth/whoami` — the smallest protected resource there is.
|
|
204
|
+
*
|
|
205
|
+
* It exists so this scaffold ships one route the guard is actually mounted on,
|
|
206
|
+
* rather than only an import somebody is told to use. That makes it the thing to
|
|
207
|
+
* curl with a fresh access token when you are checking a deployment end to end:
|
|
208
|
+
* it answers with the user the token resolved to and the scopes the **grant**
|
|
209
|
+
* currently carries, which is what the guard reads on every request.
|
|
210
|
+
*
|
|
211
|
+
* Delete it once you have a real protected surface, or keep it — it grants a
|
|
212
|
+
* connector nothing it did not already have by holding the token.
|
|
213
|
+
*/
|
|
214
|
+
oauthServerRoutes.get(
|
|
215
|
+
'/api/oauth/whoami',
|
|
216
|
+
resolveOAuthBearer,
|
|
217
|
+
requireOAuth(),
|
|
218
|
+
(req: Request, res: Response) => {
|
|
219
|
+
res.json({
|
|
220
|
+
userId: (req.user as unknown as { id: number } | undefined)?.id,
|
|
221
|
+
clientId: req.oauthGrant?.client_id ?? null,
|
|
222
|
+
scopes: req.oauthGrant?.scopes ?? [],
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
export { oauthDiscoveryRoutes, oauthServerRoutes, resolveOAuthBearer, requireOAuth };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed the operator's static OAuth clients.
|
|
3
|
+
*
|
|
4
|
+
* Run with `npm run oauth:seed-clients`.
|
|
5
|
+
*
|
|
6
|
+
* ## What a static client is for
|
|
7
|
+
*
|
|
8
|
+
* Two of the three registration modes need no operator: a connector that
|
|
9
|
+
* supports RFC 7591 registers itself, and one that publishes a Client ID
|
|
10
|
+
* Metadata Document is picked up on first sight. A *static* client is the third
|
|
11
|
+
* case — software you decided to trust, whose `client_id` you chose, and whose
|
|
12
|
+
* redirect URIs you are vouching for yourself.
|
|
13
|
+
*
|
|
14
|
+
* The registration endpoint cannot modify or delete a row written here. That is
|
|
15
|
+
* checked in `ClientRegistryService`, not left to convention: `/oauth/register`
|
|
16
|
+
* is unauthenticated, and a client the operator vouched for must not be
|
|
17
|
+
* rewritable by a stranger.
|
|
18
|
+
*
|
|
19
|
+
* ## Editing this file
|
|
20
|
+
*
|
|
21
|
+
* Add your clients to `STATIC_CLIENTS` below and re-run. Seeding is idempotent:
|
|
22
|
+
* a second run updates the metadata of an existing row and **keeps its
|
|
23
|
+
* `publicId`**, so live tokens — which carry that value as their prefix — keep
|
|
24
|
+
* resolving.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { clientRegistryService } from '../services/oauth-server.service';
|
|
28
|
+
import type { StaticClientInput } from '@boopkit/cap-oauth-server';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The clients this deployment trusts by name.
|
|
32
|
+
*
|
|
33
|
+
* The example below is Claude Code, which binds an ephemeral loopback port at
|
|
34
|
+
* connect time — hence two loopback entries with no port. `matchRedirectUri`
|
|
35
|
+
* relaxes the port for loopback `http` and for nothing else (RFC 8252 §7.3), so
|
|
36
|
+
* these two entries accept `http://127.0.0.1:52731/callback` and refuse
|
|
37
|
+
* `https://anywhere-else.example/callback`.
|
|
38
|
+
*
|
|
39
|
+
* A desktop or mobile application that is handed the response by its operating
|
|
40
|
+
* system registers its own URI scheme instead — `claude://oauth/callback`, or
|
|
41
|
+
* the reverse-DNS form `com.example.app:/oauth2redirect/cb` (§7.1). Those are
|
|
42
|
+
* matched exactly, port included, because there is no ephemeral port involved.
|
|
43
|
+
*
|
|
44
|
+
* Delete it if you do not want it: an empty array is a valid configuration and
|
|
45
|
+
* the seed is then a no-op.
|
|
46
|
+
*/
|
|
47
|
+
const STATIC_CLIENTS: StaticClientInput[] = [
|
|
48
|
+
{
|
|
49
|
+
clientId: 'claude-code',
|
|
50
|
+
name: 'Claude Code',
|
|
51
|
+
description: 'Anthropic’s coding agent, connecting from your own machine',
|
|
52
|
+
clientUri: 'https://claude.com/claude-code',
|
|
53
|
+
redirectUris: ['http://localhost/callback', 'http://127.0.0.1/callback'],
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export async function seedStaticClients(): Promise<void> {
|
|
58
|
+
for (const client of STATIC_CLIENTS) {
|
|
59
|
+
const row = await clientRegistryService.registerStaticClient(client);
|
|
60
|
+
console.log(` ${row.clientId} → publicId ${row.publicId} (${row.redirectUris.length} redirect URIs)`);
|
|
61
|
+
}
|
|
62
|
+
console.log(`Seeded ${STATIC_CLIENTS.length} static OAuth client(s).`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (require.main === module) {
|
|
66
|
+
seedStaticClients()
|
|
67
|
+
.then(() => process.exit(0))
|
|
68
|
+
.catch((err) => {
|
|
69
|
+
console.error('Seeding static OAuth clients failed:', err);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @boopkit/cap-oauth-server — service wiring.
|
|
3
|
+
*
|
|
4
|
+
* The one file that knows both halves: the app's Prisma client and cap-users'
|
|
5
|
+
* services on one side, the capability's services on the other. Everything with
|
|
6
|
+
* a decision in it lives in `@boopkit/cap-oauth-server`; this binds it.
|
|
7
|
+
*
|
|
8
|
+
* ## The identity boundary
|
|
9
|
+
*
|
|
10
|
+
* `db.oauthClient` is the *only* model this capability touches. Users,
|
|
11
|
+
* accounts, identities and properties belong to cap-users, and this capability
|
|
12
|
+
* reaches them through the services `users.service` already built — never
|
|
13
|
+
* through `db`. A `db.user` / `db.account` / `db.userIdentity` (and friends)
|
|
14
|
+
* anywhere under `src/` is what the import audit in boop's suite fails on by
|
|
15
|
+
* name.
|
|
16
|
+
*
|
|
17
|
+
* The two cap-users services handed over below arrive as the narrow
|
|
18
|
+
* `IdentityStore` and `PropertyStore` ports the package declares. The package
|
|
19
|
+
* cannot ask for more than those ports allow, which is what keeps
|
|
20
|
+
* `oauth-grants` and `oauth-codes` the only two properties it ever reaches.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import db from '../db';
|
|
24
|
+
import UserService from './users.service';
|
|
25
|
+
import {
|
|
26
|
+
createAuthorizationCodeService,
|
|
27
|
+
createClientRegistryService,
|
|
28
|
+
createGrantService,
|
|
29
|
+
createRevocationService,
|
|
30
|
+
createTokenService,
|
|
31
|
+
matchRedirectUri,
|
|
32
|
+
readServerConfig,
|
|
33
|
+
type FetchLike,
|
|
34
|
+
type IdentityStore,
|
|
35
|
+
type PropertyStore,
|
|
36
|
+
} from '@boopkit/cap-oauth-server';
|
|
37
|
+
|
|
38
|
+
/** Read once at boot: a running process has one answer, and clamps warn once. */
|
|
39
|
+
export const oauthServerConfig = readServerConfig(process.env);
|
|
40
|
+
|
|
41
|
+
export const clientRegistryService = createClientRegistryService(db.oauthClient, {
|
|
42
|
+
// Node's global `fetch`, narrowed to the shape the capability declares. This
|
|
43
|
+
// is the seam boop's own tests drive; here it is the real network.
|
|
44
|
+
fetch: globalThis.fetch as unknown as FetchLike,
|
|
45
|
+
maxRegisteredClients: oauthServerConfig.maxRegisteredClients,
|
|
46
|
+
defaultScopes: oauthServerConfig.scopesSupported,
|
|
47
|
+
onMetadataRefreshFailed: (clientId, err) => {
|
|
48
|
+
// A stale document that could not be refreshed is served from cache, so the
|
|
49
|
+
// user's consent still works — but the failure is not silent, because a
|
|
50
|
+
// client whose document has been unreachable for a week is a client whose
|
|
51
|
+
// redirect URIs may have changed under us.
|
|
52
|
+
console.warn(`[oauth-server] metadata refresh failed for ${clientId}:`, err);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The token service reads both TTLs here, once, at construction — never per
|
|
58
|
+
* request. An out-of-range value is clamped with a warning naming the value and
|
|
59
|
+
* the bound rather than refused; an application should not fail to boot over a
|
|
60
|
+
* token lifetime.
|
|
61
|
+
*/
|
|
62
|
+
export const tokenService = createTokenService({
|
|
63
|
+
identityStore: UserService.identityService as IdentityStore,
|
|
64
|
+
accessTokenTtlSeconds: oauthServerConfig.accessTokenTtlSeconds,
|
|
65
|
+
refreshTokenTtlSeconds: oauthServerConfig.refreshTokenTtlSeconds,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const grantService = createGrantService({
|
|
69
|
+
propertyStore: UserService.userPropertyService as PropertyStore,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const authorizationCodeService = createAuthorizationCodeService({
|
|
73
|
+
tokenService,
|
|
74
|
+
propertyStore: UserService.userPropertyService as PropertyStore,
|
|
75
|
+
// Injected rather than imported inside the service, so RFC 8252's
|
|
76
|
+
// loopback-port exception lives in one function with its own test table.
|
|
77
|
+
matchRedirectUri,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Disconnecting a client: end the grant with a reason, then end-date its token
|
|
82
|
+
* rows so the client's very next request fails whatever its access token's
|
|
83
|
+
* stated lifetime says.
|
|
84
|
+
*
|
|
85
|
+
* Nothing is deleted. The ended grant record is the audit trail, and it is what
|
|
86
|
+
* distinguishes a revoked client from one that merely rotated its refresh token
|
|
87
|
+
* — rotation end-dates identity rows too, so those rows cannot carry the signal.
|
|
88
|
+
*/
|
|
89
|
+
export const revocationService = createRevocationService({ grantService, tokenService });
|
|
90
|
+
|
|
91
|
+
const OAuthServerService = {
|
|
92
|
+
name: 'oauth_server' as const,
|
|
93
|
+
config: oauthServerConfig,
|
|
94
|
+
clientRegistryService,
|
|
95
|
+
tokenService,
|
|
96
|
+
grantService,
|
|
97
|
+
authorizationCodeService,
|
|
98
|
+
revocationService,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export default OAuthServerService;
|