@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,167 @@
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 fp from 'fastify-plugin';
18
+ import { ResourceGuard } from './core.js';
19
+ import { InsufficientScopeError, MissingTokenError, ResourceGuardConfigError, ResourceGuardError, } from './errors.js';
20
+ import { PRM_WELL_KNOWN_PREFIX } from './metadata.js';
21
+ const PLUGIN_NAME = '@qauth-labs/resource-guard';
22
+ /**
23
+ * Which guard validated `request.tokenClaims`. `requireScopes` may reuse
24
+ * claims only when its OWN guard checked them: claims a different guard
25
+ * validated (e.g. a parent scope's `requireBearer` hook) carry that
26
+ * resource's audience, not this one's.
27
+ */
28
+ const claimsValidatedBy = new WeakMap();
29
+ /**
30
+ * Send the RFC 6750 §3.1 response for a guard error, including the
31
+ * `WWW-Authenticate` challenge with the `resource_metadata` pointer.
32
+ */
33
+ function sendChallenge(guard, reply, error) {
34
+ const body = { error: error.bearerError ?? 'invalid_request' };
35
+ if (error instanceof InsufficientScopeError) {
36
+ body['error_description'] = error.message;
37
+ body['scope'] = error.requiredScopes.join(' ');
38
+ }
39
+ else if (error.bearerError === 'invalid_token' && 'reason' in error) {
40
+ body['error_description'] = error.reason;
41
+ }
42
+ return reply
43
+ .code(error.statusCode)
44
+ .header('WWW-Authenticate', guard.challengeHeader(error))
45
+ .send(error instanceof MissingTokenError ? {} : body);
46
+ }
47
+ const plugin = async (fastify, options) => {
48
+ const guard = new ResourceGuard(options);
49
+ const cacheControl = options.metadataCacheControl ?? 'public, max-age=3600';
50
+ // --- Guard preHandlers ----------------------------------------------------
51
+ // The preHandlers reply with the Bearer challenge directly (returning the
52
+ // reply halts the lifecycle) rather than throwing, so the plugin is fully
53
+ // self-contained and never has to take over the host app's error handler.
54
+ // `ResourceGuardError`s that still escape into the host's error path (e.g. thrown
55
+ // deep inside a route handler) can be mapped with the exported
56
+ // `sendBearerChallenge` helper.
57
+ const requireBearer = async (request, reply) => {
58
+ try {
59
+ request.tokenClaims = await guard.authenticate(request.headers.authorization);
60
+ claimsValidatedBy.set(request, guard);
61
+ }
62
+ catch (error) {
63
+ if (error instanceof ResourceGuardError) {
64
+ return sendChallenge(guard, reply, error);
65
+ }
66
+ throw error;
67
+ }
68
+ };
69
+ const requireScopes = (...scopes) => {
70
+ return async (request, reply) => {
71
+ try {
72
+ // Re-use claims an earlier preHandler of THIS guard validated, so we do
73
+ // not verify the token twice in one request. Claims another guard
74
+ // validated were checked against another resource's audience.
75
+ if (request.tokenClaims && claimsValidatedBy.get(request) === guard) {
76
+ guard.assertScopes(request.tokenClaims, scopes);
77
+ return;
78
+ }
79
+ request.tokenClaims = await guard.authenticate(request.headers.authorization, scopes);
80
+ claimsValidatedBy.set(request, guard);
81
+ }
82
+ catch (error) {
83
+ if (error instanceof ResourceGuardError) {
84
+ return sendChallenge(guard, reply, error);
85
+ }
86
+ throw error;
87
+ }
88
+ };
89
+ };
90
+ // --- Convenience decorators -----------------------------------------------
91
+ // The plugin is wrapped with `fastify-plugin` (no encapsulation) so these
92
+ // are visible to the scope that registers it and its routes — the documented
93
+ // single-guard ergonomics (`app.requireBearer`, `app.requireScopes(...)`).
94
+ //
95
+ // The decorator names are shared, so a scope carries exactly one guard. A
96
+ // host that protects several resources registers each guard in its own
97
+ // encapsulated child scope, where these decorators shadow a guard inherited
98
+ // from an ancestor. That is why this asks for an OWN property: `hasDecorator`
99
+ // also answers true for an inherited decorator.
100
+ //
101
+ // A second guard on the same scope could never be reached through the
102
+ // decorators, and its routes would look guarded while checking the first
103
+ // resource's audience and scopes. Refuse it at boot rather than warn.
104
+ if (Object.hasOwn(fastify, 'resourceGuard')) {
105
+ throw new ResourceGuardConfigError(`another guard (resource=${fastify.resourceGuard.resource}) is already registered on this ` +
106
+ `scope, and \`resourceGuard\`/\`requireBearer\`/\`requireScopes\` can reference only one. ` +
107
+ `Register the guard for resource=${guard.resource}, and the routes it protects, in an ` +
108
+ `encapsulated child scope of their own.`);
109
+ }
110
+ fastify.decorate('resourceGuard', guard);
111
+ fastify.decorate('requireBearer', requireBearer);
112
+ fastify.decorate('requireScopes', requireScopes);
113
+ // --- RFC 9728 Protected Resource Metadata ---------------------------------
114
+ const metadataHandler = async (_request, reply) => {
115
+ reply
116
+ .header('Cache-Control', cacheControl)
117
+ .header('Content-Type', 'application/json; charset=utf-8');
118
+ return reply.send(guard.getProtectedResourceMetadata());
119
+ };
120
+ // The per-resource document path is unique per resource (the resource's own
121
+ // path is nested under the well-known prefix), so this never collides.
122
+ const metadataPath = guard.getMetadataPath();
123
+ fastify.get(metadataPath, metadataHandler);
124
+ // For a path-bearing resource the canonical document lives at a nested
125
+ // well-known path; also expose the bare prefix so a client that probes the
126
+ // origin root can still discover an AS. The bare prefix is a single, shared
127
+ // path: claim it at most once across all guards, otherwise a second
128
+ // path-bearing guard would crash boot with FST_ERR_DUPLICATED_ROUTE. Ask the
129
+ // router, which every scope shares — sibling scopes cannot see each other.
130
+ if (metadataPath !== PRM_WELL_KNOWN_PREFIX) {
131
+ if (!fastify.hasRoute({ method: 'GET', url: `${fastify.prefix}${PRM_WELL_KNOWN_PREFIX}` })) {
132
+ fastify.get(PRM_WELL_KNOWN_PREFIX, metadataHandler);
133
+ }
134
+ else {
135
+ fastify.log.debug('resource-guard: bare %s already served by an earlier guard; not re-registering for resource=%s', PRM_WELL_KNOWN_PREFIX, guard.resource);
136
+ }
137
+ }
138
+ fastify.log.debug('resource-guard plugin registered (resource=%s)', guard.resource);
139
+ };
140
+ /**
141
+ * Reply to `reply` with the RFC 6750 §3.1 Bearer challenge for `error`, using
142
+ * `guard`'s `resource_metadata` pointer. Exposed so host apps that throw
143
+ * `ResourceGuardError` from their own handlers can wire it into their error handler:
144
+ *
145
+ * @example
146
+ * ```ts
147
+ * fastify.setErrorHandler((err, req, reply) => {
148
+ * if (err instanceof ResourceGuardError) {
149
+ * return sendBearerChallenge(fastify.resourceGuard, reply, err);
150
+ * }
151
+ * // ...fall through to the app's own handling
152
+ * });
153
+ * ```
154
+ */
155
+ export function sendBearerChallenge(guard, reply, error) {
156
+ return sendChallenge(guard, reply, error);
157
+ }
158
+ /**
159
+ * `@qauth-labs/resource-guard` Fastify plugin. Wrapped with `fastify-plugin` so the
160
+ * `resourceGuard` decorator and the `requireBearer` / `requireScopes` preHandlers
161
+ * are visible to the parent app and sibling plugins.
162
+ */
163
+ export const resourceGuardPlugin = fp(plugin, {
164
+ name: PLUGIN_NAME,
165
+ fastify: '5.x',
166
+ });
167
+ export default resourceGuardPlugin;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Token validation via RFC 7662 introspection against the QAuth AS.
3
+ *
4
+ * Used when tokens are opaque, or when the RS must honour near-real-time
5
+ * revocation rather than rely on a token's `exp`. RFC 7662 requires the
6
+ * introspection caller to authenticate; QAuth accepts `client_secret_basic`
7
+ * and `client_secret_post`. We use Basic auth.
8
+ *
9
+ * QAuth contract (auth-server introspect route): returns
10
+ * `{ active, sub, client_id, exp, iat, iss, aud, scope, token_type }` and
11
+ * `{ active: false }` for invalid / cross-audience tokens. The AS itself
12
+ * already enforces audience-authoritative introspection; `resource-guard`
13
+ * additionally re-checks `aud` locally so the no-passthrough guarantee does
14
+ * not depend solely on AS-side configuration.
15
+ */
16
+ import type { FetchLike, IntrospectionClientCredentials, ValidatedToken } from '../types.js';
17
+ export interface IntrospectionValidatorOptions {
18
+ /** Full URL of the AS introspection endpoint. */
19
+ endpoint: string;
20
+ /** Resource identifier that MUST appear in the token's `aud`. */
21
+ audience: string;
22
+ /** Confidential client credentials (RFC 7662 requires client auth). */
23
+ client: IntrospectionClientCredentials;
24
+ /** Injectable fetch. Defaults to the global `fetch`. */
25
+ fetch?: FetchLike;
26
+ }
27
+ /** Reusable RFC 7662 introspection validator. */
28
+ export declare class IntrospectionValidator {
29
+ private readonly endpoint;
30
+ private readonly audience;
31
+ private readonly authorization;
32
+ private readonly fetchImpl;
33
+ constructor(options: IntrospectionValidatorOptions);
34
+ /**
35
+ * Introspect a token and, if active and audience-bound, return normalised
36
+ * claims.
37
+ *
38
+ * Two error classes, deliberately distinct (RFC 6750 §3.1 semantics):
39
+ *
40
+ * @throws {InvalidTokenError} when the AS authoritatively says the *token* is
41
+ * unusable — inactive (revoked/expired/unknown) or bound to a different
42
+ * audience. Maps to a 401 `invalid_token` Bearer challenge.
43
+ * @throws {IntrospectionError} when the *introspection call itself* fails for
44
+ * a reason that is not the token's fault — the AS is unreachable, returns a
45
+ * non-2xx (e.g. THIS RS's introspection credentials are misconfigured → 401
46
+ * from the AS to *us*), or sends a malformed body. This is an operational /
47
+ * server-side fault and must surface as a 5xx, not a 401 that wrongly blames
48
+ * the client's token and can trigger pointless refresh loops.
49
+ *
50
+ * Reasons are short and non-sensitive; the token is never echoed.
51
+ */
52
+ validate(token: string): Promise<ValidatedToken>;
53
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Token validation via RFC 7662 introspection against the QAuth AS.
3
+ *
4
+ * Used when tokens are opaque, or when the RS must honour near-real-time
5
+ * revocation rather than rely on a token's `exp`. RFC 7662 requires the
6
+ * introspection caller to authenticate; QAuth accepts `client_secret_basic`
7
+ * and `client_secret_post`. We use Basic auth.
8
+ *
9
+ * QAuth contract (auth-server introspect route): returns
10
+ * `{ active, sub, client_id, exp, iat, iss, aud, scope, token_type }` and
11
+ * `{ active: false }` for invalid / cross-audience tokens. The AS itself
12
+ * already enforces audience-authoritative introspection; `resource-guard`
13
+ * additionally re-checks `aud` locally so the no-passthrough guarantee does
14
+ * not depend solely on AS-side configuration.
15
+ */
16
+ import { IntrospectionError, InvalidTokenError } from './errors.js';
17
+ import { parseScopes } from './scope.js';
18
+ function audienceList(aud) {
19
+ if (aud == null) {
20
+ return [];
21
+ }
22
+ return Array.isArray(aud) ? aud : [aud];
23
+ }
24
+ /** Reusable RFC 7662 introspection validator. */
25
+ export class IntrospectionValidator {
26
+ endpoint;
27
+ audience;
28
+ authorization;
29
+ fetchImpl;
30
+ constructor(options) {
31
+ this.endpoint = options.endpoint;
32
+ this.audience = options.audience;
33
+ // `btoa` is a standard global in every modern runtime (Node 16+, Deno,
34
+ // Bun, Cloudflare Workers) — unlike the Node-only `Buffer` — so the
35
+ // framework-agnostic core stays portable. Client credentials are ASCII, so
36
+ // `btoa`'s latin1-only limitation does not apply here.
37
+ const basic = btoa(`${options.client.clientId}:${options.client.clientSecret}`);
38
+ this.authorization = `Basic ${basic}`;
39
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
40
+ }
41
+ /**
42
+ * Introspect a token and, if active and audience-bound, return normalised
43
+ * claims.
44
+ *
45
+ * Two error classes, deliberately distinct (RFC 6750 §3.1 semantics):
46
+ *
47
+ * @throws {InvalidTokenError} when the AS authoritatively says the *token* is
48
+ * unusable — inactive (revoked/expired/unknown) or bound to a different
49
+ * audience. Maps to a 401 `invalid_token` Bearer challenge.
50
+ * @throws {IntrospectionError} when the *introspection call itself* fails for
51
+ * a reason that is not the token's fault — the AS is unreachable, returns a
52
+ * non-2xx (e.g. THIS RS's introspection credentials are misconfigured → 401
53
+ * from the AS to *us*), or sends a malformed body. This is an operational /
54
+ * server-side fault and must surface as a 5xx, not a 401 that wrongly blames
55
+ * the client's token and can trigger pointless refresh loops.
56
+ *
57
+ * Reasons are short and non-sensitive; the token is never echoed.
58
+ */
59
+ async validate(token) {
60
+ let response;
61
+ try {
62
+ response = await this.fetchImpl(this.endpoint, {
63
+ method: 'POST',
64
+ headers: {
65
+ 'content-type': 'application/x-www-form-urlencoded',
66
+ accept: 'application/json',
67
+ authorization: this.authorization,
68
+ },
69
+ // RFC 7662 §2.1 — `token` (and optional hint) in the form body.
70
+ body: new URLSearchParams({
71
+ token,
72
+ token_type_hint: 'access_token',
73
+ }).toString(),
74
+ });
75
+ }
76
+ catch (err) {
77
+ // Network / transport failure: the AS could not be reached. This is an
78
+ // RS-side operational fault, not an invalid token — surface as 5xx.
79
+ throw new IntrospectionError(`introspection endpoint unreachable: ${err instanceof Error ? err.message : String(err)}`);
80
+ }
81
+ if (!response.ok) {
82
+ // A non-2xx from the introspection endpoint means OUR request was
83
+ // rejected — typically a 401 because this RS's introspection client
84
+ // credentials are misconfigured, or a 5xx from the AS. Either way it is
85
+ // an operational fault on the server side, NOT a verdict that the
86
+ // bearer's token is invalid. Do not collapse it into 401 invalid_token.
87
+ throw new IntrospectionError(`introspection request failed with status ${response.status}`);
88
+ }
89
+ let body;
90
+ try {
91
+ body = (await response.json());
92
+ }
93
+ catch {
94
+ // A 2xx with an unparseable body is a broken/misbehaving AS — again an
95
+ // operational fault rather than an invalid token.
96
+ throw new IntrospectionError('malformed introspection response');
97
+ }
98
+ if (!body.active) {
99
+ throw new InvalidTokenError('token is not active');
100
+ }
101
+ // Defence-in-depth no-passthrough check: even though the AS only returns
102
+ // active=true to an audience-authoritative introspection client, re-verify
103
+ // the binding locally (RFC 8707) so a misconfigured AS client cannot widen
104
+ // what this RS will accept.
105
+ const auds = audienceList(body.aud);
106
+ if (!auds.includes(this.audience)) {
107
+ throw new InvalidTokenError('token audience does not match this resource');
108
+ }
109
+ return {
110
+ sub: body.sub,
111
+ clientId: body.client_id,
112
+ scopes: parseScopes(body.scope),
113
+ audience: auds,
114
+ issuer: body.iss,
115
+ expiresAt: body.exp,
116
+ issuedAt: body.iat,
117
+ raw: body,
118
+ };
119
+ }
120
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * JWT validation against the QAuth AS JWKS.
3
+ *
4
+ * This is the recommended, offline validation path. It verifies the bearer's
5
+ * signature with the AS public keys (RFC 7517 JWKS), pins the algorithm to
6
+ * defend against algorithm-confusion, and enforces `iss` and — the core
7
+ * no-passthrough control — that `aud` contains THIS resource (RFC 8707).
8
+ *
9
+ * QAuth contract (auth-server): tokens are EdDSA-signed compact JWTs with
10
+ * claims `sub`, `client_id`, `scope` (space-separated), `aud` (string or
11
+ * array), `iss`, `iat`, `exp`; JWKS is served at
12
+ * `${authorizationServer}/.well-known/jwks.json`.
13
+ */
14
+ import { type FetchImplementation } from 'jose';
15
+ import type { ValidatedToken } from '../types.js';
16
+ export interface JwtValidatorOptions {
17
+ /** JWKS document URL. */
18
+ jwksUri: string;
19
+ /** Expected issuer (`iss`) — the AS identifier. */
20
+ issuer: string;
21
+ /** Resource identifier that MUST appear in `aud`. */
22
+ audience: string;
23
+ /** Permitted signature algorithms. Defaults to `['EdDSA']`. */
24
+ allowedAlgorithms?: string[];
25
+ /**
26
+ * Require the RFC 9068 `typ: at+jwt` protected header to be PRESENT (#283).
27
+ * Defaults to `false`. See {@link ACCESS_TOKEN_TYP} for why a WRONG `typ` is
28
+ * rejected unconditionally while a MISSING one is gated on this flag.
29
+ */
30
+ requireAccessTokenTyp?: boolean;
31
+ /** JWKS reuse window in ms (jose cooldown). Defaults to 300_000 (5m). */
32
+ cacheTtlMs?: number;
33
+ /** Injectable fetch passed through to jose's remote JWKS loader. */
34
+ fetch?: FetchImplementation;
35
+ }
36
+ /**
37
+ * A reusable JWT validator. Construct once (the JWKS is cached/refreshed
38
+ * internally by jose) and call {@link JwtValidator.validate} per request.
39
+ */
40
+ export declare class JwtValidator {
41
+ private readonly jwks;
42
+ private readonly issuer;
43
+ private readonly audience;
44
+ private readonly algorithms;
45
+ private readonly requireAccessTokenTyp;
46
+ constructor(options: JwtValidatorOptions);
47
+ /**
48
+ * Verify a bearer token. Resolves to the normalised claims on success.
49
+ *
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.
54
+ */
55
+ validate(token: string): Promise<ValidatedToken>;
56
+ /**
57
+ * Reject a token that is not an RFC 9068 access token (#283).
58
+ *
59
+ * Two cases, gated differently because only one is a rollout hazard:
60
+ *
61
+ * - `typ` present but not `at+jwt` → ALWAYS rejected. This is the control
62
+ * that stops an ID token (`typ: JWT`, same signing key, same JWKS entry)
63
+ * being replayed as a bearer token, and it is safe to enforce immediately
64
+ * because no token issued before #283 carries any `typ`.
65
+ * - `typ` absent → accepted unless `requireAccessTokenTyp` is set. An AS that
66
+ * has not yet deployed #283, or one still draining tokens minted by its
67
+ * previous build, emits `typ`-less access tokens that are otherwise
68
+ * entirely valid; rejecting them by default would break every such
69
+ * deployment on upgrade of THIS library.
70
+ */
71
+ private assertAccessTokenTyp;
72
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * JWT validation against the QAuth AS JWKS.
3
+ *
4
+ * This is the recommended, offline validation path. It verifies the bearer's
5
+ * signature with the AS public keys (RFC 7517 JWKS), pins the algorithm to
6
+ * defend against algorithm-confusion, and enforces `iss` and — the core
7
+ * no-passthrough control — that `aud` contains THIS resource (RFC 8707).
8
+ *
9
+ * QAuth contract (auth-server): tokens are EdDSA-signed compact JWTs with
10
+ * claims `sub`, `client_id`, `scope` (space-separated), `aud` (string or
11
+ * array), `iss`, `iat`, `exp`; JWKS is served at
12
+ * `${authorizationServer}/.well-known/jwks.json`.
13
+ */
14
+ import { createRemoteJWKSet, customFetch, errors as joseErrors, jwtVerify, } from 'jose';
15
+ import { InvalidTokenError } from './errors.js';
16
+ import { parseScopes } from './scope.js';
17
+ /**
18
+ * RFC 9068 §2.1 `typ` of a JWT OAuth 2.0 access token.
19
+ *
20
+ * A resource server is the party this defends: the AS signs access tokens AND
21
+ * OIDC ID tokens with the SAME key, so both resolve to the same JWKS entry and
22
+ * both pass signature verification here. `typ` is the standard structural
23
+ * discriminator between them, checked before any claim is read.
24
+ *
25
+ * Duplicated as a literal rather than imported from `@qauth-labs/server-jwt`
26
+ * on purpose: `resource-guard` is the resource-server-side library and depends only
27
+ * on `jose`. Its value is fixed by RFC 9068, not by QAuth.
28
+ */
29
+ const ACCESS_TOKEN_TYP = 'at+jwt';
30
+ /**
31
+ * Normalise a jose payload to {@link ValidatedToken}. `aud` is guaranteed
32
+ * present and matching by the time we get here (jose checked it).
33
+ */
34
+ function toValidatedToken(payload) {
35
+ const aud = payload.aud;
36
+ const audience = aud == null ? [] : Array.isArray(aud) ? aud : [aud];
37
+ return {
38
+ sub: payload.sub,
39
+ clientId: typeof payload['client_id'] === 'string' ? payload['client_id'] : undefined,
40
+ scopes: parseScopes(payload['scope']),
41
+ audience,
42
+ issuer: payload.iss,
43
+ expiresAt: payload.exp,
44
+ issuedAt: payload.iat,
45
+ raw: payload,
46
+ };
47
+ }
48
+ /**
49
+ * A reusable JWT validator. Construct once (the JWKS is cached/refreshed
50
+ * internally by jose) and call {@link JwtValidator.validate} per request.
51
+ */
52
+ export class JwtValidator {
53
+ jwks;
54
+ issuer;
55
+ audience;
56
+ algorithms;
57
+ requireAccessTokenTyp;
58
+ constructor(options) {
59
+ this.issuer = options.issuer;
60
+ this.audience = options.audience;
61
+ this.algorithms = options.allowedAlgorithms ?? ['EdDSA'];
62
+ this.requireAccessTokenTyp = options.requireAccessTokenTyp === true;
63
+ const ttl = options.cacheTtlMs ?? 300_000;
64
+ this.jwks = createRemoteJWKSet(new URL(options.jwksUri), {
65
+ // `cooldownDuration` is the minimum interval between JWKS refetches and
66
+ // `cacheMaxAge` the maximum age before a refetch is forced; together they
67
+ // bound how stale a key set can be. We tie both to the configured TTL so
68
+ // a rotated AS key is picked up within `cacheTtlMs`.
69
+ cooldownDuration: ttl,
70
+ cacheMaxAge: ttl,
71
+ ...(options.fetch ? { [customFetch]: options.fetch } : {}),
72
+ });
73
+ }
74
+ /**
75
+ * Verify a bearer token. Resolves to the normalised claims on success.
76
+ *
77
+ * @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.
81
+ */
82
+ async validate(token) {
83
+ let payload;
84
+ let protectedHeader;
85
+ try {
86
+ ({ payload, protectedHeader } = await jwtVerify(token, this.jwks, {
87
+ algorithms: this.algorithms,
88
+ issuer: this.issuer,
89
+ // jose enforces audience membership: the token's `aud` (string or
90
+ // array) MUST contain this resource. This is the RFC 8707 binding
91
+ // that prevents accepting tokens minted for another audience.
92
+ audience: this.audience,
93
+ }));
94
+ }
95
+ catch (error) {
96
+ throw new InvalidTokenError(describeJoseError(error), {
97
+ jwksUnavailable: isJwksUnavailable(error),
98
+ });
99
+ }
100
+ // #283 RFC 9068 §2.1. `protectedHeader` is the header the verified
101
+ // SIGNATURE covers — never `decodeProtectedHeader`, whose output an
102
+ // attacker rewrites at will. Outside the try/catch above so this rejection
103
+ // is not laundered into a generic jose-error string.
104
+ this.assertAccessTokenTyp(protectedHeader['typ']);
105
+ return toValidatedToken(payload);
106
+ }
107
+ /**
108
+ * Reject a token that is not an RFC 9068 access token (#283).
109
+ *
110
+ * Two cases, gated differently because only one is a rollout hazard:
111
+ *
112
+ * - `typ` present but not `at+jwt` → ALWAYS rejected. This is the control
113
+ * that stops an ID token (`typ: JWT`, same signing key, same JWKS entry)
114
+ * being replayed as a bearer token, and it is safe to enforce immediately
115
+ * because no token issued before #283 carries any `typ`.
116
+ * - `typ` absent → accepted unless `requireAccessTokenTyp` is set. An AS that
117
+ * has not yet deployed #283, or one still draining tokens minted by its
118
+ * previous build, emits `typ`-less access tokens that are otherwise
119
+ * entirely valid; rejecting them by default would break every such
120
+ * deployment on upgrade of THIS library.
121
+ */
122
+ assertAccessTokenTyp(signedTyp) {
123
+ if (signedTyp === undefined) {
124
+ if (this.requireAccessTokenTyp) {
125
+ throw new InvalidTokenError('token is missing the required at+jwt type');
126
+ }
127
+ return;
128
+ }
129
+ if (signedTyp !== ACCESS_TOKEN_TYP) {
130
+ throw new InvalidTokenError('token is not an access token (typ is not at+jwt)');
131
+ }
132
+ }
133
+ }
134
+ /**
135
+ * Whether a verification error means the key set could not be obtained, as
136
+ * opposed to a verdict on the token: a JWKS fetch timeout, a malformed key set,
137
+ * jose's generic error for a non-2xx JWKS response, or a transport error that
138
+ * is not a jose error at all.
139
+ */
140
+ function isJwksUnavailable(error) {
141
+ if (error instanceof joseErrors.JWKSTimeout || error instanceof joseErrors.JWKSInvalid) {
142
+ return true;
143
+ }
144
+ if (error instanceof joseErrors.JOSEError) {
145
+ return error.code === 'ERR_JOSE_GENERIC';
146
+ }
147
+ return true;
148
+ }
149
+ /**
150
+ * Map a jose verification error to a short, non-sensitive reason string.
151
+ * Deliberately coarse — we do not want to give an attacker a precise oracle,
152
+ * but the categories below are standard and safe to surface.
153
+ */
154
+ function describeJoseError(error) {
155
+ if (error instanceof joseErrors.JWTExpired) {
156
+ return 'token expired';
157
+ }
158
+ if (error instanceof joseErrors.JWTClaimValidationFailed) {
159
+ // Covers issuer/audience mismatch — the no-passthrough rejection lands here.
160
+ if (error.claim === 'aud') {
161
+ return 'token audience does not match this resource';
162
+ }
163
+ if (error.claim === 'iss') {
164
+ return 'token issuer not trusted';
165
+ }
166
+ return 'token claim validation failed';
167
+ }
168
+ if (error instanceof joseErrors.JWSSignatureVerificationFailed) {
169
+ return 'signature verification failed';
170
+ }
171
+ if (error instanceof joseErrors.JOSEAlgNotAllowed) {
172
+ return 'token algorithm not allowed';
173
+ }
174
+ if (error instanceof joseErrors.JWKSNoMatchingKey) {
175
+ return 'no matching signing key';
176
+ }
177
+ if (error instanceof joseErrors.JOSEError) {
178
+ return 'token verification failed';
179
+ }
180
+ return 'token verification failed';
181
+ }
@@ -0,0 +1,59 @@
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
+ /** Well-known suffix for Protected Resource Metadata (RFC 9728 §3.1). */
11
+ export declare const PRM_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
12
+ /**
13
+ * RFC 9728 Protected Resource Metadata document. Permissive extra keys are
14
+ * allowed; the fields below are what QAuth-backed MCP clients consume.
15
+ */
16
+ export interface ProtectedResourceMetadata {
17
+ /** This resource's identifier (RFC 9728 §3.2 `resource`). */
18
+ resource: string;
19
+ /** Issuer identifier(s) of the AS(es) that can authorize this resource. */
20
+ authorization_servers: string[];
21
+ /** Scopes the resource understands (advertised for client convenience). */
22
+ scopes_supported?: string[];
23
+ /** Supported ways to present the token; QAuth resources use the header. */
24
+ bearer_methods_supported: string[];
25
+ /** RFC 9728 §3.2 — where to learn more (docs link). */
26
+ resource_documentation?: string;
27
+ }
28
+ export interface BuildMetadataInput {
29
+ /** Resource identifier (`aud` value tokens must carry). */
30
+ resource: string;
31
+ /** AS issuer identifier. */
32
+ authorizationServer: string;
33
+ /** Scopes to advertise as `scopes_supported`. */
34
+ scopesSupported?: string[];
35
+ /** Optional documentation URL. */
36
+ resourceDocumentation?: string;
37
+ }
38
+ /**
39
+ * Build the RFC 9728 PRM document. Pure (no I/O) so route handlers control
40
+ * caching and tests can assert the wire shape directly.
41
+ */
42
+ export declare function buildProtectedResourceMetadata(input: BuildMetadataInput): ProtectedResourceMetadata;
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 declare function metadataPathForResource(resource: string): string;
55
+ /**
56
+ * Absolute URL where this resource's PRM document is served — the value
57
+ * advertised in the `resource_metadata` challenge parameter (RFC 9728 §5.1).
58
+ */
59
+ export declare function metadataUrlForResource(resource: string): string;