@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 +1 -1
- package/src/auth/config.ts +158 -53
- package/src/auth/jwt-provider.ts +73 -41
- package/src/auth/mod.ts +16 -2
- package/src/auth/presets.ts +97 -24
- package/src/auth/types.ts +22 -11
package/package.json
CHANGED
package/src/auth/config.ts
CHANGED
|
@@ -28,33 +28,14 @@ const VALID_PROVIDERS: AuthProviderName[] = [
|
|
|
28
28
|
];
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
/**
|
|
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
|
|
164
|
+
const providerRaw = envProvider ?? yamlAuth?.provider;
|
|
128
165
|
|
|
129
166
|
// No provider configured anywhere → no auth
|
|
130
|
-
if (!
|
|
167
|
+
if (!providerRaw) return null;
|
|
131
168
|
|
|
132
|
-
// Validate provider name
|
|
133
|
-
|
|
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: "${
|
|
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
|
|
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
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
package/src/auth/jwt-provider.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { isOtelEnabled, recordAuthEvent } from "../observability/otel.js";
|
|
19
19
|
|
|
20
20
|
// ============================================================================
|
|
21
|
-
// JwtAuthProviderOptions — discriminated union (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
|
|
50
|
-
*
|
|
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.
|
|
82
|
-
*
|
|
83
|
-
* narrows
|
|
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
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// `options
|
|
161
|
-
// `
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
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
|
-
//
|
|
171
|
-
// path or query component, the well-known
|
|
172
|
-
//
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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";
|
package/src/auth/presets.ts
CHANGED
|
@@ -86,7 +86,7 @@ export interface OIDCPresetOptions extends PresetOptions {
|
|
|
86
86
|
export function createGitHubAuthProvider(
|
|
87
87
|
options: PresetOptions,
|
|
88
88
|
): JwtAuthProvider {
|
|
89
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
187
|
+
// Public bridge: raw strings → branded JwtAuthProviderOptions
|
|
188
188
|
// ============================================================================
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
|
-
*
|
|
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
|
|
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
|
|
205
|
-
* discriminated union used by the core `JwtAuthProvider` constructor.
|
|
237
|
+
* Bridge raw-string options to a fully-constructed `JwtAuthProvider`.
|
|
206
238
|
*
|
|
207
|
-
* This
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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)
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
|
222
|
-
opts:
|
|
223
|
-
|
|
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
|
-
`[${
|
|
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
|
-
`[${
|
|
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
|
|
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
|
-
/**
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|