@casys/mcp-server 0.8.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/mod.ts +161 -0
- package/package.json +32 -0
- package/src/auth/config.ts +229 -0
- package/src/auth/jwt-provider.ts +175 -0
- package/src/auth/middleware.ts +170 -0
- package/src/auth/mod.ts +44 -0
- package/src/auth/presets.ts +129 -0
- package/src/auth/provider.ts +47 -0
- package/src/auth/scope-middleware.ts +59 -0
- package/src/auth/types.ts +69 -0
- package/src/concurrency/rate-limiter.ts +190 -0
- package/src/concurrency/request-queue.ts +140 -0
- package/src/concurrent-server.ts +1899 -0
- package/src/middleware/backpressure.ts +36 -0
- package/src/middleware/mod.ts +21 -0
- package/src/middleware/rate-limit.ts +45 -0
- package/src/middleware/runner.ts +63 -0
- package/src/middleware/types.ts +60 -0
- package/src/middleware/validation.ts +28 -0
- package/src/observability/metrics.ts +378 -0
- package/src/observability/mod.ts +20 -0
- package/src/observability/otel.ts +109 -0
- package/src/runtime/runtime.ts +220 -0
- package/src/runtime/types.ts +90 -0
- package/src/sampling/sampling-bridge.ts +191 -0
- package/src/security/channel-hmac.ts +140 -0
- package/src/security/csp.ts +87 -0
- package/src/security/message-signer.ts +223 -0
- package/src/types.ts +478 -0
- package/src/validation/schema-validator.ts +238 -0
package/mod.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Concurrent Server Framework
|
|
3
|
+
*
|
|
4
|
+
* Production-ready MCP server framework with built-in concurrency control,
|
|
5
|
+
* backpressure strategies, and optional sampling support.
|
|
6
|
+
*
|
|
7
|
+
* Built on top of the official @modelcontextprotocol/sdk with added
|
|
8
|
+
* production features for reliability and performance.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { ConcurrentMCPServer } from "@casys/mcp-server";
|
|
13
|
+
*
|
|
14
|
+
* const server = new ConcurrentMCPServer({
|
|
15
|
+
* name: "my-server",
|
|
16
|
+
* version: "1.0.0",
|
|
17
|
+
* maxConcurrent: 10,
|
|
18
|
+
* backpressureStrategy: 'queue'
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* server.registerTool(
|
|
22
|
+
* { name: "greet", description: "Greet someone", inputSchema: { type: "object" } },
|
|
23
|
+
* (args) => `Hello, ${args.name}!`,
|
|
24
|
+
* );
|
|
25
|
+
*
|
|
26
|
+
* // STDIO transport
|
|
27
|
+
* await server.start();
|
|
28
|
+
*
|
|
29
|
+
* // — or — HTTP transport with security-first defaults
|
|
30
|
+
* const http = await server.startHttp({
|
|
31
|
+
* port: 3000,
|
|
32
|
+
* maxBodyBytes: 1_048_576, // 1 MB (default)
|
|
33
|
+
* corsOrigins: ["https://app.example.com"], // allowlist
|
|
34
|
+
* requireAuth: true, // fail-fast without auth
|
|
35
|
+
* ipRateLimit: { maxRequests: 60, windowMs: 60_000 },
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
39
|
+
* @module @casys/mcp-server
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
// Main server class
|
|
43
|
+
export { ConcurrentMCPServer } from "./src/concurrent-server.js";
|
|
44
|
+
|
|
45
|
+
// Concurrency primitives
|
|
46
|
+
export { RequestQueue } from "./src/concurrency/request-queue.js";
|
|
47
|
+
|
|
48
|
+
// Rate limiting
|
|
49
|
+
export { RateLimiter } from "./src/concurrency/rate-limiter.js";
|
|
50
|
+
|
|
51
|
+
// Schema validation
|
|
52
|
+
export { SchemaValidator } from "./src/validation/schema-validator.js";
|
|
53
|
+
export type {
|
|
54
|
+
ValidationError,
|
|
55
|
+
ValidationResult,
|
|
56
|
+
} from "./src/validation/schema-validator.js";
|
|
57
|
+
|
|
58
|
+
// Sampling support
|
|
59
|
+
export { SamplingBridge } from "./src/sampling/sampling-bridge.js";
|
|
60
|
+
|
|
61
|
+
// Type exports
|
|
62
|
+
export type {
|
|
63
|
+
ConcurrentServerOptions,
|
|
64
|
+
HttpRateLimitContext,
|
|
65
|
+
HttpRateLimitOptions,
|
|
66
|
+
HttpServerInstance,
|
|
67
|
+
// HTTP Server types
|
|
68
|
+
HttpServerOptions,
|
|
69
|
+
// MCP Apps types (SEP-1865)
|
|
70
|
+
MCPResource,
|
|
71
|
+
MCPTool,
|
|
72
|
+
MCPToolMeta,
|
|
73
|
+
McpUiToolMeta,
|
|
74
|
+
QueueMetrics,
|
|
75
|
+
RateLimitContext,
|
|
76
|
+
RateLimitOptions,
|
|
77
|
+
ResourceContent,
|
|
78
|
+
ResourceHandler,
|
|
79
|
+
SamplingClient,
|
|
80
|
+
SamplingParams,
|
|
81
|
+
SamplingResult,
|
|
82
|
+
ToolHandler,
|
|
83
|
+
} from "./src/types.js";
|
|
84
|
+
|
|
85
|
+
// MCP Apps constants
|
|
86
|
+
export { MCP_APP_MIME_TYPE } from "./src/types.js";
|
|
87
|
+
|
|
88
|
+
// Middleware pipeline
|
|
89
|
+
export type {
|
|
90
|
+
Middleware,
|
|
91
|
+
MiddlewareContext,
|
|
92
|
+
MiddlewareResult,
|
|
93
|
+
NextFunction,
|
|
94
|
+
} from "./src/middleware/mod.js";
|
|
95
|
+
export { createMiddlewareRunner } from "./src/middleware/mod.js";
|
|
96
|
+
|
|
97
|
+
// Auth - Core
|
|
98
|
+
export { AuthProvider } from "./src/auth/mod.js";
|
|
99
|
+
export {
|
|
100
|
+
AuthError,
|
|
101
|
+
createAuthMiddleware,
|
|
102
|
+
createForbiddenResponse,
|
|
103
|
+
createUnauthorizedResponse,
|
|
104
|
+
extractBearerToken,
|
|
105
|
+
} from "./src/auth/mod.js";
|
|
106
|
+
export { createScopeMiddleware } from "./src/auth/mod.js";
|
|
107
|
+
export type {
|
|
108
|
+
AuthInfo,
|
|
109
|
+
AuthOptions,
|
|
110
|
+
ProtectedResourceMetadata,
|
|
111
|
+
} from "./src/auth/mod.js";
|
|
112
|
+
|
|
113
|
+
// Auth - JWT Provider + Presets
|
|
114
|
+
export { JwtAuthProvider } from "./src/auth/mod.js";
|
|
115
|
+
export type { JwtAuthProviderOptions } from "./src/auth/mod.js";
|
|
116
|
+
export {
|
|
117
|
+
createAuth0AuthProvider,
|
|
118
|
+
createGitHubAuthProvider,
|
|
119
|
+
createGoogleAuthProvider,
|
|
120
|
+
createOIDCAuthProvider,
|
|
121
|
+
} from "./src/auth/mod.js";
|
|
122
|
+
export type { PresetOptions } from "./src/auth/mod.js";
|
|
123
|
+
|
|
124
|
+
// Auth - Config (YAML + env)
|
|
125
|
+
export {
|
|
126
|
+
createAuthProviderFromConfig,
|
|
127
|
+
loadAuthConfig,
|
|
128
|
+
} from "./src/auth/mod.js";
|
|
129
|
+
export type { AuthConfig, AuthProviderName } from "./src/auth/mod.js";
|
|
130
|
+
|
|
131
|
+
// Observability
|
|
132
|
+
export {
|
|
133
|
+
endToolCallSpan,
|
|
134
|
+
getServerTracer,
|
|
135
|
+
isOtelEnabled,
|
|
136
|
+
recordAuthEvent,
|
|
137
|
+
ServerMetrics,
|
|
138
|
+
type ServerMetricsSnapshot,
|
|
139
|
+
startToolCallSpan,
|
|
140
|
+
type ToolCallSpanAttributes,
|
|
141
|
+
} from "./src/observability/mod.js";
|
|
142
|
+
|
|
143
|
+
// Security - CSP utilities
|
|
144
|
+
export { buildCspHeader, injectCspMetaTag } from "./src/security/csp.js";
|
|
145
|
+
export type { CspOptions } from "./src/security/csp.js";
|
|
146
|
+
|
|
147
|
+
// Security - HMAC channel authentication for PostMessage (MCP Apps)
|
|
148
|
+
export { injectChannelAuth } from "./src/security/channel-hmac.js";
|
|
149
|
+
export { MessageSigner } from "./src/security/message-signer.js";
|
|
150
|
+
export type {
|
|
151
|
+
SignedMessage,
|
|
152
|
+
VerifyResult,
|
|
153
|
+
} from "./src/security/message-signer.js";
|
|
154
|
+
|
|
155
|
+
// Runtime port (for advanced consumers who need to inspect the adapter contract)
|
|
156
|
+
export type {
|
|
157
|
+
FetchHandler,
|
|
158
|
+
RuntimePort,
|
|
159
|
+
ServeHandle,
|
|
160
|
+
ServeOptions,
|
|
161
|
+
} from "./src/runtime/types.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@casys/mcp-server",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Production-ready MCP server framework with concurrency control, auth, and observability",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "mod.ts",
|
|
7
|
+
"types": "mod.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"test": "tsx --test src/**/*_test.ts"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@modelcontextprotocol/sdk": "^1.15.1",
|
|
14
|
+
"hono": "^4.0.0",
|
|
15
|
+
"ajv": "^8.17.1",
|
|
16
|
+
"jose": "^6.0.0",
|
|
17
|
+
"yaml": "^2.7.0",
|
|
18
|
+
"@opentelemetry/api": "^1.9.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"typescript": "^5.4.0",
|
|
22
|
+
"tsx": "^4.0.0"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20.0.0"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/Casys-AI/mcp-server"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT"
|
|
32
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth configuration loader.
|
|
3
|
+
*
|
|
4
|
+
* Loads auth config from YAML file with env var overrides.
|
|
5
|
+
* Priority: env vars > YAML > (nothing = no auth)
|
|
6
|
+
*
|
|
7
|
+
* @module lib/server/auth/config
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { parse as parseYaml } from "yaml";
|
|
11
|
+
import { env, readTextFile } from "../runtime/runtime.js";
|
|
12
|
+
import type { AuthProvider } from "./provider.js";
|
|
13
|
+
import {
|
|
14
|
+
createAuth0AuthProvider,
|
|
15
|
+
createGitHubAuthProvider,
|
|
16
|
+
createGoogleAuthProvider,
|
|
17
|
+
createOIDCAuthProvider,
|
|
18
|
+
} from "./presets.js";
|
|
19
|
+
|
|
20
|
+
/** Supported auth provider names */
|
|
21
|
+
export type AuthProviderName = "github" | "google" | "auth0" | "oidc";
|
|
22
|
+
|
|
23
|
+
const VALID_PROVIDERS: AuthProviderName[] = [
|
|
24
|
+
"github",
|
|
25
|
+
"google",
|
|
26
|
+
"auth0",
|
|
27
|
+
"oidc",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parsed auth configuration (after YAML + env merge).
|
|
32
|
+
*/
|
|
33
|
+
export interface AuthConfig {
|
|
34
|
+
provider: AuthProviderName;
|
|
35
|
+
audience: string;
|
|
36
|
+
resource: string;
|
|
37
|
+
/** Auth0 tenant domain */
|
|
38
|
+
domain?: string;
|
|
39
|
+
/** OIDC issuer */
|
|
40
|
+
issuer?: string;
|
|
41
|
+
/** OIDC JWKS URI (optional, derived from issuer if absent) */
|
|
42
|
+
jwksUri?: string;
|
|
43
|
+
/** Supported scopes */
|
|
44
|
+
scopesSupported?: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* YAML file schema (top-level has `auth` key).
|
|
49
|
+
*/
|
|
50
|
+
interface ConfigFile {
|
|
51
|
+
auth?: {
|
|
52
|
+
provider?: string;
|
|
53
|
+
audience?: string;
|
|
54
|
+
resource?: string;
|
|
55
|
+
domain?: string;
|
|
56
|
+
issuer?: string;
|
|
57
|
+
jwksUri?: string;
|
|
58
|
+
scopesSupported?: string[];
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Load auth configuration from YAML file + env var overrides.
|
|
64
|
+
*
|
|
65
|
+
* 1. Reads YAML file (if it exists)
|
|
66
|
+
* 2. Overlays env vars (MCP_AUTH_* take precedence)
|
|
67
|
+
* 3. Validates the merged config
|
|
68
|
+
* 4. Returns null if no auth is configured
|
|
69
|
+
*
|
|
70
|
+
* Env var mapping:
|
|
71
|
+
* - MCP_AUTH_PROVIDER → auth.provider
|
|
72
|
+
* - MCP_AUTH_AUDIENCE → auth.audience
|
|
73
|
+
* - MCP_AUTH_RESOURCE → auth.resource
|
|
74
|
+
* - MCP_AUTH_DOMAIN → auth.domain
|
|
75
|
+
* - MCP_AUTH_ISSUER → auth.issuer
|
|
76
|
+
* - MCP_AUTH_JWKS_URI → auth.jwksUri
|
|
77
|
+
* - MCP_AUTH_SCOPES → auth.scopesSupported (space-separated)
|
|
78
|
+
*
|
|
79
|
+
* @param configPath - Path to YAML config file. Defaults to "mcp-server.yaml" in cwd.
|
|
80
|
+
* @returns AuthConfig or null if no auth configured
|
|
81
|
+
* @throws Error if config is invalid (fail-fast)
|
|
82
|
+
*/
|
|
83
|
+
export async function loadAuthConfig(
|
|
84
|
+
configPath?: string,
|
|
85
|
+
): Promise<AuthConfig | null> {
|
|
86
|
+
// 1. Load YAML (optional - file may not exist)
|
|
87
|
+
const yamlAuth = await loadYamlAuth(configPath ?? "mcp-server.yaml");
|
|
88
|
+
|
|
89
|
+
// 2. Read env vars
|
|
90
|
+
const envProvider = env("MCP_AUTH_PROVIDER");
|
|
91
|
+
const envAudience = env("MCP_AUTH_AUDIENCE");
|
|
92
|
+
const envResource = env("MCP_AUTH_RESOURCE");
|
|
93
|
+
const envDomain = env("MCP_AUTH_DOMAIN");
|
|
94
|
+
const envIssuer = env("MCP_AUTH_ISSUER");
|
|
95
|
+
const envJwksUri = env("MCP_AUTH_JWKS_URI");
|
|
96
|
+
const envScopes = env("MCP_AUTH_SCOPES");
|
|
97
|
+
|
|
98
|
+
// 3. Merge: env overrides YAML
|
|
99
|
+
const provider = envProvider ?? yamlAuth?.provider;
|
|
100
|
+
|
|
101
|
+
// No provider configured anywhere → no auth
|
|
102
|
+
if (!provider) return null;
|
|
103
|
+
|
|
104
|
+
// Validate provider name
|
|
105
|
+
if (!VALID_PROVIDERS.includes(provider as AuthProviderName)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`[AuthConfig] Unknown auth provider: "${provider}". ` +
|
|
108
|
+
`Valid values: ${VALID_PROVIDERS.join(", ")}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const audience = envAudience ?? yamlAuth?.audience;
|
|
113
|
+
const resource = envResource ?? yamlAuth?.resource;
|
|
114
|
+
|
|
115
|
+
if (!audience) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`[AuthConfig] "audience" is required when provider="${provider}". ` +
|
|
118
|
+
"Set auth.audience in YAML or MCP_AUTH_AUDIENCE env var.",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (!resource) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`[AuthConfig] "resource" is required when provider="${provider}". ` +
|
|
124
|
+
"Set auth.resource in YAML or MCP_AUTH_RESOURCE env var.",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const config: AuthConfig = {
|
|
129
|
+
provider: provider as AuthProviderName,
|
|
130
|
+
audience,
|
|
131
|
+
resource,
|
|
132
|
+
domain: envDomain ?? yamlAuth?.domain,
|
|
133
|
+
issuer: envIssuer ?? yamlAuth?.issuer,
|
|
134
|
+
jwksUri: envJwksUri ?? yamlAuth?.jwksUri,
|
|
135
|
+
scopesSupported: envScopes
|
|
136
|
+
? envScopes.split(" ").filter(Boolean)
|
|
137
|
+
: yamlAuth?.scopesSupported,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Provider-specific validation (fail-fast)
|
|
141
|
+
if (config.provider === "auth0" && !config.domain) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
'[AuthConfig] "domain" is required for auth0 provider. ' +
|
|
144
|
+
"Set auth.domain in YAML or MCP_AUTH_DOMAIN env var.",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (config.provider === "oidc" && !config.issuer) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
'[AuthConfig] "issuer" is required for oidc provider. ' +
|
|
150
|
+
"Set auth.issuer in YAML or MCP_AUTH_ISSUER env var.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return config;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Create an AuthProvider from a loaded AuthConfig.
|
|
159
|
+
*
|
|
160
|
+
* @param config - Validated auth config
|
|
161
|
+
* @returns AuthProvider instance
|
|
162
|
+
*/
|
|
163
|
+
export function createAuthProviderFromConfig(config: AuthConfig): AuthProvider {
|
|
164
|
+
const base = {
|
|
165
|
+
audience: config.audience,
|
|
166
|
+
resource: config.resource,
|
|
167
|
+
scopesSupported: config.scopesSupported,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
switch (config.provider) {
|
|
171
|
+
case "github":
|
|
172
|
+
return createGitHubAuthProvider(base);
|
|
173
|
+
case "google":
|
|
174
|
+
return createGoogleAuthProvider(base);
|
|
175
|
+
case "auth0":
|
|
176
|
+
return createAuth0AuthProvider({ ...base, domain: config.domain! });
|
|
177
|
+
case "oidc":
|
|
178
|
+
return createOIDCAuthProvider({
|
|
179
|
+
...base,
|
|
180
|
+
issuer: config.issuer!,
|
|
181
|
+
jwksUri: config.jwksUri,
|
|
182
|
+
authorizationServers: [config.issuer!],
|
|
183
|
+
});
|
|
184
|
+
default:
|
|
185
|
+
throw new Error(`[AuthConfig] Unsupported provider: ${config.provider}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Load the auth section from a YAML config file.
|
|
191
|
+
* Returns null if file doesn't exist (not an error).
|
|
192
|
+
*/
|
|
193
|
+
async function loadYamlAuth(
|
|
194
|
+
path: string,
|
|
195
|
+
): Promise<ConfigFile["auth"] | null> {
|
|
196
|
+
// readTextFile returns null if file doesn't exist
|
|
197
|
+
const text = await readTextFile(path);
|
|
198
|
+
if (text === null) return null;
|
|
199
|
+
|
|
200
|
+
const parsed = parseYaml(text);
|
|
201
|
+
|
|
202
|
+
// Validate parsed YAML is an object (not string, array, null, etc.)
|
|
203
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const configFile = parsed as Record<string, unknown>;
|
|
208
|
+
if (
|
|
209
|
+
!configFile.auth || typeof configFile.auth !== "object" ||
|
|
210
|
+
Array.isArray(configFile.auth)
|
|
211
|
+
) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const auth = configFile.auth as Record<string, unknown>;
|
|
216
|
+
return {
|
|
217
|
+
provider: typeof auth.provider === "string" ? auth.provider : undefined,
|
|
218
|
+
audience: typeof auth.audience === "string" ? auth.audience : undefined,
|
|
219
|
+
resource: typeof auth.resource === "string" ? auth.resource : undefined,
|
|
220
|
+
domain: typeof auth.domain === "string" ? auth.domain : undefined,
|
|
221
|
+
issuer: typeof auth.issuer === "string" ? auth.issuer : undefined,
|
|
222
|
+
jwksUri: typeof auth.jwksUri === "string" ? auth.jwksUri : undefined,
|
|
223
|
+
scopesSupported: Array.isArray(auth.scopesSupported)
|
|
224
|
+
? auth.scopesSupported.filter((s: unknown): s is string =>
|
|
225
|
+
typeof s === "string"
|
|
226
|
+
)
|
|
227
|
+
: undefined,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT Auth Provider using JWKS for token validation.
|
|
3
|
+
*
|
|
4
|
+
* Validates JWT tokens against a remote JWKS endpoint,
|
|
5
|
+
* checking issuer, audience, and expiration.
|
|
6
|
+
*
|
|
7
|
+
* @module lib/server/auth/jwt-provider
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
11
|
+
import { AuthProvider } from "./provider.js";
|
|
12
|
+
import type { AuthInfo, ProtectedResourceMetadata } from "./types.js";
|
|
13
|
+
import { isOtelEnabled, recordAuthEvent } from "../observability/otel.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Configuration for JwtAuthProvider.
|
|
17
|
+
*/
|
|
18
|
+
export interface JwtAuthProviderOptions {
|
|
19
|
+
/** JWT issuer (iss claim) */
|
|
20
|
+
issuer: string;
|
|
21
|
+
/** JWT audience (aud claim) */
|
|
22
|
+
audience: string;
|
|
23
|
+
/** JWKS URI for signature validation. Defaults to {issuer}/.well-known/jwks.json */
|
|
24
|
+
jwksUri?: string;
|
|
25
|
+
/** Resource identifier for RFC 9728 */
|
|
26
|
+
resource: string;
|
|
27
|
+
/** Authorization servers that issue valid tokens */
|
|
28
|
+
authorizationServers: string[];
|
|
29
|
+
/** Scopes supported by this server */
|
|
30
|
+
scopesSupported?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* JWT Auth Provider with JWKS validation.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* const provider = new JwtAuthProvider({
|
|
39
|
+
* issuer: "https://accounts.google.com",
|
|
40
|
+
* audience: "https://my-mcp.example.com",
|
|
41
|
+
* resource: "https://my-mcp.example.com",
|
|
42
|
+
* authorizationServers: ["https://accounts.google.com"],
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* Cached auth result with expiration
|
|
48
|
+
*/
|
|
49
|
+
interface CachedAuth {
|
|
50
|
+
authInfo: AuthInfo;
|
|
51
|
+
expiresAt: number; // ms timestamp
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class JwtAuthProvider extends AuthProvider {
|
|
55
|
+
private jwks: ReturnType<typeof createRemoteJWKSet>;
|
|
56
|
+
private options: JwtAuthProviderOptions;
|
|
57
|
+
|
|
58
|
+
// Token verification cache: hash(token) → AuthInfo with TTL
|
|
59
|
+
// Prevents redundant JWKS fetches (network round-trip per tool call)
|
|
60
|
+
private tokenCache = new Map<string, CachedAuth>();
|
|
61
|
+
private static readonly MAX_CACHE_SIZE = 1000;
|
|
62
|
+
private static readonly DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
63
|
+
|
|
64
|
+
constructor(options: JwtAuthProviderOptions) {
|
|
65
|
+
super();
|
|
66
|
+
|
|
67
|
+
if (!options.issuer) {
|
|
68
|
+
throw new Error("[JwtAuthProvider] issuer is required");
|
|
69
|
+
}
|
|
70
|
+
if (!options.audience) {
|
|
71
|
+
throw new Error("[JwtAuthProvider] audience is required");
|
|
72
|
+
}
|
|
73
|
+
if (!options.resource) {
|
|
74
|
+
throw new Error("[JwtAuthProvider] resource is required");
|
|
75
|
+
}
|
|
76
|
+
if (!options.authorizationServers?.length) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
"[JwtAuthProvider] at least one authorizationServer is required",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.options = options;
|
|
83
|
+
const jwksUri = options.jwksUri ??
|
|
84
|
+
`${options.issuer}/.well-known/jwks.json`;
|
|
85
|
+
this.jwks = createRemoteJWKSet(new URL(jwksUri));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async verifyToken(token: string): Promise<AuthInfo | null> {
|
|
89
|
+
// Check cache first (avoids JWKS network round-trip)
|
|
90
|
+
const cacheKey = await this.hashToken(token);
|
|
91
|
+
const cached = this.tokenCache.get(cacheKey);
|
|
92
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
93
|
+
if (isOtelEnabled()) {
|
|
94
|
+
recordAuthEvent("cache_hit", {
|
|
95
|
+
subject: cached.authInfo.subject ?? "",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return cached.authInfo;
|
|
99
|
+
}
|
|
100
|
+
// Evict expired entry if present
|
|
101
|
+
if (cached) this.tokenCache.delete(cacheKey);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const { payload } = await jwtVerify(token, this.jwks, {
|
|
105
|
+
issuer: this.options.issuer,
|
|
106
|
+
audience: this.options.audience,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const authInfo: AuthInfo = {
|
|
110
|
+
subject: payload.sub ?? "unknown",
|
|
111
|
+
clientId: (payload.azp as string | undefined) ??
|
|
112
|
+
(payload.client_id as string | undefined),
|
|
113
|
+
scopes: this.extractScopes(payload),
|
|
114
|
+
claims: payload as Record<string, unknown>,
|
|
115
|
+
expiresAt: payload.exp,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Cache with TTL = min(token remaining lifetime, 5 minutes)
|
|
119
|
+
const tokenExpiresMs = payload.exp ? payload.exp * 1000 : Infinity;
|
|
120
|
+
const cacheTtl = Math.min(
|
|
121
|
+
tokenExpiresMs - Date.now(),
|
|
122
|
+
JwtAuthProvider.DEFAULT_CACHE_TTL_MS,
|
|
123
|
+
);
|
|
124
|
+
if (cacheTtl > 0) {
|
|
125
|
+
// Evict oldest entries if cache is full
|
|
126
|
+
if (this.tokenCache.size >= JwtAuthProvider.MAX_CACHE_SIZE) {
|
|
127
|
+
const oldestKey = this.tokenCache.keys().next().value;
|
|
128
|
+
if (oldestKey) this.tokenCache.delete(oldestKey);
|
|
129
|
+
}
|
|
130
|
+
this.tokenCache.set(cacheKey, {
|
|
131
|
+
authInfo: Object.freeze(authInfo) as AuthInfo,
|
|
132
|
+
expiresAt: Date.now() + cacheTtl,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return authInfo;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Hash a token for cache key (avoids storing raw tokens in memory)
|
|
144
|
+
*/
|
|
145
|
+
private async hashToken(token: string): Promise<string> {
|
|
146
|
+
const data = new TextEncoder().encode(token);
|
|
147
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
148
|
+
return Array.from(new Uint8Array(hash)).map((b) =>
|
|
149
|
+
b.toString(16).padStart(2, "0")
|
|
150
|
+
).join("");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
getResourceMetadata(): ProtectedResourceMetadata {
|
|
154
|
+
return {
|
|
155
|
+
resource: this.options.resource,
|
|
156
|
+
authorization_servers: this.options.authorizationServers,
|
|
157
|
+
scopes_supported: this.options.scopesSupported,
|
|
158
|
+
bearer_methods_supported: ["header"],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Extract scopes from JWT payload.
|
|
164
|
+
* Supports: "scope" claim (space-separated string) and "scp" claim (array).
|
|
165
|
+
*/
|
|
166
|
+
private extractScopes(payload: Record<string, unknown>): string[] {
|
|
167
|
+
if (typeof payload.scope === "string") {
|
|
168
|
+
return payload.scope.split(" ").filter(Boolean);
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(payload.scp)) {
|
|
171
|
+
return payload.scp.filter((s): s is string => typeof s === "string");
|
|
172
|
+
}
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|