@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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication middleware and utilities.
|
|
3
|
+
*
|
|
4
|
+
* Provides Bearer token extraction, 401/403 response factories,
|
|
5
|
+
* and the auth middleware for the pipeline.
|
|
6
|
+
*
|
|
7
|
+
* @module lib/server/auth/middleware
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AuthProvider } from "./provider.js";
|
|
11
|
+
import type { Middleware } from "../middleware/types.js";
|
|
12
|
+
import { isOtelEnabled, recordAuthEvent } from "../observability/otel.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Authentication error with structured information
|
|
16
|
+
* for generating proper HTTP error responses.
|
|
17
|
+
*/
|
|
18
|
+
export class AuthError extends Error {
|
|
19
|
+
constructor(
|
|
20
|
+
public readonly code:
|
|
21
|
+
| "missing_token"
|
|
22
|
+
| "invalid_token"
|
|
23
|
+
| "insufficient_scope",
|
|
24
|
+
public readonly resourceMetadataUrl: string,
|
|
25
|
+
public readonly requiredScopes?: string[],
|
|
26
|
+
) {
|
|
27
|
+
super(
|
|
28
|
+
code === "missing_token"
|
|
29
|
+
? "Authorization header with Bearer token required"
|
|
30
|
+
: code === "invalid_token"
|
|
31
|
+
? "Invalid or expired token"
|
|
32
|
+
: `Insufficient scope: requires ${requiredScopes?.join(", ")}`,
|
|
33
|
+
);
|
|
34
|
+
this.name = "AuthError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract Bearer token from Authorization header.
|
|
40
|
+
*
|
|
41
|
+
* @param request - HTTP Request
|
|
42
|
+
* @returns Token string or null if not present/invalid format
|
|
43
|
+
*/
|
|
44
|
+
export function extractBearerToken(request: Request): string | null {
|
|
45
|
+
const auth = request.headers.get("Authorization");
|
|
46
|
+
if (!auth?.startsWith("Bearer ")) return null;
|
|
47
|
+
const token = auth.slice(7).trim();
|
|
48
|
+
return token.length > 0 ? token : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a 401 Unauthorized response with WWW-Authenticate header.
|
|
53
|
+
*
|
|
54
|
+
* @param resourceMetadataUrl - URL to the Protected Resource Metadata endpoint
|
|
55
|
+
* @param error - OAuth error code
|
|
56
|
+
* @param errorDescription - Human-readable error description
|
|
57
|
+
*/
|
|
58
|
+
export function createUnauthorizedResponse(
|
|
59
|
+
resourceMetadataUrl: string,
|
|
60
|
+
error?: string,
|
|
61
|
+
errorDescription?: string,
|
|
62
|
+
): Response {
|
|
63
|
+
const escape = (s: string) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
64
|
+
const parts = [`Bearer resource_metadata="${escape(resourceMetadataUrl)}"`];
|
|
65
|
+
if (error) parts.push(`error="${escape(error)}"`);
|
|
66
|
+
if (errorDescription) {
|
|
67
|
+
parts.push(`error_description="${escape(errorDescription)}"`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return new Response(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
jsonrpc: "2.0",
|
|
73
|
+
id: null,
|
|
74
|
+
error: { code: -32001, message: errorDescription ?? "Unauthorized" },
|
|
75
|
+
}),
|
|
76
|
+
{
|
|
77
|
+
status: 401,
|
|
78
|
+
headers: {
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
"WWW-Authenticate": parts.join(", "),
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a 403 Forbidden response for insufficient scopes.
|
|
88
|
+
*
|
|
89
|
+
* @param requiredScopes - Scopes that were required but missing
|
|
90
|
+
*/
|
|
91
|
+
export function createForbiddenResponse(requiredScopes: string[]): Response {
|
|
92
|
+
return new Response(
|
|
93
|
+
JSON.stringify({
|
|
94
|
+
jsonrpc: "2.0",
|
|
95
|
+
id: null,
|
|
96
|
+
error: {
|
|
97
|
+
code: -32001,
|
|
98
|
+
message: `Forbidden: requires scopes ${requiredScopes.join(", ")}`,
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
{
|
|
102
|
+
status: 403,
|
|
103
|
+
headers: { "Content-Type": "application/json" },
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create an authentication middleware for the MCP pipeline.
|
|
110
|
+
*
|
|
111
|
+
* Extracts the Bearer token from the HTTP request, validates it
|
|
112
|
+
* via the AuthProvider, and injects `authInfo` into the context.
|
|
113
|
+
*
|
|
114
|
+
* For STDIO transport (no `ctx.request`), the middleware is skipped
|
|
115
|
+
* since STDIO is a local transport with no auth needed.
|
|
116
|
+
*
|
|
117
|
+
* @param provider - AuthProvider implementation
|
|
118
|
+
*/
|
|
119
|
+
export function createAuthMiddleware(provider: AuthProvider): Middleware {
|
|
120
|
+
return async (ctx, next) => {
|
|
121
|
+
// STDIO transport: no request, skip auth
|
|
122
|
+
if (!ctx.request) {
|
|
123
|
+
return next();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const metadata = provider.getResourceMetadata();
|
|
127
|
+
const resource = metadata.resource;
|
|
128
|
+
const base = resource.endsWith("/") ? resource.slice(0, -1) : resource;
|
|
129
|
+
const metadataUrl = `${base}/.well-known/oauth-protected-resource`;
|
|
130
|
+
|
|
131
|
+
const token = extractBearerToken(ctx.request);
|
|
132
|
+
if (!token) {
|
|
133
|
+
if (isOtelEnabled()) {
|
|
134
|
+
recordAuthEvent("reject", {
|
|
135
|
+
reason: "missing_token",
|
|
136
|
+
tool: ctx.toolName ?? "",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
throw new AuthError("missing_token", metadataUrl);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const authInfo = await provider.verifyToken(token);
|
|
143
|
+
if (!authInfo) {
|
|
144
|
+
if (isOtelEnabled()) {
|
|
145
|
+
recordAuthEvent("reject", {
|
|
146
|
+
reason: "invalid_token",
|
|
147
|
+
tool: ctx.toolName ?? "",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
throw new AuthError("invalid_token", metadataUrl);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (isOtelEnabled()) {
|
|
154
|
+
recordAuthEvent("verify", {
|
|
155
|
+
subject: authInfo.subject ?? "",
|
|
156
|
+
tool: ctx.toolName ?? "",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Deep-freeze authInfo to prevent mutation by downstream middlewares
|
|
161
|
+
if (authInfo.claims) Object.freeze(authInfo.claims);
|
|
162
|
+
if (authInfo.scopes) Object.freeze(authInfo.scopes);
|
|
163
|
+
ctx.authInfo = Object.freeze(authInfo);
|
|
164
|
+
|
|
165
|
+
// Propagate resourceMetadataUrl for downstream middlewares (scope-middleware)
|
|
166
|
+
ctx.resourceMetadataUrl = metadataUrl;
|
|
167
|
+
|
|
168
|
+
return next();
|
|
169
|
+
};
|
|
170
|
+
}
|
package/src/auth/mod.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth module for ConcurrentMCPServer.
|
|
3
|
+
*
|
|
4
|
+
* @module lib/server/auth
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Types
|
|
8
|
+
export type {
|
|
9
|
+
AuthInfo,
|
|
10
|
+
AuthOptions,
|
|
11
|
+
ProtectedResourceMetadata,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
// Provider base class
|
|
15
|
+
export { AuthProvider } from "./provider.js";
|
|
16
|
+
|
|
17
|
+
// Middleware and utilities
|
|
18
|
+
export {
|
|
19
|
+
AuthError,
|
|
20
|
+
createAuthMiddleware,
|
|
21
|
+
createForbiddenResponse,
|
|
22
|
+
createUnauthorizedResponse,
|
|
23
|
+
extractBearerToken,
|
|
24
|
+
} from "./middleware.js";
|
|
25
|
+
|
|
26
|
+
// Scope enforcement
|
|
27
|
+
export { createScopeMiddleware } from "./scope-middleware.js";
|
|
28
|
+
|
|
29
|
+
// JWT Provider
|
|
30
|
+
export { JwtAuthProvider } from "./jwt-provider.js";
|
|
31
|
+
export type { JwtAuthProviderOptions } from "./jwt-provider.js";
|
|
32
|
+
|
|
33
|
+
// OIDC Presets
|
|
34
|
+
export {
|
|
35
|
+
createAuth0AuthProvider,
|
|
36
|
+
createGitHubAuthProvider,
|
|
37
|
+
createGoogleAuthProvider,
|
|
38
|
+
createOIDCAuthProvider,
|
|
39
|
+
} from "./presets.js";
|
|
40
|
+
export type { PresetOptions } from "./presets.js";
|
|
41
|
+
|
|
42
|
+
// Config loader (YAML + env)
|
|
43
|
+
export { createAuthProviderFromConfig, loadAuthConfig } from "./config.js";
|
|
44
|
+
export type { AuthConfig, AuthProviderName } from "./config.js";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC auth provider presets.
|
|
3
|
+
*
|
|
4
|
+
* Factory functions for common OIDC providers.
|
|
5
|
+
* Each preset pre-configures the issuer, JWKS URI, and
|
|
6
|
+
* authorization server for the provider.
|
|
7
|
+
*
|
|
8
|
+
* @module lib/server/auth/presets
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
JwtAuthProvider,
|
|
13
|
+
type JwtAuthProviderOptions,
|
|
14
|
+
} from "./jwt-provider.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Base options shared by all presets.
|
|
18
|
+
*/
|
|
19
|
+
export interface PresetOptions {
|
|
20
|
+
/** JWT audience (aud claim) */
|
|
21
|
+
audience: string;
|
|
22
|
+
/** Resource identifier for RFC 9728 */
|
|
23
|
+
resource: string;
|
|
24
|
+
/** Scopes supported by this server */
|
|
25
|
+
scopesSupported?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* GitHub Actions OIDC provider.
|
|
30
|
+
*
|
|
31
|
+
* Validates tokens issued by GitHub Actions workflows.
|
|
32
|
+
* Issuer: https://token.actions.githubusercontent.com
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const provider = createGitHubAuthProvider({
|
|
37
|
+
* audience: "https://my-mcp.example.com",
|
|
38
|
+
* resource: "https://my-mcp.example.com",
|
|
39
|
+
* });
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export function createGitHubAuthProvider(
|
|
43
|
+
options: PresetOptions,
|
|
44
|
+
): JwtAuthProvider {
|
|
45
|
+
return new JwtAuthProvider({
|
|
46
|
+
issuer: "https://token.actions.githubusercontent.com",
|
|
47
|
+
audience: options.audience,
|
|
48
|
+
resource: options.resource,
|
|
49
|
+
authorizationServers: ["https://token.actions.githubusercontent.com"],
|
|
50
|
+
scopesSupported: options.scopesSupported,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Google OIDC provider.
|
|
56
|
+
*
|
|
57
|
+
* Validates tokens issued by Google accounts.
|
|
58
|
+
* Issuer: https://accounts.google.com
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* const provider = createGoogleAuthProvider({
|
|
63
|
+
* audience: "https://my-mcp.example.com",
|
|
64
|
+
* resource: "https://my-mcp.example.com",
|
|
65
|
+
* });
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function createGoogleAuthProvider(
|
|
69
|
+
options: PresetOptions,
|
|
70
|
+
): JwtAuthProvider {
|
|
71
|
+
return new JwtAuthProvider({
|
|
72
|
+
issuer: "https://accounts.google.com",
|
|
73
|
+
audience: options.audience,
|
|
74
|
+
resource: options.resource,
|
|
75
|
+
authorizationServers: ["https://accounts.google.com"],
|
|
76
|
+
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
|
|
77
|
+
scopesSupported: options.scopesSupported,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Auth0 OIDC provider.
|
|
83
|
+
*
|
|
84
|
+
* Validates tokens issued by an Auth0 tenant.
|
|
85
|
+
* Issuer: https://{domain}/
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```typescript
|
|
89
|
+
* const provider = createAuth0AuthProvider({
|
|
90
|
+
* domain: "my-tenant.auth0.com",
|
|
91
|
+
* audience: "https://my-mcp.example.com",
|
|
92
|
+
* resource: "https://my-mcp.example.com",
|
|
93
|
+
* });
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export function createAuth0AuthProvider(
|
|
97
|
+
options: PresetOptions & { domain: string },
|
|
98
|
+
): JwtAuthProvider {
|
|
99
|
+
const issuer = `https://${options.domain}/`;
|
|
100
|
+
return new JwtAuthProvider({
|
|
101
|
+
issuer,
|
|
102
|
+
audience: options.audience,
|
|
103
|
+
resource: options.resource,
|
|
104
|
+
authorizationServers: [issuer],
|
|
105
|
+
jwksUri: `${issuer}.well-known/jwks.json`,
|
|
106
|
+
scopesSupported: options.scopesSupported,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generic OIDC provider.
|
|
112
|
+
*
|
|
113
|
+
* For any OIDC-compliant provider not covered by presets.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```typescript
|
|
117
|
+
* const provider = createOIDCAuthProvider({
|
|
118
|
+
* issuer: "https://my-idp.example.com",
|
|
119
|
+
* audience: "https://my-mcp.example.com",
|
|
120
|
+
* resource: "https://my-mcp.example.com",
|
|
121
|
+
* authorizationServers: ["https://my-idp.example.com"],
|
|
122
|
+
* });
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export function createOIDCAuthProvider(
|
|
126
|
+
options: JwtAuthProviderOptions,
|
|
127
|
+
): JwtAuthProvider {
|
|
128
|
+
return new JwtAuthProvider(options);
|
|
129
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthProvider abstract class.
|
|
3
|
+
*
|
|
4
|
+
* Abstract class (not interface) for DI compatibility with diod tokens.
|
|
5
|
+
* Implement this to provide custom token validation (API keys, opaque tokens, etc.)
|
|
6
|
+
*
|
|
7
|
+
* @module lib/server/auth/provider
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AuthInfo, ProtectedResourceMetadata } from "./types.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Base class for authentication providers.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* class ApiKeyProvider extends AuthProvider {
|
|
18
|
+
* async verifyToken(token: string): Promise<AuthInfo | null> {
|
|
19
|
+
* const user = await db.findByApiKey(token);
|
|
20
|
+
* if (!user) return null;
|
|
21
|
+
* return { subject: user.id, scopes: user.scopes };
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* getResourceMetadata(): ProtectedResourceMetadata {
|
|
25
|
+
* return {
|
|
26
|
+
* resource: "https://my-mcp.example.com",
|
|
27
|
+
* authorization_servers: ["https://auth.example.com"],
|
|
28
|
+
* bearer_methods_supported: ["header"],
|
|
29
|
+
* };
|
|
30
|
+
* }
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export abstract class AuthProvider {
|
|
35
|
+
/**
|
|
36
|
+
* Validate a token and extract auth information.
|
|
37
|
+
*
|
|
38
|
+
* @param token - The raw Bearer token string
|
|
39
|
+
* @returns AuthInfo if valid, null if invalid/expired
|
|
40
|
+
*/
|
|
41
|
+
abstract verifyToken(token: string): Promise<AuthInfo | null>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Return RFC 9728 Protected Resource Metadata for this provider.
|
|
45
|
+
*/
|
|
46
|
+
abstract getResourceMetadata(): ProtectedResourceMetadata;
|
|
47
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope enforcement middleware.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the authenticated user has the required scopes
|
|
5
|
+
* for the tool being called. Placed after the auth middleware.
|
|
6
|
+
*
|
|
7
|
+
* @module lib/server/auth/scope-middleware
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AuthInfo } from "./types.js";
|
|
11
|
+
import { AuthError } from "./middleware.js";
|
|
12
|
+
import type { Middleware } from "../middleware/types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a scope enforcement middleware.
|
|
16
|
+
*
|
|
17
|
+
* Checks `requiredScopes` for the called tool against `ctx.authInfo.scopes`.
|
|
18
|
+
* If auth is not configured (no authInfo), the middleware passes through.
|
|
19
|
+
* If the tool has no requiredScopes, the middleware passes through.
|
|
20
|
+
*
|
|
21
|
+
* @param toolScopes - Map of tool name to required scopes
|
|
22
|
+
*/
|
|
23
|
+
export function createScopeMiddleware(
|
|
24
|
+
toolScopes: Map<string, string[]>,
|
|
25
|
+
): Middleware {
|
|
26
|
+
// deno-lint-ignore require-await
|
|
27
|
+
return async (ctx, next) => {
|
|
28
|
+
const requiredScopes = toolScopes.get(ctx.toolName);
|
|
29
|
+
|
|
30
|
+
// No scopes required for this tool
|
|
31
|
+
if (!requiredScopes?.length) return next();
|
|
32
|
+
|
|
33
|
+
// No auth configured: STDIO (no request) is fine, HTTP without authInfo is a misconfiguration
|
|
34
|
+
const authInfo = ctx.authInfo as AuthInfo | undefined;
|
|
35
|
+
if (!authInfo) {
|
|
36
|
+
if (!ctx.request) return next(); // STDIO: local transport, no auth needed
|
|
37
|
+
// HTTP request with required scopes but no authInfo = auth middleware is missing
|
|
38
|
+
throw new Error(
|
|
39
|
+
`[ScopeMiddleware] Tool "${ctx.toolName}" requires scopes [${
|
|
40
|
+
requiredScopes.join(", ")
|
|
41
|
+
}] ` +
|
|
42
|
+
"but no authInfo found on HTTP request. Ensure auth middleware is configured in the pipeline.",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hasAll = requiredScopes.every((s) => authInfo.scopes.includes(s));
|
|
47
|
+
if (!hasAll) {
|
|
48
|
+
const missingScopes = requiredScopes.filter((s) =>
|
|
49
|
+
!authInfo.scopes.includes(s)
|
|
50
|
+
);
|
|
51
|
+
// resourceMetadataUrl is not critical for 403 responses (only used in 401),
|
|
52
|
+
// but we populate it from context if available for consistency
|
|
53
|
+
const metadataUrl = (ctx.resourceMetadataUrl as string | undefined) ?? "";
|
|
54
|
+
throw new AuthError("insufficient_scope", metadataUrl, missingScopes);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return next();
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication types for ConcurrentMCPServer.
|
|
3
|
+
*
|
|
4
|
+
* Types follow RFC 9728 (OAuth Protected Resource Metadata)
|
|
5
|
+
* and MCP Auth spec (draft 2025-11-25).
|
|
6
|
+
*
|
|
7
|
+
* @module lib/server/auth/types
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Information extracted from a validated token.
|
|
12
|
+
* Frozen (Object.freeze) before being passed to tool handlers.
|
|
13
|
+
*/
|
|
14
|
+
export interface AuthInfo {
|
|
15
|
+
/** User ID (sub claim from JWT) */
|
|
16
|
+
subject: string;
|
|
17
|
+
|
|
18
|
+
/** OAuth client ID (optional - azp or client_id claim) */
|
|
19
|
+
clientId?: string;
|
|
20
|
+
|
|
21
|
+
/** Granted scopes */
|
|
22
|
+
scopes: string[];
|
|
23
|
+
|
|
24
|
+
/** Additional JWT claims */
|
|
25
|
+
claims?: Record<string, unknown>;
|
|
26
|
+
|
|
27
|
+
/** Token expiration timestamp (Unix epoch seconds) */
|
|
28
|
+
expiresAt?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Auth configuration for the server.
|
|
33
|
+
*/
|
|
34
|
+
export interface AuthOptions {
|
|
35
|
+
/** Authorization servers that issue valid tokens */
|
|
36
|
+
authorizationServers: string[];
|
|
37
|
+
|
|
38
|
+
/** Resource identifier for this MCP server (used in WWW-Authenticate header) */
|
|
39
|
+
resource: string;
|
|
40
|
+
|
|
41
|
+
/** Scopes supported by this server */
|
|
42
|
+
scopesSupported?: string[];
|
|
43
|
+
|
|
44
|
+
/** Custom auth provider (overrides default JWT validation) */
|
|
45
|
+
provider: AuthProvider;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Forward reference - actual class is in provider.ts
|
|
49
|
+
import type { AuthProvider } from "./provider.js";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* RFC 9728 Protected Resource Metadata.
|
|
53
|
+
* Returned by /.well-known/oauth-protected-resource
|
|
54
|
+
*
|
|
55
|
+
* @see https://datatracker.ietf.org/doc/html/rfc9728
|
|
56
|
+
*/
|
|
57
|
+
export interface ProtectedResourceMetadata {
|
|
58
|
+
/** Resource identifier (URL of this MCP server) */
|
|
59
|
+
resource: string;
|
|
60
|
+
|
|
61
|
+
/** Authorization servers that can issue valid tokens */
|
|
62
|
+
authorization_servers: string[];
|
|
63
|
+
|
|
64
|
+
/** Scopes this resource supports */
|
|
65
|
+
scopes_supported?: string[];
|
|
66
|
+
|
|
67
|
+
/** How bearer tokens can be presented (always ["header"]) */
|
|
68
|
+
bearer_methods_supported: string[];
|
|
69
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiter
|
|
3
|
+
*
|
|
4
|
+
* Sliding window rate limiter for per-client request throttling.
|
|
5
|
+
* Prevents server overload by limiting requests per time window.
|
|
6
|
+
*
|
|
7
|
+
* @module lib/server/rate-limiter
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sliding window rate limiter
|
|
12
|
+
*
|
|
13
|
+
* Features:
|
|
14
|
+
* - Per-key rate limiting (client ID, IP, etc.)
|
|
15
|
+
* - Sliding window for smooth rate enforcement
|
|
16
|
+
* - Automatic cleanup of old timestamps
|
|
17
|
+
* - Backoff waiting when limit exceeded
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* const limiter = new RateLimiter({ maxRequests: 100, windowMs: 60000 });
|
|
22
|
+
*
|
|
23
|
+
* if (await limiter.checkLimit("client-123")) {
|
|
24
|
+
* // Execute request
|
|
25
|
+
* } else {
|
|
26
|
+
* // Rate limited
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export class RateLimiter {
|
|
31
|
+
private requestCounts = new Map<string, number[]>();
|
|
32
|
+
private readonly windowMs: number;
|
|
33
|
+
private readonly maxRequests: number;
|
|
34
|
+
private operationsSinceLastPurge = 0;
|
|
35
|
+
private static readonly PURGE_EVERY_N_OPS = 1000;
|
|
36
|
+
|
|
37
|
+
constructor(options: { maxRequests: number; windowMs: number }) {
|
|
38
|
+
this.maxRequests = options.maxRequests;
|
|
39
|
+
this.windowMs = options.windowMs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if request is allowed for the given key
|
|
44
|
+
*
|
|
45
|
+
* @param key - Identifier (client ID, IP, etc.)
|
|
46
|
+
* @returns true if allowed, false if rate limited
|
|
47
|
+
*/
|
|
48
|
+
checkLimit(key: string): boolean {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
const requests = this.requestCounts.get(key) || [];
|
|
51
|
+
|
|
52
|
+
// Remove old requests outside the sliding window
|
|
53
|
+
const validRequests = requests.filter((time) => now - time < this.windowMs);
|
|
54
|
+
|
|
55
|
+
if (validRequests.length === 0) {
|
|
56
|
+
// No active requests for this key — remove from map to prevent unbounded growth
|
|
57
|
+
this.requestCounts.delete(key);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (validRequests.length >= this.maxRequests) {
|
|
61
|
+
// Update with cleaned list
|
|
62
|
+
this.requestCounts.set(key, validRequests);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Add current request timestamp
|
|
67
|
+
validRequests.push(now);
|
|
68
|
+
this.requestCounts.set(key, validRequests);
|
|
69
|
+
|
|
70
|
+
// Periodic full purge to catch keys that haven't been checked recently
|
|
71
|
+
this.operationsSinceLastPurge++;
|
|
72
|
+
if (this.operationsSinceLastPurge >= RateLimiter.PURGE_EVERY_N_OPS) {
|
|
73
|
+
this.purgeExpiredKeys();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Remove all keys with no active requests in the current window.
|
|
81
|
+
* Called automatically every PURGE_EVERY_N_OPS operations.
|
|
82
|
+
*/
|
|
83
|
+
purgeExpiredKeys(): number {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
let purged = 0;
|
|
86
|
+
for (const [key, requests] of this.requestCounts) {
|
|
87
|
+
const active = requests.filter((time) => now - time < this.windowMs);
|
|
88
|
+
if (active.length === 0) {
|
|
89
|
+
this.requestCounts.delete(key);
|
|
90
|
+
purged++;
|
|
91
|
+
} else {
|
|
92
|
+
this.requestCounts.set(key, active);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
this.operationsSinceLastPurge = 0;
|
|
96
|
+
return purged;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Wait until request slot is available (with exponential backoff)
|
|
101
|
+
*
|
|
102
|
+
* @param key - Identifier (client ID, IP, etc.)
|
|
103
|
+
* @throws Error if max wait time exceeded (defaults to windowMs)
|
|
104
|
+
*/
|
|
105
|
+
async waitForSlot(key: string): Promise<void> {
|
|
106
|
+
let retries = 0;
|
|
107
|
+
const baseDelay = 100;
|
|
108
|
+
const maxWaitMs = this.windowMs;
|
|
109
|
+
const startTime = Date.now();
|
|
110
|
+
|
|
111
|
+
while (!this.checkLimit(key)) {
|
|
112
|
+
if (Date.now() - startTime >= maxWaitMs) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Rate limit wait timeout: waited ${maxWaitMs}ms for key "${key}"`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
// Exponential backoff: 100ms, 200ms, 400ms, 800ms, cap at 1000ms
|
|
118
|
+
const delay = Math.min(baseDelay * Math.pow(2, retries), 1000);
|
|
119
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
120
|
+
retries++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get current request count for a key
|
|
126
|
+
*/
|
|
127
|
+
getCurrentCount(key: string): number {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const requests = this.requestCounts.get(key) || [];
|
|
130
|
+
return requests.filter((time) => now - time < this.windowMs).length;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get remaining requests for a key
|
|
135
|
+
*/
|
|
136
|
+
getRemainingRequests(key: string): number {
|
|
137
|
+
return Math.max(0, this.maxRequests - this.getCurrentCount(key));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get time until next slot is available (in ms)
|
|
142
|
+
* Returns 0 if a slot is available now
|
|
143
|
+
*/
|
|
144
|
+
getTimeUntilSlot(key: string): number {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
const requests = this.requestCounts.get(key) || [];
|
|
147
|
+
const validRequests = requests.filter((time) => now - time < this.windowMs);
|
|
148
|
+
|
|
149
|
+
if (validRequests.length < this.maxRequests) {
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Find oldest request in window - when it expires, a slot opens
|
|
154
|
+
const oldest = Math.min(...validRequests);
|
|
155
|
+
return Math.max(0, oldest + this.windowMs - now);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Clear rate limit history for a key
|
|
160
|
+
*/
|
|
161
|
+
clear(key: string): void {
|
|
162
|
+
this.requestCounts.delete(key);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Clear all rate limit history
|
|
167
|
+
*/
|
|
168
|
+
clearAll(): void {
|
|
169
|
+
this.requestCounts.clear();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get metrics for monitoring
|
|
174
|
+
*/
|
|
175
|
+
getMetrics(): { keys: number; totalRequests: number } {
|
|
176
|
+
let totalRequests = 0;
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
|
|
179
|
+
for (const requests of this.requestCounts.values()) {
|
|
180
|
+
totalRequests += requests.filter((time) =>
|
|
181
|
+
now - time < this.windowMs
|
|
182
|
+
).length;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
keys: this.requestCounts.size,
|
|
187
|
+
totalRequests,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|