diff --git a/apps/server/src/api/installations.ts b/apps/server/src/api/installations.ts index 024d25e..2e7b407 100644 --- a/apps/server/src/api/installations.ts +++ b/apps/server/src/api/installations.ts @@ -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 }); diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts index 575cc17..4cf96e8 100644 --- a/apps/server/src/db/index.ts +++ b/apps/server/src/db/index.ts @@ -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; + } +} diff --git a/apps/server/src/db/installations.ts b/apps/server/src/db/installations.ts index 04280ff..62c428d 100644 --- a/apps/server/src/db/installations.ts +++ b/apps/server/src/db/installations.ts @@ -29,6 +29,8 @@ export interface Installation { hostEnvVar: string; env: Record; 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; 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(), }); diff --git a/apps/server/src/proxy/mcpProxy.test.ts b/apps/server/src/proxy/mcpProxy.test.ts new file mode 100644 index 0000000..f3a770d --- /dev/null +++ b/apps/server/src/proxy/mcpProxy.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/server/src/proxy/mcpProxy.ts b/apps/server/src/proxy/mcpProxy.ts index 90c60d9..142f481 100644 --- a/apps/server/src/proxy/mcpProxy.ts +++ b/apps/server/src/proxy/mcpProxy.ts @@ -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 { 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; } diff --git a/apps/server/src/service/orchestrator.ts b/apps/server/src/service/orchestrator.ts index 42a0d6f..fd696e5 100644 --- a/apps/server/src/service/orchestrator.ts +++ b/apps/server/src/service/orchestrator.ts @@ -53,6 +53,8 @@ export function configureInstallation( hostEnvVar?: string; httpPath?: string; internalPort?: number; + basicAuthUsername?: string | null; + basicAuthPassword?: string | null; }, ): Installation { return updateInstallation(id, patch);