@casys/mcp-server 0.15.0 → 0.15.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casys/mcp-server",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Production-ready MCP server framework with concurrency control, auth, and observability",
5
5
  "type": "module",
6
6
  "main": "mod.ts",
@@ -68,6 +68,50 @@ interface CachedAuth {
68
68
  expiresAt: number; // ms timestamp
69
69
  }
70
70
 
71
+ /**
72
+ * Validate that a string is a parseable absolute HTTP(S) URL. Used by
73
+ * `JwtAuthProvider`'s constructor to enforce the RFC 9728 § 3 invariant
74
+ * at construction time: the `resource_metadata_url` placed in the
75
+ * `WWW-Authenticate` challenge MUST be a URL clients can fetch. Before
76
+ * 0.15.1 the constructor stored whatever string the caller passed and
77
+ * trusted the type system, which silently produced broken headers when
78
+ * the value was empty / a relative path / unparseable / contained
79
+ * trailing whitespace — the exact class of bug that 0.15.0 was meant to
80
+ * eliminate (see postmortem phase8-deployment.md § 7 and F.1).
81
+ *
82
+ * Throws with a clear error message pointing at the offending value
83
+ * (`JSON.stringify`-ed so quotes/newlines/specials don't break log
84
+ * parsing) and suggesting a concrete fix. The `source` parameter names
85
+ * which input produced the bad value so operators reading the error know
86
+ * which config key to correct.
87
+ *
88
+ * Returns the normalized URL string (via `URL.toString()`), which
89
+ * lowercases the scheme and applies minor canonicalization — so a
90
+ * caller passing `HTTPS://foo.com/` receives `https://foo.com/` back.
91
+ */
92
+ function validateAbsoluteHttpUrl(raw: string, source: string): string {
93
+ let parsed: URL;
94
+ try {
95
+ parsed = new URL(raw);
96
+ } catch {
97
+ throw new Error(
98
+ `[JwtAuthProvider] ${source} is not a parseable URL: ${
99
+ JSON.stringify(raw)
100
+ }. Expected an absolute HTTP(S) URL like ` +
101
+ `"https://my-mcp.example.com/.well-known/oauth-protected-resource".`,
102
+ );
103
+ }
104
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
105
+ throw new Error(
106
+ `[JwtAuthProvider] ${source} must use http:// or https:// scheme, ` +
107
+ `got ${JSON.stringify(parsed.protocol)} in ${JSON.stringify(raw)}. ` +
108
+ `Per RFC 9728 § 3, the protected resource metadata document must ` +
109
+ `be served over HTTP(S).`,
110
+ );
111
+ }
112
+ return parsed.toString();
113
+ }
114
+
71
115
  export class JwtAuthProvider extends AuthProvider {
72
116
  private jwks: ReturnType<typeof createRemoteJWKSet>;
73
117
  private options: JwtAuthProviderOptions;
@@ -104,26 +148,56 @@ export class JwtAuthProvider extends AuthProvider {
104
148
  // it from `resource` by string concatenation, which produced a broken
105
149
  // URL when `resource` was not itself an HTTP(S) URL. 0.15.0+ requires
106
150
  // the caller to provide the URL explicitly whenever `resource` is opaque.
107
- if (options.resourceMetadataUrl) {
108
- this.resourceMetadataUrl = options.resourceMetadataUrl;
151
+ //
152
+ // 0.15.1 hardening: both branches now run through `validateAbsoluteHttpUrl`
153
+ // which `new URL()`-parses the result and rejects non-HTTP(S) schemes.
154
+ // Empty / whitespace-only `resourceMetadataUrl` is treated as absent
155
+ // (so a YAML key with no value falls through to the derivation branch
156
+ // instead of silently producing `"://host"`). The scheme regex is now
157
+ // case-insensitive — `HTTPS://foo.com` is a valid URL per RFC 3986
158
+ // and we accept it (normalization happens in validateAbsoluteHttpUrl).
159
+ // `options.resource` is `.trim()`-ed before derivation so trailing
160
+ // whitespace doesn't produce an unparseable URL.
161
+ const explicitUrl = options.resourceMetadataUrl?.trim();
162
+ if (explicitUrl) {
163
+ this.resourceMetadataUrl = validateAbsoluteHttpUrl(
164
+ explicitUrl,
165
+ "options.resourceMetadataUrl",
166
+ );
109
167
  } else {
110
- const isUrl = /^https?:\/\//.test(options.resource);
168
+ const isUrl = /^https?:\/\//i.test(options.resource);
111
169
  if (!isUrl) {
112
170
  throw new Error(
113
171
  `[JwtAuthProvider] resourceMetadataUrl is required when 'resource' ` +
114
- `is not an HTTP(S) URL (got resource="${options.resource}"). ` +
115
- `Per RFC 9728 § 2, 'resource' is an URI identifier that can be ` +
116
- `opaque (e.g., an OIDC project ID used as JWT audience). The ` +
172
+ `is not an HTTP(S) URL (got resource=${
173
+ JSON.stringify(options.resource)
174
+ }). Per RFC 9728 § 2, 'resource' is an URI identifier that can ` +
175
+ `be opaque (e.g., an OIDC project ID used as JWT audience). The ` +
117
176
  `metadata document URL is a separate concept. Set 'resourceMetadataUrl' ` +
118
177
  `to the HTTPS URL where your /.well-known/oauth-protected-resource ` +
119
- `endpoint is served publicly (e.g., "https://my-mcp.example.com/.well-known/oauth-protected-resource").`,
178
+ `endpoint is served publicly (e.g., ` +
179
+ `"https://my-mcp.example.com/.well-known/oauth-protected-resource").`,
120
180
  );
121
181
  }
122
- const base = options.resource.replace(/\/$/, "");
123
- this.resourceMetadataUrl = `${base}/.well-known/oauth-protected-resource`;
182
+ const base = options.resource.trim().replace(/\/$/, "");
183
+ const derived = `${base}/.well-known/oauth-protected-resource`;
184
+ this.resourceMetadataUrl = validateAbsoluteHttpUrl(
185
+ derived,
186
+ "derived from options.resource",
187
+ );
124
188
  }
125
189
 
126
- this.options = options;
190
+ // Normalize `resource` at store time so `getResourceMetadata()` returns
191
+ // the trimmed value in the RFC 9728 Protected Resource Metadata JSON
192
+ // payload. Prior to 0.15.1 the derivation branch trimmed `resource` for
193
+ // URL construction BUT `this.options` kept the raw value, so clients
194
+ // fetching `/.well-known/oauth-protected-resource` would see
195
+ // `"resource": "https://api.example.com "` with trailing whitespace
196
+ // in the JSON body. The WWW-Authenticate header was always clean
197
+ // (middleware only reads `resource_metadata_url` which went through
198
+ // `validateAbsoluteHttpUrl` + `new URL().toString()` normalization),
199
+ // so runtime was never broken — just the PRM doc body was polluted.
200
+ this.options = { ...options, resource: options.resource.trim() };
127
201
  const jwksUri = options.jwksUri ??
128
202
  `${options.issuer}/.well-known/jwks.json`;
129
203
  this.jwks = createRemoteJWKSet(new URL(jwksUri));