-
-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add OAuth 2.1 authentication via Scalekit for HTTP transport #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| # ─── Meta Ads ──────────────────────────────────────────────────────────────── | ||
| # Required: Meta (Facebook) User Access Token with ads_read permission | ||
| META_ADS_ACCESS_TOKEN=your_meta_access_token_here | ||
|
|
||
| # ─── Transport ──────────────────────────────────────────────────────────────── | ||
| # "stdio" (default) or "http" | ||
| TRANSPORT=http | ||
| PORT=3000 | ||
|
|
||
| # ─── Scalekit OAuth 2.1 ────────────────────────────────────────────────────── | ||
| # Set OAUTH_ENABLED=true to require valid Scalekit JWT on all /mcp requests. | ||
| # Leave unset or set to false for local dev / stdio mode. | ||
| OAUTH_ENABLED=true | ||
|
|
||
| # From Scalekit dashboard → API Credentials | ||
| SCALEKIT_ENVIRONMENT_URL=https://<your-env>.scalekit.cloud | ||
| SCALEKIT_CLIENT_ID=skc_... | ||
| SCALEKIT_CLIENT_SECRET=sks_... | ||
|
|
||
| # Your MCP server's public URL (used as OAuth resource identifier + audience claim). | ||
| # Must match the "Server URL" configured in Scalekit dashboard → MCP Servers. | ||
| MCP_SERVER_URL=https://mcp.yourapp.com | ||
|
|
||
| # Autogenerated resource ID from Scalekit dashboard (only needed if MCP_SERVER_URL is NOT set). | ||
| # SCALEKIT_RESOURCE_ID=res_... | ||
|
|
||
| # Comma-separated list of OAuth scopes your server advertises. | ||
| # These must match the scopes defined in Scalekit dashboard → MCP Servers → Scopes. | ||
| MCP_SCOPES=meta_ads:read |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| import { Scalekit } from "@scalekit-sdk/node"; | ||
| import type { NextFunction, Request, Response } from "express"; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Scalekit client (lazy-initialized so stdio mode never touches these vars) | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| let _scalekit: Scalekit | null = null; | ||
|
|
||
| function getScalekitClient(): Scalekit { | ||
| if (_scalekit) return _scalekit; | ||
|
|
||
| const envUrl = process.env.SCALEKIT_ENVIRONMENT_URL; | ||
| const clientId = process.env.SCALEKIT_CLIENT_ID; | ||
| const clientSecret = process.env.SCALEKIT_CLIENT_SECRET; | ||
|
|
||
| if (!envUrl || !clientId || !clientSecret) { | ||
| throw new Error( | ||
| "OAuth is enabled but Scalekit credentials are missing. " + | ||
| "Set SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, and SCALEKIT_CLIENT_SECRET." | ||
| ); | ||
| } | ||
|
|
||
| _scalekit = new Scalekit(envUrl, clientId, clientSecret); | ||
| return _scalekit; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // OAuth resource metadata | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** | ||
| * The resource identifier for this MCP server. | ||
| * Falls back to the autogenerated resource_id from the Scalekit dashboard if | ||
| * MCP_SERVER_URL is not set. | ||
| */ | ||
| export function getResourceId(): string { | ||
| return process.env.MCP_SERVER_URL ?? process.env.SCALEKIT_RESOURCE_ID ?? ""; | ||
| } | ||
|
|
||
| export function getMetadataEndpoint(): string { | ||
| const base = process.env.MCP_SERVER_URL ?? ""; | ||
| return `${base}/.well-known/oauth-protected-resource`; | ||
| } | ||
|
|
||
| /** Scopes advertised by this MCP server (comma-separated env var). */ | ||
| export function getScopesSupported(): string[] { | ||
| return (process.env.MCP_SCOPES ?? "meta_ads:read").split(",").map((s) => s.trim()); | ||
| } | ||
|
|
||
| /** Build the OAuth Protected Resource Metadata document (RFC 9728). */ | ||
| export function buildResourceMetadata() { | ||
| const envUrl = process.env.SCALEKIT_ENVIRONMENT_URL ?? ""; | ||
| const resourceId = getResourceId(); | ||
| const scaleKitResourceId = process.env.SCALEKIT_RESOURCE_ID ?? resourceId; | ||
|
|
||
| return { | ||
| resource: resourceId, | ||
| authorization_servers: [`${envUrl}/resources/${scaleKitResourceId}`], | ||
| bearer_methods_supported: ["header"], | ||
| resource_documentation: `${resourceId}/docs`, | ||
| scopes_supported: getScopesSupported(), | ||
| }; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // WWW-Authenticate header helpers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| export const WWWHeader = { | ||
| key: "WWW-Authenticate", | ||
| value: () => | ||
| `Bearer realm="OAuth", resource_metadata="${getMetadataEndpoint()}"`, | ||
| }; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Express middleware | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** | ||
| * Auth middleware for the HTTP MCP server. | ||
| * | ||
| * - Bypasses `.well-known` paths (public discovery endpoints). | ||
| * - Extracts and validates the Bearer JWT issued by Scalekit. | ||
| * - Returns 401 with a proper WWW-Authenticate header on failure. | ||
| * | ||
| * Enabled only when OAUTH_ENABLED=true; otherwise passes through (useful for | ||
| * local development or stdio mode). | ||
| */ | ||
| export async function authMiddleware( | ||
| req: Request, | ||
| res: Response, | ||
| next: NextFunction | ||
| ): Promise<void> { | ||
| // Allow public discovery + health check without auth | ||
| if (req.path.includes(".well-known") || req.path === "/health") { | ||
| return next(); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Overly broad
|
||
|
|
||
| try { | ||
| const authHeader = req.headers["authorization"]; | ||
| const token = | ||
| authHeader?.startsWith("Bearer ") ? authHeader.slice(7).trim() : null; | ||
|
|
||
| if (!token) { | ||
| res.status(401).set(WWWHeader.key, WWWHeader.value()).end(); | ||
| return; | ||
| } | ||
|
|
||
| const resourceId = getResourceId(); | ||
| const scalekit = getScalekitClient(); | ||
|
|
||
| await scalekit.validateToken(token, { | ||
| audience: resourceId ? [resourceId] : undefined, | ||
| }); | ||
|
|
||
| next(); | ||
| } catch { | ||
| res.status(401).set(WWWHeader.key, WWWHeader.value()).end(); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Configuration errors silently swallowed as 401 responsesMedium Severity The |
||
| } | ||
|
|
||
| /** | ||
| * Scope-checking helper for individual tools that need finer-grained control. | ||
| * | ||
| * Usage: | ||
| * await requireScope(req, res, "meta_ads:write"); | ||
| * // returns false and sends 403 if scope is missing | ||
| */ | ||
| export async function requireScope( | ||
| req: Request, | ||
| res: Response, | ||
| scope: string | ||
| ): Promise<boolean> { | ||
| const authHeader = req.headers["authorization"]; | ||
| const token = | ||
| authHeader?.startsWith("Bearer ") ? authHeader.slice(7).trim() : null; | ||
|
|
||
| if (!token) { | ||
| res.status(401).set(WWWHeader.key, WWWHeader.value()).end(); | ||
| return false; | ||
| } | ||
|
|
||
| try { | ||
| const resourceId = getResourceId(); | ||
| const scalekit = getScalekitClient(); | ||
| await scalekit.validateToken(token, { | ||
| audience: resourceId ? [resourceId] : undefined, | ||
| requiredScopes: [scope], | ||
| }); | ||
| return true; | ||
| } catch { | ||
| res.status(403).json({ | ||
| error: "insufficient_scope", | ||
| error_description: `Required scope: ${scope}`, | ||
| scope, | ||
| }); | ||
| return false; | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wrong
authorization_serversURL format in OAuth metadataHigh Severity
The
authorization_serversURL is constructed as${envUrl}/resources/${scaleKitResourceId}, but per Scalekit's own documentation, it simply needs to be the environment URL (e.g.,["https://your-org.scalekit.com"]). Additionally, whenSCALEKIT_RESOURCE_IDis not set,scaleKitResourceIdfalls back toresourceId(the fullMCP_SERVER_URL), producing a malformed URL likehttps://env.scalekit.cloud/resources/https://mcp.yourapp.com. MCP clients consuming this discovery metadata will fail to locate the correct authorization server.Additional Locations (1)
src/auth/scalekit.ts#L54-L55