@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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/express/index.d.ts +1 -0
- package/express/index.esm.js +321 -0
- package/express/package.json +29 -0
- package/express/src/index.d.ts +1 -0
- package/express/src/lib/bearer.middleware.d.ts +78 -0
- package/express/src/lib/index.d.ts +2 -0
- package/express/src/lib/well-known.router.d.ts +35 -0
- package/firebase/index.d.ts +1 -0
- package/firebase/index.esm.js +1924 -0
- package/firebase/package.json +27 -0
- package/firebase/src/index.d.ts +1 -0
- package/firebase/src/lib/firestore/firestore.sdk-identity.d.ts +165 -0
- package/firebase/src/lib/firestore/index.d.ts +1 -0
- package/firebase/src/lib/index.d.ts +2 -0
- package/firebase/src/lib/session/firebase-client.config.d.ts +86 -0
- package/firebase/src/lib/session/firebase-user-session.d.ts +223 -0
- package/firebase/src/lib/session/firebase-user-session.pool.d.ts +168 -0
- package/firebase/src/lib/session/firestore-session.cache.d.ts +79 -0
- package/firebase/src/lib/session/firestore-session.client.d.ts +149 -0
- package/firebase/src/lib/session/index.d.ts +5 -0
- package/index.d.ts +1 -0
- package/index.esm.js +1066 -0
- package/package.json +53 -0
- package/src/index.d.ts +1 -0
- package/src/lib/auth/index.d.ts +1 -0
- package/src/lib/auth/oauth.resource.auth.d.ts +55 -0
- package/src/lib/challenge/bearer.challenge.d.ts +73 -0
- package/src/lib/challenge/index.d.ts +1 -0
- package/src/lib/error/index.d.ts +1 -0
- package/src/lib/error/oauth.resource.error.d.ts +76 -0
- package/src/lib/index.d.ts +6 -0
- package/src/lib/issuer/index.d.ts +1 -0
- package/src/lib/issuer/issuer.profile.d.ts +98 -0
- package/src/lib/metadata/index.d.ts +1 -0
- package/src/lib/metadata/protected-resource.metadata.d.ts +64 -0
- package/src/lib/verify/index.d.ts +1 -0
- 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;
|