From cc8fc841a11f1d0381036792cbab4224b0d0697f Mon Sep 17 00:00:00 2001 From: dk-blackfuel Date: Thu, 12 Mar 2026 16:14:53 +0100 Subject: [PATCH] feat(auth): add trusted reverse proxy / header authentication (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MC_PROXY_AUTH_HEADER env var. When configured, getUserFromRequest reads the named header for the authenticated username and resolves (or optionally auto-provisions) the MC user without requiring a password. This enables SSO via a trusted gateway such as Envoy OIDC — the gateway injects preferred_username as a header from the validated JWT claim, and MC maps it to an existing user, skipping the local login form entirely. New env vars: MC_PROXY_AUTH_HEADER — header name to read username from MC_PROXY_AUTH_DEFAULT_ROLE — role for auto-provisioned users (optional) Auto-provisioned users receive a random unusable password so they cannot bypass the proxy and log in locally. Closes #305 Co-authored-by: Claude Sonnet 4.6 --- .env.example | 11 ++++++++ src/lib/auth.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 7bc99a2..527edf8 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,17 @@ MC_COOKIE_SAMESITE=strict MC_ALLOW_ANY_HOST= MC_ALLOWED_HOSTS=localhost,127.0.0.1 +# Trusted reverse proxy / header authentication +# When set, Mission Control reads the named header for the authenticated username +# and resolves (or auto-provisions) the MC user without requiring a password. +# Only enable this when MC is deployed behind a trusted gateway that injects the +# header from a verified identity (e.g. Envoy OIDC claimToHeaders: email → X-User-Email). +# MC users must be created with their email address as the username. +# MC_PROXY_AUTH_HEADER=X-User-Email +# Role assigned to auto-provisioned users (viewer | operator | admin). Leave unset +# to require an admin to create accounts manually before users can access via proxy auth. +# MC_PROXY_AUTH_DEFAULT_ROLE=viewer + # Google OAuth client IDs for Google Sign-In approval workflow # Create in Google Cloud Console (Web application) and set authorized origins/redirects GOOGLE_CLIENT_ID= diff --git a/src/lib/auth.ts b/src/lib/auth.ts index dbbfde0..e49aa75 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -34,7 +34,7 @@ export interface User { role: 'admin' | 'operator' | 'viewer' workspace_id: number tenant_id: number - provider?: 'local' | 'google' + provider?: 'local' | 'google' | 'proxy' email?: string | null avatar_url?: string | null is_approved?: number @@ -341,10 +341,75 @@ export function deleteUser(id: number): boolean { * Get user from request - checks session cookie or API key. * For API key auth, returns a synthetic "api" user. */ +/** + * Resolve a user by username for proxy auth. + * If the user does not exist and MC_PROXY_AUTH_DEFAULT_ROLE is set, auto-provisions them. + * Auto-provisioned users receive a random unusable password — they cannot log in locally. + */ +function resolveOrProvisionProxyUser(username: string): User | null { + try { + const db = getDatabase() + const { workspaceId } = getDefaultWorkspaceContext() + + const row = db.prepare(` + SELECT u.id, u.username, u.display_name, u.role, u.workspace_id, + COALESCE(w.tenant_id, 1) as tenant_id, + u.provider, u.email, u.avatar_url, u.is_approved, + u.created_at, u.updated_at, u.last_login_at + FROM users u + LEFT JOIN workspaces w ON w.id = u.workspace_id + WHERE u.username = ? + `).get(username) as UserQueryRow | undefined + + if (row) { + if ((row.is_approved ?? 1) !== 1) return null + return { + id: row.id, + username: row.username, + display_name: row.display_name, + role: row.role, + workspace_id: row.workspace_id || workspaceId, + tenant_id: resolveTenantForWorkspace(row.workspace_id || workspaceId), + provider: row.provider || 'local', + email: row.email ?? null, + avatar_url: row.avatar_url ?? null, + is_approved: row.is_approved ?? 1, + created_at: row.created_at, + updated_at: row.updated_at, + last_login_at: row.last_login_at, + } + } + + // Auto-provision if MC_PROXY_AUTH_DEFAULT_ROLE is configured + const defaultRole = (process.env.MC_PROXY_AUTH_DEFAULT_ROLE || '').trim() + if (!defaultRole || !(['viewer', 'operator', 'admin'] as const).includes(defaultRole as User['role'])) { + return null + } + + // Random password — proxy users cannot log in via the local login form + return createUser(username, randomBytes(32).toString('hex'), username, defaultRole as User['role']) + } catch { + return null + } +} + export function getUserFromRequest(request: Request): User | null { // Extract agent identity header (optional, for attribution) const agentName = (request.headers.get('x-agent-name') || '').trim() || null + // Proxy / trusted-header auth (MC_PROXY_AUTH_HEADER) + // When the gateway has already authenticated the user and injects their username + // as a trusted header (e.g. X-Auth-Username from Envoy OIDC claimToHeaders), + // skip the local login form entirely. + const proxyAuthHeader = (process.env.MC_PROXY_AUTH_HEADER || '').trim() + if (proxyAuthHeader) { + const proxyUsername = (request.headers.get(proxyAuthHeader) || '').trim() + if (proxyUsername) { + const user = resolveOrProvisionProxyUser(proxyUsername) + if (user) return { ...user, agent_name: agentName } + } + } + // Check session cookie const cookieHeader = request.headers.get('cookie') || '' const sessionToken = parseMcSessionCookieHeader(cookieHeader)