@casys/mcp-server 0.2.1

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.
@@ -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
+ }
@@ -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
+ }
@@ -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";