@civic/auth-mcp
Version:
Civic Auth integration for MCP servers
518 lines (507 loc) • 16.9 kB
JavaScript
// src/index.ts
import { Router } from "express";
// src/McpServerAuth.ts
import { createRemoteJWKSet, jwtVerify } from "jose";
// src/constants.ts
var DEFAULT_WELLKNOWN_URL = "https://auth.civic.com/oauth/.well-known/openid-configuration";
var DEFAULT_SCOPES = ["openid", "profile", "email", "offline_access"];
var DEFAULT_CALLBACK_PORT = 8080;
var DEFAULT_MCP_ROUTE = "/mcp";
var PUBLIC_CIVIC_CLIENT_ID = "12220cf4-1a9a-4964-8eb7-7c6d7d049f34";
// src/types.ts
var AuthenticationError = class extends Error {
};
var JWTVerificationError = class extends AuthenticationError {
constructor(message, originalError) {
super(message);
this.originalError = originalError;
this.name = "JWTVerificationError";
}
};
// src/McpServerAuth.ts
var getExpectedClientId = (options) => {
if (options.clientId) {
return options.clientId;
}
if (!options.wellKnownUrl || options.wellKnownUrl === DEFAULT_WELLKNOWN_URL) {
return PUBLIC_CIVIC_CLIENT_ID;
}
return void 0;
};
var getAuthServer = (options) => {
if (options.wellKnownUrl && options.wellKnownUrl !== DEFAULT_WELLKNOWN_URL) return options.wellKnownUrl;
if (options.allowDynamicClientRegistration) {
const clientId = getExpectedClientId(options) ?? PUBLIC_CIVIC_CLIENT_ID;
return DEFAULT_WELLKNOWN_URL.replace("https://", `https://${clientId}.`);
}
return DEFAULT_WELLKNOWN_URL;
};
var verifyClientId = (payload, expectedClientId) => {
if (!expectedClientId) return;
const clientIdMatches = payload.client_id === expectedClientId;
const tidMatches = payload.tid === expectedClientId;
if (!clientIdMatches && !tidMatches) {
throw new AuthenticationError(`Invalid client_id or tid in token. Expected: ${expectedClientId}`);
}
};
var McpServerAuth = class _McpServerAuth {
constructor(oidcConfig, options) {
this.oidcConfig = oidcConfig;
this.options = options;
this.jwks = createRemoteJWKSet(new URL(oidcConfig.jwks_uri));
}
/**
* Initialize the auth core by fetching OIDC configuration
*/
static async init(options = {}) {
const wellKnownUrl = getAuthServer(options);
console.log(`Fetching Civic Auth OIDC configuration from ${wellKnownUrl}`);
const response = await fetch(wellKnownUrl);
if (!response.ok) {
throw new Error(`Failed to fetch Civic Auth configuration: ${response.statusText}`);
}
const oidcConfig = await response.json();
return new _McpServerAuth(oidcConfig, options);
}
/**
* Get the OAuth Protected Resource metadata
* @param issuerUrl The issuer URL of the resource server (e.g., https://my-server.com)
*/
getProtectedResourceMetadata(issuerUrl) {
return {
resource: issuerUrl,
authorization_servers: [this.oidcConfig.issuer],
scopes_supported: this.options.scopesSupported || DEFAULT_SCOPES,
bearer_methods_supported: ["header"]
};
}
/**
* Create auth info from a token (or null) and request
* @param token The JWT token (can be null)
* @param payload The JWT payload if token was already verified
* @param request Optional request object to pass to onLogin callback
* @returns ExtendedAuthInfo if successful, null otherwise
*/
async createAuthInfo(token, payload, request) {
const inputAuthInfo = token && payload ? {
token,
clientId: payload.client_id || payload.aud,
scopes: payload.scope ? payload.scope.split(" ") : [],
expiresAt: payload.exp,
extra: {
sub: payload.sub
}
} : null;
if (!this.options.onLogin) return inputAuthInfo;
return this.options.onLogin(inputAuthInfo, request);
}
/**
* Extract and verify bearer token from authorization header
* @param authHeader The Authorization header value
* @returns Object with token and payload if valid, throws if invalid token, returns null values if no token
*/
async extractBearerToken(authHeader) {
if (!authHeader?.startsWith("Bearer ")) {
return { token: null, payload: null };
}
const token = authHeader.substring(7);
try {
const { payload } = await jwtVerify(token, this.jwks, {
issuer: this.oidcConfig.issuer
});
verifyClientId(payload, getExpectedClientId(this.options));
return { token, payload };
} catch (error) {
throw new JWTVerificationError(
error instanceof Error ? error.message : "JWT verification failed",
error instanceof Error ? error : void 0
);
}
}
/**
* Handle a request by extracting and verifying the bearer token
* @param request The request object
* @returns ExtendedAuthInfo if valid
* @throws Error if authentication fails
*/
async handleRequest(request) {
const { token, payload } = await this.extractBearerToken(request.headers.authorization);
const authInfo = await this.createAuthInfo(token, payload, request);
if (!authInfo) throw new AuthenticationError("Authentication failed");
return authInfo;
}
};
// src/client/providers/persistence/InMemoryTokenPersistence.ts
var InMemoryTokenPersistence = class {
saveTokens(tokens) {
this.tokens = tokens;
}
loadTokens() {
return this.tokens;
}
clearTokens() {
this.tokens = void 0;
}
};
// src/client/providers/CivicAuthProvider.ts
var CivicAuthProvider = class {
constructor(options) {
this.clientSecret = options.clientSecret;
this.tokenPersistence = options.tokenPersistence ?? new InMemoryTokenPersistence();
}
saveTokens(tokens) {
return this.tokenPersistence.saveTokens(tokens);
}
/**
* Returns the stored tokens
*/
tokens() {
return this.tokenPersistence.loadTokens();
}
/**
* Clears the stored tokens
*/
clearTokens() {
return this.tokenPersistence.clearTokens();
}
};
// src/client/providers/CLIAuthProvider.ts
import { execFile } from "child_process";
import crypto from "crypto";
import http from "http";
import url from "url";
import { promisify } from "util";
import escapeHtml from "escape-html";
var CLIAuthProvider = class extends CivicAuthProvider {
constructor(options) {
super(options);
this.clientId = options.clientId;
this.scope = options.scope ?? DEFAULT_SCOPES.join(" ");
this.callbackPort = options.callbackPort ?? DEFAULT_CALLBACK_PORT;
this.enablePortFallback = options.enablePortFallback ?? true;
this.authTimeoutMs = options.authTimeoutMs ?? 5 * 60 * 1e3;
this.successHtml = options.successHtml ?? '<html lang="en"><body><h1>Authorization Successful</h1><p>You can now close this window.</p></body></html>';
this.errorHtml = options.errorHtml ?? '<html lang="en"><body><h1>Authorization Failed</h1><p>{{error}}</p></body></html>';
}
clientInformation() {
const info = {
client_id: this.clientId
};
if (this.clientSecret) {
info.client_secret = this.clientSecret;
}
return info;
}
get clientMetadata() {
return {
redirect_uris: [this.getCallbackUrl(this.callbackPort)],
client_name: this.clientId,
scope: this.scope
};
}
codeVerifier() {
if (!this.storedCodeVerifier) {
this.storedCodeVerifier = crypto.randomBytes(32).toString("base64url");
}
return this.storedCodeVerifier;
}
async redirectToAuthorization(authorizationUrl) {
if (this.callbackServer) {
throw new Error("Authorization flow already in progress. Please wait for it to complete.");
}
console.log(`Opening authorization URL in browser: ${authorizationUrl.href}`);
const actualPort = await this.startCallbackServer();
let urlToOpen = authorizationUrl.href;
if (actualPort) {
this.callbackPort = actualPort;
const authUrlObj = new URL(authorizationUrl);
authUrlObj.searchParams.set("redirect_uri", this.getCallbackUrl(actualPort));
urlToOpen = authUrlObj.href;
}
await this.openInBrowser(urlToOpen);
console.log("Please complete the authorization in your browser.");
}
/**
* Registers the transport with the auth provider so that we can call finishAuth when the code is received.
* @param transport
*/
registerTransport(transport) {
this.transport = transport;
}
get redirectUrl() {
return new URL(this.getCallbackUrl(this.callbackPort));
}
saveCodeVerifier(codeVerifier) {
this.storedCodeVerifier = codeVerifier;
}
getCallbackUrl(port) {
return `http://localhost:${port}/callback`;
}
/**
* Listen on Port Promise
* @param server
* @param port
* @private port that is being listened on.
*/
listenOnPort(server, port) {
return new Promise((resolve, reject) => {
const onError = (err) => {
server.off("listening", onListening);
reject(err);
};
const onListening = () => {
server.off("error", onError);
const address = server.address();
resolve(address.port);
};
server.once("error", onError);
server.once("listening", onListening);
server.listen(port, "localhost");
});
}
/**
* Starts a local HTTP server to handle the OAuth callback with port fallback support
* @returns The actual port number if different from the configured port, undefined otherwise
*/
async startCallbackServer() {
this.authorizationCodePromise = new Promise((resolveCode, rejectCode) => {
this.authorizationCodeResolve = resolveCode;
this.authorizationCodeReject = rejectCode;
});
this.callbackServer = http.createServer((req, res) => {
try {
if (!req.url) {
res.writeHead(400);
res.end("Bad Request");
return;
}
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === "/callback") {
const code = parsedUrl.query.code;
const error = parsedUrl.query.error;
if (error) {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(this.errorHtml.replace("{{error}}", escapeHtml(error)));
this.authorizationCodeReject?.(new Error(`OAuth error: ${error}`));
} else if (code) {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(this.successHtml);
if (this.transport) {
this.transport.finishAuth(code).then(() => this.authorizationCodeResolve?.(code)).catch((error2) => {
console.error("Error in finishAuth:", error2);
this.authorizationCodeReject?.(error2);
});
} else {
this.authorizationCodeReject?.(new Error("No transport registered"));
}
} else {
res.writeHead(400);
res.end("Missing authorization code");
}
} else {
res.writeHead(404);
res.end("Not Found");
}
} finally {
this.cleanup();
}
});
let actualPort;
try {
actualPort = await this.listenOnPort(this.callbackServer, this.callbackPort);
} catch (err) {
if (err.code === "EADDRINUSE" && this.enablePortFallback) {
console.warn(`Port ${this.callbackPort} in use. Trying a random port...`);
actualPort = await this.listenOnPort(this.callbackServer, 0);
} else {
throw err;
}
}
this.serverTimeout = setTimeout(() => {
console.warn(`OAuth callback server timeout reached after ${this.authTimeoutMs / 1e3}s. Closing server.`);
this.cleanup();
}, this.authTimeoutMs);
return actualPort !== this.callbackPort ? actualPort : void 0;
}
/**
* Resets the instance to its post-initialization state
* Stops any active server, clears timeouts
*/
cleanup() {
if (this.callbackServer) {
this.callbackServer.close();
this.callbackServer = void 0;
}
if (this.serverTimeout) {
clearTimeout(this.serverTimeout);
this.serverTimeout = void 0;
}
}
/**
* Waits for the authorization code from the callback
*/
async waitForAuthorizationCode() {
if (!this.authorizationCodePromise) {
throw new Error("Authorization flow not started");
}
return this.authorizationCodePromise;
}
async openInBrowser(url2) {
const execFileAsync = promisify(execFile);
try {
switch (process.platform) {
case "darwin":
await execFileAsync("open", [url2]);
break;
case "win32":
await execFileAsync("cmd", ["/c", "start", url2]);
break;
default:
await execFileAsync("xdg-open", [url2]);
}
} catch (error) {
console.error("Failed to open browser:", error);
console.log("Please open this URL manually:", url2);
}
}
};
// src/client/providers/TokenAuthProvider.ts
var TokenAuthProvider = class extends CivicAuthProvider {
/**
* Create a new TokenAuthProvider
* @param tokenOrOptions - Either a token string or full options object
*/
constructor(tokenOrOptions) {
const options = typeof tokenOrOptions === "string" ? { tokens: { access_token: tokenOrOptions, token_type: "Bearer" } } : tokenOrOptions;
super(options);
this.tokenPersistence.saveTokens(options.tokens);
}
get redirectUrl() {
return "";
}
get clientMetadata() {
return {
redirect_uris: []
};
}
clientInformation() {
return {
client_id: "token-client"
};
}
redirectToAuthorization(_authorizationUrl) {
}
saveCodeVerifier(_codeVerifier) {
}
codeVerifier() {
return "";
}
};
// src/client/transport/RestartableStreamableHTTPClientTransport.ts
import {
StreamableHTTPClientTransport
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
var RestartableStreamableHTTPClientTransport = class extends StreamableHTTPClientTransport {
constructor(url2, opts) {
super(url2, opts);
this._cliAuthProvider = opts.authProvider;
this._cliAuthProvider.registerTransport(this);
}
get authProvider() {
return this._cliAuthProvider;
}
/**
* Extends the start method to properly handle reconnection.
* If the transport has already been started, it will disconnect first,
* then start again to establish a fresh connection.
*/
async start() {
try {
await super.start();
} catch (_error) {
}
}
async close() {
}
};
// src/client/CLIClient.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
var CLIClient = class extends Client {
/**
* Connect to MCP server with automatic authentication handling
* If the first connection fails due to auth, it will wait for the OAuth flow
* to complete and then retry the connection
*/
async connect(transport) {
try {
await super.connect(transport);
} catch (error) {
if (error instanceof Error) {
if (error.message === "Unauthorized") {
console.log("Authorization required, waiting for user to complete OAuth flow...");
const authProvider = transport.authProvider;
await authProvider.waitForAuthorizationCode();
console.log("Authorization completed.");
return await super.connect(transport);
}
}
throw error;
}
}
};
// src/index.ts
async function auth(options = {}) {
console.log(`Civic Auth MCP middleware initialized with options: ${JSON.stringify(options)}`);
const mcpServerAuth = await McpServerAuth.init(options);
const mcpRoute = options.mcpRoute ?? DEFAULT_MCP_ROUTE;
const router = Router();
router.get("/.well-known/oauth-protected-resource", (req, res) => {
const issuerUrl = options.issuerUrl || `${req.protocol}://${req.get("host")}`;
const issuerUrlString = typeof issuerUrl === "string" ? issuerUrl : issuerUrl.toString();
const metadata = mcpServerAuth.getProtectedResourceMetadata(issuerUrlString);
res.json(metadata);
});
router.use(async (req, res, next) => {
if (req.path === "/.well-known/oauth-protected-resource") {
return next();
}
if (!req.path.startsWith(mcpRoute)) {
return next();
}
try {
const authInfo = await mcpServerAuth.handleRequest(req);
req.auth = authInfo;
next();
} catch (error) {
if (error instanceof AuthenticationError) {
return res.status(401).json({
error: "authentication_error",
error_description: error.message
});
}
return res.status(500).json({
error: "internal_error",
error_description: "An unexpected error occurred"
});
}
});
return router;
}
export {
AuthenticationError,
CLIAuthProvider,
CLIClient,
CivicAuthProvider,
DEFAULT_CALLBACK_PORT,
DEFAULT_MCP_ROUTE,
DEFAULT_SCOPES,
DEFAULT_WELLKNOWN_URL,
InMemoryTokenPersistence,
JWTVerificationError,
McpServerAuth,
PUBLIC_CIVIC_CLIENT_ID,
RestartableStreamableHTTPClientTransport,
TokenAuthProvider,
auth
};
//# sourceMappingURL=index.js.map