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.
This commit is contained in:
@@ -2,19 +2,19 @@
|
||||
// the actual HTTP login/callback flow that issues a *human* session,
|
||||
// not just /alerting's RoleService credential (-mint-service-token) or
|
||||
// the RBAC-enforcement plumbing that assumes a session already exists.
|
||||
// enterprise/internal/oidc does the OAuth2/OIDC protocol mechanics
|
||||
// (discovery, the auth-code redirect, code exchange, ID token
|
||||
// verification); this package is the two HTTP handlers that drive it
|
||||
// and decide what happens with a verified identity: look up or create a
|
||||
// users row, resolve which tenant/role that user belongs to, and issue
|
||||
// a session.Manager-signed session cookie.
|
||||
// enterprise/internal/oidc and enterprise/internal/saml do the protocol
|
||||
// mechanics (discovery/AuthnRequest generation, code exchange/assertion
|
||||
// parsing, signature verification); this package is the HTTP handlers
|
||||
// that drive them and decide what happens with a verified identity: look
|
||||
// up or create a users row, resolve which tenant/role that user belongs
|
||||
// to, and issue a session.Manager-signed session cookie. Both protocols
|
||||
// share that decision (resolveIdentity below) -- only how the identity
|
||||
// gets verified differs.
|
||||
//
|
||||
// Deliberately out of scope here: SAML's equivalent (ACS endpoint) --
|
||||
// same shape, not yet built, following this package's pattern once it
|
||||
// is. Multi-tenant users (one identity with memberships in more than
|
||||
// one tenant) are refused with a clear error rather than guessing which
|
||||
// tenant to log them into -- a tenant-selection step is real,
|
||||
// undesigned future work, not silently approximated.
|
||||
// Multi-tenant users (one identity with memberships in more than one
|
||||
// tenant) are refused with a clear error rather than guessing which
|
||||
// tenant to log them into -- a tenant-selection step is real, undesigned
|
||||
// future work, not silently approximated.
|
||||
package loginhandler
|
||||
|
||||
import (
|
||||
@@ -28,20 +28,28 @@ import (
|
||||
"github.com/sentry/sentry/enterprise/internal/authhandler"
|
||||
"github.com/sentry/sentry/enterprise/internal/oidc"
|
||||
"github.com/sentry/sentry/enterprise/internal/rbacstore"
|
||||
"github.com/sentry/sentry/enterprise/internal/saml"
|
||||
"github.com/sentry/sentry/enterprise/internal/session"
|
||||
)
|
||||
|
||||
// stateCookieName carries the CSRF-protection state value between the
|
||||
// login redirect and the callback -- a short-lived, scoped-to-the-
|
||||
// oidcStateCookieName carries OIDC's CSRF-protection state value between
|
||||
// the login redirect and the callback -- a short-lived, scoped-to-the-
|
||||
// callback-path cookie (the "double-submit cookie" pattern) rather than
|
||||
// server-side state, since this service otherwise has no per-browser
|
||||
// session store to put it in before a session exists.
|
||||
const stateCookieName = "sentry_oidc_state"
|
||||
const oidcStateCookieName = "sentry_oidc_state"
|
||||
|
||||
// stateCookieTTL bounds how long a user has to complete the IdP round
|
||||
// samlRequestCookieName is SAML's analog -- carries the AuthnRequest ID
|
||||
// LoginURL generated, so the ACS handler can pass it back to
|
||||
// ParseResponse's possibleRequestIDs (SAML's actual replay/unsolicited-
|
||||
// response defense -- see saml.ServiceProvider.LoginURL's doc comment).
|
||||
const samlRequestCookieName = "sentry_saml_request"
|
||||
|
||||
// loginCookieTTL bounds how long a user has to complete the IdP round
|
||||
// trip -- generous enough for a real login form, short enough that a
|
||||
// stale state cookie isn't a long-lived CSRF token sitting in a browser.
|
||||
const stateCookieTTL = 10 * time.Minute
|
||||
// stale cookie isn't a long-lived CSRF token sitting in a browser.
|
||||
// Shared by both protocols' cookies.
|
||||
const loginCookieTTL = 10 * time.Minute
|
||||
|
||||
// userStore is the narrow interface Handler depends on -- *rbacstore.Store
|
||||
// is the production implementation; tests use a fake, same pattern used
|
||||
@@ -60,9 +68,16 @@ type oidcProvider interface {
|
||||
Exchange(ctx context.Context, code string) (*oidc.Claims, error)
|
||||
}
|
||||
|
||||
// samlProvider mirrors oidcProvider's reasoning for SAML.
|
||||
type samlProvider interface {
|
||||
LoginURL(relayState string) (redirectURL, requestID string, err error)
|
||||
ParseResponse(r *http.Request, possibleRequestIDs []string) (*saml.Claims, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
logger *slog.Logger
|
||||
oidc oidcProvider // nil if OIDC isn't configured -- RegisterRoutes registers nothing in that case
|
||||
saml samlProvider // nil if SAML isn't configured -- same
|
||||
session *session.Manager
|
||||
users userStore
|
||||
// postLoginRedirectURL is where the browser lands after a session
|
||||
@@ -70,36 +85,46 @@ type Handler struct {
|
||||
postLoginRedirectURL string
|
||||
}
|
||||
|
||||
// New takes a concrete *oidc.Provider (nilable), not the oidcProvider
|
||||
// interface directly -- a nil *oidc.Provider assigned straight into an
|
||||
// interface-typed field would produce a non-nil interface wrapping a
|
||||
// nil pointer (Go's classic typed-nil trap), which would silently break
|
||||
// RegisterRoutes'/handleLogin's `h.oidc == nil` checks the moment a
|
||||
// caller (enterprise-auth's main.go) passes a `var p *oidc.Provider`
|
||||
// that's legitimately still nil because OIDC isn't configured. Checking
|
||||
// the concrete pointer here, before it ever becomes the interface
|
||||
// field, is what keeps that check meaningful.
|
||||
func New(logger *slog.Logger, provider *oidc.Provider, sessionManager *session.Manager, users userStore, postLoginRedirectURL string) *Handler {
|
||||
// New takes concrete *oidc.Provider/*saml.ServiceProvider (both
|
||||
// nilable), not the narrower interfaces directly -- assigning a nil
|
||||
// pointer straight into an interface-typed field would produce a
|
||||
// non-nil interface wrapping a nil pointer (Go's classic typed-nil
|
||||
// trap), which would silently break RegisterRoutes'/the handlers'
|
||||
// `h.oidc == nil`/`h.saml == nil` checks the moment a caller
|
||||
// (enterprise-auth's main.go) passes a `var p *oidc.Provider` that's
|
||||
// legitimately still nil because that protocol isn't configured.
|
||||
// Checking the concrete pointers here, before they ever become the
|
||||
// interface fields, is what keeps those checks meaningful -- see
|
||||
// loginhandler_test.go's TestRegisterRoutesNoOpWithTypedNilProviderVariable
|
||||
// for the regression test that caught this the first time (OIDC; SAML
|
||||
// follows the same fix from day one).
|
||||
func New(logger *slog.Logger, oidcProvider *oidc.Provider, samlProvider *saml.ServiceProvider, sessionManager *session.Manager, users userStore, postLoginRedirectURL string) *Handler {
|
||||
h := &Handler{logger: logger, session: sessionManager, users: users, postLoginRedirectURL: postLoginRedirectURL}
|
||||
if provider != nil {
|
||||
h.oidc = provider
|
||||
if oidcProvider != nil {
|
||||
h.oidc = oidcProvider
|
||||
}
|
||||
if samlProvider != nil {
|
||||
h.saml = samlProvider
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// RegisterRoutes registers OIDC's two routes only if OIDC is actually
|
||||
// configured (h.oidc != nil) -- matches the "absent, not broken" default
|
||||
// RegisterRoutes registers each protocol's routes only if that protocol
|
||||
// is actually configured -- matches the "absent, not broken" default
|
||||
// every other optional-config path in this codebase follows (e.g.
|
||||
// api/authz.RequireRole's nil-authorizer no-op).
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
if h.oidc == nil {
|
||||
return
|
||||
if h.oidc != nil {
|
||||
mux.HandleFunc("GET /auth/oidc/login", h.handleOIDCLogin)
|
||||
mux.HandleFunc("GET /auth/oidc/callback", h.handleOIDCCallback)
|
||||
}
|
||||
if h.saml != nil {
|
||||
mux.HandleFunc("GET /auth/saml/login", h.handleSAMLLogin)
|
||||
mux.HandleFunc("POST /auth/saml/acs", h.handleSAMLACS)
|
||||
}
|
||||
mux.HandleFunc("GET /auth/oidc/login", h.handleLogin)
|
||||
mux.HandleFunc("GET /auth/oidc/callback", h.handleCallback)
|
||||
}
|
||||
|
||||
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handler) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := oidc.NewState()
|
||||
if err != nil {
|
||||
h.logger.Error("generating oidc state", "error", err)
|
||||
@@ -107,29 +132,29 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookieName, Value: state, Path: "/auth/oidc/callback",
|
||||
Name: oidcStateCookieName, Value: state, Path: "/auth/oidc/callback",
|
||||
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(stateCookieTTL.Seconds()),
|
||||
MaxAge: int(loginCookieTTL.Seconds()),
|
||||
})
|
||||
http.Redirect(w, r, h.oidc.AuthCodeURL(state), http.StatusFound)
|
||||
}
|
||||
|
||||
// clearStateCookie is called on every path out of handleCallback --
|
||||
// the state cookie is single-use regardless of whether the login
|
||||
// ultimately succeeds, same reasoning a CSRF token gets discarded after
|
||||
// one use rather than left around for reuse.
|
||||
func clearStateCookie(w http.ResponseWriter, r *http.Request) {
|
||||
// clearCookie is called on every path out of the two callback handlers
|
||||
// below -- the state/request cookie is single-use regardless of whether
|
||||
// the login ultimately succeeds, same reasoning a CSRF token gets
|
||||
// discarded after one use rather than left around for reuse.
|
||||
func clearCookie(w http.ResponseWriter, r *http.Request, name, path string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookieName, Value: "", Path: "/auth/oidc/callback",
|
||||
Name: name, Value: "", Path: path,
|
||||
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
defer clearStateCookie(w, r)
|
||||
func (h *Handler) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
defer clearCookie(w, r, oidcStateCookieName, "/auth/oidc/callback")
|
||||
|
||||
stateCookie, err := r.Cookie(stateCookieName)
|
||||
stateCookie, err := r.Cookie(oidcStateCookieName)
|
||||
if err != nil || stateCookie.Value == "" {
|
||||
http.Error(w, "missing or expired login state -- start over at /auth/oidc/login", http.StatusBadRequest)
|
||||
return
|
||||
@@ -156,9 +181,79 @@ func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
identity, status, err := h.resolveIdentity(r.Context(), claims)
|
||||
h.finishLogin(w, r, claims.Subject, claims.Email)
|
||||
}
|
||||
|
||||
func (h *Handler) handleSAMLLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// relayState isn't used to carry anything here (postLoginRedirectURL
|
||||
// is a fixed server-side config, not per-request) -- still generated
|
||||
// fresh per login and round-tripped, since crewjam/saml's API expects
|
||||
// one and an empty/constant value would be a needless deviation from
|
||||
// how a real SP-initiated flow looks.
|
||||
relayState, err := oidc.NewState() // same random-value generator, protocol-agnostic despite the package name
|
||||
if err != nil {
|
||||
h.logger.Error("resolving identity after oidc login", "error", err, "email", claims.Email)
|
||||
h.logger.Error("generating saml relay state", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
redirectURL, requestID, err := h.saml.LoginURL(relayState)
|
||||
if err != nil {
|
||||
h.logger.Error("building saml login url", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// SameSiteNoneMode, not Lax like OIDC's state cookie: SAML's
|
||||
// HTTP-POST binding means the browser POSTs to /auth/saml/acs
|
||||
// *from the IdP's origin* -- a cross-site POST, which SameSite=Lax
|
||||
// cookies are never sent on (Lax only exempts top-level GET
|
||||
// navigations, which is what OIDC's redirect-based callback is, but
|
||||
// SAML's response delivery isn't). SameSite=None requires Secure
|
||||
// per the cookie spec, so this genuinely needs the deployment to be
|
||||
// on HTTPS -- realistic for any real SAML IdP integration (they
|
||||
// require it too), but worth stating plainly: unlike OIDC, SAML
|
||||
// login will not work correctly over plain HTTP.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: samlRequestCookieName, Value: requestID, Path: "/auth/saml/acs",
|
||||
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteNoneMode,
|
||||
MaxAge: int(loginCookieTTL.Seconds()),
|
||||
})
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *Handler) handleSAMLACS(w http.ResponseWriter, r *http.Request) {
|
||||
defer clearCookie(w, r, samlRequestCookieName, "/auth/saml/acs")
|
||||
|
||||
requestCookie, err := r.Cookie(samlRequestCookieName)
|
||||
if err != nil || requestCookie.Value == "" {
|
||||
http.Error(w, "missing or expired login state -- start over at /auth/saml/login", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.saml.ParseResponse(r, []string{requestCookie.Value})
|
||||
if err != nil {
|
||||
h.logger.Error("parsing saml response", "error", err)
|
||||
http.Error(w, "login failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if claims.Email == "" {
|
||||
http.Error(w, "identity provider did not return an email attribute", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if claims.NameID == "" {
|
||||
http.Error(w, "identity provider did not return a NameID", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
h.finishLogin(w, r, claims.NameID, claims.Email)
|
||||
}
|
||||
|
||||
// finishLogin is the point both protocols converge on: a verified
|
||||
// (subject, email) pair, still needing tenant/role resolution and a
|
||||
// session cookie -- everything from here down is protocol-agnostic.
|
||||
func (h *Handler) finishLogin(w http.ResponseWriter, r *http.Request, subject, email string) {
|
||||
identity, status, err := h.resolveIdentity(r.Context(), subject, email)
|
||||
if err != nil {
|
||||
h.logger.Error("resolving identity after login", "error", err, "email", email)
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
@@ -181,7 +276,7 @@ var (
|
||||
// ErrNoMembership and ErrMultipleMemberships are exported so tests
|
||||
// (and any future caller that wants to distinguish these outcomes,
|
||||
// e.g. to render a real tenant-picker UI instead of a flat error
|
||||
// page) don't have to string-match handleCallback's HTTP error body.
|
||||
// page) don't have to string-match the HTTP error body.
|
||||
ErrNoMembership = errors.New("loginhandler: this identity has no tenant membership -- contact your administrator")
|
||||
ErrMultipleMemberships = errors.New("loginhandler: this identity belongs to multiple tenants -- tenant selection is not supported yet")
|
||||
)
|
||||
@@ -193,12 +288,14 @@ type resolvedIdentity struct {
|
||||
}
|
||||
|
||||
// resolveIdentity is the policy decision this whole package exists to
|
||||
// make: given a verified external identity, which tenant/role does it
|
||||
// map to. Deliberately conservative -- exactly one tenant_memberships
|
||||
// row is the only case handled; zero or multiple both refuse rather
|
||||
// than guess (see this package's doc comment).
|
||||
func (h *Handler) resolveIdentity(ctx context.Context, claims *oidc.Claims) (resolvedIdentity, int, error) {
|
||||
user, err := h.users.UpsertUserBySSO(ctx, claims.Subject, claims.Email, claims.Email)
|
||||
// make: given a verified external identity (subject, email -- OIDC's
|
||||
// "sub"/"email" claims or SAML's NameID/email attribute, already
|
||||
// protocol-normalized by the caller), which tenant/role does it map to.
|
||||
// Deliberately conservative -- exactly one tenant_memberships row is the
|
||||
// only case handled; zero or multiple both refuse rather than guess
|
||||
// (see this package's doc comment).
|
||||
func (h *Handler) resolveIdentity(ctx context.Context, subject, email string) (resolvedIdentity, int, error) {
|
||||
user, err := h.users.UpsertUserBySSO(ctx, subject, email, email)
|
||||
if err != nil {
|
||||
return resolvedIdentity{}, http.StatusInternalServerError, fmt.Errorf("loginhandler: upserting user: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user