@dereekb/oauth-resource 14.4.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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/express/index.d.ts +1 -0
  4. package/express/index.esm.js +321 -0
  5. package/express/package.json +29 -0
  6. package/express/src/index.d.ts +1 -0
  7. package/express/src/lib/bearer.middleware.d.ts +78 -0
  8. package/express/src/lib/index.d.ts +2 -0
  9. package/express/src/lib/well-known.router.d.ts +35 -0
  10. package/firebase/index.d.ts +1 -0
  11. package/firebase/index.esm.js +1924 -0
  12. package/firebase/package.json +27 -0
  13. package/firebase/src/index.d.ts +1 -0
  14. package/firebase/src/lib/firestore/firestore.sdk-identity.d.ts +165 -0
  15. package/firebase/src/lib/firestore/index.d.ts +1 -0
  16. package/firebase/src/lib/index.d.ts +2 -0
  17. package/firebase/src/lib/session/firebase-client.config.d.ts +86 -0
  18. package/firebase/src/lib/session/firebase-user-session.d.ts +223 -0
  19. package/firebase/src/lib/session/firebase-user-session.pool.d.ts +168 -0
  20. package/firebase/src/lib/session/firestore-session.cache.d.ts +79 -0
  21. package/firebase/src/lib/session/firestore-session.client.d.ts +149 -0
  22. package/firebase/src/lib/session/index.d.ts +5 -0
  23. package/index.d.ts +1 -0
  24. package/index.esm.js +1066 -0
  25. package/package.json +53 -0
  26. package/src/index.d.ts +1 -0
  27. package/src/lib/auth/index.d.ts +1 -0
  28. package/src/lib/auth/oauth.resource.auth.d.ts +55 -0
  29. package/src/lib/challenge/bearer.challenge.d.ts +73 -0
  30. package/src/lib/challenge/index.d.ts +1 -0
  31. package/src/lib/error/index.d.ts +1 -0
  32. package/src/lib/error/oauth.resource.error.d.ts +76 -0
  33. package/src/lib/index.d.ts +6 -0
  34. package/src/lib/issuer/index.d.ts +1 -0
  35. package/src/lib/issuer/issuer.profile.d.ts +98 -0
  36. package/src/lib/metadata/index.d.ts +1 -0
  37. package/src/lib/metadata/protected-resource.metadata.d.ts +64 -0
  38. package/src/lib/verify/index.d.ts +1 -0
  39. package/src/lib/verify/verify.bearer.d.ts +95 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Hapier Creative LLC.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,185 @@
