@casys/mcp-server 0.15.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casys/mcp-server",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Production-ready MCP server framework with concurrency control, auth, and observability",
5
5
  "type": "module",
6
6
  "main": "mod.ts",
@@ -29,6 +29,16 @@ const VALID_PROVIDERS: AuthProviderName[] = [
29
29
 
30
30
  /**
31
31
  * Parsed auth configuration (after YAML + env merge).
32
+ *
33
+ * TODO(0.17.0): convert to a discriminated union on `provider`. Each variant
34
+ * should encode which provider-specific fields are required:
35
+ * - `"github"` / `"google"`: base only
36
+ * - `"auth0"`: base + required `domain`
37
+ * - `"oidc"`: base + required `issuer`, optional `jwksUri`
38
+ * This lifts the current runtime checks in `loadAuthConfig()` (lines 157-168)
39
+ * to the type level, matching what 0.16.0 did for `JwtAuthProviderOptions`.
40
+ * See `CHANGELOG.md` [Unreleased] section and
41
+ * `memory/project_mcp_server_016_deferred.md` for the deferred-work anchor.
32
42
  */
33
43
  export interface AuthConfig {
34
44
  provider: AuthProviderName;
@@ -47,9 +57,9 @@ export interface AuthConfig {
47
57
  * metadata document is served publicly (RFC 9728 § 3).
48
58
  *
49
59
  * Required when `resource` is an opaque URI (e.g., an OIDC project ID used
50
- * as JWT audience per RFC 9728 § 2) — otherwise `JwtAuthProvider` will
60
+ * as JWT audience per RFC 9728 § 2) — otherwise preset factories will
51
61
  * throw at construction. When `resource` is itself an HTTP(S) URL, this
52
- * field is optional and auto-derived by the factory. Mirrors
62
+ * field is optional and auto-derived by the preset bridge layer. Mirrors
53
63
  * `JwtAuthProviderOptions.resourceMetadataUrl`.
54
64
  */
55
65
  resourceMetadataUrl?: string;
@@ -9,57 +9,99 @@
9
9
 
10
10
  import { createRemoteJWKSet, jwtVerify } from "jose";
11
11
  import { AuthProvider } from "./provider.js";
12
- import type { AuthInfo, ProtectedResourceMetadata } from "./types.js";
12
+ import {
13
+ type AuthInfo,
14
+ type HttpsUrl,
15
+ httpsUrl,
16
+ type ProtectedResourceMetadata,
17
+ } from "./types.js";
13
18
  import { isOtelEnabled, recordAuthEvent } from "../observability/otel.js";
14
19
 
20
+ // ============================================================================
21
+ // JwtAuthProviderOptions — discriminated union (0.16.0)
22
+ // ============================================================================
23
+
15
24
  /**
16
- * Configuration for JwtAuthProvider.
25
+ * Fields shared by both branches of {@link JwtAuthProviderOptions}.
17
26
  */
18
- export interface JwtAuthProviderOptions {
27
+ interface JwtAuthProviderOptionsBase {
19
28
  /** JWT issuer (iss claim) */
20
29
  issuer: string;
21
30
  /** JWT audience (aud claim) */
22
31
  audience: string;
23
- /** JWKS URI for signature validation. Defaults to {issuer}/.well-known/jwks.json */
32
+ /**
33
+ * JWKS URI for signature validation. Defaults to
34
+ * `{issuer}/.well-known/jwks.json` when omitted.
35
+ */
24
36
  jwksUri?: string;
25
- /** Resource identifier for RFC 9728 */
26
- resource: string;
27
- /** Authorization servers that issue valid tokens */
28
- authorizationServers: string[];
37
+ /**
38
+ * Authorization servers that issue valid tokens. Each entry is a branded
39
+ * {@link HttpsUrl} raw strings are rejected at compile time. Construct
40
+ * via {@link httpsUrl}.
41
+ */
42
+ authorizationServers: HttpsUrl[];
29
43
  /** Scopes supported by this server */
30
44
  scopesSupported?: string[];
45
+ }
46
+
47
+ /**
48
+ * Options where `resource` is a verified HTTP(S) URL. The metadata URL is
49
+ * auto-derived when `resourceMetadataUrl` is omitted, by appending
50
+ * `/.well-known/oauth-protected-resource` to `resource`.
51
+ */
52
+ export interface JwtAuthProviderOptionsUrlResource
53
+ extends JwtAuthProviderOptionsBase {
54
+ /** RFC 9728 § 2 resource identifier, pre-validated as HTTP(S) URL. */
55
+ resource: HttpsUrl;
31
56
  /**
32
- * Absolute HTTP(S) URL where the `/.well-known/oauth-protected-resource`
33
- * metadata document is served publicly. Used to populate the
34
- * `resource_metadata` parameter of the WWW-Authenticate challenge
35
- * (RFC 9728 § 5) and the `resource_metadata_url` field of
36
- * `ProtectedResourceMetadata`.
37
- *
38
- * - When omitted AND `resource` is an HTTP(S) URL, the factory
39
- * auto-derives `${resource}/.well-known/oauth-protected-resource`.
40
- * - When omitted AND `resource` is an opaque URI (e.g., an OIDC project
41
- * ID used as JWT audience — valid per RFC 9728 § 2), the factory
42
- * throws at construction. Set this option explicitly in that case
43
- * (fail-fast, no silent broken header).
44
- *
45
- * @example "https://my-mcp.example.com/.well-known/oauth-protected-resource"
57
+ * Metadata URL, optional auto-derived from `resource` when omitted.
46
58
  */
47
- resourceMetadataUrl?: string;
59
+ resourceMetadataUrl?: HttpsUrl;
48
60
  }
49
61
 
50
62
  /**
51
- * JWT Auth Provider with JWKS validation.
63
+ * Options where `resource` is an opaque URI (per RFC 9728 § 2 — e.g., an
64
+ * OIDC project ID used as JWT audience). The metadata URL is MANDATORY
65
+ * because it cannot be derived from an opaque identifier.
66
+ */
67
+ export interface JwtAuthProviderOptionsOpaqueResource
68
+ extends JwtAuthProviderOptionsBase {
69
+ /** RFC 9728 § 2 opaque resource identifier (NOT an URL). */
70
+ resource: string;
71
+ /**
72
+ * Metadata URL, REQUIRED — cannot be derived from opaque `resource`.
73
+ * Construct via {@link httpsUrl}.
74
+ */
75
+ resourceMetadataUrl: HttpsUrl;
76
+ }
77
+
78
+ /**
79
+ * Configuration for JwtAuthProvider.
52
80
  *
53
- * @example
54
- * ```typescript
55
- * const provider = new JwtAuthProvider({
56
- * issuer: "https://accounts.google.com",
57
- * audience: "https://my-mcp.example.com",
58
- * resource: "https://my-mcp.example.com",
59
- * authorizationServers: ["https://accounts.google.com"],
60
- * });
61
- * ```
81
+ * 0.16.0: discriminated union enforcing at compile time that callers with an
82
+ * opaque `resource` MUST supply `resourceMetadataUrl` explicitly. TypeScript
83
+ * narrows based on which branch accepts the given fields:
84
+ *
85
+ * - `resource: HttpsUrl` (via {@link httpsUrl}) → {@link
86
+ * JwtAuthProviderOptionsUrlResource} branch, `resourceMetadataUrl`
87
+ * optional (auto-derived from `resource`).
88
+ * - `resource: string` (raw) → {@link JwtAuthProviderOptionsOpaqueResource}
89
+ * branch, `resourceMetadataUrl: HttpsUrl` REQUIRED.
90
+ *
91
+ * A caller forgetting to wrap an HTTP(S) URL in `httpsUrl()` gets a compile
92
+ * error telling them to either wrap the resource or supply an explicit
93
+ * metadata URL — structurally closing the bug class that 0.15.x fixed with
94
+ * runtime guards (see `postmortems/phase8-deployment.md` § 7 and
95
+ * `CHANGELOG.md` 0.15.0 / 0.15.1 for the motivating history).
62
96
  */
97
+ export type JwtAuthProviderOptions =
98
+ | JwtAuthProviderOptionsUrlResource
99
+ | JwtAuthProviderOptionsOpaqueResource;
100
+
101
+ // ============================================================================
102
+ // JwtAuthProvider
103
+ // ============================================================================
104
+
63
105
  /**
64
106
  * Cached auth result with expiration
65
107
  */
@@ -68,10 +110,25 @@ interface CachedAuth {
68
110
  expiresAt: number; // ms timestamp
69
111
  }
70
112
 
113
+ /**
114
+ * JWT Auth Provider with JWKS validation.
115
+ *
116
+ * @example
117
+ * ```typescript
118
+ * import { httpsUrl, JwtAuthProvider } from "@casys/mcp-server";
119
+ *
120
+ * const provider = new JwtAuthProvider({
121
+ * issuer: "https://accounts.google.com",
122
+ * audience: "https://my-mcp.example.com",
123
+ * resource: httpsUrl("https://my-mcp.example.com"),
124
+ * authorizationServers: [httpsUrl("https://accounts.google.com")],
125
+ * });
126
+ * ```
127
+ */
71
128
  export class JwtAuthProvider extends AuthProvider {
72
129
  private jwks: ReturnType<typeof createRemoteJWKSet>;
73
130
  private options: JwtAuthProviderOptions;
74
- private readonly resourceMetadataUrl: string;
131
+ private readonly resourceMetadataUrl: HttpsUrl;
75
132
 
76
133
  // Token verification cache: hash(token) → AuthInfo with TTL
77
134
  // Prevents redundant JWKS fetches (network round-trip per tool call)
@@ -97,30 +154,44 @@ export class JwtAuthProvider extends AuthProvider {
97
154
  );
98
155
  }
99
156
 
100
- // Resolve resource_metadata_url: explicit > auto-derive (if URL) > throw.
101
- // Per RFC 9728 § 2, `resource` is an URI identifier and MAY be opaque
102
- // (e.g., an OIDC project ID used as JWT audience). The metadata document
103
- // URL is a separate concept always an HTTP(S) URL. We used to derive
104
- // it from `resource` by string concatenation, which produced a broken
105
- // URL when `resource` was not itself an HTTP(S) URL. 0.15.0+ requires
106
- // the caller to provide the URL explicitly whenever `resource` is opaque.
107
- if (options.resourceMetadataUrl) {
157
+ // DU invariant: the `OpaqueResource` branch requires `resourceMetadataUrl`,
158
+ // so at runtime the `else` branch below can only be reached when the
159
+ // caller constructed via the `UrlResource` branch meaning
160
+ // `options.resource` is, by construction at input time, a valid
161
+ // `HttpsUrl`. TypeScript does NOT narrow this at the function-body level
162
+ // (because `HttpsUrl extends string`, the union `HttpsUrl | string`
163
+ // collapses to `string`, and there is no tag field to discriminate on),
164
+ // so the type-checker sees `options.resource: string` here — but the
165
+ // runtime value is guaranteed parseable because it came through
166
+ // `httpsUrl()` at the call site. The `new URL()` call below is therefore
167
+ // sound, and the `httpsUrl()` wrapper on the derived value acts as
168
+ // defense-in-depth.
169
+ //
170
+ // Derivation follows RFC 9728 § 3.1 exactly: when the resource has a
171
+ // path or query component, the well-known path suffix is inserted
172
+ // BETWEEN the host and the path, not appended after it:
173
+ //
174
+ // resource = https://api.example.com/v1/mcp
175
+ // metadata = https://api.example.com/.well-known/oauth-protected-resource/v1/mcp
176
+ //
177
+ // Prior to 0.16.0 the derivation was a naive `${resource}/.well-known/...`
178
+ // concat which produced `.../v1/mcp/.well-known/oauth-protected-resource`
179
+ // — a 404 for RFC 9728-compliant discovery clients. Root-path resources
180
+ // (`pathname === "/"`) are the only case the old code happened to get
181
+ // right; everything else was silently broken. All current tests exercise
182
+ // root-path resources which is why the bug survived until the 0.16.0
183
+ // review (see `code-reviewer` findings).
184
+ //
185
+ // Fragments (`#...`) are intentionally dropped — they're client-side-only
186
+ // per RFC 3986 § 3.5 and never part of a server-side metadata endpoint.
187
+ if (options.resourceMetadataUrl !== undefined) {
108
188
  this.resourceMetadataUrl = options.resourceMetadataUrl;
109
189
  } else {
110
- const isUrl = /^https?:\/\//.test(options.resource);
111
- if (!isUrl) {
112
- throw new Error(
113
- `[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 ` +
117
- `metadata document URL is a separate concept. Set 'resourceMetadataUrl' ` +
118
- `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").`,
120
- );
121
- }
122
- const base = options.resource.replace(/\/$/, "");
123
- this.resourceMetadataUrl = `${base}/.well-known/oauth-protected-resource`;
190
+ const parsed = new URL(options.resource);
191
+ const pathPart = parsed.pathname === "/" ? "" : parsed.pathname;
192
+ this.resourceMetadataUrl = httpsUrl(
193
+ `${parsed.origin}/.well-known/oauth-protected-resource${pathPart}${parsed.search}`,
194
+ );
124
195
  }
125
196
 
126
197
  this.options = options;
package/src/auth/mod.ts CHANGED
@@ -8,8 +8,13 @@
8
8
  export type {
9
9
  AuthInfo,
10
10
  AuthOptions,
11
+ HttpsUrl,
11
12
  ProtectedResourceMetadata,
12
13
  } from "./types.js";
14
+ // HttpsUrl brand factories (0.16.0) — construct branded URL values for
15
+ // `JwtAuthProviderOptions`, `ProtectedResourceMetadata`, and anywhere else
16
+ // that requires a validated absolute HTTP(S) URL.
17
+ export { httpsUrl, tryHttpsUrl } from "./types.js";
13
18
 
14
19
  // Provider base class
15
20
  export { AuthProvider } from "./provider.js";
@@ -36,7 +41,14 @@ export type {
36
41
 
37
42
  // JWT Provider
38
43
  export { JwtAuthProvider } from "./jwt-provider.js";
39
- export type { JwtAuthProviderOptions } from "./jwt-provider.js";
44
+ // JwtAuthProviderOptions is a discriminated union in 0.16.0 — both branch
45
+ // types are exported so advanced callers can type-annotate variables
46
+ // explicitly.
47
+ export type {
48
+ JwtAuthProviderOptions,
49
+ JwtAuthProviderOptionsOpaqueResource,
50
+ JwtAuthProviderOptionsUrlResource,
51
+ } from "./jwt-provider.js";
40
52
 
41
53
  // OIDC Presets
42
54
  export {
@@ -45,7 +57,7 @@ export {
45
57
  createGoogleAuthProvider,
46
58
  createOIDCAuthProvider,
47
59
  } from "./presets.js";
48
- export type { PresetOptions } from "./presets.js";
60
+ export type { OIDCPresetOptions, PresetOptions } from "./presets.js";
49
61
 
50
62
  // Config loader (YAML + env)
51
63
  export { createAuthProviderFromConfig, loadAuthConfig } from "./config.js";
@@ -1,17 +1,21 @@
1
1
  /**
2
2
  * OIDC auth provider presets.
3
3
  *
4
- * Factory functions for common OIDC providers.
5
- * Each preset pre-configures the issuer, JWKS URI, and
6
- * authorization server for the provider.
4
+ * Factory functions for common OIDC providers. Each preset pre-configures
5
+ * the issuer, JWKS URI, and authorization server for the provider.
6
+ *
7
+ * Bridge layer (0.16.0): presets accept raw `string` fields for `resource`,
8
+ * `resourceMetadataUrl`, and `authorizationServers`, then wrap them through
9
+ * {@link httpsUrl} before passing to the branded {@link JwtAuthProviderOptions}
10
+ * constructor. This keeps the YAML/env config pipeline (`createAuthProviderFromConfig`)
11
+ * working with raw strings while the core `JwtAuthProvider` API enforces the
12
+ * `HttpsUrl` brand at the type level.
7
13
  *
8
14
  * @module lib/server/auth/presets
9
15
  */
10
16
 
11
- import {
12
- JwtAuthProvider,
13
- type JwtAuthProviderOptions,
14
- } from "./jwt-provider.js";
17
+ import { JwtAuthProvider } from "./jwt-provider.js";
18
+ import { type HttpsUrl, httpsUrl, tryHttpsUrl } from "./types.js";
15
19
 
16
20
  /**
17
21
  * Base options shared by all presets.
@@ -19,18 +23,52 @@ import {
19
23
  export interface PresetOptions {
20
24
  /** JWT audience (aud claim) */
21
25
  audience: string;
22
- /** Resource identifier for RFC 9728 */
26
+ /**
27
+ * RFC 9728 § 2 resource identifier, as a raw string. Presets detect
28
+ * whether this is an HTTP(S) URL or an opaque URI and pick the correct
29
+ * {@link JwtAuthProviderOptions} branch internally. When opaque, you
30
+ * MUST also supply {@link resourceMetadataUrl}.
31
+ */
23
32
  resource: string;
24
33
  /** Scopes supported by this server */
25
34
  scopesSupported?: string[];
26
35
  /**
27
36
  * Absolute HTTP(S) URL where the `/.well-known/oauth-protected-resource`
28
37
  * metadata document is served publicly (RFC 9728 § 3). Required when
29
- * `resource` is an opaque URI. See `JwtAuthProviderOptions.resourceMetadataUrl`.
38
+ * `resource` is an opaque URI; optional when `resource` is itself an
39
+ * HTTP(S) URL (in which case the preset auto-derives). Empty and
40
+ * whitespace-only values are treated as absent.
30
41
  */
31
42
  resourceMetadataUrl?: string;
32
43
  }
33
44
 
45
+ /**
46
+ * Options for {@link createOIDCAuthProvider}. Unlike 0.15.x where this
47
+ * function accepted `JwtAuthProviderOptions` directly, 0.16.0 exposes a
48
+ * preset-style interface with raw string fields — the preset wraps them
49
+ * through {@link httpsUrl} internally.
50
+ *
51
+ * BREAKING: callers that previously passed a pre-built
52
+ * `JwtAuthProviderOptions` to `createOIDCAuthProvider` must now pass
53
+ * raw strings. Migration: just drop the `httpsUrl()` wrappers you'd
54
+ * otherwise need at the call site.
55
+ */
56
+ export interface OIDCPresetOptions extends PresetOptions {
57
+ /** OIDC issuer (typically an HTTPS URL) */
58
+ issuer: string;
59
+ /**
60
+ * JWKS URI for signature validation. Defaults to
61
+ * `{issuer}/.well-known/jwks.json`.
62
+ */
63
+ jwksUri?: string;
64
+ /**
65
+ * Authorization servers, as raw strings. Defaults to `[issuer]` when
66
+ * omitted. Each entry must be a valid absolute HTTP(S) URL — the preset
67
+ * validates via {@link httpsUrl}.
68
+ */
69
+ authorizationServers?: string[];
70
+ }
71
+
34
72
  /**
35
73
  * GitHub Actions OIDC provider.
36
74
  *
@@ -48,14 +86,14 @@ export interface PresetOptions {
48
86
  export function createGitHubAuthProvider(
49
87
  options: PresetOptions,
50
88
  ): JwtAuthProvider {
51
- return new JwtAuthProvider({
89
+ return buildJwtProvider({
52
90
  issuer: "https://token.actions.githubusercontent.com",
53
91
  audience: options.audience,
54
- resource: options.resource,
55
92
  authorizationServers: ["https://token.actions.githubusercontent.com"],
56
93
  scopesSupported: options.scopesSupported,
94
+ resource: options.resource,
57
95
  resourceMetadataUrl: options.resourceMetadataUrl,
58
- });
96
+ }, "createGitHubAuthProvider");
59
97
  }
60
98
 
61
99
  /**
@@ -75,15 +113,15 @@ export function createGitHubAuthProvider(
75
113
  export function createGoogleAuthProvider(
76
114
  options: PresetOptions,
77
115
  ): JwtAuthProvider {
78
- return new JwtAuthProvider({
116
+ return buildJwtProvider({
79
117
  issuer: "https://accounts.google.com",
80
118
  audience: options.audience,
81
- resource: options.resource,
82
119
  authorizationServers: ["https://accounts.google.com"],
83
120
  jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
84
121
  scopesSupported: options.scopesSupported,
122
+ resource: options.resource,
85
123
  resourceMetadataUrl: options.resourceMetadataUrl,
86
- });
124
+ }, "createGoogleAuthProvider");
87
125
  }
88
126
 
89
127
  /**
@@ -105,15 +143,15 @@ export function createAuth0AuthProvider(
105
143
  options: PresetOptions & { domain: string },
106
144
  ): JwtAuthProvider {
107
145
  const issuer = `https://${options.domain}/`;
108
- return new JwtAuthProvider({
146
+ return buildJwtProvider({
109
147
  issuer,
110
148
  audience: options.audience,
111
- resource: options.resource,
112
149
  authorizationServers: [issuer],
113
150
  jwksUri: `${issuer}.well-known/jwks.json`,
114
151
  scopesSupported: options.scopesSupported,
152
+ resource: options.resource,
115
153
  resourceMetadataUrl: options.resourceMetadataUrl,
116
- });
154
+ }, "createAuth0AuthProvider");
117
155
  }
118
156
 
119
157
  /**
@@ -132,7 +170,109 @@ export function createAuth0AuthProvider(
132
170
  * ```
133
171
  */
134
172
  export function createOIDCAuthProvider(
135
- options: JwtAuthProviderOptions,
173
+ options: OIDCPresetOptions,
174
+ ): JwtAuthProvider {
175
+ return buildJwtProvider({
176
+ issuer: options.issuer,
177
+ audience: options.audience,
178
+ jwksUri: options.jwksUri,
179
+ authorizationServers: options.authorizationServers ?? [options.issuer],
180
+ scopesSupported: options.scopesSupported,
181
+ resource: options.resource,
182
+ resourceMetadataUrl: options.resourceMetadataUrl,
183
+ }, "createOIDCAuthProvider");
184
+ }
185
+
186
+ // ============================================================================
187
+ // Internal bridge: raw strings → branded JwtAuthProviderOptions
188
+ // ============================================================================
189
+
190
+ /**
191
+ * Internal options for the bridge helper.
192
+ */
193
+ interface BuildJwtProviderOptions {
194
+ issuer: string;
195
+ audience: string;
196
+ jwksUri?: string;
197
+ authorizationServers: string[];
198
+ scopesSupported?: string[];
199
+ resource: string;
200
+ resourceMetadataUrl?: string;
201
+ }
202
+
203
+ /**
204
+ * Bridge raw-string preset options to the branded {@link JwtAuthProviderOptions}
205
+ * discriminated union used by the core `JwtAuthProvider` constructor.
206
+ *
207
+ * This helper centralizes the preset → constructor translation so:
208
+ * 1. All four presets share one validation pathway.
209
+ * 2. Error messages name the preset that failed (via `presetName`).
210
+ * 3. The DU branch choice (URL resource vs opaque) happens in exactly
211
+ * one place.
212
+ *
213
+ * Rules:
214
+ * - `authorizationServers[]`: every entry must be a valid HTTP(S) URL.
215
+ * - `resource`: if parseable as HTTP(S) URL → UrlResource branch (metadata
216
+ * URL derivable). Otherwise → OpaqueResource branch requiring explicit
217
+ * `resourceMetadataUrl`.
218
+ * - `resourceMetadataUrl`: empty or whitespace-only strings are treated as
219
+ * absent (matches YAML-key-with-no-value semantics from 0.15.1).
220
+ */
221
+ function buildJwtProvider(
222
+ opts: BuildJwtProviderOptions,
223
+ presetName: string,
136
224
  ): JwtAuthProvider {
137
- return new JwtAuthProvider(options);
225
+ const wrappedAuthServers: HttpsUrl[] = opts.authorizationServers.map(
226
+ (raw, i) => {
227
+ try {
228
+ return httpsUrl(raw);
229
+ } catch (err) {
230
+ throw new Error(
231
+ `[${presetName}] authorizationServers[${i}] is not a valid ` +
232
+ `HTTP(S) URL: ${(err as Error).message}`,
233
+ );
234
+ }
235
+ },
236
+ );
237
+
238
+ const explicitMetadata: HttpsUrl | undefined = opts.resourceMetadataUrl &&
239
+ opts.resourceMetadataUrl.trim().length > 0
240
+ ? httpsUrl(opts.resourceMetadataUrl)
241
+ : undefined;
242
+
243
+ const base = {
244
+ issuer: opts.issuer,
245
+ audience: opts.audience,
246
+ jwksUri: opts.jwksUri,
247
+ authorizationServers: wrappedAuthServers,
248
+ scopesSupported: opts.scopesSupported,
249
+ };
250
+
251
+ const resourceUrl = tryHttpsUrl(opts.resource);
252
+ if (resourceUrl !== null) {
253
+ // URL resource branch — metadata URL optional (derivable)
254
+ return new JwtAuthProvider({
255
+ ...base,
256
+ resource: resourceUrl,
257
+ resourceMetadataUrl: explicitMetadata,
258
+ });
259
+ }
260
+
261
+ // Opaque resource branch — metadata URL required
262
+ if (!explicitMetadata) {
263
+ throw new Error(
264
+ `[${presetName}] resourceMetadataUrl is required when 'resource' is ` +
265
+ `not an HTTP(S) URL (got resource=${JSON.stringify(opts.resource)}). ` +
266
+ `Per RFC 9728 § 2, 'resource' can be an opaque URI (e.g., an OIDC ` +
267
+ `project ID used as JWT audience); in that case the metadata ` +
268
+ `document URL is a separate concept and must be provided explicitly. ` +
269
+ `Set 'resourceMetadataUrl' to the HTTPS URL where your ` +
270
+ `/.well-known/oauth-protected-resource endpoint is served publicly.`,
271
+ );
272
+ }
273
+ return new JwtAuthProvider({
274
+ ...base,
275
+ resource: opts.resource,
276
+ resourceMetadataUrl: explicitMetadata,
277
+ });
138
278
  }
package/src/auth/types.ts CHANGED
@@ -7,6 +7,95 @@
7
7
  * @module lib/server/auth/types
8
8
  */
9
9
 
10
+ // ============================================================================
11
+ // HttpsUrl brand — 0.16.0
12
+ // ============================================================================
13
+
14
+ declare const httpsUrlBrand: unique symbol;
15
+
16
+ /**
17
+ * A string that has been validated as a syntactically valid absolute HTTP(S)
18
+ * URL. Cannot be constructed by type assertion — callers MUST go through
19
+ * {@link httpsUrl} (which parses, normalizes, and throws on invalid input) so
20
+ * the invariant is enforced at both the type level and runtime.
21
+ *
22
+ * Added in 0.16.0 to lift `resource_metadata_url`, `authorization_servers`,
23
+ * and the URL-resource branch of {@link JwtAuthProviderOptions} from raw
24
+ * `string` into a type that structurally encodes the invariant. The motivating
25
+ * incident was 0.14.x silently producing `"://host/.well-known/..."` in
26
+ * `WWW-Authenticate` headers when callers mis-set `resource` — a class of bug
27
+ * that 0.15.x closed with runtime guards and 0.16.0 closes at the type level.
28
+ */
29
+ export type HttpsUrl = string & { readonly [httpsUrlBrand]: never };
30
+
31
+ /**
32
+ * Parse, validate, and normalize a string as an absolute HTTP(S) URL.
33
+ *
34
+ * Trims leading/trailing whitespace before parsing so YAML keys with trailing
35
+ * spaces don't produce unparseable URLs. Delegates parsing to `new URL()`,
36
+ * which lowercases the scheme — so `HTTPS://foo.com` is accepted and returned
37
+ * as `https://foo.com/`.
38
+ *
39
+ * @throws if the string is empty/whitespace-only, unparseable as a URL, or
40
+ * uses a non-HTTP(S) scheme (RFC 9728 § 3 requires HTTPS for metadata
41
+ * documents; http is permitted here for local dev only).
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * const url = httpsUrl("https://my-mcp.example.com");
46
+ * // url is of type HttpsUrl and can be passed wherever HttpsUrl is required.
47
+ * ```
48
+ */
49
+ export function httpsUrl(raw: string): HttpsUrl {
50
+ const trimmed = raw.trim();
51
+ if (trimmed.length === 0) {
52
+ throw new Error(
53
+ `[httpsUrl] empty or whitespace-only string is not a valid URL. ` +
54
+ `Expected an absolute HTTP(S) URL like ` +
55
+ `"https://my-mcp.example.com/.well-known/oauth-protected-resource".`,
56
+ );
57
+ }
58
+ let parsed: URL;
59
+ try {
60
+ parsed = new URL(trimmed);
61
+ } catch {
62
+ throw new Error(
63
+ `[httpsUrl] not a parseable URL: ${JSON.stringify(raw)}. ` +
64
+ `Expected an absolute HTTP(S) URL like ` +
65
+ `"https://my-mcp.example.com/.well-known/oauth-protected-resource".`,
66
+ );
67
+ }
68
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
69
+ throw new Error(
70
+ `[httpsUrl] must use http:// or https:// scheme, got ${
71
+ JSON.stringify(parsed.protocol)
72
+ } in ${JSON.stringify(raw)}. Per RFC 9728 § 3, the protected resource ` +
73
+ `metadata document must be served over HTTP(S).`,
74
+ );
75
+ }
76
+ return parsed.toString() as HttpsUrl;
77
+ }
78
+
79
+ /**
80
+ * Non-throwing variant of {@link httpsUrl}. Returns the normalized `HttpsUrl`
81
+ * on success, `null` on any validation failure. Use this when you need to
82
+ * branch on validity without catching exceptions — typically to detect whether
83
+ * an RFC 9728 § 2 `resource` identifier is an HTTP(S) URL (the URL-resource
84
+ * branch) or an opaque URI (the opaque-resource branch that requires an
85
+ * explicit metadata URL).
86
+ */
87
+ export function tryHttpsUrl(raw: string): HttpsUrl | null {
88
+ try {
89
+ return httpsUrl(raw);
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ // ============================================================================
96
+ // Runtime auth types
97
+ // ============================================================================
98
+
10
99
  /**
11
100
  * Information extracted from a validated token.
12
101
  * Frozen (Object.freeze) before being passed to tool handlers.
@@ -69,7 +158,9 @@ export interface ProtectedResourceMetadata {
69
158
  /**
70
159
  * RFC 9728 § 2 resource identifier. URI that identifies the protected
71
160
  * resource — used as the JWT `aud` claim. Can be an HTTP(S) URL OR an
72
- * opaque URI (e.g., an OIDC project ID). Do NOT assume it's an URL.
161
+ * opaque URI (e.g., an OIDC project ID). Stays `string` because RFC 9728
162
+ * explicitly allows opaque URIs; callers must NOT assume it parses as
163
+ * an URL.
73
164
  */
74
165
  resource: string;
75
166
 
@@ -79,17 +170,23 @@ export interface ProtectedResourceMetadata {
79
170
  * WWW-Authenticate challenge. Always an HTTP(S) URL, regardless of
80
171
  * whether `resource` itself is an URL or an opaque URI.
81
172
  *
82
- * REQUIRED as of 0.15.0 previously derived at the middleware level
83
- * from `resource`, which produced a broken URL when `resource` was not
84
- * itself an HTTP(S) URL. Callers using the `createOIDCAuthProvider`
85
- * factory or `JwtAuthProvider` can omit the explicit value when their
86
- * `resource` IS an HTTP(S) URL (the factory auto-derives). Custom
87
- * `AuthProvider` subclasses must always set it explicitly.
173
+ * 0.16.0: typed as {@link HttpsUrl} (branded). The invariant is now
174
+ * structurally enforced producers must construct the value via
175
+ * {@link httpsUrl}, which validates at runtime AND returns the brand.
176
+ * Prior to 0.16.0 this was a raw `string` guarded only by a runtime
177
+ * validator in the `JwtAuthProvider` constructor (0.15.1).
88
178
  */
89
- resource_metadata_url: string;
179
+ resource_metadata_url: HttpsUrl;
90
180
 
91
- /** Authorization servers that can issue valid tokens */
92
- authorization_servers: string[];
181
+ /**
182
+ * Authorization servers that can issue valid tokens.
183
+ *
184
+ * 0.16.0: each element is branded as {@link HttpsUrl} so downstream
185
+ * consumers (e.g., MCP clients building discovery URLs) can rely on
186
+ * them being parseable absolute URLs without re-validation. Construct
187
+ * via {@link httpsUrl}.
188
+ */
189
+ authorization_servers: HttpsUrl[];
93
190
 
94
191
  /** Scopes this resource supports */
95
192
  scopes_supported?: string[];