@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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * RFC 9728 — OAuth 2.0 Protected Resource Metadata (PRM).
3
+ *
4
+ * The RS publishes a JSON document describing itself and the authorization
5
+ * server(s) that protect it. MCP clients fetch it (advertised via the
6
+ * `WWW-Authenticate: ... resource_metadata="…"` challenge, or discovered at
7
+ * the well-known location as a fallback) to learn which AS to authenticate
8
+ * with — the entry point of the MCP authorization handshake.
9
+ */
10
+ import { advertisableScopes } from './scope.js';
11
+ /** Well-known suffix for Protected Resource Metadata (RFC 9728 §3.1). */
12
+ export const PRM_WELL_KNOWN_PREFIX = '/.well-known/oauth-protected-resource';
13
+ /** Strip a single trailing slash; leaves the root path `/` untouched-ish. */
14
+ function stripTrailingSlash(value) {
15
+ return value.length > 1 && value.endsWith('/') ? value.slice(0, -1) : value;
16
+ }
17
+ /**
18
+ * Build the RFC 9728 PRM document. Pure (no I/O) so route handlers control
19
+ * caching and tests can assert the wire shape directly.
20
+ */
21
+ export function buildProtectedResourceMetadata(input) {
22
+ const metadata = {
23
+ resource: stripTrailingSlash(input.resource),
24
+ authorization_servers: [stripTrailingSlash(input.authorizationServer)],
25
+ bearer_methods_supported: ['header'],
26
+ };
27
+ // Advertise only scopes that are genuinely a requirement of THIS resource:
28
+ // `offline_access` and friends concern refresh-token issuance at the AS, not
29
+ // resource authorization, and MCP Authorization 2026-07-28 ("Refresh
30
+ // Tokens") says a protected resource SHOULD NOT list them here (#284).
31
+ // Filtering can empty the list, in which case the key is omitted —
32
+ // RFC 9728 §3.2 makes `scopes_supported` OPTIONAL, and an empty array would
33
+ // wrongly assert the resource understands no scopes at all.
34
+ const scopesSupported = advertisableScopes(input.scopesSupported ?? []);
35
+ if (scopesSupported.length > 0) {
36
+ metadata.scopes_supported = scopesSupported;
37
+ }
38
+ if (input.resourceDocumentation) {
39
+ metadata.resource_documentation = input.resourceDocumentation;
40
+ }
41
+ return metadata;
42
+ }
43
+ /**
44
+ * Compute the well-known PRM **path** for a resource, per RFC 9728 §3.1.
45
+ *
46
+ * For a resource with no path component the document lives at
47
+ * `/.well-known/oauth-protected-resource`. For a resource that has a path
48
+ * (e.g. `https://host/mcp/memory`), the path is inserted **after** the
49
+ * well-known prefix: `/.well-known/oauth-protected-resource/mcp/memory`. This
50
+ * lets a single host expose distinct metadata per protected resource.
51
+ *
52
+ * @returns the path (always starting with the well-known prefix).
53
+ */
54
+ export function metadataPathForResource(resource) {
55
+ let pathname;
56
+ try {
57
+ pathname = new URL(resource).pathname;
58
+ }
59
+ catch {
60
+ // Not parseable as a URL — treat as opaque, root path.
61
+ pathname = '/';
62
+ }
63
+ // Only hierarchical (http/https) URLs have a `/`-rooted path we can insert
64
+ // after the well-known prefix. Opaque URIs (e.g. `urn:example:resource`)
65
+ // expose a non-rooted `pathname`; for those we serve the bare prefix.
66
+ if (!pathname.startsWith('/')) {
67
+ return PRM_WELL_KNOWN_PREFIX;
68
+ }
69
+ const normalized = stripTrailingSlash(pathname);
70
+ if (normalized === '' || normalized === '/') {
71
+ return PRM_WELL_KNOWN_PREFIX;
72
+ }
73
+ return `${PRM_WELL_KNOWN_PREFIX}${normalized}`;
74
+ }
75
+ /**
76
+ * Absolute URL where this resource's PRM document is served — the value
77
+ * advertised in the `resource_metadata` challenge parameter (RFC 9728 §5.1).
78
+ */
79
+ export function metadataUrlForResource(resource) {
80
+ const path = metadataPathForResource(resource);
81
+ try {
82
+ const url = new URL(resource);
83
+ return `${url.origin}${path}`;
84
+ }
85
+ catch {
86
+ return path;
87
+ }
88
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Scope utilities. OAuth scopes are a space-delimited, case-sensitive set
3
+ * (RFC 6749 §3.3). QAuth emits the `scope` claim as a single space-separated
4
+ * string; introspection returns the same shape (RFC 7662 §2.2).
5
+ */
6
+ /**
7
+ * Parse a scope claim into a de-duplicated list. Accepts the wire string
8
+ * form, an already-split array, or `undefined`/empty (→ no scopes). Splits on
9
+ * any run of ASCII whitespace and discards empty segments.
10
+ */
11
+ export declare function parseScopes(scope: string | string[] | undefined | null): string[];
12
+ /**
13
+ * Return the required scopes the granted set does not satisfy. Empty result
14
+ * means the granted set is sufficient. Matching is exact and case-sensitive
15
+ * per RFC 6749 §3.3 (no hierarchical/prefix semantics).
16
+ *
17
+ * MCP Authorization 2026-07-28 ("Step-Up Authorization Flow") adds: servers
18
+ * MUST account for scope hierarchies, "where a broader scope implies narrower
19
+ * ones", when deciding whether a token is sufficient. QAuth's scope vocabulary
20
+ * declares no such implications — every scope is independent, so no broader
21
+ * scope stands in for a narrower one — which makes exact matching the complete
22
+ * and correct reading of that rule here, not a shortfall. A host that defines a
23
+ * genuinely hierarchical vocabulary of its own must expand the implied set
24
+ * itself when it configures `requiredScopes` / `requireScopes(...)`.
25
+ */
26
+ export declare function missingScopes(granted: string[], required: string[]): string[];
27
+ /** True when every required scope is present in the granted set. */
28
+ export declare function hasRequiredScopes(granted: string[], required: string[]): boolean;
29
+ /**
30
+ * Filter a scope list down to what is meaningful to advertise as a resource
31
+ * requirement — see {@link NON_RESOURCE_SCOPES}.
32
+ *
33
+ * This is applied at the two *advertisement* surfaces only (the
34
+ * `WWW-Authenticate` challenge and PRM `scopes_supported`), never to
35
+ * enforcement: a host that configures such a scope still has it checked
36
+ * against the token, so this cannot widen access.
37
+ */
38
+ export declare function advertisableScopes(scopes: string[]): string[];
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Scope utilities. OAuth scopes are a space-delimited, case-sensitive set
3
+ * (RFC 6749 §3.3). QAuth emits the `scope` claim as a single space-separated
4
+ * string; introspection returns the same shape (RFC 7662 §2.2).
5
+ */
6
+ /**
7
+ * Parse a scope claim into a de-duplicated list. Accepts the wire string
8
+ * form, an already-split array, or `undefined`/empty (→ no scopes). Splits on
9
+ * any run of ASCII whitespace and discards empty segments.
10
+ */
11
+ export function parseScopes(scope) {
12
+ if (scope == null) {
13
+ return [];
14
+ }
15
+ const tokens = Array.isArray(scope) ? scope : scope.split(/\s+/);
16
+ const seen = new Set();
17
+ for (const token of tokens) {
18
+ const trimmed = token.trim();
19
+ if (trimmed.length > 0) {
20
+ seen.add(trimmed);
21
+ }
22
+ }
23
+ return [...seen];
24
+ }
25
+ /**
26
+ * Return the required scopes the granted set does not satisfy. Empty result
27
+ * means the granted set is sufficient. Matching is exact and case-sensitive
28
+ * per RFC 6749 §3.3 (no hierarchical/prefix semantics).
29
+ *
30
+ * MCP Authorization 2026-07-28 ("Step-Up Authorization Flow") adds: servers
31
+ * MUST account for scope hierarchies, "where a broader scope implies narrower
32
+ * ones", when deciding whether a token is sufficient. QAuth's scope vocabulary
33
+ * declares no such implications — every scope is independent, so no broader
34
+ * scope stands in for a narrower one — which makes exact matching the complete
35
+ * and correct reading of that rule here, not a shortfall. A host that defines a
36
+ * genuinely hierarchical vocabulary of its own must expand the implied set
37
+ * itself when it configures `requiredScopes` / `requireScopes(...)`.
38
+ */
39
+ export function missingScopes(granted, required) {
40
+ if (required.length === 0) {
41
+ return [];
42
+ }
43
+ const grantedSet = new Set(granted);
44
+ return required.filter((scope) => !grantedSet.has(scope));
45
+ }
46
+ /** True when every required scope is present in the granted set. */
47
+ export function hasRequiredScopes(granted, required) {
48
+ return missingScopes(granted, required).length === 0;
49
+ }
50
+ /**
51
+ * Scopes that describe the client's relationship with the *authorization
52
+ * server* rather than anything this resource will authorize, and which must
53
+ * therefore never be advertised as a resource requirement (#284).
54
+ *
55
+ * MCP Authorization 2026-07-28 ("Refresh Tokens") names `offline_access`
56
+ * explicitly — a protected resource SHOULD NOT include it in a
57
+ * `WWW-Authenticate` scope or in PRM `scopes_supported`, since it only governs
58
+ * whether the AS issues a refresh token. Advertising it would tell a client to
59
+ * obtain consent that has no bearing on the call it is trying to make.
60
+ */
61
+ const NON_RESOURCE_SCOPES = new Set(['offline_access']);
62
+ /**
63
+ * Filter a scope list down to what is meaningful to advertise as a resource
64
+ * requirement — see {@link NON_RESOURCE_SCOPES}.
65
+ *
66
+ * This is applied at the two *advertisement* surfaces only (the
67
+ * `WWW-Authenticate` challenge and PRM `scopes_supported`), never to
68
+ * enforcement: a host that configures such a scope still has it checked
69
+ * against the token, so this cannot widen access.
70
+ */
71
+ export function advertisableScopes(scopes) {
72
+ return scopes.filter((scope) => !NON_RESOURCE_SCOPES.has(scope));
73
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Public types for `@qauth-labs/resource-guard`.
3
+ *
4
+ * `resource-guard` is the resource-server (RS) side of the QAuth OAuth 2.1 stack.
5
+ * An MCP-server author drops it in to obtain spec-correct OAuth from a
6
+ * self-hosted QAuth authorization server (AS), per the MCP Authorization
7
+ * profile revision 2026-07-28 and the RFCs it references:
8
+ *
9
+ * - RFC 9728 — OAuth 2.0 Protected Resource Metadata (PRM)
10
+ * - RFC 8707 — Resource Indicators (audience binding; no token passthrough)
11
+ * - RFC 7662 — OAuth 2.0 Token Introspection
12
+ * - RFC 6750 §3 — Bearer token `WWW-Authenticate` challenges
13
+ */
14
+ /**
15
+ * Token-validation strategy.
16
+ *
17
+ * - `jwt` — verify the bearer locally against the AS JWKS (offline, fast,
18
+ * the recommended path). Checks signature, `iss`, `exp`, and that `aud`
19
+ * contains this resource's identifier.
20
+ * - `introspection` — verify the bearer locally as in `jwt` mode, then call
21
+ * the AS RFC 7662 endpoint for the authoritative verdict. Use when the RS
22
+ * must honour near-real-time revocation (or needs the detached PQC
23
+ * signature the AS delivers over introspection). Only a token the AS signed
24
+ * is ever introspected, so an unauthenticated caller cannot spend the RS's
25
+ * introspection rate-limit budget. Requires confidential
26
+ * introspection-client credentials.
27
+ */
28
+ export type ValidationMode = 'jwt' | 'introspection';
29
+ /**
30
+ * Result of a successful token validation. Claims are normalised to the
31
+ * shapes QAuth emits (see auth-server `signAccessToken` / introspect route).
32
+ */
33
+ export interface ValidatedToken {
34
+ /** Subject — user UUID (authorization_code) or client id (client_credentials). */
35
+ sub?: string;
36
+ /** OAuth client that the token was issued to (`client_id` claim). */
37
+ clientId?: string;
38
+ /** Granted scopes, already split on whitespace (RFC 8693 `scope`). */
39
+ scopes: string[];
40
+ /** Audience — always the resource identifier(s) this token is bound to. */
41
+ audience: string[];
42
+ /** Issuer (`iss`). */
43
+ issuer?: string;
44
+ /** Expiry, seconds since epoch. */
45
+ expiresAt?: number;
46
+ /** Issued-at, seconds since epoch. */
47
+ issuedAt?: number;
48
+ /**
49
+ * Raw claims as received (JWT payload, or introspection response body).
50
+ * Provided for advanced callers; prefer the normalised fields above.
51
+ */
52
+ raw: Record<string, unknown>;
53
+ }
54
+ /**
55
+ * Confidential client credentials used to authenticate to the AS
56
+ * introspection endpoint (RFC 7662 requires client authentication).
57
+ */
58
+ export interface IntrospectionClientCredentials {
59
+ clientId: string;
60
+ clientSecret: string;
61
+ }
62
+ /**
63
+ * Minimal `fetch` shape used by the JWKS and introspection clients. Defaults
64
+ * to the global `fetch`; injectable so tests need no network and deployments
65
+ * can wrap it with an SSRF-guarded / instrumented implementation.
66
+ */
67
+ export type FetchLike = (input: string | URL, init?: {
68
+ method?: string;
69
+ headers?: Record<string, string>;
70
+ body?: string;
71
+ signal?: AbortSignal;
72
+ }) => Promise<{
73
+ ok: boolean;
74
+ status: number;
75
+ json: () => Promise<unknown>;
76
+ text: () => Promise<string>;
77
+ }>;
78
+ /**
79
+ * Configuration shared by the framework-agnostic core and the Fastify plugin.
80
+ */
81
+ export interface ResourceGuardConfig {
82
+ /**
83
+ * Identifier of THIS protected resource — the canonical URL clients use to
84
+ * reach it (RFC 9728 `resource`, RFC 8707 resource indicator). Every
85
+ * accepted token MUST carry this value in `aud`; tokens minted for a
86
+ * different audience are rejected (no token passthrough).
87
+ *
88
+ * @example 'https://memory.mcp.example.com'
89
+ */
90
+ resource: string;
91
+ /**
92
+ * Issuer identifier of the QAuth authorization server. Advertised in PRM
93
+ * as `authorization_servers[]` and used to validate the JWT `iss` claim.
94
+ * Trailing slashes are normalised away.
95
+ *
96
+ * @example 'https://auth.example.com'
97
+ */
98
+ authorizationServer: string;
99
+ /**
100
+ * Scopes a caller must hold to access the resource by default. An empty
101
+ * array means "any valid, audience-bound token is accepted". Per-operation
102
+ * step-up requirements are expressed at the call site (see `requireScopes`).
103
+ */
104
+ requiredScopes?: string[];
105
+ /** Token-validation strategy. Defaults to `'jwt'`. */
106
+ validationMode?: ValidationMode;
107
+ /**
108
+ * JWKS URL of the AS. Defaults to `${authorizationServer}/.well-known/jwks.json`,
109
+ * matching the QAuth AS contract. Used in both modes: introspection mode
110
+ * verifies the bearer against the JWKS before introspecting it.
111
+ */
112
+ jwksUri?: string;
113
+ /**
114
+ * How long (ms) a fetched JWKS may be reused before the next refresh is
115
+ * allowed. Maps to the jose remote-JWKS cooldown. Defaults to 300_000 (5m).
116
+ */
117
+ jwksCacheTtlMs?: number;
118
+ /**
119
+ * Introspection endpoint URL. Defaults to `${authorizationServer}/oauth/introspect`,
120
+ * matching the QAuth AS contract. Only used in `introspection` mode.
121
+ */
122
+ introspectionEndpoint?: string;
123
+ /**
124
+ * In `introspection` mode, verify the bearer locally against the JWKS before
125
+ * introspecting it, so a bearer the AS never signed is refused without
126
+ * spending an introspection call (the AS rate-limits introspection per
127
+ * caller). Defaults to `true`. Set `false` only for an AS that issues opaque
128
+ * (non-JWT) access tokens.
129
+ */
130
+ introspectionLocalCheck?: boolean;
131
+ /**
132
+ * Confidential credentials for the introspection endpoint. REQUIRED in
133
+ * `introspection` mode (RFC 7662 mandates client authentication).
134
+ */
135
+ introspectionClient?: IntrospectionClientCredentials;
136
+ /**
137
+ * Accepted JWT signature algorithms. Defaults to `['EdDSA']` — the only
138
+ * algorithm QAuth signs with (Ed25519). Constrained to defend against
139
+ * algorithm-confusion attacks.
140
+ */
141
+ allowedAlgorithms?: string[];
142
+ /**
143
+ * Require the RFC 9068 `typ: at+jwt` protected header to be PRESENT on every
144
+ * bearer token (#283). Defaults to `false`.
145
+ *
146
+ * A token whose `typ` is present but WRONG — an OIDC ID token, `typ: JWT`,
147
+ * signed by the same AS key — is rejected regardless of this flag. The flag
148
+ * governs only whether a `typ`-LESS token is tolerated, which matters when
149
+ * the upstream AS predates #283 or is still draining tokens its previous
150
+ * build minted (up to one `ACCESS_TOKEN_LIFESPAN`, default 900s).
151
+ *
152
+ * Set it to `true` once the AS you point at is known to stamp `typ`.
153
+ */
154
+ requireAccessTokenTyp?: boolean;
155
+ /**
156
+ * Injectable `fetch`. Defaults to the global `fetch`. Override for tests or
157
+ * to apply an SSRF-guarded / instrumented HTTP client.
158
+ */
159
+ fetch?: FetchLike;
160
+ }
package/dist/types.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Public types for `@qauth-labs/resource-guard`.
3
+ *
4
+ * `resource-guard` is the resource-server (RS) side of the QAuth OAuth 2.1 stack.
5
+ * An MCP-server author drops it in to obtain spec-correct OAuth from a
6
+ * self-hosted QAuth authorization server (AS), per the MCP Authorization
7
+ * profile revision 2026-07-28 and the RFCs it references:
8
+ *
9
+ * - RFC 9728 — OAuth 2.0 Protected Resource Metadata (PRM)
10
+ * - RFC 8707 — Resource Indicators (audience binding; no token passthrough)
11
+ * - RFC 7662 — OAuth 2.0 Token Introspection
12
+ * - RFC 6750 §3 — Bearer token `WWW-Authenticate` challenges
13
+ */
14
+ export {};
package/package.json CHANGED
@@ -1,17 +1,69 @@
1
1
  {
2
2
  "name": "@qauth-labs/resource-guard",
3
- "version": "0.0.0",
4
- "description": "Placeholder; see https://github.com/qauth-labs/qauth",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
8
- },
9
- "keywords": [],
10
- "author": "",
3
+ "version": "0.2.0-rc.0",
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
+ "keywords": [
6
+ "oauth",
7
+ "oauth2.1",
8
+ "mcp",
9
+ "resource-server",
10
+ "rfc9728",
11
+ "bearer",
12
+ "jwt",
13
+ "qauth"
14
+ ],
11
15
  "license": "Apache-2.0",
12
- "type": "commonjs",
16
+ "homepage": "https://docs.qauth.dev/integrate/mcp-quickstart/",
13
17
  "repository": {
14
18
  "type": "git",
15
- "url": "git+https://github.com/qauth-labs/qauth.git"
16
- }
17
- }
19
+ "url": "git+https://github.com/qauth-labs/qauth.git",
20
+ "directory": "libs/sdk/resource-guard"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/qauth-labs/qauth/issues"
24
+ },
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
30
+ "main": "./dist/index.js",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "import": "./dist/index.js"
35
+ },
36
+ "./fastify": {
37
+ "types": "./dist/fastify.d.ts",
38
+ "import": "./dist/fastify.js"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "dependencies": {
51
+ "fastify-plugin": "^6.0.1",
52
+ "jose": "^6.2.12"
53
+ },
54
+ "peerDependencies": {
55
+ "fastify": "^5.12.7"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "fastify": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "devDependencies": {
63
+ "@vitest/coverage-v8": "^5.0.5",
64
+ "@vitest/ui": "^5.0.5",
65
+ "fastify": "^5.12.7",
66
+ "vitest": "^5.0.5"
67
+ },
68
+ "types": "./dist/index.d.ts"
69
+ }