@qauth-labs/resource-guard 0.0.0 → 0.2.0-rc.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 +201 -0
- package/README.md +247 -1
- package/dist/fastify.d.ts +9 -0
- package/dist/fastify.js +9 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +30 -0
- package/dist/lib/challenge.d.ts +32 -0
- package/dist/lib/challenge.js +72 -0
- package/dist/lib/core.d.ts +75 -0
- package/dist/lib/core.js +217 -0
- package/dist/lib/errors.d.ts +116 -0
- package/dist/lib/errors.js +137 -0
- package/dist/lib/fastify-plugin.d.ts +76 -0
- package/dist/lib/fastify-plugin.js +167 -0
- package/dist/lib/introspection-validator.d.ts +53 -0
- package/dist/lib/introspection-validator.js +120 -0
- package/dist/lib/jwt-validator.d.ts +72 -0
- package/dist/lib/jwt-validator.js +181 -0
- package/dist/lib/metadata.d.ts +59 -0
- package/dist/lib/metadata.js +88 -0
- package/dist/lib/scope.d.ts +38 -0
- package/dist/lib/scope.js +73 -0
- package/dist/types.d.ts +160 -0
- package/dist/types.js +14 -0
- package/package.json +64 -12
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic core of `resource-guard`.
|
|
3
|
+
*
|
|
4
|
+
* `ResourceGuard` encapsulates everything that does not depend on a web framework:
|
|
5
|
+
* config resolution, validator selection (JWT vs introspection), bearer
|
|
6
|
+
* extraction, the RFC 9728 metadata document, and scope enforcement / step-up.
|
|
7
|
+
* The Fastify plugin is a thin adapter over this; other frameworks can wrap it
|
|
8
|
+
* identically.
|
|
9
|
+
*
|
|
10
|
+
* No-passthrough guarantee: this core only ever *validates* an inbound token
|
|
11
|
+
* and returns its claims. It never returns or forwards the raw token to an
|
|
12
|
+
* upstream call site, and audience binding is enforced by both validators, so
|
|
13
|
+
* a token minted for another resource cannot be replayed here.
|
|
14
|
+
*/
|
|
15
|
+
import type { ResourceGuardConfig, ValidatedToken, ValidationMode } from '../types.js';
|
|
16
|
+
import { type ResourceGuardError } from './errors.js';
|
|
17
|
+
import { type ProtectedResourceMetadata } from './metadata.js';
|
|
18
|
+
/**
|
|
19
|
+
* Extract a bearer token from an `Authorization` header value (RFC 6750 §2.1).
|
|
20
|
+
* The scheme match is case-insensitive; the credential is returned verbatim.
|
|
21
|
+
* Returns `null` when the header is absent or not a non-empty Bearer.
|
|
22
|
+
*
|
|
23
|
+
* Parsed by slicing after a linear scheme match rather than with one
|
|
24
|
+
* `/^Bearer[ ]+(.+)$/i`: that pattern backtracks polynomially when many spaces
|
|
25
|
+
* precede a line terminator, and this function is exported for callers that
|
|
26
|
+
* may not have an HTTP parser's header rules in front of it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function extractBearerToken(authorization: string | undefined | null): string | null;
|
|
29
|
+
export declare class ResourceGuard {
|
|
30
|
+
/** Canonical resource identifier (`aud` tokens must carry). */
|
|
31
|
+
readonly resource: string;
|
|
32
|
+
/** Canonical AS issuer identifier. */
|
|
33
|
+
readonly authorizationServer: string;
|
|
34
|
+
/** Default scopes required for any access. */
|
|
35
|
+
readonly requiredScopes: string[];
|
|
36
|
+
readonly validationMode: ValidationMode;
|
|
37
|
+
private readonly validator;
|
|
38
|
+
private readonly metadata;
|
|
39
|
+
constructor(config: ResourceGuardConfig);
|
|
40
|
+
private buildValidator;
|
|
41
|
+
private buildJwtValidator;
|
|
42
|
+
/** The RFC 9728 PRM document for this resource. */
|
|
43
|
+
getProtectedResourceMetadata(): ProtectedResourceMetadata;
|
|
44
|
+
/** Well-known PRM path for this resource (RFC 9728 §3.1). */
|
|
45
|
+
getMetadataPath(): string;
|
|
46
|
+
/** Absolute PRM URL advertised in the `resource_metadata` challenge param. */
|
|
47
|
+
getMetadataUrl(): string;
|
|
48
|
+
/**
|
|
49
|
+
* Build the `WWW-Authenticate` header value for a guard error, with the
|
|
50
|
+
* `resource_metadata` pointer baked in. The Fastify adapter calls this to
|
|
51
|
+
* set the header on 401 / 403 responses.
|
|
52
|
+
*/
|
|
53
|
+
challengeHeader(error: ResourceGuardError): string;
|
|
54
|
+
/**
|
|
55
|
+
* Authenticate and authorize a request from its `Authorization` header.
|
|
56
|
+
*
|
|
57
|
+
* @param authorization the raw header value.
|
|
58
|
+
* @param scopes additional per-operation scopes to require on top of the
|
|
59
|
+
* guard's default `requiredScopes` (the step-up surface).
|
|
60
|
+
* @returns the validated, audience-bound token claims.
|
|
61
|
+
* @throws {MissingTokenError} when no bearer is present (→ 401, bare challenge).
|
|
62
|
+
* @throws {InvalidTokenError} when validation fails (→ 401 `invalid_token`).
|
|
63
|
+
* @throws {InsufficientScopeError} when scopes are insufficient
|
|
64
|
+
* (→ 403 `insufficient_scope` step-up challenge).
|
|
65
|
+
*/
|
|
66
|
+
authenticate(authorization: string | undefined | null, scopes?: string[]): Promise<ValidatedToken>;
|
|
67
|
+
/**
|
|
68
|
+
* Assert that a previously validated token satisfies a scope set. Use this
|
|
69
|
+
* for incremental, mid-handler step-up checks (e.g. a privileged tool call)
|
|
70
|
+
* where the token was already validated by {@link authenticate}.
|
|
71
|
+
*
|
|
72
|
+
* @throws {InsufficientScopeError} listing every scope the request needs.
|
|
73
|
+
*/
|
|
74
|
+
assertScopes(token: ValidatedToken, scopes?: string[]): void;
|
|
75
|
+
}
|
package/dist/lib/core.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic core of `resource-guard`.
|
|
3
|
+
*
|
|
4
|
+
* `ResourceGuard` encapsulates everything that does not depend on a web framework:
|
|
5
|
+
* config resolution, validator selection (JWT vs introspection), bearer
|
|
6
|
+
* extraction, the RFC 9728 metadata document, and scope enforcement / step-up.
|
|
7
|
+
* The Fastify plugin is a thin adapter over this; other frameworks can wrap it
|
|
8
|
+
* identically.
|
|
9
|
+
*
|
|
10
|
+
* No-passthrough guarantee: this core only ever *validates* an inbound token
|
|
11
|
+
* and returns its claims. It never returns or forwards the raw token to an
|
|
12
|
+
* upstream call site, and audience binding is enforced by both validators, so
|
|
13
|
+
* a token minted for another resource cannot be replayed here.
|
|
14
|
+
*/
|
|
15
|
+
import { challengeForError } from './challenge.js';
|
|
16
|
+
import { InsufficientScopeError, InvalidTokenError, MissingTokenError, ResourceGuardConfigError, } from './errors.js';
|
|
17
|
+
import { IntrospectionValidator } from './introspection-validator.js';
|
|
18
|
+
import { JwtValidator } from './jwt-validator.js';
|
|
19
|
+
import { buildProtectedResourceMetadata, metadataPathForResource, metadataUrlForResource, } from './metadata.js';
|
|
20
|
+
import { hasRequiredScopes, missingScopes } from './scope.js';
|
|
21
|
+
function stripTrailingSlash(value) {
|
|
22
|
+
return value.length > 1 && value.endsWith('/') ? value.slice(0, -1) : value;
|
|
23
|
+
}
|
|
24
|
+
/** `Bearer` plus the spaces after it; anchored, one quantifier, so matching is linear. */
|
|
25
|
+
const BEARER_SCHEME = /^Bearer +/i;
|
|
26
|
+
/** Line terminators, which a credential may not contain (RFC 6750 §2.1 b64token). */
|
|
27
|
+
const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
|
|
28
|
+
/**
|
|
29
|
+
* Extract a bearer token from an `Authorization` header value (RFC 6750 §2.1).
|
|
30
|
+
* The scheme match is case-insensitive; the credential is returned verbatim.
|
|
31
|
+
* Returns `null` when the header is absent or not a non-empty Bearer.
|
|
32
|
+
*
|
|
33
|
+
* Parsed by slicing after a linear scheme match rather than with one
|
|
34
|
+
* `/^Bearer[ ]+(.+)$/i`: that pattern backtracks polynomially when many spaces
|
|
35
|
+
* precede a line terminator, and this function is exported for callers that
|
|
36
|
+
* may not have an HTTP parser's header rules in front of it.
|
|
37
|
+
*/
|
|
38
|
+
export function extractBearerToken(authorization) {
|
|
39
|
+
if (!authorization) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const value = authorization.trim();
|
|
43
|
+
const scheme = BEARER_SCHEME.exec(value);
|
|
44
|
+
if (!scheme) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
// Checked before trimming: `trim()` would strip a leading line terminator
|
|
48
|
+
// and let `"Bearer \nx"` through, which the regex form never accepted.
|
|
49
|
+
const rest = value.slice(scheme[0].length);
|
|
50
|
+
if (LINE_TERMINATOR.test(rest)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const token = rest.trim();
|
|
54
|
+
return token.length > 0 ? token : null;
|
|
55
|
+
}
|
|
56
|
+
export class ResourceGuard {
|
|
57
|
+
/** Canonical resource identifier (`aud` tokens must carry). */
|
|
58
|
+
resource;
|
|
59
|
+
/** Canonical AS issuer identifier. */
|
|
60
|
+
authorizationServer;
|
|
61
|
+
/** Default scopes required for any access. */
|
|
62
|
+
requiredScopes;
|
|
63
|
+
validationMode;
|
|
64
|
+
validator;
|
|
65
|
+
metadata;
|
|
66
|
+
constructor(config) {
|
|
67
|
+
if (!config.resource) {
|
|
68
|
+
throw new ResourceGuardConfigError('`resource` is required');
|
|
69
|
+
}
|
|
70
|
+
if (!config.authorizationServer) {
|
|
71
|
+
throw new ResourceGuardConfigError('`authorizationServer` is required');
|
|
72
|
+
}
|
|
73
|
+
this.resource = stripTrailingSlash(config.resource);
|
|
74
|
+
this.authorizationServer = stripTrailingSlash(config.authorizationServer);
|
|
75
|
+
this.requiredScopes = config.requiredScopes ?? [];
|
|
76
|
+
this.validationMode = config.validationMode ?? 'jwt';
|
|
77
|
+
this.validator = this.buildValidator(config);
|
|
78
|
+
this.metadata = buildProtectedResourceMetadata({
|
|
79
|
+
resource: this.resource,
|
|
80
|
+
authorizationServer: this.authorizationServer,
|
|
81
|
+
scopesSupported: this.requiredScopes.length > 0 ? this.requiredScopes : undefined,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
buildValidator(config) {
|
|
85
|
+
if (this.validationMode === 'introspection') {
|
|
86
|
+
if (!config.introspectionClient) {
|
|
87
|
+
throw new ResourceGuardConfigError('introspection mode requires `introspectionClient` credentials (RFC 7662 client auth)');
|
|
88
|
+
}
|
|
89
|
+
const introspection = new IntrospectionValidator({
|
|
90
|
+
endpoint: config.introspectionEndpoint ?? `${this.authorizationServer}/oauth/introspect`,
|
|
91
|
+
audience: this.resource,
|
|
92
|
+
client: config.introspectionClient,
|
|
93
|
+
fetch: config.fetch,
|
|
94
|
+
});
|
|
95
|
+
// Verify locally BEFORE introspecting. The AS only ever reports a token
|
|
96
|
+
// active after verifying it as a JWT it signed, so a bearer the JWKS
|
|
97
|
+
// cannot verify can never be active and is refused here without an
|
|
98
|
+
// introspection call. Without this gate every inbound bearer, made-up
|
|
99
|
+
// ones included, costs one call against the AS's per-caller
|
|
100
|
+
// introspection rate limit (30/min by default) — anyone able to reach
|
|
101
|
+
// this RS could empty that budget and have every valid token answered
|
|
102
|
+
// with an upstream 429 for the rest of the window. The introspection
|
|
103
|
+
// verdict stays authoritative: it reflects revocation and carries the
|
|
104
|
+
// detached PQC signature, neither of which the local check can see.
|
|
105
|
+
//
|
|
106
|
+
// `introspectionLocalCheck: false` turns the gate off for an AS that
|
|
107
|
+
// issues opaque tokens. A JWKS outage is not a verdict on the token, so
|
|
108
|
+
// it falls through to the authoritative introspection call rather than
|
|
109
|
+
// answering 401 for every caller.
|
|
110
|
+
if (config.introspectionLocalCheck === false) {
|
|
111
|
+
return introspection;
|
|
112
|
+
}
|
|
113
|
+
const local = this.buildJwtValidator(config);
|
|
114
|
+
return {
|
|
115
|
+
validate: async (token) => {
|
|
116
|
+
try {
|
|
117
|
+
await local.validate(token);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (!(error instanceof InvalidTokenError && error.jwksUnavailable)) {
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return introspection.validate(token);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// Default: local JWT verification against the AS JWKS.
|
|
129
|
+
return this.buildJwtValidator(config);
|
|
130
|
+
}
|
|
131
|
+
buildJwtValidator(config) {
|
|
132
|
+
return new JwtValidator({
|
|
133
|
+
jwksUri: config.jwksUri ?? `${this.authorizationServer}/.well-known/jwks.json`,
|
|
134
|
+
issuer: this.authorizationServer,
|
|
135
|
+
audience: this.resource,
|
|
136
|
+
allowedAlgorithms: config.allowedAlgorithms,
|
|
137
|
+
cacheTtlMs: config.jwksCacheTtlMs,
|
|
138
|
+
// #283 rollout switch. Default-off here mirrors the validator's own
|
|
139
|
+
// default; a wrong `typ` is rejected either way.
|
|
140
|
+
requireAccessTokenTyp: config.requireAccessTokenTyp === true,
|
|
141
|
+
// The injectable FetchLike is API-compatible with what jose calls; the
|
|
142
|
+
// cast is safe because we only use `(url, init) => Promise<Response-ish>`.
|
|
143
|
+
fetch: config.fetch,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** The RFC 9728 PRM document for this resource. */
|
|
147
|
+
getProtectedResourceMetadata() {
|
|
148
|
+
// Return a copy so callers cannot mutate cached state.
|
|
149
|
+
return {
|
|
150
|
+
...this.metadata,
|
|
151
|
+
authorization_servers: [...this.metadata.authorization_servers],
|
|
152
|
+
...(this.metadata.scopes_supported
|
|
153
|
+
? { scopes_supported: [...this.metadata.scopes_supported] }
|
|
154
|
+
: {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** Well-known PRM path for this resource (RFC 9728 §3.1). */
|
|
158
|
+
getMetadataPath() {
|
|
159
|
+
return metadataPathForResource(this.resource);
|
|
160
|
+
}
|
|
161
|
+
/** Absolute PRM URL advertised in the `resource_metadata` challenge param. */
|
|
162
|
+
getMetadataUrl() {
|
|
163
|
+
return metadataUrlForResource(this.resource);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Build the `WWW-Authenticate` header value for a guard error, with the
|
|
167
|
+
* `resource_metadata` pointer baked in. The Fastify adapter calls this to
|
|
168
|
+
* set the header on 401 / 403 responses.
|
|
169
|
+
*/
|
|
170
|
+
challengeHeader(error) {
|
|
171
|
+
return challengeForError(error, this.getMetadataUrl());
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Authenticate and authorize a request from its `Authorization` header.
|
|
175
|
+
*
|
|
176
|
+
* @param authorization the raw header value.
|
|
177
|
+
* @param scopes additional per-operation scopes to require on top of the
|
|
178
|
+
* guard's default `requiredScopes` (the step-up surface).
|
|
179
|
+
* @returns the validated, audience-bound token claims.
|
|
180
|
+
* @throws {MissingTokenError} when no bearer is present (→ 401, bare challenge).
|
|
181
|
+
* @throws {InvalidTokenError} when validation fails (→ 401 `invalid_token`).
|
|
182
|
+
* @throws {InsufficientScopeError} when scopes are insufficient
|
|
183
|
+
* (→ 403 `insufficient_scope` step-up challenge).
|
|
184
|
+
*/
|
|
185
|
+
async authenticate(authorization, scopes = []) {
|
|
186
|
+
const token = extractBearerToken(authorization);
|
|
187
|
+
if (!token) {
|
|
188
|
+
// Advertise the FULL requirement for THIS route (guard defaults plus the
|
|
189
|
+
// per-call step-up scopes) on the 401 challenge — #284. Same set the 403
|
|
190
|
+
// step-up would advertise, so an unauthenticated client and a
|
|
191
|
+
// under-scoped one are told the same thing.
|
|
192
|
+
throw new MissingTokenError(dedupe([...this.requiredScopes, ...scopes]));
|
|
193
|
+
}
|
|
194
|
+
const validated = await this.validator.validate(token);
|
|
195
|
+
this.assertScopes(validated, scopes);
|
|
196
|
+
return validated;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Assert that a previously validated token satisfies a scope set. Use this
|
|
200
|
+
* for incremental, mid-handler step-up checks (e.g. a privileged tool call)
|
|
201
|
+
* where the token was already validated by {@link authenticate}.
|
|
202
|
+
*
|
|
203
|
+
* @throws {InsufficientScopeError} listing every scope the request needs.
|
|
204
|
+
*/
|
|
205
|
+
assertScopes(token, scopes = []) {
|
|
206
|
+
// The challenge advertises the FULL required set (defaults + step-up) so
|
|
207
|
+
// the client can request a single, sufficient authorization in one round.
|
|
208
|
+
const required = dedupe([...this.requiredScopes, ...scopes]);
|
|
209
|
+
if (hasRequiredScopes(token.scopes, required)) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
throw new InsufficientScopeError(required, missingScopes(token.scopes, required));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function dedupe(values) {
|
|
216
|
+
return [...new Set(values)];
|
|
217
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error model for `resource-guard`.
|
|
3
|
+
*
|
|
4
|
+
* These map directly to the Bearer-token error responses defined by
|
|
5
|
+
* RFC 6750 §3.1 and refined by the MCP Authorization profile (2026-07-28):
|
|
6
|
+
*
|
|
7
|
+
* - `MissingTokenError` → 401, no `error` code (RFC 6750: a bare
|
|
8
|
+
* `WWW-Authenticate: Bearer` challenge when no credentials were sent).
|
|
9
|
+
* - `InvalidTokenError` → 401, `error="invalid_token"` (malformed,
|
|
10
|
+
* expired, bad signature, wrong issuer, or wrong audience).
|
|
11
|
+
* - `InsufficientScopeError` → 403, `error="insufficient_scope"` with the
|
|
12
|
+
* set of scopes that would satisfy the request (step-up challenge).
|
|
13
|
+
*
|
|
14
|
+
* The classes are intentionally self-contained (no dependency on the QAuth
|
|
15
|
+
* AS error model) so the SDK stays portable for external MCP-server authors.
|
|
16
|
+
*/
|
|
17
|
+
/** Discriminant for the Bearer challenge an error should produce. */
|
|
18
|
+
export type BearerErrorCode = 'invalid_token' | 'insufficient_scope';
|
|
19
|
+
/** Base class for all guard rejections that should surface a Bearer challenge. */
|
|
20
|
+
export declare abstract class ResourceGuardError extends Error {
|
|
21
|
+
/** HTTP status the RS should return. */
|
|
22
|
+
abstract readonly statusCode: 401 | 403;
|
|
23
|
+
/**
|
|
24
|
+
* RFC 6750 `error` parameter for the `WWW-Authenticate` header, or
|
|
25
|
+
* `undefined` for a credential-absent challenge (no `error` per §3.1).
|
|
26
|
+
*/
|
|
27
|
+
abstract readonly bearerError?: BearerErrorCode;
|
|
28
|
+
protected constructor(message: string);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* No bearer credentials were presented. RFC 6750 §3: respond 401 with a bare
|
|
32
|
+
* `WWW-Authenticate: Bearer` challenge (no `error` code), pointing the client
|
|
33
|
+
* at the resource metadata so it can discover the AS and authenticate.
|
|
34
|
+
*
|
|
35
|
+
* The error carries the scopes the attempted route requires so the challenge
|
|
36
|
+
* can advertise them (#284). MCP Authorization 2026-07-28 ("Scope Selection
|
|
37
|
+
* Strategy") says the RS SHOULD do this, letting a client authorize correctly
|
|
38
|
+
* on its first attempt instead of fetching the PRM document and guessing.
|
|
39
|
+
*/
|
|
40
|
+
export declare class MissingTokenError extends ResourceGuardError {
|
|
41
|
+
readonly statusCode: 401;
|
|
42
|
+
readonly bearerError: undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Scopes the attempted operation requires (guard defaults + any per-route
|
|
45
|
+
* step-up), for the `scope` challenge parameter. Empty when the route
|
|
46
|
+
* requires none — the challenge then omits `scope` entirely rather than
|
|
47
|
+
* emitting a meaningless `scope=""`.
|
|
48
|
+
*/
|
|
49
|
+
readonly requiredScopes: string[];
|
|
50
|
+
constructor(requiredScopes?: string[], message?: string);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The presented token is unusable: malformed, expired, bad signature, wrong
|
|
54
|
+
* issuer, or — critically for no-passthrough — not audience-bound to this
|
|
55
|
+
* resource (RFC 8707). RFC 6750 §3.1: 401 `error="invalid_token"`.
|
|
56
|
+
*
|
|
57
|
+
* The human-readable `reason` is suitable for the optional
|
|
58
|
+
* `error_description` challenge parameter and for logs; it must never echo
|
|
59
|
+
* the token itself.
|
|
60
|
+
*/
|
|
61
|
+
export declare class InvalidTokenError extends ResourceGuardError {
|
|
62
|
+
readonly statusCode: 401;
|
|
63
|
+
readonly bearerError: 'invalid_token';
|
|
64
|
+
/** Short reason for `error_description` / structured logs. */
|
|
65
|
+
readonly reason: string;
|
|
66
|
+
/**
|
|
67
|
+
* True when local verification failed because the AS key set could not be
|
|
68
|
+
* fetched or parsed (timeout, network error, non-2xx, malformed JWKS), i.e.
|
|
69
|
+
* no verdict on the token itself was reached.
|
|
70
|
+
*/
|
|
71
|
+
readonly jwksUnavailable: boolean;
|
|
72
|
+
constructor(reason: string, options?: {
|
|
73
|
+
jwksUnavailable?: boolean;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The token is valid but lacks one or more required scopes. RFC 6750 §3.1:
|
|
78
|
+
* 403 `error="insufficient_scope"`, advertising the scopes that would satisfy
|
|
79
|
+
* the request — the MCP 2026-07-28 incremental-consent step-up signal.
|
|
80
|
+
*/
|
|
81
|
+
export declare class InsufficientScopeError extends ResourceGuardError {
|
|
82
|
+
readonly statusCode: 403;
|
|
83
|
+
readonly bearerError: 'insufficient_scope';
|
|
84
|
+
/** Full set of scopes the request requires (for the `scope` challenge param). */
|
|
85
|
+
readonly requiredScopes: string[];
|
|
86
|
+
/** Subset of {@link requiredScopes} the token did not satisfy. */
|
|
87
|
+
readonly missingScopes: string[];
|
|
88
|
+
constructor(requiredScopes: string[], missingScopes: string[]);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Configuration error raised at setup time (e.g. introspection mode without
|
|
92
|
+
* client credentials). Not a Bearer challenge — it is a programming error in
|
|
93
|
+
* the host application and should surface as a 500 / startup failure.
|
|
94
|
+
*/
|
|
95
|
+
export declare class ResourceGuardConfigError extends Error {
|
|
96
|
+
constructor(message: string);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* An operational failure while validating a token *that is not the token's
|
|
100
|
+
* fault*: the authorization server was unreachable, returned a non-2xx (e.g.
|
|
101
|
+
* because THIS resource server's introspection credentials are misconfigured),
|
|
102
|
+
* or sent a malformed response.
|
|
103
|
+
*
|
|
104
|
+
* Crucially this is **not** an {@link ResourceGuardError}, so it does not produce a
|
|
105
|
+
* 401 `invalid_token` Bearer challenge. Blaming the client's token for an
|
|
106
|
+
* RS/AS-side fault is wrong HTTP semantics (RFC 6750 §3.1) and can drive
|
|
107
|
+
* clients into futile token-refresh loops (OWASP A10, mishandling exceptional
|
|
108
|
+
* conditions). It propagates to the host's error handler and should surface as
|
|
109
|
+
* a 5xx so operators see — and clients are told — that the failure is
|
|
110
|
+
* server-side, not a bad credential.
|
|
111
|
+
*
|
|
112
|
+
* The message is short and non-sensitive; the token is never echoed.
|
|
113
|
+
*/
|
|
114
|
+
export declare class IntrospectionError extends Error {
|
|
115
|
+
constructor(message: string);
|
|
116
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error model for `resource-guard`.
|
|
3
|
+
*
|
|
4
|
+
* These map directly to the Bearer-token error responses defined by
|
|
5
|
+
* RFC 6750 §3.1 and refined by the MCP Authorization profile (2026-07-28):
|
|
6
|
+
*
|
|
7
|
+
* - `MissingTokenError` → 401, no `error` code (RFC 6750: a bare
|
|
8
|
+
* `WWW-Authenticate: Bearer` challenge when no credentials were sent).
|
|
9
|
+
* - `InvalidTokenError` → 401, `error="invalid_token"` (malformed,
|
|
10
|
+
* expired, bad signature, wrong issuer, or wrong audience).
|
|
11
|
+
* - `InsufficientScopeError` → 403, `error="insufficient_scope"` with the
|
|
12
|
+
* set of scopes that would satisfy the request (step-up challenge).
|
|
13
|
+
*
|
|
14
|
+
* The classes are intentionally self-contained (no dependency on the QAuth
|
|
15
|
+
* AS error model) so the SDK stays portable for external MCP-server authors.
|
|
16
|
+
*/
|
|
17
|
+
/** Base class for all guard rejections that should surface a Bearer challenge. */
|
|
18
|
+
export class ResourceGuardError extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = new.target.name;
|
|
22
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
23
|
+
if (Error.captureStackTrace) {
|
|
24
|
+
Error.captureStackTrace(this, new.target);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* No bearer credentials were presented. RFC 6750 §3: respond 401 with a bare
|
|
30
|
+
* `WWW-Authenticate: Bearer` challenge (no `error` code), pointing the client
|
|
31
|
+
* at the resource metadata so it can discover the AS and authenticate.
|
|
32
|
+
*
|
|
33
|
+
* The error carries the scopes the attempted route requires so the challenge
|
|
34
|
+
* can advertise them (#284). MCP Authorization 2026-07-28 ("Scope Selection
|
|
35
|
+
* Strategy") says the RS SHOULD do this, letting a client authorize correctly
|
|
36
|
+
* on its first attempt instead of fetching the PRM document and guessing.
|
|
37
|
+
*/
|
|
38
|
+
export class MissingTokenError extends ResourceGuardError {
|
|
39
|
+
statusCode = 401;
|
|
40
|
+
bearerError = undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Scopes the attempted operation requires (guard defaults + any per-route
|
|
43
|
+
* step-up), for the `scope` challenge parameter. Empty when the route
|
|
44
|
+
* requires none — the challenge then omits `scope` entirely rather than
|
|
45
|
+
* emitting a meaningless `scope=""`.
|
|
46
|
+
*/
|
|
47
|
+
requiredScopes;
|
|
48
|
+
constructor(requiredScopes = [], message = 'No bearer token presented') {
|
|
49
|
+
super(message);
|
|
50
|
+
this.requiredScopes = requiredScopes;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The presented token is unusable: malformed, expired, bad signature, wrong
|
|
55
|
+
* issuer, or — critically for no-passthrough — not audience-bound to this
|
|
56
|
+
* resource (RFC 8707). RFC 6750 §3.1: 401 `error="invalid_token"`.
|
|
57
|
+
*
|
|
58
|
+
* The human-readable `reason` is suitable for the optional
|
|
59
|
+
* `error_description` challenge parameter and for logs; it must never echo
|
|
60
|
+
* the token itself.
|
|
61
|
+
*/
|
|
62
|
+
export class InvalidTokenError extends ResourceGuardError {
|
|
63
|
+
statusCode = 401;
|
|
64
|
+
bearerError = 'invalid_token';
|
|
65
|
+
/** Short reason for `error_description` / structured logs. */
|
|
66
|
+
reason;
|
|
67
|
+
/**
|
|
68
|
+
* True when local verification failed because the AS key set could not be
|
|
69
|
+
* fetched or parsed (timeout, network error, non-2xx, malformed JWKS), i.e.
|
|
70
|
+
* no verdict on the token itself was reached.
|
|
71
|
+
*/
|
|
72
|
+
jwksUnavailable;
|
|
73
|
+
constructor(reason, options = {}) {
|
|
74
|
+
super(`Invalid bearer token: ${reason}`);
|
|
75
|
+
this.reason = reason;
|
|
76
|
+
this.jwksUnavailable = options.jwksUnavailable === true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The token is valid but lacks one or more required scopes. RFC 6750 §3.1:
|
|
81
|
+
* 403 `error="insufficient_scope"`, advertising the scopes that would satisfy
|
|
82
|
+
* the request — the MCP 2026-07-28 incremental-consent step-up signal.
|
|
83
|
+
*/
|
|
84
|
+
export class InsufficientScopeError extends ResourceGuardError {
|
|
85
|
+
statusCode = 403;
|
|
86
|
+
bearerError = 'insufficient_scope';
|
|
87
|
+
/** Full set of scopes the request requires (for the `scope` challenge param). */
|
|
88
|
+
requiredScopes;
|
|
89
|
+
/** Subset of {@link requiredScopes} the token did not satisfy. */
|
|
90
|
+
missingScopes;
|
|
91
|
+
constructor(requiredScopes, missingScopes) {
|
|
92
|
+
super(`Insufficient scope; missing: ${missingScopes.join(' ')}`);
|
|
93
|
+
this.requiredScopes = requiredScopes;
|
|
94
|
+
this.missingScopes = missingScopes;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Configuration error raised at setup time (e.g. introspection mode without
|
|
99
|
+
* client credentials). Not a Bearer challenge — it is a programming error in
|
|
100
|
+
* the host application and should surface as a 500 / startup failure.
|
|
101
|
+
*/
|
|
102
|
+
export class ResourceGuardConfigError extends Error {
|
|
103
|
+
constructor(message) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.name = 'ResourceGuardConfigError';
|
|
106
|
+
Object.setPrototypeOf(this, ResourceGuardConfigError.prototype);
|
|
107
|
+
if (Error.captureStackTrace) {
|
|
108
|
+
Error.captureStackTrace(this, ResourceGuardConfigError);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* An operational failure while validating a token *that is not the token's
|
|
114
|
+
* fault*: the authorization server was unreachable, returned a non-2xx (e.g.
|
|
115
|
+
* because THIS resource server's introspection credentials are misconfigured),
|
|
116
|
+
* or sent a malformed response.
|
|
117
|
+
*
|
|
118
|
+
* Crucially this is **not** an {@link ResourceGuardError}, so it does not produce a
|
|
119
|
+
* 401 `invalid_token` Bearer challenge. Blaming the client's token for an
|
|
120
|
+
* RS/AS-side fault is wrong HTTP semantics (RFC 6750 §3.1) and can drive
|
|
121
|
+
* clients into futile token-refresh loops (OWASP A10, mishandling exceptional
|
|
122
|
+
* conditions). It propagates to the host's error handler and should surface as
|
|
123
|
+
* a 5xx so operators see — and clients are told — that the failure is
|
|
124
|
+
* server-side, not a bad credential.
|
|
125
|
+
*
|
|
126
|
+
* The message is short and non-sensitive; the token is never echoed.
|
|
127
|
+
*/
|
|
128
|
+
export class IntrospectionError extends Error {
|
|
129
|
+
constructor(message) {
|
|
130
|
+
super(message);
|
|
131
|
+
this.name = 'IntrospectionError';
|
|
132
|
+
Object.setPrototypeOf(this, IntrospectionError.prototype);
|
|
133
|
+
if (Error.captureStackTrace) {
|
|
134
|
+
Error.captureStackTrace(this, IntrospectionError);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fastify adapter for `resource-guard`.
|
|
3
|
+
*
|
|
4
|
+
* Registering this plugin:
|
|
5
|
+
*
|
|
6
|
+
* 1. serves the RFC 9728 Protected Resource Metadata document at the
|
|
7
|
+
* resource's well-known path (and, for a path-bearing resource, also at the
|
|
8
|
+
* bare `/.well-known/oauth-protected-resource` for discovery convenience);
|
|
9
|
+
* 2. decorates the instance with `resourceGuard` (the core), a `requireBearer`
|
|
10
|
+
* preHandler, and a `requireScopes(...)` preHandler factory for step-up;
|
|
11
|
+
* 3. converts `ResourceGuardError`s thrown anywhere in the encapsulation context
|
|
12
|
+
* into RFC 6750 §3.1 responses with the correct `WWW-Authenticate` header.
|
|
13
|
+
*
|
|
14
|
+
* Because the guard is exposed to parent/sibling contexts, the plugin is
|
|
15
|
+
* wrapped with `fastify-plugin`.
|
|
16
|
+
*/
|
|
17
|
+
import type { FastifyPluginAsync, FastifyReply, preHandlerHookHandler } from 'fastify';
|
|
18
|
+
import type { ResourceGuardConfig, ValidatedToken } from '../types.js';
|
|
19
|
+
import { ResourceGuard } from './core.js';
|
|
20
|
+
import { ResourceGuardError } from './errors.js';
|
|
21
|
+
declare module 'fastify' {
|
|
22
|
+
interface FastifyInstance {
|
|
23
|
+
/** The framework-agnostic guard core. */
|
|
24
|
+
resourceGuard: ResourceGuard;
|
|
25
|
+
/**
|
|
26
|
+
* preHandler that requires a valid, audience-bound bearer carrying the
|
|
27
|
+
* guard's default `requiredScopes`. On success, `request.tokenClaims` is
|
|
28
|
+
* populated. Use as a route `preHandler`.
|
|
29
|
+
*/
|
|
30
|
+
requireBearer: preHandlerHookHandler;
|
|
31
|
+
/**
|
|
32
|
+
* Build a preHandler that additionally requires the given scopes — the
|
|
33
|
+
* step-up surface for privileged operations (MCP 2026-07-28 incremental
|
|
34
|
+
* consent). Failures emit a 403 `insufficient_scope` challenge.
|
|
35
|
+
*/
|
|
36
|
+
requireScopes: (...scopes: string[]) => preHandlerHookHandler;
|
|
37
|
+
}
|
|
38
|
+
interface FastifyRequest {
|
|
39
|
+
/** Claims of the validated bearer, set by `requireBearer`/`requireScopes`. */
|
|
40
|
+
tokenClaims?: ValidatedToken;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Configuration for the Fastify plugin. Extends the core config with route
|
|
45
|
+
* options.
|
|
46
|
+
*/
|
|
47
|
+
export interface ResourceGuardPluginOptions extends ResourceGuardConfig {
|
|
48
|
+
/**
|
|
49
|
+
* `Cache-Control` for the PRM document. RFC 9728 documents are highly
|
|
50
|
+
* cacheable. Defaults to `public, max-age=3600`.
|
|
51
|
+
*/
|
|
52
|
+
metadataCacheControl?: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Reply to `reply` with the RFC 6750 §3.1 Bearer challenge for `error`, using
|
|
56
|
+
* `guard`'s `resource_metadata` pointer. Exposed so host apps that throw
|
|
57
|
+
* `ResourceGuardError` from their own handlers can wire it into their error handler:
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* fastify.setErrorHandler((err, req, reply) => {
|
|
62
|
+
* if (err instanceof ResourceGuardError) {
|
|
63
|
+
* return sendBearerChallenge(fastify.resourceGuard, reply, err);
|
|
64
|
+
* }
|
|
65
|
+
* // ...fall through to the app's own handling
|
|
66
|
+
* });
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export declare function sendBearerChallenge(guard: ResourceGuard, reply: FastifyReply, error: ResourceGuardError): FastifyReply;
|
|
70
|
+
/**
|
|
71
|
+
* `@qauth-labs/resource-guard` Fastify plugin. Wrapped with `fastify-plugin` so the
|
|
72
|
+
* `resourceGuard` decorator and the `requireBearer` / `requireScopes` preHandlers
|
|
73
|
+
* are visible to the parent app and sibling plugins.
|
|
74
|
+
*/
|
|
75
|
+
export declare const resourceGuardPlugin: FastifyPluginAsync<ResourceGuardPluginOptions>;
|
|
76
|
+
export default resourceGuardPlugin;
|