1
+ # @dereekb/oauth-resource
2
+
3
+ The **resource-server** half of OAuth: verify a bearer token that somebody else issued.
4
+
5
+ `@dereekb/firebase-server/oidc` is a complete authorization server, but its bearer middleware only
6
+ works **in-process** — it validates by looking the token up in the provider's own Firestore adapter.
7
+ Any service that is not that API process (a Docker'd sidecar, a worker, a standalone MCP host) needs
8
+ to verify by **signature**, against a published JWKS, and that is what this package does.
9
+
10
+ It is deliberately tiny. `jose` + `@dereekb/util` are the only hard dependencies; `express` and
11
+ `cors` are optional peers used only by the `/express` subpath. Nothing Firebase, Nest, or
12
+ oidc-provider shaped is anywhere in its install graph, so a plain Express service does not end up
13
+ installing a native image library in order to check a signature.
14
+
15
+ ## Entry points
16
+
17
+ | Entry | Purpose |
18
+ |---|---|
19
+ | `@dereekb/oauth-resource` | Issuer profiles + JWKS discovery, `verifyBearerJwt`, the RFC 6750 challenge builder, the RFC 9728 metadata document, and the `AuthInfo`-shaped verified caller. Framework-free. |
20
+ | `@dereekb/oauth-resource/express` | `requireBearer()` middleware and `createWellKnownRouter()`. Adds `express` + `cors` as optional peers, and ships the `express-serve-static-core` `req.auth` augmentation. |
21
+ | `@dereekb/oauth-resource/firebase` | The consume side of the user-scoped Firestore session bridge: `openFirebaseUserSession`, `firebaseUserSessionPool`, and the SDK-identity diagnostic. Adds `firebase` + `@dereekb/firebase` as optional peers. |
22
+
23
+ ## Trust model
24
+
25
+ A resource server trusts a small, explicit set of **issuers**. The incoming token's `iss` claim
26
+ selects one; that profile's JWKS verifies the signature; `jose` then enforces `iss` / `aud` / `exp` /
27
+ `nbf` / `iat` with a 60 s tolerance. Two issuer kinds are built in:
28
+
29
+ - **`firebase`** — Firebase Auth ID tokens for a project, verified against Google's shared
30
+ `securetoken` JWKS. The only accepted audience is the project id.
31
+ - **`oidc`** — any OAuth 2.0 / OpenID Connect authorization server. Its `jwks_uri` is discovered
32
+ from `{iss}/.well-known/openid-configuration`, falling back to the conventional `{iss}/jwks`.
33
+
34
+ ```ts
35
+ import { buildIssuerProfiles, verifyBearerJwt } from '@dereekb/oauth-resource';
36
+
37
+ const profiles = buildIssuerProfiles({
38
+ firebaseProjectIds: ['my-project'],
39
+ oidcIssuers: ['https://api.example.com/oidc'],
40
+ audiences: ['https://db.example.com', 'https://db.example.com/mcp']
41
+ });
42
+
43
+ const verified = await verifyBearerJwt(token, { profiles });
44
+ ```
45
+
46
+ Two behaviors here are load-bearing and deliberate:
47
+
48
+ - **`getKey` is lazy, and discovery is memoized on success only.** A discovery outage surfaces as a
49
+ 401 on the affected request instead of taking the service down at boot, and the next request
50
+ retries rather than latching onto the failure.
51
+ - **The key resolver is injectable.** A spec passes `createLocalJWKSet(...)`, and an authorization
52
+ server verifying its *own* tokens in-process passes its local key set rather than making an HTTP
53
+ call back to itself.
54
+
55
+ ## The audience is the point
56
+
57
+ An OAuth access token is issued **for a resource**. A client asks for one with RFC 8707
58
+ `resource=https://db.example.com/mcp`, and the authorization server stamps that resource server's
59
+ `audience` onto the token. Verifying `aud` is therefore what stops a token minted for one service
60
+ from being replayed against another.
61
+
62
+ On the dbx-components authorization-server side, that entry is declared with
63
+ `buildOidcResourceServer({ url, scope, audience, accessTokenFormat, accessTokenTTL })` from
64
+ `@dereekb/firebase-server/oidc`, and `firebaseServerIssuerProfiles()` emits the matching
65
+ `buildIssuerProfiles` config from the same `OidcModuleConfig` — so an API and its satellites cannot
66
+ drift on issuer or audience strings.
67
+
68
+ **Set `accessTokenFormat: 'jwt'` for anything off-box.** The default format is opaque: a database
69
+ key that only the issuing provider can validate. A remote service fundamentally cannot verify one.
70
+ The trade-off is that a JWT access token has no adapter record and therefore **cannot be revoked
71
+ before `exp`** — keep its TTL short.
72
+
73
+ ## Policy gates
74
+
75
+ `verifyBearerJwt` proves *who* the caller is; it does not decide *what* they may do.
76
+
77
+ - `requiredClaims` / `claimPredicate` gate on account claims. They apply to `firebase` issuers only
78
+ by default (`claimGateKinds`), because an OAuth access token carries scopes rather than app
79
+ account claims. A failure is `forbidden` (403 / `insufficient_scope`), not `unauthorized`.
80
+ - Scope enforcement is per-route and belongs to the caller — the Express middleware's
81
+ `requiredScopes` covers the simple case.
82
+
83
+ Every rejection throws an `OAuthResourceError` carrying a code (`unauthorized` / `forbidden`), an
84
+ HTTP status, and a `toEnvelope()` body. Supply an `errorFactory` to throw your own API error type
85
+ instead, and an `errorResponseFactory` on the middleware to shape the response body to match.
86
+
87
+ ## Express
88
+
89
+ ```ts
90
+ import { createWellKnownRouter, requireBearer } from '@dereekb/oauth-resource/express';
91
+
92
+ app.use(createWellKnownRouter({ resource: `${PUBLIC_URL}/mcp`, authorizationServers: [ISSUER], scopesSupported: SCOPES }));
93
+ app.use('/mcp', requireBearer({ verify: { profiles }, resourceMetadataUrl: RESOURCE_METADATA_URL, realm: 'my-db' }));
94
+ ```
95
+
96
+ `requireBearer` attaches the verified caller to `req.auth` in the MCP SDK's `AuthInfo` shape (a
97
+ structurally identical local interface — taking an SDK peer dependency to borrow a five-field type
98
+ was not worth it), and answers a failure with the correct RFC 6750 challenge: `invalid_request` when
99
+ no token was presented, `invalid_token` when one was but failed, `insufficient_scope` on a 403 —
100
+ each carrying the `resource_metadata=` discovery hint.
101
+
102
+ `createWellKnownRouter` serves the RFC 9728 document at **both** the path-suffixed
103
+ (`/.well-known/oauth-protected-resource/mcp`, what clients try first) and bare paths, CORS-open. It
104
+ is hand-rolled rather than taken from the MCP SDK because the SDK's builder also wants the
105
+ authorization-server metadata document, which a resource server never hosts — it points at one.
106
+
107
+ ## User-scoped Firestore sessions
108
+
109
+ `@dereekb/oauth-resource/firebase` turns a verified bearer token carrying the `session.firestore`
110
+ scope into a live, **rules-evaluated** `FirestoreContext` for that token's user:
111
+
112
+ ```
113
+ verified bearer token (scope: session.firestore)
114
+ → GET <apiBaseUrl>/session/firestore Authorization: Bearer <access_token>
115
+ → { uid, customToken, appCheckToken?, expiresAt }
116
+ → initializeApp → initializeAppCheck(CustomProvider) → signInWithCustomToken
117
+ → clientFirebaseFirestoreContextFactory(getFirestore(app))
118
+ → make<App>FirestoreCollections(ctx) ← the same object the Angular app builds
119
+ ```
120
+
121
+ The mint endpoint is `@dereekb/firebase-server`'s session module; this package is the consume side
122
+ only.
123
+
124
+ ```ts
125
+ import { firebaseUserSessionPool } from '@dereekb/oauth-resource/firebase';
126
+
127
+ const pool = firebaseUserSessionPool({ namespace: 'my-service', firebase: FIREBASE_CLIENT_CONFIG, apiBaseUrl: API_BASE_URL });
128
+
129
+ // `verified.subject` is the Firebase uid for a firebase-server-issued OIDC token
130
+ const rows = await pool.useSession({ uid: verified.subject, accessToken }, async (session) => {
131
+ const collections = makeMyAppFirestoreCollections(session.firestoreContext);
132
+ return collections.thing.queryDocument(/* … */).getDocs();
133
+ });
134
+ ```
135
+
136
+ **This is not an Admin-SDK bypass — that is the whole point.** The client SDK is the only Firestore
137
+ transport that carries a user ID token, so every read and write here is evaluated against
138
+ `firestore.rules` exactly as it would be in the browser app, and the user's stored custom claims land
139
+ at the top level of the exchanged ID token so `request.auth.token.<claim>` behaves identically. An
140
+ Admin-SDK context reaching this path is a bug, and
141
+ `inspectFirebaseClientFirestoreIdentity` reports it as `unexpected-driver`.
142
+
143
+ The custom token is always minted for the presented token's own `auth.uid`, with no way to name
144
+ another user. Pass `uid` to `openFirebaseUserSession` (the pool always does) and that property is
145
+ asserted locally too, before any Firebase app is registered.
146
+
147
+ ### App Check comes first, and a session never reuses an app
148
+
149
+ `initializeAppCheck` must run before any other Firebase call, or requests go out unattested and are
150
+ rejected in production. It also means a session that mints its own credentials must initialize a
151
+ **fresh** `FirebaseApp`: `initializeAppCheck` on an app whose provider is already initialized
152
+ silently returns the existing instance when `CustomProvider.isEqual` matches, and `isEqual` compares
153
+ `getToken.toString()` — the source text of the closure, which is identical across two closures built
154
+ at the same call site over different tokens. Reusing an app therefore keeps the *first* attestation
155
+ and drops the newly minted one. App reuse is the pool's job, at the session-object level.
156
+
157
+ Every app is named `<namespace>::<scope>::<uid>` (scope defaults to the project id), so
158
+ `closeFirebaseUserSessionApps({ namespace })` sweeps every app an owner registered from `getApps()`
159
+ alone — no side registry, idempotent by construction.
160
+
161
+ ### Pool cap and TTL
162
+
163
+ A signed-in `Auth` runs a token-refresh timer and a live `Firestore` holds handles, so both leak per
164
+ user without teardown. The pool is the per-`(scope, uid)` lifecycle owner:
165
+
166
+ - **Lease-based.** `useSession(input, fn)` borrows for the callback's duration; `openSession` +
167
+ `release()` is the escape hatch. Reference counting is what makes eviction safe — the pool never
168
+ tears down a session someone is mid-query on.
169
+ - **`maxSessions` (default 32)** bounds *concurrently distinct users*. At cap the least-recently-used
170
+ **idle** entry is evicted. When every entry is leased the pool emits `over-capacity`, marks the LRU
171
+ entry for teardown-on-release, and **admits anyway**: the cap is a target, its overshoot is bounded
172
+ by the host's own request concurrency, and refusing a user because others are busy is worse than a
173
+ brief overshoot.
174
+ - **TTL is driven by App Check, not Auth.** A signed-in `Auth` refreshes its ID token indefinitely;
175
+ the App Check token does not — it is minted once by the API with no local attestation to refresh
176
+ against. So the ceiling is the envelope's `expiresAt`, floored by `maxSessionAgeMs` (default one
177
+ hour, the Firebase credential ceiling). Past `expiresAt - refreshSkewMs` the whole app is torn down
178
+ and re-minted: one round trip, versus a silently unattested connection.
179
+
180
+ ### Credential handling
181
+
182
+ A `FirestoreSessionCredentials` is a **bearer credential for its user**. The pool's own state is
183
+ in-memory and dies with the process; this package ships the `FirestoreSessionCredentialsCache` port
184
+ and the expiry policy but **no** persistent store. Supply one only if you can protect it at least as
185
+ well as a 0600 file.
@@ -0,0 +1 @@
1
+ export * from "./src/index";
@@ -0,0 +1,321 @@
1
+ import { isOAuthResourceError, OAuthResourceError, defaultOAuthResourceErrorFactory, buildBearerChallenge, bearerChallengeErrorForCode, oauthResourceAuthInfoForVerifiedBearer, readBearerToken, verifyBearerJwt, buildProtectedResourceMetadata, oauthProtectedResourcePathForResource, OAUTH_PROTECTED_RESOURCE_PATH } from '@dereekb/oauth-resource';
2
+ import cors from 'cors';
3
+ import { Router } from 'express';
4
+
5
+ function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
6
+ try {
7
+ var info = gen[key](arg);
8
+ var value = info.value;
9
+ } catch (error) {
10
+ reject(error);
11
+ return;
12
+ }
13
+ if (info.done) resolve(value);
14
+ else Promise.resolve(value).then(_next, _throw);
15
+ }
16
+ function _async_to_generator(fn) {
17
+ return function() {
18
+ var self = this, args = arguments;
19
+ return new Promise(function(resolve, reject) {
20
+ var gen = fn.apply(self, args);
21
+ function _next(value) {
22
+ asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value);
23
+ }
24
+ function _throw(err) {
25
+ asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err);
26
+ }
27
+ _next(undefined);
28
+ });
29
+ };
30
+ }
31
+ function _define_property(obj, key, value) {
32
+ if (key in obj) {
33
+ Object.defineProperty(obj, key, {
34
+ value: value,
35
+ enumerable: true,
36
+ configurable: true,
37
+ writable: true
38
+ });
39
+ } else obj[key] = value;
40
+ return obj;
41
+ }
42
+ function _object_spread(target) {
43
+ for(var i = 1; i < arguments.length; i++){
44
+ var source = arguments[i] != null ? arguments[i] : {};
45
+ var ownKeys = Object.keys(source);
46
+ if (typeof Object.getOwnPropertySymbols === "function") {
47
+ ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) {
48
+ return Object.getOwnPropertyDescriptor(source, sym).enumerable;
49
+ }));
50
+ }
51
+ ownKeys.forEach(function(key) {
52
+ _define_property(target, key, source[key]);
53
+ });
54
+ }
55
+ return target;
56
+ }
57
+ function _ts_generator(thisArg, body) {
58
+ var f, y, t, _ = {
59
+ label: 0,
60
+ sent: function() {
61
+ if (t[0] & 1) throw t[1];
62
+ return t[1];
63
+ },
64
+ trys: [],
65
+ ops: []
66
+ }, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype), d = Object.defineProperty;
67
+ return d(g, "next", {
68
+ value: verb(0)
69
+ }), d(g, "throw", {
70
+ value: verb(1)
71
+ }), d(g, "return", {
72
+ value: verb(2)
73
+ }), typeof Symbol === "function" && d(g, Symbol.iterator, {
74
+ value: function() {
75
+ return this;
76
+ }
77
+ }), g;
78
+ function verb(n) {
79
+ return function(v) {
80
+ return step([
81
+ n,
82
+ v
83
+ ]);
84
+ };
85
+ }
86
+ function step(op) {
87
+ if (f) throw new TypeError("Generator is already executing.");
88
+ while(g && (g = 0, op[0] && (_ = 0)), _)try {
89
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
90
+ if (y = 0, t) op = [
91
+ op[0] & 2,
92
+ t.value
93
+ ];
94
+ switch(op[0]){
95
+ case 0:
96
+ case 1:
97
+ t = op;
98
+ break;
99
+ case 4:
100
+ _.label++;
101
+ return {
102
+ value: op[1],
103
+ done: false
104
+ };
105
+ case 5:
106
+ _.label++;
107
+ y = op[1];
108
+ op = [
109
+ 0
110
+ ];
111
+ continue;
112
+ case 7:
113
+ op = _.ops.pop();
114
+ _.trys.pop();
115
+ continue;
116
+ default:
117
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) {
118
+ _ = 0;
119
+ continue;
120
+ }
121
+ if (op[0] === 3 && (!t || op[1] > t[0] && op[1] < t[3])) {
122
+ _.label = op[1];
123
+ break;
124
+ }
125
+ if (op[0] === 6 && _.label < t[1]) {
126
+ _.label = t[1];
127
+ t = op;
128
+ break;
129
+ }
130
+ if (t && _.label < t[2]) {
131
+ _.label = t[2];
132
+ _.ops.push(op);
133
+ break;
134
+ }
135
+ if (t[2]) _.ops.pop();
136
+ _.trys.pop();
137
+ continue;
138
+ }
139
+ op = body.call(thisArg, _);
140
+ } catch (e) {
141
+ op = [
142
+ 6,
143
+ e
144
+ ];
145
+ y = 0;
146
+ } finally{
147
+ f = t = 0;
148
+ }
149
+ if (op[0] & 5) throw op[1];
150
+ return {
151
+ value: op[0] ? op[1] : void 0,
152
+ done: true
153
+ };
154
+ }
155
+ }
156
+ // MARK: Constants
157
+ /**
158
+ * Placeholder auth attached to every request when verification is disabled.
159
+ */ var AUTH_DISABLED_CLIENT_ID = 'dev';
160
+ /**
161
+ * Lifetime, in seconds, stamped on the placeholder auth when verification is disabled.
162
+ */ var AUTH_DISABLED_EXPIRES_IN_SECONDS = 3600;
163
+ /**
164
+ * Default {@link OAuthResourceErrorResponseFactory}. Reads an {@link OAuthResourceError}'s own
165
+ * status / code / envelope; anything else is reported as a generic 401.
166
+ *
167
+ * @param error - The thrown error.
168
+ * @returns The response to send.
169
+ */ var defaultOAuthResourceErrorResponseFactory = function defaultOAuthResourceErrorResponseFactory(error) {
170
+ var resourceError = isOAuthResourceError(error) ? error : new OAuthResourceError({
171
+ code: 'unauthorized',
172
+ message: 'Invalid bearer token.'
173
+ });
174
+ return {
175
+ status: resourceError.status,
176
+ code: resourceError.code,
177
+ body: resourceError.toEnvelope()
178
+ };
179
+ };
180
+ /**
181
+ * Express middleware that requires a valid bearer JWT (see {@link verifyBearerJwt}) and attaches it
182
+ * as `req.auth`. A failure answers with the configured error envelope plus an RFC 6750
183
+ * `WWW-Authenticate` challenge carrying the protected-resource metadata URL.
184
+ *
185
+ * @param config - Verification options plus the challenge / policy settings.
186
+ * @returns The middleware.
187
+ */ function requireBearer(config) {
188
+ var _config_errorResponseFactory, _config_requiredScopes, _config_verify_errorFactory;
189
+ var errorResponseFactory = (_config_errorResponseFactory = config.errorResponseFactory) !== null && _config_errorResponseFactory !== void 0 ? _config_errorResponseFactory : defaultOAuthResourceErrorResponseFactory;
190
+ var requiredScopes = (_config_requiredScopes = config.requiredScopes) !== null && _config_requiredScopes !== void 0 ? _config_requiredScopes : [];
191
+ var errorFactory = (_config_verify_errorFactory = config.verify.errorFactory) !== null && _config_verify_errorFactory !== void 0 ? _config_verify_errorFactory : defaultOAuthResourceErrorFactory;
192
+ return function(req, res, next) {
193
+ return _async_to_generator(function() {
194
+ var token, verified, authInfo, missingScope, error, response, challenge;
195
+ return _ts_generator(this, function(_state) {
196
+ switch(_state.label){
197
+ case 0:
198
+ token = readBearerToken(req.headers.authorization);
199
+ _state.label = 1;
200
+ case 1:
201
+ _state.trys.push([
202
+ 1,
203
+ 5,
204
+ ,
205
+ 6
206
+ ]);
207
+ if (!(config.authDisabled === true)) return [
208
+ 3,
209
+ 2
210
+ ];
211
+ req.auth = {
212
+ token: AUTH_DISABLED_CLIENT_ID,
213
+ clientId: AUTH_DISABLED_CLIENT_ID,
214
+ scopes: [],
215
+ expiresAt: Math.floor(Date.now() / 1000) + AUTH_DISABLED_EXPIRES_IN_SECONDS
216
+ };
217
+ return [
218
+ 3,
219
+ 4
220
+ ];
221
+ case 2:
222
+ if (token === undefined) {
223
+ throw errorFactory({
224
+ code: 'unauthorized',
225
+ message: 'Missing bearer token.'
226
+ });
227
+ }
228
+ return [
229
+ 4,
230
+ verifyBearerJwt(token, config.verify)
231
+ ];
232
+ case 3:
233
+ verified = _state.sent();
234
+ authInfo = oauthResourceAuthInfoForVerifiedBearer(verified);
235
+ missingScope = requiredScopes.find(function(scope) {
236
+ return !authInfo.scopes.includes(scope);
237
+ });
238
+ if (missingScope != null) {
239
+ throw errorFactory({
240
+ code: 'forbidden',
241
+ message: 'Token is missing the required "'.concat(missingScope, '" scope.'),
242
+ details: {
243
+ scope: missingScope
244
+ }
245
+ });
246
+ }
247
+ req.auth = authInfo;
248
+ _state.label = 4;
249
+ case 4:
250
+ next();
251
+ return [
252
+ 3,
253
+ 6
254
+ ];
255
+ case 5:
256
+ error = _state.sent();
257
+ response = errorResponseFactory(error);
258
+ challenge = buildBearerChallenge(_object_spread({
259
+ error: bearerChallengeErrorForCode({
260
+ code: response.code,
261
+ hadToken: token !== undefined
262
+ }),
263
+ realm: config.realm,
264
+ resourceMetadataUrl: config.resourceMetadataUrl
265
+ }, response.code === 'forbidden' && requiredScopes.length > 0 ? {
266
+ scope: requiredScopes.join(' ')
267
+ } : {}));
268
+ res.status(response.status).setHeader('WWW-Authenticate', challenge).json(response.body);
269
+ return [
270
+ 3,
271
+ 6
272
+ ];
273
+ case 6:
274
+ return [
275
+ 2
276
+ ];
277
+ }
278
+ });
279
+ })();
280
+ };
281
+ }
282
+
283
+ // MARK: Constants
284
+ /**
285
+ * How long a client may cache the protected-resource metadata document, in seconds.
286
+ */ var DEFAULT_PROTECTED_RESOURCE_METADATA_MAX_AGE = 300;
287
+ /**
288
+ * Serves the RFC 9728 metadata at both the path-suffixed (`…/oauth-protected-resource/mcp`,
289
+ * what clients try first) and bare well-known paths. Public and CORS-open so a browser-based
290
+ * client can discover the issuer, and built once at construction.
291
+ *
292
+ * @param config - The metadata document's inputs plus the resource path and cache lifetime.
293
+ * @returns The router.
294
+ */ function createWellKnownRouter(config) {
295
+ var _config_maxAge, _config_resourcePath;
296
+ var document = buildProtectedResourceMetadata(config);
297
+ var maxAge = (_config_maxAge = config.maxAge) !== null && _config_maxAge !== void 0 ? _config_maxAge : DEFAULT_PROTECTED_RESOURCE_METADATA_MAX_AGE;
298
+ var serve = function serve(_req, res) {
299
+ res.setHeader('cache-control', "public, max-age=".concat(maxAge)).json(document);
300
+ };
301
+ var router = Router();
302
+ var suffixedPath = oauthProtectedResourcePathForResource((_config_resourcePath = config.resourcePath) !== null && _config_resourcePath !== void 0 ? _config_resourcePath : resourcePathForResource(config.resource));
303
+ if (suffixedPath !== OAUTH_PROTECTED_RESOURCE_PATH) {
304
+ router.get(suffixedPath, cors(), serve);
305
+ }
306
+ router.get(OAUTH_PROTECTED_RESOURCE_PATH, cors(), serve);
307
+ return router;
308
+ }
309
+ // MARK: Internal
310
+ function resourcePathForResource(resource) {
311
+ var path = '';
312
+ try {
313
+ path = new URL(resource).pathname;
314
+ } catch (unused) {
315
+ // not an absolute URL — treat the whole value as the path
316
+ path = resource;
317
+ }
318
+ return path;
319
+ }
320
+
321
+ export { AUTH_DISABLED_CLIENT_ID, AUTH_DISABLED_EXPIRES_IN_SECONDS, DEFAULT_PROTECTED_RESOURCE_METADATA_MAX_AGE, createWellKnownRouter, defaultOAuthResourceErrorResponseFactory, requireBearer };
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@dereekb/oauth-resource/express",
3
+ "version": "14.4.0",
4
+ "sideEffects": false,
5
+ "type": "module",
6
+ "peerDependencies": {
7
+ "@dereekb/oauth-resource": "14.4.0",
8
+ "@dereekb/util": "14.4.0",
9
+ "cors": "^2.8.6",
10
+ "express": "^5.2.1",
11
+ "jose": "^6.2.12",
12
+ "make-error": "^1.3.6"
13
+ },
14
+ "devDependencies": {
15
+ "jose": "^6.2.12",
16
+ "supertest": "^7.2.2"
17
+ },
18
+ "exports": {
19
+ "./package.json": "./package.json",
20
+ ".": {
21
+ "types": "./index.d.ts",
22
+ "import": "./index.esm.js",
23
+ "default": "./index.esm.js"
24
+ }
25
+ },
26
+ "module": "./index.esm.js",
27
+ "main": "./index.esm.js",
28
+ "types": "./index.d.ts"
29
+ }
@@ -0,0 +1 @@
1
+ export * from './lib';
@@ -0,0 +1,78 @@
1
+ import { type OAuthResourceAuthInfo, type OAuthResourceErrorCode, type VerifyBearerOptions } from '@dereekb/oauth-resource';
2
+ import { type Maybe } from '@dereekb/util';
3
+ import { type RequestHandler } from 'express';
4
+ declare module 'express-serve-static-core' {
5
+ interface Request {
6
+ /**
7
+ * The verified bearer, in the MCP SDK's `AuthInfo` shape — the SDK's `toNodeHandler`
8
+ * forwards it to the MCP handler as `authInfo`.
9
+ */
10
+ auth?: OAuthResourceAuthInfo;
11
+ }
12
+ }
13
+ /**
14
+ * Placeholder auth attached to every request when verification is disabled.
15
+ */
16
+ export declare const AUTH_DISABLED_CLIENT_ID = "dev";
17
+ /**
18
+ * Lifetime, in seconds, stamped on the placeholder auth when verification is disabled.
19
+ */
20
+ export declare const AUTH_DISABLED_EXPIRES_IN_SECONDS = 3600;
21
+ /**
22
+ * How a rejected request is answered: the status, the RFC 6750 error token to challenge with, and
23
+ * the JSON body.
24
+ */
25
+ export interface OAuthResourceErrorResponse {
26
+ readonly status: number;
27
+ readonly code: OAuthResourceErrorCode;
28
+ readonly body: unknown;
29
+ }
30
+ /**
31
+ * Maps a thrown error onto the response the middleware sends.
32
+ *
33
+ * The companion of {@link VerifyBearerOptions.errorFactory}: a consumer that throws its own API
34
+ * error type supplies one of these to shape the body with its own envelope.
35
+ */
36
+ export type OAuthResourceErrorResponseFactory = (error: unknown) => OAuthResourceErrorResponse;
37
+ /**
38
+ * Default {@link OAuthResourceErrorResponseFactory}. Reads an {@link OAuthResourceError}'s own
39
+ * status / code / envelope; anything else is reported as a generic 401.
40
+ *
41
+ * @param error - The thrown error.
42
+ * @returns The response to send.
43
+ */
44
+ export declare const defaultOAuthResourceErrorResponseFactory: OAuthResourceErrorResponseFactory;
45
+ export interface RequireBearerConfig {
46
+ readonly verify: VerifyBearerOptions;
47
+ /**
48
+ * Skip verification and attach a placeholder auth. Development only — it authorizes every caller.
49
+ */
50
+ readonly authDisabled?: Maybe<boolean>;
51
+ /**
52
+ * Absolute URL of the RFC 9728 protected-resource metadata, advertised in the
53
+ * `WWW-Authenticate` challenge so OAuth clients can discover the authorization server.
54
+ */
55
+ readonly resourceMetadataUrl?: Maybe<string>;
56
+ /**
57
+ * Protection space name emitted as the challenge's `realm`. Omitted by default.
58
+ */
59
+ readonly realm?: Maybe<string>;
60
+ /**
61
+ * Scopes the token must carry. A token missing any of them is rejected as `forbidden`, which
62
+ * emits an `insufficient_scope` challenge carrying the required scopes.
63
+ */
64
+ readonly requiredScopes?: Maybe<readonly string[]>;
65
+ /**
66
+ * Maps a thrown error onto the response. Defaults to {@link defaultOAuthResourceErrorResponseFactory}.
67
+ */
68
+ readonly errorResponseFactory?: Maybe<OAuthResourceErrorResponseFactory>;
69
+ }
70
+ /**
71
+ * Express middleware that requires a valid bearer JWT (see {@link verifyBearerJwt}) and attaches it
72
+ * as `req.auth`. A failure answers with the configured error envelope plus an RFC 6750
73
+ * `WWW-Authenticate` challenge carrying the protected-resource metadata URL.
74
+ *
75
+ * @param config - Verification options plus the challenge / policy settings.
76
+ * @returns The middleware.
77
+ */
78
+ export declare function requireBearer(config: RequireBearerConfig): RequestHandler;
@@ -0,0 +1,2 @@
1
+ export * from './bearer.middleware';
2
+ export * from './well-known.router';