@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 +1 -1
- package/src/auth/jwt-provider.ts +84 -10
package/package.json
CHANGED
package/src/auth/jwt-provider.ts
CHANGED
|
@@ -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
|
-
|
|
108
|
-
|
|
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
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
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.,
|
|
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
|
-
|
|
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
|
-
|
|
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));
|