@casys/mcp-server 0.15.1 → 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 +1 -1
- package/src/auth/config.ts +12 -2
- package/src/auth/jwt-provider.ts +123 -126
- package/src/auth/mod.ts +14 -2
- package/src/auth/presets.ts +160 -20
- package/src/auth/types.ts +107 -10
package/package.json
CHANGED
package/src/auth/config.ts
CHANGED
|
@@ -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
|
|
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
|
|
62
|
+
* field is optional and auto-derived by the preset bridge layer. Mirrors
|
|
53
63
|
* `JwtAuthProviderOptions.resourceMetadataUrl`.
|
|
54
64
|
*/
|
|
55
65
|
resourceMetadataUrl?: string;
|
package/src/auth/jwt-provider.ts
CHANGED
|
@@ -9,57 +9,99 @@
|
|
|
9
9
|
|
|
10
10
|
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
11
11
|
import { AuthProvider } from "./provider.js";
|
|
12
|
-
import
|
|
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
|
-
*
|
|
25
|
+
* Fields shared by both branches of {@link JwtAuthProviderOptions}.
|
|
17
26
|
*/
|
|
18
|
-
|
|
27
|
+
interface JwtAuthProviderOptionsBase {
|
|
19
28
|
/** JWT issuer (iss claim) */
|
|
20
29
|
issuer: string;
|
|
21
30
|
/** JWT audience (aud claim) */
|
|
22
31
|
audience: string;
|
|
23
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* JWKS URI for signature validation. Defaults to
|
|
34
|
+
* `{issuer}/.well-known/jwks.json` when omitted.
|
|
35
|
+
*/
|
|
24
36
|
jwksUri?: string;
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
*
|
|
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?:
|
|
59
|
+
resourceMetadataUrl?: HttpsUrl;
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
/**
|
|
51
|
-
*
|
|
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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
*/
|
|
@@ -69,53 +111,24 @@ interface CachedAuth {
|
|
|
69
111
|
}
|
|
70
112
|
|
|
71
113
|
/**
|
|
72
|
-
*
|
|
73
|
-
* `JwtAuthProvider`'s constructor to enforce the RFC 9728 § 3 invariant
|
|
74
|
-
* at construction time: the `resource_metadata_url` placed in the
|
|
75
|
-
* `WWW-Authenticate` challenge MUST be a URL clients can fetch. Before
|
|
76
|
-
* 0.15.1 the constructor stored whatever string the caller passed and
|
|
77
|
-
* trusted the type system, which silently produced broken headers when
|
|
78
|
-
* the value was empty / a relative path / unparseable / contained
|
|
79
|
-
* trailing whitespace — the exact class of bug that 0.15.0 was meant to
|
|
80
|
-
* eliminate (see postmortem phase8-deployment.md § 7 and F.1).
|
|
114
|
+
* JWT Auth Provider with JWKS validation.
|
|
81
115
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* which input produced the bad value so operators reading the error know
|
|
86
|
-
* which config key to correct.
|
|
116
|
+
* @example
|
|
117
|
+
* ```typescript
|
|
118
|
+
* import { httpsUrl, JwtAuthProvider } from "@casys/mcp-server";
|
|
87
119
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
+
* ```
|
|
91
127
|
*/
|
|
92
|
-
function validateAbsoluteHttpUrl(raw: string, source: string): string {
|
|
93
|
-
let parsed: URL;
|
|
94
|
-
try {
|
|
95
|
-
parsed = new URL(raw);
|
|
96
|
-
} catch {
|
|
97
|
-
throw new Error(
|
|
98
|
-
`[JwtAuthProvider] ${source} is not a parseable URL: ${
|
|
99
|
-
JSON.stringify(raw)
|
|
100
|
-
}. Expected an absolute HTTP(S) URL like ` +
|
|
101
|
-
`"https://my-mcp.example.com/.well-known/oauth-protected-resource".`,
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
105
|
-
throw new Error(
|
|
106
|
-
`[JwtAuthProvider] ${source} must use http:// or https:// scheme, ` +
|
|
107
|
-
`got ${JSON.stringify(parsed.protocol)} in ${JSON.stringify(raw)}. ` +
|
|
108
|
-
`Per RFC 9728 § 3, the protected resource metadata document must ` +
|
|
109
|
-
`be served over HTTP(S).`,
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
return parsed.toString();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
128
|
export class JwtAuthProvider extends AuthProvider {
|
|
116
129
|
private jwks: ReturnType<typeof createRemoteJWKSet>;
|
|
117
130
|
private options: JwtAuthProviderOptions;
|
|
118
|
-
private readonly resourceMetadataUrl:
|
|
131
|
+
private readonly resourceMetadataUrl: HttpsUrl;
|
|
119
132
|
|
|
120
133
|
// Token verification cache: hash(token) → AuthInfo with TTL
|
|
121
134
|
// Prevents redundant JWKS fetches (network round-trip per tool call)
|
|
@@ -141,63 +154,47 @@ export class JwtAuthProvider extends AuthProvider {
|
|
|
141
154
|
);
|
|
142
155
|
}
|
|
143
156
|
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
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.
|
|
151
169
|
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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) {
|
|
188
|
+
this.resourceMetadataUrl = options.resourceMetadataUrl;
|
|
167
189
|
} else {
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
`is not an HTTP(S) URL (got resource=${
|
|
173
|
-
JSON.stringify(options.resource)
|
|
174
|
-
}). Per RFC 9728 § 2, 'resource' is an URI identifier that can ` +
|
|
175
|
-
`be opaque (e.g., an OIDC project ID used as JWT audience). The ` +
|
|
176
|
-
`metadata document URL is a separate concept. Set 'resourceMetadataUrl' ` +
|
|
177
|
-
`to the HTTPS URL where your /.well-known/oauth-protected-resource ` +
|
|
178
|
-
`endpoint is served publicly (e.g., ` +
|
|
179
|
-
`"https://my-mcp.example.com/.well-known/oauth-protected-resource").`,
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
const base = options.resource.trim().replace(/\/$/, "");
|
|
183
|
-
const derived = `${base}/.well-known/oauth-protected-resource`;
|
|
184
|
-
this.resourceMetadataUrl = validateAbsoluteHttpUrl(
|
|
185
|
-
derived,
|
|
186
|
-
"derived from options.resource",
|
|
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}`,
|
|
187
194
|
);
|
|
188
195
|
}
|
|
189
196
|
|
|
190
|
-
|
|
191
|
-
// the trimmed value in the RFC 9728 Protected Resource Metadata JSON
|
|
192
|
-
// payload. Prior to 0.15.1 the derivation branch trimmed `resource` for
|
|
193
|
-
// URL construction BUT `this.options` kept the raw value, so clients
|
|
194
|
-
// fetching `/.well-known/oauth-protected-resource` would see
|
|
195
|
-
// `"resource": "https://api.example.com "` with trailing whitespace
|
|
196
|
-
// in the JSON body. The WWW-Authenticate header was always clean
|
|
197
|
-
// (middleware only reads `resource_metadata_url` which went through
|
|
198
|
-
// `validateAbsoluteHttpUrl` + `new URL().toString()` normalization),
|
|
199
|
-
// so runtime was never broken — just the PRM doc body was polluted.
|
|
200
|
-
this.options = { ...options, resource: options.resource.trim() };
|
|
197
|
+
this.options = options;
|
|
201
198
|
const jwksUri = options.jwksUri ??
|
|
202
199
|
`${options.issuer}/.well-known/jwks.json`;
|
|
203
200
|
this.jwks = createRemoteJWKSet(new URL(jwksUri));
|
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
|
-
|
|
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";
|
package/src/auth/presets.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OIDC auth provider presets.
|
|
3
3
|
*
|
|
4
|
-
* Factory functions for common OIDC providers.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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).
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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:
|
|
179
|
+
resource_metadata_url: HttpsUrl;
|
|
90
180
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
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[];
|