feat: support optional per-installation Basic Auth on the MCP proxy
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:
2026-10-08 05:05:11 +02:00
parent 021fa2220e
commit fb59d422a2
6 changed files with 124 additions and 7 deletions

View File

@@ -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 });

View File

@@ -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;
}
}

View File

@@ -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(),
}); });

View 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);
});
});

View File

@@ -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;
} }

View File

@@ -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);