feat(auth): add trusted reverse proxy / header authentication (#306)

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 <noreply@anthropic.com>
This commit is contained in:
dk-blackfuel 2026-03-12 16:14:53 +01:00 committed by GitHub
parent eddfd752c2
commit cc8fc841a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 77 additions and 1 deletions

View File

@ -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=

View File

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