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) => {
|
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 {
|
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));
|
res.json(toPublicJson(installation));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(404).json({ error: (err as Error).message });
|
res.status(404).json({ error: (err as Error).message });
|
||||||
|
|||||||
@@ -30,3 +30,16 @@ CREATE TABLE IF NOT EXISTS installations (
|
|||||||
updated_at TEXT NOT NULL
|
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;
|
hostEnvVar: string;
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
bearerToken: string | null;
|
bearerToken: string | null;
|
||||||
|
basicAuthUsername: string | null;
|
||||||
|
basicAuthPassword: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -50,6 +52,8 @@ interface Row {
|
|||||||
host_env_var: string;
|
host_env_var: string;
|
||||||
bearer_token_encrypted: string | null;
|
bearer_token_encrypted: string | null;
|
||||||
env_json_encrypted: string | null;
|
env_json_encrypted: string | null;
|
||||||
|
basic_auth_username: string | null;
|
||||||
|
basic_auth_password_encrypted: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -72,6 +76,8 @@ function rowToInstallation(row: Row): Installation {
|
|||||||
hostEnvVar: row.host_env_var,
|
hostEnvVar: row.host_env_var,
|
||||||
env: row.env_json_encrypted ? JSON.parse(decryptSecret(row.env_json_encrypted)) : {},
|
env: row.env_json_encrypted ? JSON.parse(decryptSecret(row.env_json_encrypted)) : {},
|
||||||
bearerToken: row.bearer_token_encrypted ? decryptSecret(row.bearer_token_encrypted) : null,
|
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,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
@@ -132,6 +138,8 @@ export interface InstallationUpdate {
|
|||||||
hostEnvVar?: string;
|
hostEnvVar?: string;
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
bearerToken?: string | null;
|
bearerToken?: string | null;
|
||||||
|
basicAuthUsername?: string | null;
|
||||||
|
basicAuthPassword?: string | null;
|
||||||
gitRef?: string | null;
|
gitRef?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +158,10 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
|
|||||||
hostEnvVar: patch.hostEnvVar ?? current.hostEnvVar,
|
hostEnvVar: patch.hostEnvVar ?? current.hostEnvVar,
|
||||||
env: patch.env ?? current.env,
|
env: patch.env ?? current.env,
|
||||||
bearerToken: patch.bearerToken !== undefined ? patch.bearerToken : current.bearerToken,
|
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,
|
gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,6 +177,8 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
|
|||||||
host_env_var = @hostEnvVar,
|
host_env_var = @hostEnvVar,
|
||||||
env_json_encrypted = @envJsonEncrypted,
|
env_json_encrypted = @envJsonEncrypted,
|
||||||
bearer_token_encrypted = @bearerTokenEncrypted,
|
bearer_token_encrypted = @bearerTokenEncrypted,
|
||||||
|
basic_auth_username = @basicAuthUsername,
|
||||||
|
basic_auth_password_encrypted = @basicAuthPasswordEncrypted,
|
||||||
git_ref = @gitRef,
|
git_ref = @gitRef,
|
||||||
updated_at = @updatedAt
|
updated_at = @updatedAt
|
||||||
WHERE id = @id`,
|
WHERE id = @id`,
|
||||||
@@ -180,6 +194,8 @@ export function updateInstallation(id: string, patch: InstallationUpdate): Insta
|
|||||||
hostEnvVar: next.hostEnvVar,
|
hostEnvVar: next.hostEnvVar,
|
||||||
envJsonEncrypted: encryptSecret(JSON.stringify(next.env)),
|
envJsonEncrypted: encryptSecret(JSON.stringify(next.env)),
|
||||||
bearerTokenEncrypted: next.bearerToken ? encryptSecret(next.bearerToken) : null,
|
bearerTokenEncrypted: next.bearerToken ? encryptSecret(next.bearerToken) : null,
|
||||||
|
basicAuthUsername: next.basicAuthUsername,
|
||||||
|
basicAuthPasswordEncrypted: next.basicAuthPassword ? encryptSecret(next.basicAuthPassword) : null,
|
||||||
gitRef: next.gitRef,
|
gitRef: next.gitRef,
|
||||||
updatedAt: new Date().toISOString(),
|
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 crypto from "node:crypto";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import httpProxy from "http-proxy";
|
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";
|
import { getContainerIp } from "../docker/containers.js";
|
||||||
|
|
||||||
const proxy = httpProxy.createProxyServer();
|
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 a = Buffer.from(expected);
|
||||||
const b = Buffer.from(provided);
|
const b = Buffer.from(provided);
|
||||||
if (a.length !== b.length) return false;
|
if (a.length !== b.length) return false;
|
||||||
return crypto.timingSafeEqual(a, b);
|
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> {
|
export async function handleMcpProxyRequest(req: Request, res: Response): Promise<void> {
|
||||||
const { slug } = req.params;
|
const { slug } = req.params;
|
||||||
const installation = getInstallationBySlug(slug);
|
const installation = getInstallationBySlug(slug);
|
||||||
@@ -26,9 +46,7 @@ export async function handleMcpProxyRequest(req: Request, res: Response): Promis
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const authHeader = req.get("authorization") ?? "";
|
if (!isAuthorized(installation, req.get("authorization") ?? "")) {
|
||||||
const provided = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
||||||
if (!provided || !tokensMatch(installation.bearerToken, provided)) {
|
|
||||||
res.status(401).json({ error: "unauthorized" });
|
res.status(401).json({ error: "unauthorized" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export function configureInstallation(
|
|||||||
hostEnvVar?: string;
|
hostEnvVar?: string;
|
||||||
httpPath?: string;
|
httpPath?: string;
|
||||||
internalPort?: number;
|
internalPort?: number;
|
||||||
|
basicAuthUsername?: string | null;
|
||||||
|
basicAuthPassword?: string | null;
|
||||||
},
|
},
|
||||||
): Installation {
|
): Installation {
|
||||||
return updateInstallation(id, patch);
|
return updateInstallation(id, patch);
|
||||||
|
|||||||
Reference in New Issue
Block a user