@casys/mcp-server 0.16.1 → 0.17.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.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Production-ready MCP server framework with concurrency control, auth, and observability",
5
5
  "type": "module",
6
6
  "main": "mod.ts",
@@ -28,33 +28,14 @@ const VALID_PROVIDERS: AuthProviderName[] = [
28
28
  ];
29
29
 
30
30
  /**
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 ~167-178)
39
- * to the type level, matching what 0.16.0 did for `JwtAuthProviderOptions`.
40
- *
41
- * Tracked in:
42
- * - GitHub issue: https://github.com/Casys-AI/mcp-server/issues/11 (primary)
43
- * - `CHANGELOG.md` [Unreleased] section
44
- * Hard trigger: do this refactor the next time a new provider preset is added
45
- * (per 0.16.0 type-design review recommendation — don't let it slip past 0.17.0).
31
+ * Fields shared by all {@link AuthConfig} variants.
46
32
  */
47
- export interface AuthConfig {
48
- provider: AuthProviderName;
33
+ interface AuthConfigBase {
34
+ /** JWT audience (aud claim) */
49
35
  audience: string;
36
+ /** RFC 9728 § 2 resource identifier (HTTP(S) URL or opaque URI) */
50
37
  resource: string;
51
- /** Auth0 tenant domain */
52
- domain?: string;
53
- /** OIDC issuer */
54
- issuer?: string;
55
- /** OIDC JWKS URI (optional, derived from issuer if absent) */
56
- jwksUri?: string;
57
- /** Supported scopes */
38
+ /** Scopes supported by this server */
58
39
  scopesSupported?: string[];
59
40
  /**
60
41
  * Absolute HTTP(S) URL where the `/.well-known/oauth-protected-resource`
@@ -69,6 +50,62 @@ export interface AuthConfig {
69
50
  resourceMetadataUrl?: string;
70
51
  }
71
52
 
53
+ /** Auth config for GitHub Actions OIDC (no provider-specific fields). */
54
+ export interface GitHubAuthConfig extends AuthConfigBase {
55
+ provider: "github";
56
+ }
57
+
58
+ /** Auth config for Google OIDC (no provider-specific fields). */
59
+ export interface GoogleAuthConfig extends AuthConfigBase {
60
+ provider: "google";
61
+ }
62
+
63
+ /** Auth config for Auth0 — `domain` is REQUIRED at the type level. */
64
+ export interface Auth0AuthConfig extends AuthConfigBase {
65
+ provider: "auth0";
66
+ /** Auth0 tenant domain (e.g., `"my-tenant.auth0.com"`). */
67
+ domain: string;
68
+ }
69
+
70
+ /** Auth config for generic OIDC — `issuer` is REQUIRED at the type level. */
71
+ export interface OIDCAuthConfig extends AuthConfigBase {
72
+ provider: "oidc";
73
+ /** OIDC issuer URL (used as both JWT `iss` and JWKS discovery root). */
74
+ issuer: string;
75
+ /** JWKS URI, optional — derived from `issuer` if absent. */
76
+ jwksUri?: string;
77
+ }
78
+
79
+ /**
80
+ * Parsed auth configuration (after YAML + env merge).
81
+ *
82
+ * 0.17.0: discriminated union on `provider` tag. Each variant encodes
83
+ * its provider-specific required fields at the type level:
84
+ * - `"github"` / `"google"`: base only
85
+ * - `"auth0"`: base + required `domain`
86
+ * - `"oidc"`: base + required `issuer`, optional `jwksUri`
87
+ *
88
+ * TypeScript narrows on `config.provider`, so `createAuthProviderFromConfig`
89
+ * no longer needs non-null assertions (`config.domain!`) — the required
90
+ * fields are typed correctly in each branch.
91
+ *
92
+ * The runtime checks in `loadAuthConfig()` stay as defense-in-depth for
93
+ * YAML/env input (untyped), but TS callers constructing `AuthConfig` literals
94
+ * directly now get compile-time safety.
95
+ *
96
+ * BREAKING from 0.16.x: callers who constructed `AuthConfig` literals with
97
+ * optional `domain`/`issuer` and relied on runtime validation now get a
98
+ * compile error if they don't match a variant's required fields. Migration:
99
+ * ensure the literal satisfies the discriminated variant (e.g.,
100
+ * `provider: "auth0"` requires `domain`). See
101
+ * `Casys-AI/mcp-server#11`.
102
+ */
103
+ export type AuthConfig =
104
+ | GitHubAuthConfig
105
+ | GoogleAuthConfig
106
+ | Auth0AuthConfig
107
+ | OIDCAuthConfig;
108
+
72
109
  /**
73
110
  * YAML file schema (top-level has `auth` key).
74
111
  */
@@ -124,18 +161,20 @@ export async function loadAuthConfig(
124
161
  const envResourceMetadataUrl = env("MCP_AUTH_RESOURCE_METADATA_URL");
125
162
 
126
163
  // 3. Merge: env overrides YAML
127
- const provider = envProvider ?? yamlAuth?.provider;
164
+ const providerRaw = envProvider ?? yamlAuth?.provider;
128
165
 
129
166
  // No provider configured anywhere → no auth
130
- if (!provider) return null;
167
+ if (!providerRaw) return null;
131
168
 
132
- // Validate provider name
133
- if (!VALID_PROVIDERS.includes(provider as AuthProviderName)) {
169
+ // Validate provider name — untyped YAML/env input requires a runtime check
170
+ // even after 0.17.0's compile-time DU. This is the defense-in-depth layer.
171
+ if (!VALID_PROVIDERS.includes(providerRaw as AuthProviderName)) {
134
172
  throw new Error(
135
- `[AuthConfig] Unknown auth provider: "${provider}". ` +
173
+ `[AuthConfig] Unknown auth provider: "${providerRaw}". ` +
136
174
  `Valid values: ${VALID_PROVIDERS.join(", ")}`,
137
175
  );
138
176
  }
177
+ const provider = providerRaw as AuthProviderName;
139
178
 
140
179
  const audience = envAudience ?? yamlAuth?.audience;
141
180
  const resource = envResource ?? yamlAuth?.resource;
@@ -153,13 +192,9 @@ export async function loadAuthConfig(
153
192
  );
154
193
  }
155
194
 
156
- const config: AuthConfig = {
157
- provider: provider as AuthProviderName,
195
+ const base: AuthConfigBase = {
158
196
  audience,
159
197
  resource,
160
- domain: envDomain ?? yamlAuth?.domain,
161
- issuer: envIssuer ?? yamlAuth?.issuer,
162
- jwksUri: envJwksUri ?? yamlAuth?.jwksUri,
163
198
  scopesSupported: envScopes
164
199
  ? envScopes.split(" ").filter(Boolean)
165
200
  : yamlAuth?.scopesSupported,
@@ -167,26 +202,87 @@ export async function loadAuthConfig(
167
202
  yamlAuth?.resourceMetadataUrl,
168
203
  };
169
204
 
170
- // Provider-specific validation (fail-fast)
171
- if (config.provider === "auth0" && !config.domain) {
172
- throw new Error(
173
- '[AuthConfig] "domain" is required for auth0 provider. ' +
174
- "Set auth.domain in YAML or MCP_AUTH_DOMAIN env var.",
175
- );
176
- }
177
- if (config.provider === "oidc" && !config.issuer) {
178
- throw new Error(
179
- '[AuthConfig] "issuer" is required for oidc provider. ' +
180
- "Set auth.issuer in YAML or MCP_AUTH_ISSUER env var.",
181
- );
205
+ // 4. Construct the correct DU variant, enforcing provider-specific
206
+ // required fields at runtime. The runtime checks mirror the type-level
207
+ // constraints in the DU variants (Auth0AuthConfig.domain, OIDCAuthConfig.issuer).
208
+ //
209
+ // The `issuer` / `domain` fields are URL-shaped at runtime even though the
210
+ // DU variants type them as `string`. 0.17.0 runs them through `new URL()`
211
+ // here (at the YAML/env boundary) to catch typos like
212
+ // `MCP_AUTH_ISSUER=not-a-url` with a clear error naming the offending field,
213
+ // instead of surfacing a confusing `authorizationServers[0]` error deep
214
+ // inside the preset bridge later (pre-existing issue caught during 0.17.0
215
+ // review, code-reviewer finding).
216
+ switch (provider) {
217
+ case "github":
218
+ return { provider: "github", ...base };
219
+ case "google":
220
+ return { provider: "google", ...base };
221
+ case "auth0": {
222
+ const domain = envDomain ?? yamlAuth?.domain;
223
+ if (!domain) {
224
+ throw new Error(
225
+ '[AuthConfig] "domain" is required for auth0 provider. ' +
226
+ "Set auth.domain in YAML or MCP_AUTH_DOMAIN env var.",
227
+ );
228
+ }
229
+ return { provider: "auth0", ...base, domain };
230
+ }
231
+ case "oidc": {
232
+ const issuer = envIssuer ?? yamlAuth?.issuer;
233
+ if (!issuer) {
234
+ throw new Error(
235
+ '[AuthConfig] "issuer" is required for oidc provider. ' +
236
+ "Set auth.issuer in YAML or MCP_AUTH_ISSUER env var.",
237
+ );
238
+ }
239
+ try {
240
+ const parsed = new URL(issuer);
241
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
242
+ throw new Error(
243
+ `[AuthConfig] "issuer" for oidc provider must use http(s):// ` +
244
+ `scheme, got ${JSON.stringify(parsed.protocol)} in ` +
245
+ `${JSON.stringify(issuer)}.`,
246
+ );
247
+ }
248
+ } catch (err) {
249
+ // If new URL() threw, re-wrap with AuthConfig-labelled error.
250
+ // If our explicit scheme check threw, re-throw as-is (already labelled).
251
+ if (err instanceof Error && err.message.startsWith("[AuthConfig]")) {
252
+ throw err;
253
+ }
254
+ throw new Error(
255
+ `[AuthConfig] "issuer" for oidc provider is not a valid URL: ` +
256
+ `${JSON.stringify(issuer)}. Set auth.issuer in YAML or ` +
257
+ `MCP_AUTH_ISSUER env var to an absolute http(s)://... URL.`,
258
+ );
259
+ }
260
+ const jwksUri = envJwksUri ?? yamlAuth?.jwksUri;
261
+ return { provider: "oidc", ...base, issuer, jwksUri };
262
+ }
263
+ default: {
264
+ // Exhaustiveness guard: future provider additions land here as a TS
265
+ // error at this function. Runtime throw is belt-and-suspenders — the
266
+ // earlier `VALID_PROVIDERS.includes` check at line ~130 already
267
+ // eliminates unknown provider strings.
268
+ const _exhaustive: never = provider;
269
+ throw new Error(
270
+ `[AuthConfig] Unreachable — unhandled provider ${
271
+ JSON.stringify(_exhaustive)
272
+ }`,
273
+ );
274
+ }
182
275
  }
183
-
184
- return config;
185
276
  }
186
277
 
187
278
  /**
188
279
  * Create an AuthProvider from a loaded AuthConfig.
189
280
  *
281
+ * 0.17.0: `config` is a discriminated union on `provider`, so TypeScript
282
+ * narrows `config.domain`/`config.issuer` as required (non-optional) in
283
+ * their respective branches. No more non-null assertions (`config.domain!`)
284
+ * or defensive runtime checks here — the type system guarantees them.
285
+ *
190
286
  * @param config - Validated auth config
191
287
  * @returns AuthProvider instance
192
288
  */
@@ -204,16 +300,25 @@ export function createAuthProviderFromConfig(config: AuthConfig): AuthProvider {
204
300
  case "google":
205
301
  return createGoogleAuthProvider(base);
206
302
  case "auth0":
207
- return createAuth0AuthProvider({ ...base, domain: config.domain! });
303
+ return createAuth0AuthProvider({ ...base, domain: config.domain });
208
304
  case "oidc":
209
305
  return createOIDCAuthProvider({
210
306
  ...base,
211
- issuer: config.issuer!,
307
+ issuer: config.issuer,
212
308
  jwksUri: config.jwksUri,
213
- authorizationServers: [config.issuer!],
309
+ authorizationServers: [config.issuer],
214
310
  });
215
- default:
216
- throw new Error(`[AuthConfig] Unsupported provider: ${config.provider}`);
311
+ default: {
312
+ // Exhaustiveness guard matching the one in loadAuthConfig — when a
313
+ // 5th variant is added to AuthConfig, TS flags this function as
314
+ // incomplete before any caller is affected.
315
+ const _exhaustive: never = config;
316
+ throw new Error(
317
+ `[AuthConfig] Unreachable — unhandled variant ${
318
+ JSON.stringify(_exhaustive)
319
+ }`,
320
+ );
321
+ }
217
322
  }
218
323
  }
219
324
 
@@ -18,7 +18,7 @@ import {
18
18
  import { isOtelEnabled, recordAuthEvent } from "../observability/otel.js";
19
19
 
20
20
  // ============================================================================
21
- // JwtAuthProviderOptions — discriminated union (0.16.0)
21
+ // JwtAuthProviderOptions — tagged discriminated union (0.17.0)
22
22
  // ============================================================================
23
23
 
24
24
  /**
@@ -46,11 +46,18 @@ interface JwtAuthProviderOptionsBase {
46
46
 
47
47
  /**
48
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`.
49
+ * auto-derived when `resourceMetadataUrl` is omitted, by applying RFC 9728
50
+ * § 3.1 insertion to `resource`.
51
+ *
52
+ * Tagged with `kind: "url"` so TypeScript narrowing is sound at the function
53
+ * body level (see 0.17.0 CHANGELOG — this fixes finding C1 from the 0.16.0
54
+ * review where narrowing on `resourceMetadataUrl === undefined` failed to
55
+ * propagate because `HttpsUrl extends string` collapsed the union).
51
56
  */
52
57
  export interface JwtAuthProviderOptionsUrlResource
53
58
  extends JwtAuthProviderOptionsBase {
59
+ /** Discriminant tag — MUST be `"url"` for this branch. */
60
+ kind: "url";
54
61
  /** RFC 9728 § 2 resource identifier, pre-validated as HTTP(S) URL. */
55
62
  resource: HttpsUrl;
56
63
  /**
@@ -63,9 +70,16 @@ export interface JwtAuthProviderOptionsUrlResource
63
70
  * Options where `resource` is an opaque URI (per RFC 9728 § 2 — e.g., an
64
71
  * OIDC project ID used as JWT audience). The metadata URL is MANDATORY
65
72
  * because it cannot be derived from an opaque identifier.
73
+ *
74
+ * Tagged with `kind: "opaque"` to make the DU structurally disjoint (see
75
+ * finding C2 from the 0.16.0 review — without the tag, `UrlResource` was
76
+ * a structural subtype of `OpaqueResource` because `HttpsUrl extends
77
+ * string`).
66
78
  */
67
79
  export interface JwtAuthProviderOptionsOpaqueResource
68
80
  extends JwtAuthProviderOptionsBase {
81
+ /** Discriminant tag — MUST be `"opaque"` for this branch. */
82
+ kind: "opaque";
69
83
  /** RFC 9728 § 2 opaque resource identifier (NOT an URL). */
70
84
  resource: string;
71
85
  /**
@@ -78,21 +92,30 @@ export interface JwtAuthProviderOptionsOpaqueResource
78
92
  /**
79
93
  * Configuration for JwtAuthProvider.
80
94
  *
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:
95
+ * 0.17.0: tagged discriminated union on `kind: "url" | "opaque"`. TypeScript
96
+ * narrowing is now structurally sound — `if (options.kind === "url")` truly
97
+ * narrows `options.resource` to `HttpsUrl` at the constructor body level.
98
+ * The tag also makes the branches strictly disjoint: a caller cannot
99
+ * accidentally satisfy `OpaqueResource` with an `HttpsUrl`-branded `resource`
100
+ * (as was possible in 0.16.x — finding C2 from the type-design review).
101
+ *
102
+ * - `kind: "url"` → {@link JwtAuthProviderOptionsUrlResource},
103
+ * `resource: HttpsUrl` (wrap with {@link httpsUrl}),
104
+ * `resourceMetadataUrl` optional (auto-derived).
105
+ * - `kind: "opaque"` → {@link JwtAuthProviderOptionsOpaqueResource},
106
+ * `resource: string` (raw opaque identifier),
107
+ * `resourceMetadataUrl: HttpsUrl` REQUIRED.
84
108
  *
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.
109
+ * The preset factories and the public `buildJwtAuthProvider` bridge accept
110
+ * raw string `resource` and set the `kind` tag automatically via detection
111
+ * only callers that construct `JwtAuthProvider` directly (not through the
112
+ * bridge) need to supply `kind` themselves. Custom OIDC providers written
113
+ * on top of `buildJwtAuthProvider` are unaffected.
90
114
  *
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).
115
+ * BREAKING from 0.16.x: every direct `new JwtAuthProvider(...)` site must
116
+ * add `kind: "url"` or `kind: "opaque"`. Preset users are unaffected.
117
+ * See `postmortems/phase8-deployment.md` § 7 and `CHANGELOG.md` 0.15.0 /
118
+ * 0.15.1 / 0.16.0 for the bug-class history this refactor closes.
96
119
  */
97
120
  export type JwtAuthProviderOptions =
98
121
  | JwtAuthProviderOptionsUrlResource
@@ -113,17 +136,35 @@ interface CachedAuth {
113
136
  /**
114
137
  * JWT Auth Provider with JWKS validation.
115
138
  *
116
- * @example
139
+ * @example URL resource (auto-derives metadata URL)
117
140
  * ```typescript
118
141
  * import { httpsUrl, JwtAuthProvider } from "@casys/mcp-server";
119
142
  *
120
143
  * const provider = new JwtAuthProvider({
144
+ * kind: "url",
121
145
  * issuer: "https://accounts.google.com",
122
146
  * audience: "https://my-mcp.example.com",
123
147
  * resource: httpsUrl("https://my-mcp.example.com"),
124
148
  * authorizationServers: [httpsUrl("https://accounts.google.com")],
125
149
  * });
126
150
  * ```
151
+ *
152
+ * @example Opaque resource (explicit metadata URL required)
153
+ * ```typescript
154
+ * import { httpsUrl, JwtAuthProvider } from "@casys/mcp-server";
155
+ *
156
+ * // RFC 9728 § 2 Option B: OIDC project ID as JWT audience
157
+ * const provider = new JwtAuthProvider({
158
+ * kind: "opaque",
159
+ * issuer: "https://my-tenant.zitadel.cloud",
160
+ * audience: "367545125829670172",
161
+ * resource: "367545125829670172",
162
+ * resourceMetadataUrl: httpsUrl(
163
+ * "https://my-mcp.example.com/.well-known/oauth-protected-resource",
164
+ * ),
165
+ * authorizationServers: [httpsUrl("https://my-tenant.zitadel.cloud")],
166
+ * });
167
+ * ```
127
168
  */
128
169
  export class JwtAuthProvider extends AuthProvider {
129
170
  private jwks: ReturnType<typeof createRemoteJWKSet>;
@@ -154,37 +195,28 @@ export class JwtAuthProvider extends AuthProvider {
154
195
  );
155
196
  }
156
197
 
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.
198
+ // 0.17.0: narrow on the `kind` discriminant tag. TypeScript structurally
199
+ // narrows the DU here after `if (options.kind === "opaque")`, `options`
200
+ // is `JwtAuthProviderOptionsOpaqueResource` and `resourceMetadataUrl` is
201
+ // guaranteed non-undefined; after `else`, `options` is
202
+ // `JwtAuthProviderOptionsUrlResource` and `options.resource` is
203
+ // `HttpsUrl` (no widening, no runtime guess). This fixes finding C1 from
204
+ // the 0.16.0 review the previous narrowing on `resourceMetadataUrl !==
205
+ // undefined` was unsound because `HttpsUrl extends string` collapsed the
206
+ // union back to `string`. Tagging makes the branches strictly disjoint.
169
207
  //
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:
208
+ // URL branch derivation follows RFC 9728 § 3.1: when `resource` has a
209
+ // path or query component, the well-known suffix is INSERTED between
210
+ // the host and the path, not appended after it:
173
211
  //
174
212
  // resource = https://api.example.com/v1/mcp
175
213
  // metadata = https://api.example.com/.well-known/oauth-protected-resource/v1/mcp
176
214
  //
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
215
  // Fragments (`#...`) are intentionally dropped — they're client-side-only
186
216
  // per RFC 3986 § 3.5 and never part of a server-side metadata endpoint.
187
- if (options.resourceMetadataUrl !== undefined) {
217
+ if (options.kind === "opaque") {
218
+ this.resourceMetadataUrl = options.resourceMetadataUrl;
219
+ } else if (options.resourceMetadataUrl !== undefined) {
188
220
  this.resourceMetadataUrl = options.resourceMetadataUrl;
189
221
  } else {
190
222
  const parsed = new URL(options.resource);
package/src/auth/mod.ts CHANGED
@@ -52,13 +52,27 @@ export type {
52
52
 
53
53
  // OIDC Presets
54
54
  export {
55
+ buildJwtAuthProvider,
55
56
  createAuth0AuthProvider,
56
57
  createGitHubAuthProvider,
57
58
  createGoogleAuthProvider,
58
59
  createOIDCAuthProvider,
59
60
  } from "./presets.js";
60
- export type { OIDCPresetOptions, PresetOptions } from "./presets.js";
61
+ export type {
62
+ BuildJwtAuthProviderOptions,
63
+ OIDCPresetOptions,
64
+ PresetOptions,
65
+ } from "./presets.js";
61
66
 
62
67
  // Config loader (YAML + env)
63
68
  export { createAuthProviderFromConfig, loadAuthConfig } from "./config.js";
64
- export type { AuthConfig, AuthProviderName } from "./config.js";
69
+ // `AuthConfig` is a discriminated union in 0.17.0 — each variant is also
70
+ // exported for callers that want to annotate variables explicitly.
71
+ export type {
72
+ Auth0AuthConfig,
73
+ AuthConfig,
74
+ AuthProviderName,
75
+ GitHubAuthConfig,
76
+ GoogleAuthConfig,
77
+ OIDCAuthConfig,
78
+ } from "./config.js";
@@ -86,7 +86,7 @@ export interface OIDCPresetOptions extends PresetOptions {
86
86
  export function createGitHubAuthProvider(
87
87
  options: PresetOptions,
88
88
  ): JwtAuthProvider {
89
- return buildJwtProvider({
89
+ return buildJwtAuthProvider({
90
90
  issuer: "https://token.actions.githubusercontent.com",
91
91
  audience: options.audience,
92
92
  authorizationServers: ["https://token.actions.githubusercontent.com"],
@@ -113,7 +113,7 @@ export function createGitHubAuthProvider(
113
113
  export function createGoogleAuthProvider(
114
114
  options: PresetOptions,
115
115
  ): JwtAuthProvider {
116
- return buildJwtProvider({
116
+ return buildJwtAuthProvider({
117
117
  issuer: "https://accounts.google.com",
118
118
  audience: options.audience,
119
119
  authorizationServers: ["https://accounts.google.com"],
@@ -143,7 +143,7 @@ export function createAuth0AuthProvider(
143
143
  options: PresetOptions & { domain: string },
144
144
  ): JwtAuthProvider {
145
145
  const issuer = `https://${options.domain}/`;
146
- return buildJwtProvider({
146
+ return buildJwtAuthProvider({
147
147
  issuer,
148
148
  audience: options.audience,
149
149
  authorizationServers: [issuer],
@@ -172,7 +172,7 @@ export function createAuth0AuthProvider(
172
172
  export function createOIDCAuthProvider(
173
173
  options: OIDCPresetOptions,
174
174
  ): JwtAuthProvider {
175
- return buildJwtProvider({
175
+ return buildJwtAuthProvider({
176
176
  issuer: options.issuer,
177
177
  audience: options.audience,
178
178
  jwksUri: options.jwksUri,
@@ -184,43 +184,112 @@ export function createOIDCAuthProvider(
184
184
  }
185
185
 
186
186
  // ============================================================================
187
- // Internal bridge: raw strings → branded JwtAuthProviderOptions
187
+ // Public bridge: raw strings → branded JwtAuthProviderOptions
188
188
  // ============================================================================
189
189
 
190
190
  /**
191
- * Internal options for the bridge helper.
191
+ * Raw-string options accepted by {@link buildJwtAuthProvider}. Mirrors
192
+ * {@link JwtAuthProviderOptions} but with all URL fields as plain `string`
193
+ * instead of {@link HttpsUrl}, and without the `kind` discriminant tag
194
+ * (the bridge auto-detects).
195
+ *
196
+ * Exported for 3rd-party OIDC provider implementations that want to accept
197
+ * YAML/env-style raw string config and delegate URL validation to the
198
+ * bridge instead of reimplementing it. See `createGitHubAuthProvider`,
199
+ * `createGoogleAuthProvider`, `createAuth0AuthProvider`, and
200
+ * `createOIDCAuthProvider` for built-in consumers.
192
201
  */
193
- interface BuildJwtProviderOptions {
202
+ export interface BuildJwtAuthProviderOptions {
203
+ /** JWT issuer (iss claim) */
194
204
  issuer: string;
205
+ /** JWT audience (aud claim) */
195
206
  audience: string;
207
+ /**
208
+ * JWKS URI for signature validation. Defaults to
209
+ * `{issuer}/.well-known/jwks.json` when omitted.
210
+ */
196
211
  jwksUri?: string;
212
+ /**
213
+ * Authorization servers that issue valid tokens, as raw strings. Each
214
+ * entry must be a valid absolute HTTP(S) URL — the bridge validates
215
+ * via {@link httpsUrl} and throws with a preset-named error if invalid.
216
+ */
197
217
  authorizationServers: string[];
218
+ /** Scopes supported by this server */
198
219
  scopesSupported?: string[];
220
+ /**
221
+ * RFC 9728 § 2 resource identifier, as a raw string. The bridge detects
222
+ * whether this is an HTTP(S) URL or an opaque URI and picks the correct
223
+ * {@link JwtAuthProviderOptions} branch internally. When opaque, you
224
+ * MUST also supply {@link resourceMetadataUrl}.
225
+ */
199
226
  resource: string;
227
+ /**
228
+ * Absolute HTTP(S) URL where the `/.well-known/oauth-protected-resource`
229
+ * metadata document is served publicly (RFC 9728 § 3). Required when
230
+ * `resource` is an opaque URI; optional when `resource` is itself an
231
+ * HTTP(S) URL. Empty and whitespace-only values are treated as absent.
232
+ */
200
233
  resourceMetadataUrl?: string;
201
234
  }
202
235
 
203
236
  /**
204
- * Bridge raw-string preset options to the branded {@link JwtAuthProviderOptions}
205
- * discriminated union used by the core `JwtAuthProvider` constructor.
237
+ * Bridge raw-string options to a fully-constructed `JwtAuthProvider`.
206
238
  *
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.
239
+ * This is the shared validation + DU-branch-selection pathway used by all
240
+ * four built-in preset factories (`createGitHubAuthProvider` et al). 0.17.0
241
+ * exports it as public API so 3rd-party OIDC provider implementations
242
+ * (Keycloak, Zitadel custom, Okta, ...) can reuse the same pathway instead
243
+ * of reimplementing URL-vs-opaque detection, `HttpsUrl` wrapping, and
244
+ * empty-metadata fall-through.
212
245
  *
213
246
  * 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`.
247
+ * - `authorizationServers[]`: every entry must be a valid absolute HTTP(S)
248
+ * URL. Invalid entries throw with a `[${label}] authorizationServers[i]`
249
+ * prefix for pinpointing.
250
+ * - `resource`: if parseable as HTTP(S) URL → `UrlResource` branch (metadata
251
+ * URL derivable per RFC 9728 § 3.1). Otherwise → `OpaqueResource` branch
252
+ * requiring explicit `resourceMetadataUrl`.
218
253
  * - `resourceMetadataUrl`: empty or whitespace-only strings are treated as
219
254
  * absent (matches YAML-key-with-no-value semantics from 0.15.1).
255
+ *
256
+ * @param opts Raw-string options, as from YAML/env config.
257
+ * @param label Identifier used in error messages — REQUIRED. Pass your
258
+ * factory name so error prefixes point at the actual caller rather than
259
+ * the anonymous bridge layer. E.g., `"createKeycloakAuthProvider"`.
260
+ * (0.17.0: required, was optional with a misleading default in the
261
+ * draft — type-design review recommendation Q4.)
262
+ *
263
+ * @example Custom OIDC provider factory
264
+ * ```typescript
265
+ * import { buildJwtAuthProvider, type JwtAuthProvider } from "@casys/mcp-server";
266
+ *
267
+ * export interface KeycloakPresetOptions {
268
+ * keycloakHost: string;
269
+ * realm: string;
270
+ * audience: string;
271
+ * resource: string;
272
+ * resourceMetadataUrl?: string;
273
+ * }
274
+ *
275
+ * export function createKeycloakAuthProvider(
276
+ * options: KeycloakPresetOptions,
277
+ * ): JwtAuthProvider {
278
+ * const issuer = `https://${options.keycloakHost}/realms/${options.realm}`;
279
+ * return buildJwtAuthProvider({
280
+ * issuer,
281
+ * audience: options.audience,
282
+ * authorizationServers: [issuer],
283
+ * jwksUri: `${issuer}/protocol/openid-connect/certs`,
284
+ * resource: options.resource,
285
+ * resourceMetadataUrl: options.resourceMetadataUrl,
286
+ * }, "createKeycloakAuthProvider");
287
+ * }
288
+ * ```
220
289
  */
221
- function buildJwtProvider(
222
- opts: BuildJwtProviderOptions,
223
- presetName: string,
290
+ export function buildJwtAuthProvider(
291
+ opts: BuildJwtAuthProviderOptions,
292
+ label: string,
224
293
  ): JwtAuthProvider {
225
294
  const wrappedAuthServers: HttpsUrl[] = opts.authorizationServers.map(
226
295
  (raw, i) => {
@@ -228,7 +297,7 @@ function buildJwtProvider(
228
297
  return httpsUrl(raw);
229
298
  } catch (err) {
230
299
  throw new Error(
231
- `[${presetName}] authorizationServers[${i}] is not a valid ` +
300
+ `[${label}] authorizationServers[${i}] is not a valid ` +
232
301
  `HTTP(S) URL: ${(err as Error).message}`,
233
302
  );
234
303
  }
@@ -250,9 +319,12 @@ function buildJwtProvider(
250
319
 
251
320
  const resourceUrl = tryHttpsUrl(opts.resource);
252
321
  if (resourceUrl !== null) {
253
- // URL resource branch — metadata URL optional (derivable)
322
+ // URL resource branch — metadata URL optional (derivable). 0.17.0 sets
323
+ // the explicit `kind: "url"` tag so the DU narrowing in the constructor
324
+ // body is structurally sound.
254
325
  return new JwtAuthProvider({
255
326
  ...base,
327
+ kind: "url",
256
328
  resource: resourceUrl,
257
329
  resourceMetadataUrl: explicitMetadata,
258
330
  });
@@ -261,7 +333,7 @@ function buildJwtProvider(
261
333
  // Opaque resource branch — metadata URL required
262
334
  if (!explicitMetadata) {
263
335
  throw new Error(
264
- `[${presetName}] resourceMetadataUrl is required when 'resource' is ` +
336
+ `[${label}] resourceMetadataUrl is required when 'resource' is ` +
265
337
  `not an HTTP(S) URL (got resource=${JSON.stringify(opts.resource)}). ` +
266
338
  `Per RFC 9728 § 2, 'resource' can be an opaque URI (e.g., an OIDC ` +
267
339
  `project ID used as JWT audience); in that case the metadata ` +
@@ -272,6 +344,7 @@ function buildJwtProvider(
272
344
  }
273
345
  return new JwtAuthProvider({
274
346
  ...base,
347
+ kind: "opaque",
275
348
  resource: opts.resource,
276
349
  resourceMetadataUrl: explicitMetadata,
277
350
  });
package/src/auth/types.ts CHANGED
@@ -129,19 +129,30 @@ export interface AuthInfo {
129
129
  }
130
130
 
131
131
  /**
132
- * Auth configuration for the server.
132
+ * Auth configuration slot for {@link McpAppOptions.auth}.
133
+ *
134
+ * 0.17.0: slimmed to a single `provider` field. Previously exposed
135
+ * `authorizationServers`, `resource`, and `scopesSupported` fields that
136
+ * were NEVER read by `McpApp` at runtime (only `provider` was consumed at
137
+ * `mcp-app.ts:1061`). Those fields were vestigial from a pre-0.15 design
138
+ * where `McpApp` could auto-construct a `JwtAuthProvider` from its own
139
+ * `auth:` option; that auto-construction path was removed when
140
+ * `createAuthProviderFromConfig` took over YAML/env provider construction,
141
+ * but the dead fields stayed in the interface. 0.17.0 deletes them as part
142
+ * of the auth-module cleanup (Casys-AI/mcp-server#13).
143
+ *
144
+ * The interface is kept (rather than reducing `McpAppOptions.auth` to a
145
+ * bare `AuthProvider`) so that future additions like pipeline-level auth
146
+ * hooks have a named extension point without another BC break.
133
147
  */
134
148
  export interface AuthOptions {
135
- /** Authorization servers that issue valid tokens */
136
- authorizationServers: string[];
137
-
138
- /** Resource identifier for this MCP server (used in WWW-Authenticate header) */
139
- resource: string;
140
-
141
- /** Scopes supported by this server */
142
- scopesSupported?: string[];
143
-
144
- /** Custom auth provider (overrides default JWT validation) */
149
+ /**
150
+ * The `AuthProvider` that validates bearer tokens and produces RFC 9728
151
+ * metadata. Construct via {@link JwtAuthProvider}, any of the preset
152
+ * factories (`createGitHubAuthProvider`, `createGoogleAuthProvider`,
153
+ * `createAuth0AuthProvider`, `createOIDCAuthProvider`), or a custom
154
+ * `AuthProvider` subclass for non-JWT token schemes.
155
+ */
145
156
  provider: AuthProvider;
146
157
  }
147
158