From a73b65dfc0a0b4fda94c553b5e835e423b3427e9 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 31 Jul 2026 04:33:55 -0700 Subject: [PATCH 1/4] feat(server): add bearer token auth for HTTP transport (#66) Anyone with the HTTP server URL could reach the MCP endpoint unauthenticated. Adds an opt-in --auth-token/DBHUB_AUTH_TOKEN flag: a comma-separated list of bearer tokens checked with constant-time comparison on every request except /healthz. Configuring a token is itself the enforcement switch, so there's no separate flag to forget. Deliberately a flat shared-secret allow-list rather than full OAuth 2.1 resource-server machinery (RFC 9728 metadata, DCR, PKCE), matching the pattern used by mcp-remote, Sentry MCP's self-hosted mode, and the community crystaldba/postgres-mcp nginx template. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 1 + docs/config/command-line.mdx | 34 +++++++++++++- src/config/env.ts | 28 ++++++++++++ src/server.ts | 33 +++++++++++++- src/utils/__tests__/auth-token.test.ts | 58 ++++++++++++++++++++++++ src/utils/auth-token.ts | 63 ++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 src/utils/__tests__/auth-token.test.ts create mode 100644 src/utils/auth-token.ts diff --git a/CLAUDE.md b/CLAUDE.md index 88c32827..f5642edd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,7 @@ DBHub supports three configuration methods: - `--port`: HTTP server port (default: 8080) - `--host`: HTTP bind host (default: `0.0.0.0`; env `DBHUB_HOST`) - `--allowed-hosts`: Comma-separated extra hostnames accepted in the HTTP `Host`/`Origin` headers, for DNS-rebinding protection (env `DBHUB_ALLOWED_HOSTS`). Loopback is always allowed; on a wildcard bind (`0.0.0.0`/`::`) this machine's hostname and IPs are auto-allowed so local/by-IP access needs no config. Set the flag for other names (e.g. a reverse-proxy/public DNS name); use `*` to disable the check when fronted by your own auth/proxy. See `buildAllowedHosts`/`getSelfHosts` in `src/utils/cross-origin.ts`. +- `--auth-token`: Comma-separated bearer token(s) required on every HTTP request via `Authorization: Bearer ` (env `DBHUB_AUTH_TOKEN`). Unset by default (no auth); configuring a token is itself the opt-in — there is no separate enforcement flag. A comma-separated list supports zero-downtime rotation (add the new token, redeploy, drop the old one) and per-client tokens. `/healthz` is exempt. Not OAuth — a flat shared-secret allow-list, matching the pattern used by `mcp-remote`, Sentry MCP's self-hosted mode, and the community `crystaldba/postgres-mcp` nginx template; see `validateAuthToken` in `src/utils/auth-token.ts`. - `--config`: Path to TOML configuration file - `--demo`: Use bundled SQLite employee database - `--readonly`: Restrict to read-only SQL operations (deprecated - use TOML configuration instead) diff --git a/docs/config/command-line.mdx b/docs/config/command-line.mdx index fac87a8c..49afab0e 100644 --- a/docs/config/command-line.mdx +++ b/docs/config/command-line.mdx @@ -83,7 +83,7 @@ This page covers command-line flags and environment variables. For TOML configur ``` - The default `0.0.0.0` exposes DBHub on every network interface. For production, set `--host 127.0.0.1` and place DBHub behind a reverse proxy (nginx/Caddy) or restrict with a firewall — DBHub does not authenticate HTTP clients. + The default `0.0.0.0` exposes DBHub on every network interface. For production, set `--host 127.0.0.1` and place DBHub behind a reverse proxy (nginx/Caddy) or restrict with a firewall, or configure `--auth-token` (see below) to require a bearer token on every request. @@ -127,6 +127,36 @@ This page covers command-line flags and environment variables. For TOML configur +### --auth-token + + + Comma-separated list of bearer tokens required on every HTTP request. Only used when `--transport=http`. Unset by default — auth is off unless you configure this. + + Clients must send a matching token as `Authorization: Bearer `; requests without one get `401 Unauthorized` with a `WWW-Authenticate: Bearer` header. `/healthz` is exempt so uptime monitors don't need a token. + + ```bash + # Single token + npx @bytebase/dbhub@latest --transport http --auth-token "s3cr3t-token" --dsn "..." + + # Multiple tokens (rotate without downtime, or issue one per client) + npx @bytebase/dbhub@latest --transport http --auth-token "token-for-ci,token-for-agent" --dsn "..." + ``` + + Client request: + + ```bash + curl -H "Authorization: Bearer s3cr3t-token" http://localhost:8080/mcp + ``` + + + Configuring a token *is* the opt-in — there's no separate "require auth" flag to remember. A comma-separated list lets you rotate a leaked or expiring token by adding the new one, redeploying, and then removing the old one, and lets you hand different tokens to different clients so one can be revoked without affecting the others. + + + + This is a flat shared-secret check, not OAuth — it answers "does this request have the secret," not "who is this user" (no per-user scopes or audit trail). If you need real identity-based authorization, front DBHub with your own OAuth-aware proxy or IdP-integrated gateway instead. + + + ### --dsn @@ -357,6 +387,8 @@ npx @bytebase/dbhub@latest --dsn "..." \ | `--transport` | `TRANSPORT` | string | Transport mode: stdio or http (default: `stdio`) | | `--port` | `PORT` | number | HTTP server port (http transport only, default: `8080`) | | `--host` | `DBHUB_HOST` | string | HTTP bind address (http transport only, default: `0.0.0.0`) | +| `--allowed-hosts` | `DBHUB_ALLOWED_HOSTS` | string | Extra hostnames accepted in Host/Origin headers (http transport only, default: loopback + this machine) | +| `--auth-token` | `DBHUB_AUTH_TOKEN` | string | Comma-separated bearer token(s) required on requests (http transport only, default: auth disabled) | | `--demo` | - | boolean | Use sample employee database | | `--id` | `ID` | string | Instance identifier for tool names | | `--config` | - | string | Path to TOML config file (default: `./dbhub.toml`) | diff --git a/src/config/env.ts b/src/config/env.ts index 666fbb4d..19ed9905 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -466,6 +466,34 @@ function splitHostList(value: string): string[] { .filter((h) => h.length > 0); } +/** + * Resolve the list of bearer tokens accepted by the HTTP transport. + * + * Sources (highest priority first): + * 1. --auth-token=token1,token2 + * 2. DBHUB_AUTH_TOKEN=token1,token2 environment variable + * + * An empty list (the default) means auth is disabled — configuring a token + * is itself the opt-in, so there is no separate "--require-auth" flag to + * forget. A comma-separated list supports zero-downtime token rotation (add + * the new token, redeploy, remove the old one) and per-client tokens. + */ +export function resolveAuthTokens(): { tokens: string[]; source: string } { + const args = parseCommandLineArgs(); + + const cliValue = requireFlagValue("auth-token", args, "secret1,secret2"); + if (cliValue !== undefined) { + return { tokens: splitHostList(cliValue), source: "command line argument" }; + } + + const envValue = process.env.DBHUB_AUTH_TOKEN?.trim(); + if (envValue) { + return { tokens: splitHostList(envValue), source: "environment variable" }; + } + + return { tokens: [], source: "default" }; +} + /** * Redact sensitive information from a DSN string * Replaces the password with asterisks diff --git a/src/server.ts b/src/server.ts index 9030e477..934feed8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from "url"; import { ConnectorManager } from "./connectors/manager.js"; import { ConnectorRegistry } from "./connectors/interface.js"; -import { resolveTransport, resolvePort, resolveHost, resolveAllowedHosts, resolveSourceConfigs, isDemoMode } from "./config/env.js"; +import { resolveTransport, resolvePort, resolveHost, resolveAllowedHosts, resolveAuthTokens, resolveSourceConfigs, isDemoMode } from "./config/env.js"; import { registerTools } from "./tools/index.js"; import { listSources, getSource } from "./api/sources.js"; import { listRequests } from "./api/requests.js"; @@ -17,6 +17,7 @@ import { generateStartupTable, buildSourceDisplayInfo } from "./utils/startup-ta import { getToolsForSource } from "./utils/tool-metadata.js"; import { startConfigWatcher } from "./utils/config-watcher.js"; import { validateOrigin, buildAllowedHosts, getSelfHosts, ALLOW_ANY_HOST } from "./utils/cross-origin.js"; +import { validateAuthToken } from "./utils/auth-token.js"; // Create __dirname equivalent for ES modules const __filename = fileURLToPath(import.meta.url); @@ -163,6 +164,12 @@ See documentation for more details on configuring database connections. ? buildAllowedHosts(resolveAllowedHosts().hosts, host ?? undefined, getSelfHosts()) : new Set(); + // Bearer token auth for the HTTP transport (issue #66). Configuring a + // token is itself the opt-in — an empty list (the default) leaves + // today's unauthenticated behavior unchanged. + const authTokensResult = transportData.type === "http" ? resolveAuthTokens() : { tokens: [], source: "default" }; + const authTokens = authTokensResult.tokens; + // Print ASCII art banner with version and slogan // Collect active modes const activeModes: string[] = []; @@ -224,7 +231,7 @@ See documentation for more details on configuring database connections. // mirroring them for exactly this reason.) res.header('Access-Control-Allow-Origin', origin || 'http://localhost'); res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id, MCP-Protocol-Version, Mcp-Method, Mcp-Name'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Mcp-Method, Mcp-Name'); res.header('Access-Control-Allow-Credentials', 'true'); if (req.method === 'OPTIONS') { @@ -233,6 +240,21 @@ See documentation for more details on configuring database connections. next(); }); + // Bearer token auth (issue #66): rejects requests missing a valid + // `Authorization: Bearer ` header when --auth-token/DBHUB_AUTH_TOKEN + // is configured; a no-op when it isn't. /healthz is exempt so uptime + // monitors don't need the token. + app.use((req, res, next) => { + if (req.path === '/healthz') return next(); + + const result = validateAuthToken(req.headers.authorization, authTokens); + if (!result.ok) { + res.header('WWW-Authenticate', 'Bearer'); + return res.status(result.status).json({ error: 'Unauthorized', message: result.message }); + } + next(); + }); + // Serve static frontend files const frontendPath = path.join(__dirname, "public"); app.use(express.static(frontendPath)); @@ -301,6 +323,13 @@ See documentation for more details on configuring database connections. console.error(`Allowed hosts: ${[...allowedHosts].join(', ')} (set --allowed-hosts to serve other hostnames)`); } + // Surface whether bearer token auth is enforced (issue #66). + if (authTokens.length > 0) { + console.error(`Auth: bearer token required (${authTokens.length} token(s) configured via ${authTokensResult.source})`); + } else { + console.error('Auth: disabled (set --auth-token or DBHUB_AUTH_TOKEN to require a bearer token)'); + } + // In development mode, suggest using the Vite dev server for hot reloading. // Vite serves from localhost; use the same hostname for the backend hint so // cross-origin calls from Vite satisfy the DNS-rebinding middleware check. diff --git a/src/utils/__tests__/auth-token.test.ts b/src/utils/__tests__/auth-token.test.ts new file mode 100644 index 00000000..9346d94b --- /dev/null +++ b/src/utils/__tests__/auth-token.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { validateAuthToken } from '../auth-token.js'; + +describe('validateAuthToken', () => { + it('allows any request when no tokens are configured', () => { + expect(validateAuthToken(undefined, [])).toEqual({ ok: true }); + expect(validateAuthToken('Bearer whatever', [])).toEqual({ ok: true }); + }); + + it('accepts a matching bearer token', () => { + const result = validateAuthToken('Bearer secret123', ['secret123']); + expect(result).toEqual({ ok: true }); + }); + + it('accepts a token that matches any entry in a multi-token list', () => { + const tokens = ['first-token', 'second-token', 'third-token']; + expect(validateAuthToken('Bearer second-token', tokens)).toEqual({ ok: true }); + }); + + it('rejects a missing Authorization header', () => { + const result = validateAuthToken(undefined, ['secret123']); + expect(result.ok).toBe(false); + expect(result).toMatchObject({ status: 401 }); + }); + + it('rejects a header without the Bearer scheme', () => { + const result = validateAuthToken('secret123', ['secret123']); + expect(result.ok).toBe(false); + expect(result).toMatchObject({ status: 401 }); + }); + + it('rejects a wrong scheme such as Basic auth', () => { + const result = validateAuthToken('Basic dXNlcjpwYXNz', ['secret123']); + expect(result.ok).toBe(false); + expect(result).toMatchObject({ status: 401 }); + }); + + it('rejects an incorrect token', () => { + const result = validateAuthToken('Bearer wrong-token', ['secret123']); + expect(result.ok).toBe(false); + expect(result).toMatchObject({ status: 401 }); + }); + + it('rejects an empty bearer token', () => { + const result = validateAuthToken('Bearer ', ['secret123']); + expect(result.ok).toBe(false); + }); + + it('rejects a token differing only in length from a configured one', () => { + const result = validateAuthToken('Bearer secret1234extra', ['secret123']); + expect(result.ok).toBe(false); + }); + + it('is case sensitive on token comparison', () => { + const result = validateAuthToken('Bearer SECRET123', ['secret123']); + expect(result.ok).toBe(false); + }); +}); diff --git a/src/utils/auth-token.ts b/src/utils/auth-token.ts new file mode 100644 index 00000000..d83b959b --- /dev/null +++ b/src/utils/auth-token.ts @@ -0,0 +1,63 @@ +import { timingSafeEqual } from "node:crypto"; + +/** + * Result of validating an HTTP request's `Authorization` header against the + * configured bearer token allow-list. + */ +export type AuthTokenValidation = + | { ok: true } + | { ok: false; status: 401; message: string }; + +const BEARER_PREFIX = "Bearer "; + +/** + * Constant-time string equality. `timingSafeEqual` throws on unequal-length + * buffers, so unequal lengths are rejected up front — this leaks only the + * length of the configured token, not which bytes matched, which is the same + * trade-off `timingSafeEqual` itself makes. + */ +function constantTimeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + +/** + * Bearer token auth for the HTTP transport (issue #66): "anyone with the + * server URL can access the database." An empty `tokens` list means auth is + * disabled — configuring a token is itself the opt-in (see + * `resolveAuthTokens()` in `src/config/env.ts`), so there is no separate + * "--require-auth" flag to forget to set. + * + * This intentionally stops at a shared-secret allow-list rather than + * implementing the MCP spec's full OAuth 2.1 resource-server model (RFC 9728 + * protected-resource metadata, authorization-server discovery, dynamic client + * registration, PKCE). That machinery solves multi-tenant identity + * federation; DBHub's actual gap is coarser — "is this request from someone + * who has the secret," not "who is this user and what scopes do they have." + * mcp-remote, Sentry MCP's self-hosted fallback, and the community + * crystaldba/postgres-mcp nginx template all converge on the same shape. + */ +export function validateAuthToken( + authorizationHeader: string | undefined, + tokens: string[] +): AuthTokenValidation { + if (tokens.length === 0) return { ok: true }; + + if (!authorizationHeader || !authorizationHeader.startsWith(BEARER_PREFIX)) { + return { + ok: false, + status: 401, + message: "Missing or malformed Authorization header. Expected: Bearer ", + }; + } + + const presented = authorizationHeader.slice(BEARER_PREFIX.length); + const matches = tokens.some((token) => constantTimeEqual(presented, token)); + if (!matches) { + return { ok: false, status: 401, message: "Invalid bearer token" }; + } + + return { ok: true }; +} From 37c44144df533d292a2ad7c936c459a6b343b2d3 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 31 Jul 2026 04:42:17 -0700 Subject: [PATCH 2/4] refactor: simplify auth token resolution and healthz exemption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the transport-gated ternary around resolveAuthTokens() — it's cheap to call unconditionally and was duplicating the function's own default-value literal. Move the /healthz route registration ahead of the auth middleware instead of hardcoding a path exemption inside it, so the middleware stays a plain token gate with no awareness of which routes are public. Co-Authored-By: Claude Sonnet 5 --- src/server.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/server.ts b/src/server.ts index 934feed8..c9585b58 100644 --- a/src/server.ts +++ b/src/server.ts @@ -166,9 +166,9 @@ See documentation for more details on configuring database connections. // Bearer token auth for the HTTP transport (issue #66). Configuring a // token is itself the opt-in — an empty list (the default) leaves - // today's unauthenticated behavior unchanged. - const authTokensResult = transportData.type === "http" ? resolveAuthTokens() : { tokens: [], source: "default" }; - const authTokens = authTokensResult.tokens; + // today's unauthenticated behavior unchanged. Only meaningful for http, + // but resolving it is cheap enough not to gate on transport type. + const { tokens: authTokens, source: authTokenSource } = resolveAuthTokens(); // Print ASCII art banner with version and slogan // Collect active modes @@ -240,13 +240,18 @@ See documentation for more details on configuring database connections. next(); }); + // Health check endpoint. Registered before the auth middleware below so + // it never requires a token — uptime monitors don't have one — without + // the auth middleware needing to know about specific unauthenticated + // routes; it responds directly and never calls next(). + app.get("/healthz", (req, res) => { + res.status(200).send("OK"); + }); + // Bearer token auth (issue #66): rejects requests missing a valid // `Authorization: Bearer ` header when --auth-token/DBHUB_AUTH_TOKEN - // is configured; a no-op when it isn't. /healthz is exempt so uptime - // monitors don't need the token. + // is configured; a no-op when it isn't. app.use((req, res, next) => { - if (req.path === '/healthz') return next(); - const result = validateAuthToken(req.headers.authorization, authTokens); if (!result.ok) { res.header('WWW-Authenticate', 'Bearer'); @@ -259,11 +264,6 @@ See documentation for more details on configuring database connections. const frontendPath = path.join(__dirname, "public"); app.use(express.static(frontendPath)); - // Health check endpoint - app.get("/healthz", (req, res) => { - res.status(200).send("OK"); - }); - // Data sources API endpoints app.get("/api/sources", listSources); app.get("/api/sources/:sourceId", getSource); @@ -325,7 +325,7 @@ See documentation for more details on configuring database connections. // Surface whether bearer token auth is enforced (issue #66). if (authTokens.length > 0) { - console.error(`Auth: bearer token required (${authTokens.length} token(s) configured via ${authTokensResult.source})`); + console.error(`Auth: bearer token required (${authTokens.length} token(s) configured via ${authTokenSource})`); } else { console.error('Auth: disabled (set --auth-token or DBHUB_AUTH_TOKEN to require a bearer token)'); } From 670eeb9539b1c4cf3cb65d1abc20f8bbbc287bbb Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 31 Jul 2026 04:46:54 -0700 Subject: [PATCH 3/4] docs: drop vendor name-dropping from auth rationale The design rationale for the shared-secret approach stands on its own; naming other MCP servers' auth implementations isn't necessary to justify it. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 2 +- src/utils/auth-token.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f5642edd..f355fa1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,7 @@ DBHub supports three configuration methods: - `--port`: HTTP server port (default: 8080) - `--host`: HTTP bind host (default: `0.0.0.0`; env `DBHUB_HOST`) - `--allowed-hosts`: Comma-separated extra hostnames accepted in the HTTP `Host`/`Origin` headers, for DNS-rebinding protection (env `DBHUB_ALLOWED_HOSTS`). Loopback is always allowed; on a wildcard bind (`0.0.0.0`/`::`) this machine's hostname and IPs are auto-allowed so local/by-IP access needs no config. Set the flag for other names (e.g. a reverse-proxy/public DNS name); use `*` to disable the check when fronted by your own auth/proxy. See `buildAllowedHosts`/`getSelfHosts` in `src/utils/cross-origin.ts`. -- `--auth-token`: Comma-separated bearer token(s) required on every HTTP request via `Authorization: Bearer ` (env `DBHUB_AUTH_TOKEN`). Unset by default (no auth); configuring a token is itself the opt-in — there is no separate enforcement flag. A comma-separated list supports zero-downtime rotation (add the new token, redeploy, drop the old one) and per-client tokens. `/healthz` is exempt. Not OAuth — a flat shared-secret allow-list, matching the pattern used by `mcp-remote`, Sentry MCP's self-hosted mode, and the community `crystaldba/postgres-mcp` nginx template; see `validateAuthToken` in `src/utils/auth-token.ts`. +- `--auth-token`: Comma-separated bearer token(s) required on every HTTP request via `Authorization: Bearer ` (env `DBHUB_AUTH_TOKEN`). Unset by default (no auth); configuring a token is itself the opt-in — there is no separate enforcement flag. A comma-separated list supports zero-downtime rotation (add the new token, redeploy, drop the old one) and per-client tokens. `/healthz` is exempt. Not OAuth — a flat shared-secret allow-list, not full identity-based authorization; see `validateAuthToken` in `src/utils/auth-token.ts`. - `--config`: Path to TOML configuration file - `--demo`: Use bundled SQLite employee database - `--readonly`: Restrict to read-only SQL operations (deprecated - use TOML configuration instead) diff --git a/src/utils/auth-token.ts b/src/utils/auth-token.ts index d83b959b..99384b96 100644 --- a/src/utils/auth-token.ts +++ b/src/utils/auth-token.ts @@ -36,8 +36,6 @@ function constantTimeEqual(a: string, b: string): boolean { * registration, PKCE). That machinery solves multi-tenant identity * federation; DBHub's actual gap is coarser — "is this request from someone * who has the secret," not "who is this user and what scopes do they have." - * mcp-remote, Sentry MCP's self-hosted fallback, and the community - * crystaldba/postgres-mcp nginx template all converge on the same shape. */ export function validateAuthToken( authorizationHeader: string | undefined, From d447f30f17bc097c6aa9369240266d5b174fe6bb Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 31 Jul 2026 04:54:57 -0700 Subject: [PATCH 4/4] fix: address Copilot review findings on auth-token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Give resolveAuthTokens() its own splitTokenList() instead of reusing the host-oriented splitHostList() — decouples token parsing from any future host normalization (lowercasing, port-stripping) that must never touch case-sensitive tokens. - Fix the --auth-token docs example: /mcp requires a JSON-RPC POST body, so the curl example wasn't copy-pasteable. Switched to /api/sources, a plain GET behind the same auth middleware. Co-Authored-By: Claude Sonnet 5 --- docs/config/command-line.mdx | 5 +++-- src/config/env.ts | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/config/command-line.mdx b/docs/config/command-line.mdx index 49afab0e..e266718c 100644 --- a/docs/config/command-line.mdx +++ b/docs/config/command-line.mdx @@ -142,10 +142,11 @@ This page covers command-line flags and environment variables. For TOML configur npx @bytebase/dbhub@latest --transport http --auth-token "token-for-ci,token-for-agent" --dsn "..." ``` - Client request: + Client request (`/api/sources` is a plain `GET`; the MCP endpoint itself + requires a JSON-RPC POST body, so it's not a copy-pasteable example): ```bash - curl -H "Authorization: Bearer s3cr3t-token" http://localhost:8080/mcp + curl -H "Authorization: Bearer s3cr3t-token" http://localhost:8080/api/sources ``` diff --git a/src/config/env.ts b/src/config/env.ts index 19ed9905..4012f872 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -483,17 +483,30 @@ export function resolveAuthTokens(): { tokens: string[]; source: string } { const cliValue = requireFlagValue("auth-token", args, "secret1,secret2"); if (cliValue !== undefined) { - return { tokens: splitHostList(cliValue), source: "command line argument" }; + return { tokens: splitTokenList(cliValue), source: "command line argument" }; } const envValue = process.env.DBHUB_AUTH_TOKEN?.trim(); if (envValue) { - return { tokens: splitHostList(envValue), source: "environment variable" }; + return { tokens: splitTokenList(envValue), source: "environment variable" }; } return { tokens: [], source: "default" }; } +/** + * Split a comma-separated token list, trimming and dropping empty entries. + * Deliberately separate from `splitHostList()` even though the bodies match + * today: hostnames may later gain normalization (lowercasing, port-stripping) + * that must never apply to tokens, which are compared byte-for-byte. + */ +function splitTokenList(value: string): string[] { + return value + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + /** * Redact sensitive information from a DSN string * Replaces the password with asterisks