Files
cairnobs/enterprise/internal/config/config.go
T
jcoffey-dev 08a90a27aa Build SAML login (enterprise/internal/loginhandler), mirroring OIDC
Adds GET /auth/saml/login + POST /auth/saml/acs alongside the existing
OIDC pair, both converging on the same upsert-user/resolve-tenant/
issue-session path. loginhandler.New now takes an optional
*saml.ServiceProvider, RegisterRoutes registers each protocol's routes
independently so either, both, or neither can be configured. SAML's
replay/unsolicited-response defense (InResponseTo, standing in for
OIDC's state) is carried via a SameSite=None sentry_saml_request cookie
-- None because the ACS endpoint receives a cross-site POST from the
IdP's origin, which SameSite=Lax cookies are never sent on.
enterprise-auth's main.go now fetches+parses SAML_IDP_METADATA_URL at
startup (samlsp.FetchMetadata) and wires the result through.

Verified to the same bar as OIDC: a real fake IdP
(crewjam/saml/samlidp, genuine XML signing/verification) drives the
full login->ACS->session-cookie round trip and negative paths (bad
InResponseTo, missing request cookie, missing email/NameID, no/multiple
tenant memberships), all in loginhandler/saml_test.go, no Docker
needed. The login-form HTML is bypassed by pre-seeding a saml.Session
directly into samlidp's session store and presenting the matching
`session` cookie -- an IdP-supported shortcut (confirmed by reading
GetSession), the same "skip the UI, keep the crypto real" approach
oidctest gave the OIDC tests.

Writing that test caught two real bugs in internal/saml.ParseResponse,
both fixed here: it never called r.ParseForm() before reading the
POSTed SAMLResponse field, so every real ACS POST would have silently
decoded an empty response; and its email-attribute matching missed
urn:oid:0.9.2342.19200300.100.1.3 (the standard LDAP "mail" OID), which
is what an IdP sends by default absent an explicit
AttributeConsumingService request for "email" -- exactly what
samlidp's own DefaultAssertionMaker does, and plausibly what real IdPs'
default SAML app templates do too.

Docs (CLAUDE.md, threat-model.md, architecture.md, enterprise/README.md,
phase-4-runbook.md, docker-compose.yml's enterprise-auth comment)
updated in lockstep: SAML login moves from "protocol mechanics only" to
"built, verified with a real fake IdP, not yet tried against a real
external IdP or a running enterprise-auth container" -- the same
disclosed gap OIDC already carried.
2026-08-14 06:39:36 -07:00

95 lines
2.9 KiB
Go

// Package config loads enterprise-auth's configuration from environment
// variables, same convention as every other Go service in this repo.
package config
import (
"fmt"
"os"
)
type Config struct {
HTTPListenAddr string
Postgres PostgresConfig
OIDC OIDCConfig
SAML SAMLConfig
SessionSigningKey []byte
// PostLoginRedirectURL is where the browser lands after
// internal/loginhandler sets a session cookie -- web's base URL in
// a real deployment.
PostLoginRedirectURL string
}
type PostgresConfig struct {
Addr string
Database string
Username string
Password string
}
// OIDCConfig is optional -- a deployment might configure OIDC, SAML,
// both, or (during early rollout) neither yet. Load() doesn't fail if
// these are unset; internal/oidc.New is only called once IssuerURL is
// actually present.
type OIDCConfig struct {
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
}
// SAMLConfig is likewise optional. cmd/enterprise-auth/main.go fetches
// and parses IDPMetadataURL into the *saml.EntityDescriptor
// internal/saml.New requires at startup (crewjam/saml's
// samlsp.FetchMetadata) -- this struct just carries the raw config
// values this package's job (env-var loading) is scoped to.
type SAMLConfig struct {
EntityID string
ACSURL string
IDPMetadataURL string
}
func Load() (Config, error) {
cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8082"),
Postgres: PostgresConfig{
Addr: getenv("POSTGRES_ADDR", "localhost:5432"),
Database: getenv("POSTGRES_DATABASE", "sentry_metadata"),
Username: getenv("POSTGRES_USERNAME", "sentry"),
Password: getenv("POSTGRES_PASSWORD", ""),
},
OIDC: OIDCConfig{
IssuerURL: getenv("OIDC_ISSUER_URL", ""),
ClientID: getenv("OIDC_CLIENT_ID", ""),
ClientSecret: getenv("OIDC_CLIENT_SECRET", ""),
RedirectURL: getenv("OIDC_REDIRECT_URL", ""),
},
SAML: SAMLConfig{
EntityID: getenv("SAML_ENTITY_ID", ""),
ACSURL: getenv("SAML_ACS_URL", ""),
IDPMetadataURL: getenv("SAML_IDP_METADATA_URL", ""),
},
PostLoginRedirectURL: getenv("POST_LOGIN_REDIRECT_URL", "http://localhost:3000"),
}
// Required, unlike OIDC/SAML above: every enterprise-auth deployment
// issues and validates session/service tokens (internal/session),
// even one that hasn't configured any IdP yet. 32 bytes matches
// internal/session.MinSigningKeyBytes -- not imported here to avoid
// a config->session dependency for one constant, but the two values
// must be kept in sync.
signingKey := getenv("ENTERPRISE_SESSION_SIGNING_KEY", "")
if len(signingKey) < 32 {
return Config{}, fmt.Errorf("ENTERPRISE_SESSION_SIGNING_KEY must be set to at least 32 bytes (got %d)", len(signingKey))
}
cfg.SessionSigningKey = []byte(signingKey)
return cfg, nil
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}