@qauth-labs/resource-guard 0.0.0 → 0.1.4-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.
@@ -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
+ }
@@ -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;