Spaces:
Runtime error
feat(auth): add trusted reverse proxy / header authentication (#306)
Browse filesAdds 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>
- .env.example +11 -0
- src/lib/auth.ts +66 -1
|
@@ -31,6 +31,17 @@ MC_COOKIE_SAMESITE=strict
|
|
| 31 |
MC_ALLOW_ANY_HOST=
|
| 32 |
MC_ALLOWED_HOSTS=localhost,127.0.0.1
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
# Google OAuth client IDs for Google Sign-In approval workflow
|
| 35 |
# Create in Google Cloud Console (Web application) and set authorized origins/redirects
|
| 36 |
GOOGLE_CLIENT_ID=
|
|
|
|
| 31 |
MC_ALLOW_ANY_HOST=
|
| 32 |
MC_ALLOWED_HOSTS=localhost,127.0.0.1
|
| 33 |
|
| 34 |
+
# Trusted reverse proxy / header authentication
|
| 35 |
+
# When set, Mission Control reads the named header for the authenticated username
|
| 36 |
+
# and resolves (or auto-provisions) the MC user without requiring a password.
|
| 37 |
+
# Only enable this when MC is deployed behind a trusted gateway that injects the
|
| 38 |
+
# header from a verified identity (e.g. Envoy OIDC claimToHeaders: email → X-User-Email).
|
| 39 |
+
# MC users must be created with their email address as the username.
|
| 40 |
+
# MC_PROXY_AUTH_HEADER=X-User-Email
|
| 41 |
+
# Role assigned to auto-provisioned users (viewer | operator | admin). Leave unset
|
| 42 |
+
# to require an admin to create accounts manually before users can access via proxy auth.
|
| 43 |
+
# MC_PROXY_AUTH_DEFAULT_ROLE=viewer
|
| 44 |
+
|
| 45 |
# Google OAuth client IDs for Google Sign-In approval workflow
|
| 46 |
# Create in Google Cloud Console (Web application) and set authorized origins/redirects
|
| 47 |
GOOGLE_CLIENT_ID=
|
|
@@ -34,7 +34,7 @@ export interface User {
|
|
| 34 |
role: 'admin' | 'operator' | 'viewer'
|
| 35 |
workspace_id: number
|
| 36 |
tenant_id: number
|
| 37 |
-
provider?: 'local' | 'google'
|
| 38 |
email?: string | null
|
| 39 |
avatar_url?: string | null
|
| 40 |
is_approved?: number
|
|
@@ -341,10 +341,75 @@ export function deleteUser(id: number): boolean {
|
|
| 341 |
* Get user from request - checks session cookie or API key.
|
| 342 |
* For API key auth, returns a synthetic "api" user.
|
| 343 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
export function getUserFromRequest(request: Request): User | null {
|
| 345 |
// Extract agent identity header (optional, for attribution)
|
| 346 |
const agentName = (request.headers.get('x-agent-name') || '').trim() || null
|
| 347 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
// Check session cookie
|
| 349 |
const cookieHeader = request.headers.get('cookie') || ''
|
| 350 |
const sessionToken = parseMcSessionCookieHeader(cookieHeader)
|
|
|
|
| 34 |
role: 'admin' | 'operator' | 'viewer'
|
| 35 |
workspace_id: number
|
| 36 |
tenant_id: number
|
| 37 |
+
provider?: 'local' | 'google' | 'proxy'
|
| 38 |
email?: string | null
|
| 39 |
avatar_url?: string | null
|
| 40 |
is_approved?: number
|
|
|
|
| 341 |
* Get user from request - checks session cookie or API key.
|
| 342 |
* For API key auth, returns a synthetic "api" user.
|
| 343 |
*/
|
| 344 |
+
/**
|
| 345 |
+
* Resolve a user by username for proxy auth.
|
| 346 |
+
* If the user does not exist and MC_PROXY_AUTH_DEFAULT_ROLE is set, auto-provisions them.
|
| 347 |
+
* Auto-provisioned users receive a random unusable password — they cannot log in locally.
|
| 348 |
+
*/
|
| 349 |
+
function resolveOrProvisionProxyUser(username: string): User | null {
|
| 350 |
+
try {
|
| 351 |
+
const db = getDatabase()
|
| 352 |
+
const { workspaceId } = getDefaultWorkspaceContext()
|
| 353 |
+
|
| 354 |
+
const row = db.prepare(`
|
| 355 |
+
SELECT u.id, u.username, u.display_name, u.role, u.workspace_id,
|
| 356 |
+
COALESCE(w.tenant_id, 1) as tenant_id,
|
| 357 |
+
u.provider, u.email, u.avatar_url, u.is_approved,
|
| 358 |
+
u.created_at, u.updated_at, u.last_login_at
|
| 359 |
+
FROM users u
|
| 360 |
+
LEFT JOIN workspaces w ON w.id = u.workspace_id
|
| 361 |
+
WHERE u.username = ?
|
| 362 |
+
`).get(username) as UserQueryRow | undefined
|
| 363 |
+
|
| 364 |
+
if (row) {
|
| 365 |
+
if ((row.is_approved ?? 1) !== 1) return null
|
| 366 |
+
return {
|
| 367 |
+
id: row.id,
|
| 368 |
+
username: row.username,
|
| 369 |
+
display_name: row.display_name,
|
| 370 |
+
role: row.role,
|
| 371 |
+
workspace_id: row.workspace_id || workspaceId,
|
| 372 |
+
tenant_id: resolveTenantForWorkspace(row.workspace_id || workspaceId),
|
| 373 |
+
provider: row.provider || 'local',
|
| 374 |
+
email: row.email ?? null,
|
| 375 |
+
avatar_url: row.avatar_url ?? null,
|
| 376 |
+
is_approved: row.is_approved ?? 1,
|
| 377 |
+
created_at: row.created_at,
|
| 378 |
+
updated_at: row.updated_at,
|
| 379 |
+
last_login_at: row.last_login_at,
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
// Auto-provision if MC_PROXY_AUTH_DEFAULT_ROLE is configured
|
| 384 |
+
const defaultRole = (process.env.MC_PROXY_AUTH_DEFAULT_ROLE || '').trim()
|
| 385 |
+
if (!defaultRole || !(['viewer', 'operator', 'admin'] as const).includes(defaultRole as User['role'])) {
|
| 386 |
+
return null
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
// Random password — proxy users cannot log in via the local login form
|
| 390 |
+
return createUser(username, randomBytes(32).toString('hex'), username, defaultRole as User['role'])
|
| 391 |
+
} catch {
|
| 392 |
+
return null
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
export function getUserFromRequest(request: Request): User | null {
|
| 397 |
// Extract agent identity header (optional, for attribution)
|
| 398 |
const agentName = (request.headers.get('x-agent-name') || '').trim() || null
|
| 399 |
|
| 400 |
+
// Proxy / trusted-header auth (MC_PROXY_AUTH_HEADER)
|
| 401 |
+
// When the gateway has already authenticated the user and injects their username
|
| 402 |
+
// as a trusted header (e.g. X-Auth-Username from Envoy OIDC claimToHeaders),
|
| 403 |
+
// skip the local login form entirely.
|
| 404 |
+
const proxyAuthHeader = (process.env.MC_PROXY_AUTH_HEADER || '').trim()
|
| 405 |
+
if (proxyAuthHeader) {
|
| 406 |
+
const proxyUsername = (request.headers.get(proxyAuthHeader) || '').trim()
|
| 407 |
+
if (proxyUsername) {
|
| 408 |
+
const user = resolveOrProvisionProxyUser(proxyUsername)
|
| 409 |
+
if (user) return { ...user, agent_name: agentName }
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
// Check session cookie
|
| 414 |
const cookieHeader = request.headers.get('cookie') || ''
|
| 415 |
const sessionToken = parseMcSessionCookieHeader(cookieHeader)
|