feat: support optional per-installation Basic Auth on the MCP proxy
All checks were successful
CI / test (push) Successful in 31s
All checks were successful
CI / test (push) Successful in 31s
Lets a client still using Basic Auth credentials (e.g. against the previous nginx + htpasswd setup) keep working without switching to a bearer token immediately. Bearer auth remains the default and the only option when no Basic Auth credentials are configured. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -58,9 +58,17 @@ installationsRouter.post("/", async (req, res) => {
|
||||
});
|
||||
|
||||
installationsRouter.patch("/:id/config", (req, res) => {
|
||||
const { env, portEnvVar, hostEnvVar, httpPath, internalPort } = req.body ?? {};
|
||||
const { env, portEnvVar, hostEnvVar, httpPath, internalPort, basicAuthUsername, basicAuthPassword } = req.body ?? {};
|
||||
try {
|
||||
const installation = configureInstallation(req.params.id, { env, portEnvVar, hostEnvVar, httpPath, internalPort });
|
||||
const installation = configureInstallation(req.params.id, {
|
||||
env,
|
||||
portEnvVar,
|
||||
hostEnvVar,
|
||||
httpPath,
|
||||
internalPort,
|
||||
basicAuthUsername,
|
||||
basicAuthPassword,
|
||||
});
|
||||
res.json(toPublicJson(installation));
|
||||
} catch (err) {
|
||||
res.status(404).json({ error: (err as Error).message });
|
||||
|
||||
@@ -30,3 +30,16 @@ CREATE TABLE IF NOT EXISTS installations (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Added after the initial release — ALTER TABLE has no "ADD COLUMN IF NOT EXISTS" in SQLite,
|
||||
// so just ignore the "duplicate column" error on a DB that already has it.
|
||||
for (const ddl of [
|
||||
"ALTER TABLE installations ADD COLUMN basic_auth_username TEXT",
|
||||
"ALTER TABLE installations ADD COLUMN basic_auth_password_encrypted TEXT",
|
||||
]) {
|
||||
try {
|
||||
db.exec(ddl);
|
||||
} catch (err) {
|
||||
if (!(err as Error).message.includes("duplicate column")) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface Installation {
|
||||
hostEnvVar: string;
|
||||
env: Record<string, string>;
|
||||
bearerToken: string | null;
|
||||
basicAuthUsername: string | null;
|
||||
basicAuthPassword: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -50,6 +52,8 @@ interface Row {
|
||||
host_env_var: string;
|
||||
bearer_token_encrypted: string | null;
|
||||
env_json_encrypted: string | null;
|
||||
basic_auth_username: string | null;
|
||||
basic_auth_password_encrypted: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -72,6 +76,8 @@ function rowToInstallation(row: Row): Installation {
|
||||
hostEnvVar: row.host_env_var,
|
||||
env: row.env_json_encrypted ? JSON.parse(decryptSecret(row.env_json_encrypted)) : {},
|
||||
bearerToken: row.bearer_token_encrypted ? decryptSecret(row.bearer_token_encrypted) : null,
|
||||
basicAuthUsername: row.basic_auth_username,
|
||||
basicAuthPassword: row.basic_auth_password_encrypted ? decryptSecret(row.basic_auth_password_encrypted) : null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
@@ -132,6 +138,8 @@ export interface InstallationUpdate {
|
||||
hostEnvVar?: string;
|
||||
env?: Record<string, string>;
|
||||
bearerToken?: string | null;
|
||||
basicAuthUsername?: string | null;
|
||||
basicAuthPassword?: string | null;
|
||||
gitRef?: string | null;
|
||||
}
|
||||
|
||||
@@ -150,6 +158,10 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
|
||||
hostEnvVar: patch.hostEnvVar ?? current.hostEnvVar,
|
||||
env: patch.env ?? current.env,
|
||||
bearerToken: patch.bearerToken !== undefined ? patch.bearerToken : current.bearerToken,
|
||||
basicAuthUsername:
|
||||
patch.basicAuthUsername !== undefined ? patch.basicAuthUsername : current.basicAuthUsername,
|
||||
basicAuthPassword:
|
||||
patch.basicAuthPassword !== undefined ? patch.basicAuthPassword : current.basicAuthPassword,
|
||||
gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef,
|
||||
};
|
||||
|
||||
@@ -165,6 +177,8 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
|
||||
host_env_var = @hostEnvVar,
|
||||
env_json_encrypted = @envJsonEncrypted,
|
||||
bearer_token_encrypted = @bearerTokenEncrypted,
|
||||
basic_auth_username = @basicAuthUsername,
|
||||
basic_auth_password_encrypted = @basicAuthPasswordEncrypted,
|
||||
git_ref = @gitRef,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = @id`,
|
||||
@@ -180,6 +194,8 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
|
||||
hostEnvVar: next.hostEnvVar,
|
||||
envJsonEncrypted: encryptSecret(JSON.stringify(next.env)),
|
||||
bearerTokenEncrypted: next.bearerToken ? encryptSecret(next.bearerToken) : null,
|
||||
basicAuthUsername: next.basicAuthUsername,
|
||||
basicAuthPasswordEncrypted: next.basicAuthPassword ? encryptSecret(next.basicAuthPassword) : null,
|
||||
gitRef: next.gitRef,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
60
apps/server/src/proxy/mcpProxy.test.ts
Normal file
60
apps/server/src/proxy/mcpProxy.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAuthorized } from "./mcpProxy.js";
|
||||
import type { Installation } from "../db/installations.js";
|
||||
|
||||
function makeInstallation(overrides: Partial<Installation> = {}): Installation {
|
||||
return {
|
||||
id: "1",
|
||||
slug: "test",
|
||||
name: "Test",
|
||||
repoUrl: "https://example.com/repo.git",
|
||||
gitRef: null,
|
||||
status: "running",
|
||||
statusDetail: null,
|
||||
imageTag: "mcpmgr/test:latest",
|
||||
containerId: null,
|
||||
containerName: "mcp-test",
|
||||
internalPort: 8080,
|
||||
httpPath: "/mcp",
|
||||
portEnvVar: "PORT",
|
||||
hostEnvVar: "HOST",
|
||||
env: {},
|
||||
bearerToken: "secret-token",
|
||||
basicAuthUsername: null,
|
||||
basicAuthPassword: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isAuthorized", () => {
|
||||
it("accepts a matching bearer token", () => {
|
||||
expect(isAuthorized(makeInstallation(), "Bearer secret-token")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a wrong bearer token", () => {
|
||||
expect(isAuthorized(makeInstallation(), "Bearer wrong")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects basic auth when no credentials are configured", () => {
|
||||
const creds = Buffer.from("user:pass").toString("base64");
|
||||
expect(isAuthorized(makeInstallation(), `Basic ${creds}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts matching basic auth when credentials are configured", () => {
|
||||
const installation = makeInstallation({ basicAuthUsername: "user", basicAuthPassword: "pass" });
|
||||
const creds = Buffer.from("user:pass").toString("base64");
|
||||
expect(isAuthorized(installation, `Basic ${creds}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects wrong basic auth credentials", () => {
|
||||
const installation = makeInstallation({ basicAuthUsername: "user", basicAuthPassword: "pass" });
|
||||
const creds = Buffer.from("user:wrong").toString("base64");
|
||||
expect(isAuthorized(installation, `Basic ${creds}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when no Authorization header is present", () => {
|
||||
expect(isAuthorized(makeInstallation(), "")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import httpProxy from "http-proxy";
|
||||
import { getInstallationBySlug } from "../db/installations.js";
|
||||
import { getInstallationBySlug, type Installation } from "../db/installations.js";
|
||||
import { getContainerIp } from "../docker/containers.js";
|
||||
|
||||
const proxy = httpProxy.createProxyServer();
|
||||
@@ -11,13 +11,33 @@ proxy.on("error", (err, _req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
function tokensMatch(expected: string, provided: string): boolean {
|
||||
function stringsMatch(expected: string, provided: string): boolean {
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(provided);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export function isAuthorized(installation: Installation, authHeader: string): boolean {
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
const provided = authHeader.slice(7);
|
||||
return Boolean(installation.bearerToken) && stringsMatch(installation.bearerToken!, provided);
|
||||
}
|
||||
|
||||
// Optional, per-installation: lets a client still using Basic Auth against the old nginx
|
||||
// setup keep its credentials instead of switching to a bearer token right away.
|
||||
if (authHeader.startsWith("Basic ") && installation.basicAuthUsername && installation.basicAuthPassword) {
|
||||
const decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf8");
|
||||
const separatorIndex = decoded.indexOf(":");
|
||||
if (separatorIndex === -1) return false;
|
||||
const user = decoded.slice(0, separatorIndex);
|
||||
const pass = decoded.slice(separatorIndex + 1);
|
||||
return stringsMatch(installation.basicAuthUsername, user) && stringsMatch(installation.basicAuthPassword, pass);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function handleMcpProxyRequest(req: Request, res: Response): Promise<void> {
|
||||
const { slug } = req.params;
|
||||
const installation = getInstallationBySlug(slug);
|
||||
@@ -26,9 +46,7 @@ export async function handleMcpProxyRequest(req: Request, res: Response): Promis
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = req.get("authorization") ?? "";
|
||||
const provided = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
||||
if (!provided || !tokensMatch(installation.bearerToken, provided)) {
|
||||
if (!isAuthorized(installation, req.get("authorization") ?? "")) {
|
||||
res.status(401).json({ error: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ export function configureInstallation(
|
||||
hostEnvVar?: string;
|
||||
httpPath?: string;
|
||||
internalPort?: number;
|
||||
basicAuthUsername?: string | null;
|
||||
basicAuthPassword?: string | null;
|
||||
},
|
||||
): Installation {
|
||||
return updateInstallation(id, patch);
|
||||
|
||||
Reference in New Issue
Block a user