@qauth-labs/resource-guard 0.2.0-rc.1 → 0.2.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/README.md CHANGED
@@ -19,7 +19,8 @@ Drop it into an MCP server and you get spec-correct OAuth in minutes:
19
19
  is filtered out of both advertisement surfaces — it concerns refresh-token
20
20
  issuance at the AS, not access to this resource
21
21
  - Bearer-token validation in two modes — local **JWT** verification against the
22
- QAuth JWKS, or **RFC 7662** introspection
22
+ QAuth JWKS, or **RFC 7662** introspection. Sender-constrained (DPoP-bound or
23
+ certificate-bound) tokens are refused; see [Security model](#security-model)
23
24
  - **No token passthrough**: tokens not audience-bound to this resource
24
25
  (**RFC 8707**) are rejected, and the inbound token is never forwarded upstream
25
26
  - **Step-up** `403 insufficient_scope` scope challenges for incremental consent
@@ -212,6 +213,22 @@ try {
212
213
  never returns the raw token to a call site or forwards it to upstream APIs.
213
214
  - **Algorithm pinning.** JWT mode accepts `EdDSA` only by default, closing
214
215
  algorithm-confusion attacks.
216
+ - **Sender-constrained tokens are refused.** A token bound to a key, DPoP-bound
217
+ (`cnf.jkt`, RFC 9449) or certificate-bound (`cnf["x5t#S256"]`, RFC 8705), is
218
+ refused with `401 invalid_token` in both modes, because this version verifies
219
+ no proof of possession. JWT mode refuses any token that carries `cnf`;
220
+ introspection mode refuses a response that carries `cnf` or whose
221
+ `token_type` is present and is not `Bearer` (compared ignoring case), so a
222
+ bound type another authorization server names differently is refused too. Accepting such a token as a plain bearer would let anyone who copied
223
+ it use it (RFC 9449 §7.2). The `DPoP` authorization scheme is not accepted
224
+ either: it gets the same bare `Bearer` challenge as a request without a token
225
+ (RFC 6750 §3.1), and the thrown `MissingTokenError` names the scheme. A
226
+ client that calls a resource server on this SDK must use unbound tokens: it
227
+ sends no `DPoP` proof to the token endpoint. With QAuth, three kinds of token
228
+ are always bound, so they cannot be used here until this SDK verifies DPoP
229
+ proofs: the tokens of a client marked `dpop_bound_access_tokens`, a token
230
+ exchanged from a DPoP-bound subject token, and an access token issued for a
231
+ `cnf`-bound ID-JAG. DPoP proof support is planned.
215
232
  - **Exact scope matching.** Scopes are compared exactly and case-sensitively
216
233
  (RFC 6749 §3.3) — no prefix or wildcard semantics. QAuth's scope vocabulary
217
234
  declares no scope hierarchy (no scope implies another), so the MCP 2026-07-28
@@ -58,8 +58,11 @@ export declare class ResourceGuard {
58
58
  * @param scopes additional per-operation scopes to require on top of the
59
59
  * guard's default `requiredScopes` (the step-up surface).
60
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`).
61
+ * @throws {MissingTokenError} when no bearer is present (→ 401, bare
62
+ * challenge), the `DPoP` scheme included: this version accepts `Bearer`
63
+ * only, and the error's message says so.
64
+ * @throws {InvalidTokenError} when validation fails (→ 401 `invalid_token`),
65
+ * a sender-constrained (`cnf`-carrying) token included.
63
66
  * @throws {InsufficientScopeError} when scopes are insufficient
64
67
  * (→ 403 `insufficient_scope` step-up challenge).
65
68
  */
package/dist/lib/core.js CHANGED
@@ -25,6 +25,17 @@ function stripTrailingSlash(value) {
25
25
  const BEARER_SCHEME = /^Bearer +/i;
26
26
  /** Line terminators, which a credential may not contain (RFC 6750 §2.1 b64token). */
27
27
  const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
28
+ /**
29
+ * The RFC 9449 §7.1 `DPoP` scheme, matched case-insensitively (RFC 9110
30
+ * §11.1), which this version does not accept.
31
+ */
32
+ const DPOP_SCHEME = /^DPoP +/i;
33
+ /**
34
+ * Why a request under the `DPoP` scheme is refused. This version verifies no
35
+ * DPoP proof, so it accepts only the `Bearer` scheme, and a sender-constrained
36
+ * token is refused under that scheme too.
37
+ */
38
+ const DPOP_SCHEME_UNSUPPORTED = 'The DPoP authorization scheme is not supported; this resource accepts unbound Bearer tokens only';
28
39
  /**
29
40
  * Extract a bearer token from an `Authorization` header value (RFC 6750 §2.1).
30
41
  * The scheme match is case-insensitive; the credential is returned verbatim.
@@ -177,8 +188,11 @@ export class ResourceGuard {
177
188
  * @param scopes additional per-operation scopes to require on top of the
178
189
  * guard's default `requiredScopes` (the step-up surface).
179
190
  * @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`).
191
+ * @throws {MissingTokenError} when no bearer is present (→ 401, bare
192
+ * challenge), the `DPoP` scheme included: this version accepts `Bearer`
193
+ * only, and the error's message says so.
194
+ * @throws {InvalidTokenError} when validation fails (→ 401 `invalid_token`),
195
+ * a sender-constrained (`cnf`-carrying) token included.
182
196
  * @throws {InsufficientScopeError} when scopes are insufficient
183
197
  * (→ 403 `insufficient_scope` step-up challenge).
184
198
  */
@@ -189,7 +203,17 @@ export class ResourceGuard {
189
203
  // per-call step-up scopes) on the 401 challenge — #284. Same set the 403
190
204
  // step-up would advertise, so an unauthenticated client and a
191
205
  // under-scoped one are told the same thing.
192
- throw new MissingTokenError(dedupe([...this.requiredScopes, ...scopes]));
206
+ const required = dedupe([...this.requiredScopes, ...scopes]);
207
+ // A `DPoP` credential gets the same bare `Bearer` challenge as no
208
+ // credential at all: RFC 6750 §3.1 gives no error code to a request
209
+ // that "attempted using an unsupported authentication method", and a
210
+ // `DPoP` challenge would advertise support this version lacks. The
211
+ // challenge naming only `Bearer` tells the client which scheme is
212
+ // accepted; the message tells the host's logs why.
213
+ if (authorization && DPOP_SCHEME.test(authorization.trim())) {
214
+ throw new MissingTokenError(required, DPOP_SCHEME_UNSUPPORTED);
215
+ }
216
+ throw new MissingTokenError(required);
193
217
  }
194
218
  const validated = await this.validator.validate(token);
195
219
  this.assertScopes(validated, scopes);
@@ -7,7 +7,8 @@
7
7
  * and `client_secret_post`. We use Basic auth.
8
8
  *
9
9
  * QAuth contract (auth-server introspect route): returns
10
- * `{ active, sub, client_id, exp, iat, iss, aud, scope, token_type }` and
10
+ * `{ active, sub, client_id, exp, iat, iss, aud, scope, token_type }`, plus
11
+ * `cnf` with `token_type: DPoP` for a DPoP-bound token, and
11
12
  * `{ active: false }` for invalid / cross-audience tokens. The AS itself
12
13
  * already enforces audience-authoritative introspection; `resource-guard`
13
14
  * additionally re-checks `aud` locally so the no-passthrough guarantee does
@@ -39,7 +40,10 @@ export declare class IntrospectionValidator {
39
40
  *
40
41
  * @throws {InvalidTokenError} when the AS authoritatively says the *token* is
41
42
  * unusable — inactive (revoked/expired/unknown) or bound to a different
42
- * audience. Maps to a 401 `invalid_token` Bearer challenge.
43
+ * audience — or reports it as anything but a plain bearer token (a
44
+ * `token_type` that is present and not `Bearer`, or a `cnf` member): a
45
+ * sender-constrained token needs a proof this SDK does not verify. Maps to a
46
+ * 401 `invalid_token` Bearer challenge.
43
47
  * @throws {IntrospectionError} when the *introspection call itself* fails for
44
48
  * a reason that is not the token's fault — the AS is unreachable, returns a
45
49
  * non-2xx (e.g. THIS RS's introspection credentials are misconfigured → 401
@@ -7,7 +7,8 @@
7
7
  * and `client_secret_post`. We use Basic auth.
8
8
  *
9
9
  * QAuth contract (auth-server introspect route): returns
10
- * `{ active, sub, client_id, exp, iat, iss, aud, scope, token_type }` and
10
+ * `{ active, sub, client_id, exp, iat, iss, aud, scope, token_type }`, plus
11
+ * `cnf` with `token_type: DPoP` for a DPoP-bound token, and
11
12
  * `{ active: false }` for invalid / cross-audience tokens. The AS itself
12
13
  * already enforces audience-authoritative introspection; `resource-guard`
13
14
  * additionally re-checks `aud` locally so the no-passthrough guarantee does
@@ -15,6 +16,7 @@
15
16
  */
16
17
  import { IntrospectionError, InvalidTokenError } from './errors.js';
17
18
  import { parseScopes } from './scope.js';
19
+ import { refuseNonBearerToken } from './sender-constraint.js';
18
20
  function audienceList(aud) {
19
21
  if (aud == null) {
20
22
  return [];
@@ -46,7 +48,10 @@ export class IntrospectionValidator {
46
48
  *
47
49
  * @throws {InvalidTokenError} when the AS authoritatively says the *token* is
48
50
  * unusable — inactive (revoked/expired/unknown) or bound to a different
49
- * audience. Maps to a 401 `invalid_token` Bearer challenge.
51
+ * audience — or reports it as anything but a plain bearer token (a
52
+ * `token_type` that is present and not `Bearer`, or a `cnf` member): a
53
+ * sender-constrained token needs a proof this SDK does not verify. Maps to a
54
+ * 401 `invalid_token` Bearer challenge.
50
55
  * @throws {IntrospectionError} when the *introspection call itself* fails for
51
56
  * a reason that is not the token's fault — the AS is unreachable, returns a
52
57
  * non-2xx (e.g. THIS RS's introspection credentials are misconfigured → 401
@@ -106,6 +111,13 @@ export class IntrospectionValidator {
106
111
  if (!auds.includes(this.audience)) {
107
112
  throw new InvalidTokenError('token audience does not match this resource');
108
113
  }
114
+ // RFC 9449 §6.2 / §7.2: QAuth reports a DPoP-bound token with
115
+ // `token_type: DPoP` and its `cnf`. Such a token needs a proof of
116
+ // possession, and this SDK verifies none, so it is refused rather than
117
+ // accepted as a plain bearer. Either signal alone is enough. `token_type`
118
+ // is an allow-list: absent or `Bearer` only, so a bound type another AS
119
+ // names differently is refused too.
120
+ refuseNonBearerToken(body.cnf, body.token_type);
109
121
  return {
110
122
  sub: body.sub,
111
123
  clientId: body.client_id,
@@ -48,9 +48,11 @@ export declare class JwtValidator {
48
48
  * Verify a bearer token. Resolves to the normalised claims on success.
49
49
  *
50
50
  * @throws {InvalidTokenError} for any verification failure — bad signature,
51
- * expiry, wrong issuer, wrong/absent audience, disallowed algorithm, or a
52
- * `typ` that is not `at+jwt` (#283). The reason is short and non-sensitive
53
- * (safe for `error_description`/logs); the token is never included.
51
+ * expiry, wrong issuer, wrong/absent audience, disallowed algorithm, a
52
+ * `typ` that is not `at+jwt` (#283), or a sender-constrained token (one
53
+ * carrying `cnf`, such as a DPoP-bound token), which needs a proof this SDK
54
+ * does not verify. The reason is short and non-sensitive (safe for
55
+ * `error_description`/logs); the token is never included.
54
56
  */
55
57
  validate(token: string): Promise<ValidatedToken>;
56
58
  /**
@@ -14,6 +14,7 @@
14
14
  import { createRemoteJWKSet, customFetch, errors as joseErrors, jwtVerify, } from 'jose';
15
15
  import { InvalidTokenError } from './errors.js';
16
16
  import { parseScopes } from './scope.js';
17
+ import { refuseNonBearerToken } from './sender-constraint.js';
17
18
  /**
18
19
  * RFC 9068 §2.1 `typ` of a JWT OAuth 2.0 access token.
19
20
  *
@@ -75,9 +76,11 @@ export class JwtValidator {
75
76
  * Verify a bearer token. Resolves to the normalised claims on success.
76
77
  *
77
78
  * @throws {InvalidTokenError} for any verification failure — bad signature,
78
- * expiry, wrong issuer, wrong/absent audience, disallowed algorithm, or a
79
- * `typ` that is not `at+jwt` (#283). The reason is short and non-sensitive
80
- * (safe for `error_description`/logs); the token is never included.
79
+ * expiry, wrong issuer, wrong/absent audience, disallowed algorithm, a
80
+ * `typ` that is not `at+jwt` (#283), or a sender-constrained token (one
81
+ * carrying `cnf`, such as a DPoP-bound token), which needs a proof this SDK
82
+ * does not verify. The reason is short and non-sensitive (safe for
83
+ * `error_description`/logs); the token is never included.
81
84
  */
82
85
  async validate(token) {
83
86
  let payload;
@@ -102,6 +105,10 @@ export class JwtValidator {
102
105
  // attacker rewrites at will. Outside the try/catch above so this rejection
103
106
  // is not laundered into a generic jose-error string.
104
107
  this.assertAccessTokenTyp(protectedHeader['typ']);
108
+ // RFC 9449 §7.2: a token bound to a key (`cnf`) needs a proof of
109
+ // possession, and this SDK verifies none, so it is refused rather than
110
+ // accepted as a plain bearer. Read from the verified payload only.
111
+ refuseNonBearerToken(payload['cnf']);
105
112
  return toValidatedToken(payload);
106
113
  }
107
114
  /**
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Refusal of every access token that is not a plain bearer token.
3
+ *
4
+ * A token that carries a `cnf` (confirmation) claim (RFC 7800 §3) is bound to
5
+ * a key. A DPoP-bound token names that key's thumbprint in `cnf.jkt` (RFC 9449
6
+ * §6) and is reported by introspection with `token_type: DPoP` (RFC 9449
7
+ * §6.2). A certificate-bound token names a certificate in `cnf["x5t#S256"]`
8
+ * (RFC 8705 §3.1). Other confirmation methods exist. Whatever the method, the
9
+ * holder must prove possession of the key on every request, and the resource
10
+ * server must check that proof before it grants access (RFC 9449 §7.1, RFC
11
+ * 8705 §3). Accepting such a token as a plain bearer would let anyone who
12
+ * copied it use it, which is exactly the theft binding exists to stop
13
+ * (RFC 9449 §7.2).
14
+ *
15
+ * This version of the SDK verifies no proof of possession of any kind, so it
16
+ * refuses every sender-constrained token outright. QAuth's own protected
17
+ * routes refuse a bound token presented as a Bearer token in the same way.
18
+ *
19
+ * An introspection `token_type` is read as an allow-list, not a deny-list:
20
+ * only an absent one or `Bearer` is a token this SDK can accept. Another
21
+ * authorization server may name a bound token type this SDK has never heard
22
+ * of (`PoP`, a misspelt `DPoP`, a non-string value), and a deny-list would let
23
+ * it through as a bearer token.
24
+ *
25
+ * Internal: not exported from the package entry points.
26
+ */
27
+ /** The reason given for a token bound to a DPoP key. */
28
+ export declare const DPOP_BOUND_REASON = "sender-constrained (DPoP-bound) token presented without a proof";
29
+ /** The reason given for a token bound by any other confirmation method. */
30
+ export declare const SENDER_CONSTRAINED_REASON = "sender-constrained token presented without a proof";
31
+ /** The reason given for an introspected `token_type` that is neither absent nor `Bearer`. */
32
+ export declare const NOT_BEARER_REASON = "token type is not Bearer";
33
+ /**
34
+ * Refuse a token that is not a plain bearer token. Call it after the token
35
+ * has been verified, with the verified claims only.
36
+ *
37
+ * @param cnf the token's `cnf` claim (JWT payload) or member (introspection
38
+ * response). Any value other than `undefined` refuses the token, a malformed
39
+ * one included, so an unknown confirmation method fails closed.
40
+ * @param tokenType the introspection response's `token_type`, when there is
41
+ * one. Only `undefined` (absent) or `Bearer` in any case is accepted; `DPoP`
42
+ * is refused as DPoP-bound, and any other value as not Bearer. The JWT
43
+ * validator passes nothing, since a JWT carries no `token_type`.
44
+ * @throws {InvalidTokenError} when the token is sender-constrained or its type
45
+ * is not Bearer.
46
+ */
47
+ export declare function refuseNonBearerToken(cnf: unknown, tokenType?: unknown): void;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Refusal of every access token that is not a plain bearer token.
3
+ *
4
+ * A token that carries a `cnf` (confirmation) claim (RFC 7800 §3) is bound to
5
+ * a key. A DPoP-bound token names that key's thumbprint in `cnf.jkt` (RFC 9449
6
+ * §6) and is reported by introspection with `token_type: DPoP` (RFC 9449
7
+ * §6.2). A certificate-bound token names a certificate in `cnf["x5t#S256"]`
8
+ * (RFC 8705 §3.1). Other confirmation methods exist. Whatever the method, the
9
+ * holder must prove possession of the key on every request, and the resource
10
+ * server must check that proof before it grants access (RFC 9449 §7.1, RFC
11
+ * 8705 §3). Accepting such a token as a plain bearer would let anyone who
12
+ * copied it use it, which is exactly the theft binding exists to stop
13
+ * (RFC 9449 §7.2).
14
+ *
15
+ * This version of the SDK verifies no proof of possession of any kind, so it
16
+ * refuses every sender-constrained token outright. QAuth's own protected
17
+ * routes refuse a bound token presented as a Bearer token in the same way.
18
+ *
19
+ * An introspection `token_type` is read as an allow-list, not a deny-list:
20
+ * only an absent one or `Bearer` is a token this SDK can accept. Another
21
+ * authorization server may name a bound token type this SDK has never heard
22
+ * of (`PoP`, a misspelt `DPoP`, a non-string value), and a deny-list would let
23
+ * it through as a bearer token.
24
+ *
25
+ * Internal: not exported from the package entry points.
26
+ */
27
+ import { InvalidTokenError } from './errors.js';
28
+ /** The reason given for a token bound to a DPoP key. */
29
+ export const DPOP_BOUND_REASON = 'sender-constrained (DPoP-bound) token presented without a proof';
30
+ /** The reason given for a token bound by any other confirmation method. */
31
+ export const SENDER_CONSTRAINED_REASON = 'sender-constrained token presented without a proof';
32
+ /** The reason given for an introspected `token_type` that is neither absent nor `Bearer`. */
33
+ export const NOT_BEARER_REASON = 'token type is not Bearer';
34
+ /**
35
+ * Whether an introspection `token_type` is `want`, compared ignoring case as
36
+ * RFC 6749 §5.1 says, but otherwise exactly: no trimming, strings only.
37
+ */
38
+ function isTokenType(tokenType, want) {
39
+ return typeof tokenType === 'string' && tokenType.toLowerCase() === want;
40
+ }
41
+ /** Whether a `cnf` value carries the DPoP member `jkt`. */
42
+ function hasJwkThumbprint(cnf) {
43
+ return typeof cnf === 'object' && cnf !== null && 'jkt' in cnf;
44
+ }
45
+ /**
46
+ * Refuse a token that is not a plain bearer token. Call it after the token
47
+ * has been verified, with the verified claims only.
48
+ *
49
+ * @param cnf the token's `cnf` claim (JWT payload) or member (introspection
50
+ * response). Any value other than `undefined` refuses the token, a malformed
51
+ * one included, so an unknown confirmation method fails closed.
52
+ * @param tokenType the introspection response's `token_type`, when there is
53
+ * one. Only `undefined` (absent) or `Bearer` in any case is accepted; `DPoP`
54
+ * is refused as DPoP-bound, and any other value as not Bearer. The JWT
55
+ * validator passes nothing, since a JWT carries no `token_type`.
56
+ * @throws {InvalidTokenError} when the token is sender-constrained or its type
57
+ * is not Bearer.
58
+ */
59
+ export function refuseNonBearerToken(cnf, tokenType) {
60
+ if (isTokenType(tokenType, 'dpop') || hasJwkThumbprint(cnf)) {
61
+ throw new InvalidTokenError(DPOP_BOUND_REASON);
62
+ }
63
+ if (cnf !== undefined) {
64
+ throw new InvalidTokenError(SENDER_CONSTRAINED_REASON);
65
+ }
66
+ if (tokenType !== undefined && !isTokenType(tokenType, 'bearer')) {
67
+ throw new InvalidTokenError(NOT_BEARER_REASON);
68
+ }
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qauth-labs/resource-guard",
3
- "version": "0.2.0-rc.1",
3
+ "version": "0.2.0",
4
4
  "description": "Resource-server SDK for QAuth: RFC 9728 protected-resource metadata, RFC 6750 challenges and audience-bound bearer-token validation for MCP servers and other OAuth 2.1 resource servers.",
5
5
  "keywords": [
6
6
  "oauth",