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.
201 lines
7.3 KiB
Go
201 lines
7.3 KiB
Go
// Package saml wires crewjam/saml into a small SP (service provider)
|
|
// client: build the login redirect, and validate/parse an incoming
|
|
// assertion. Deliberately not using crewjam's samlsp.Middleware, which
|
|
// owns its own session/cookie handling -- Sentry's session concept lives
|
|
// in internal/session, one layer up, so this package only does the SAML
|
|
// protocol mechanics (XML signing/parsing), per the explicit instruction
|
|
// not to hand-roll that crypto.
|
|
package saml
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/crewjam/saml"
|
|
)
|
|
|
|
type Config struct {
|
|
// EntityID identifies Sentry to the IdP, conventionally Sentry's own
|
|
// metadata URL.
|
|
EntityID string
|
|
// ACSURL is where the IdP redirects the browser back to with the
|
|
// assertion (the "assertion consumer service" endpoint).
|
|
ACSURL string
|
|
// IDPMetadata is the IdP's metadata XML, fetched out-of-band (IdP
|
|
// admin provides a URL or a file) and parsed by the caller via
|
|
// samltypes/crewjam's metadata parsing -- kept out of this package's
|
|
// constructor so it isn't doing its own network fetch of
|
|
// admin-supplied, potentially untrusted URLs.
|
|
IDPMetadata *saml.EntityDescriptor
|
|
// Certificate/Key sign outgoing AuthnRequests and are required by
|
|
// crewjam/saml's ServiceProvider even when the IdP doesn't mandate
|
|
// signed requests. If nil, New generates a self-signed keypair --
|
|
// fine for development, but a real deployment should supply a
|
|
// certificate its IdP is configured to trust for encrypted
|
|
// assertions, not rely on the generated one long-term.
|
|
Certificate *tls.Certificate
|
|
}
|
|
|
|
type ServiceProvider struct {
|
|
sp saml.ServiceProvider
|
|
}
|
|
|
|
func New(cfg Config) (*ServiceProvider, error) {
|
|
if cfg.EntityID == "" || cfg.ACSURL == "" {
|
|
return nil, fmt.Errorf("saml: EntityID and ACSURL are required")
|
|
}
|
|
if cfg.IDPMetadata == nil {
|
|
return nil, fmt.Errorf("saml: IDPMetadata is required")
|
|
}
|
|
|
|
cert := cfg.Certificate
|
|
if cert == nil {
|
|
generated, err := selfSignedCert()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("saml: generating a self-signed certificate: %w", err)
|
|
}
|
|
cert = generated
|
|
}
|
|
|
|
acsURL, err := url.Parse(cfg.ACSURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("saml: parsing ACSURL: %w", err)
|
|
}
|
|
entityID, err := url.Parse(cfg.EntityID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("saml: parsing EntityID: %w", err)
|
|
}
|
|
|
|
return &ServiceProvider{
|
|
sp: saml.ServiceProvider{
|
|
Key: cert.PrivateKey.(*rsa.PrivateKey),
|
|
Certificate: parseLeaf(cert),
|
|
MetadataURL: *entityID,
|
|
AcsURL: *acsURL,
|
|
IDPMetadata: cfg.IDPMetadata,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// LoginURL builds the redirect that starts SP-initiated SSO. relayState
|
|
// round-trips through the IdP and comes back with the response --
|
|
// typically where to send the browser after login completes, validated
|
|
// by the caller the same way OIDC's state parameter is (this package
|
|
// doesn't store it). Also returns the AuthnRequest's ID: the caller must
|
|
// persist it (e.g. a short-lived cookie, the same pattern
|
|
// enterprise/internal/loginhandler uses for OIDC's state) and pass it
|
|
// back via ParseResponse's possibleRequestIDs -- SAML's actual replay/
|
|
// unsolicited-response defense, standing in for OIDC's simpler state
|
|
// check. Skipping this (e.g. passing nil to ParseResponse) is exactly
|
|
// the "don't validate you asked for this response" mistake that would
|
|
// let an attacker replay a captured assertion.
|
|
func (s *ServiceProvider) LoginURL(relayState string) (redirectURL, requestID string, err error) {
|
|
req, err := s.sp.MakeAuthenticationRequest(s.sp.GetSSOBindingLocation(saml.HTTPRedirectBinding), saml.HTTPRedirectBinding, saml.HTTPPostBinding)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("saml: building authentication request: %w", err)
|
|
}
|
|
redirect, err := req.Redirect(relayState, &s.sp)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("saml: building redirect URL: %w", err)
|
|
}
|
|
return redirect.String(), req.ID, nil
|
|
}
|
|
|
|
// Claims is the subset of an assertion Sentry uses -- same "extend
|
|
// deliberately" reasoning as oidc.Claims.
|
|
type Claims struct {
|
|
NameID string
|
|
Email string
|
|
}
|
|
|
|
// ParseResponse validates an incoming SAML response (signature, issuer,
|
|
// audience, timing) and extracts the fields Sentry cares about. This is
|
|
// the step that actually establishes trust -- crewjam/saml's
|
|
// ParseResponse does the XML signature verification, not this package.
|
|
func (s *ServiceProvider) ParseResponse(r *http.Request, possibleRequestIDs []string) (*Claims, error) {
|
|
// crewjam/saml's ServiceProvider.ParseResponse reads req.PostForm
|
|
// directly rather than parsing the body itself -- net/http only
|
|
// populates PostForm once something calls ParseForm, which the
|
|
// stdlib server never does on its own. Skipping this turns every
|
|
// real ACS POST into an empty SAMLResponse (silently failing at the
|
|
// base64-decode step), a bug this package's own real-fake-IdP test
|
|
// caught immediately since it drives a genuine POST body.
|
|
if err := r.ParseForm(); err != nil {
|
|
return nil, fmt.Errorf("saml: parsing ACS POST body: %w", err)
|
|
}
|
|
|
|
assertion, err := s.sp.ParseResponse(r, possibleRequestIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("saml: parsing/validating response: %w", err)
|
|
}
|
|
|
|
claims := &Claims{}
|
|
if assertion.Subject != nil && assertion.Subject.NameID != nil {
|
|
claims.NameID = assertion.Subject.NameID.Value
|
|
}
|
|
for _, stmt := range assertion.AttributeStatements {
|
|
for _, attr := range stmt.Attributes {
|
|
switch attr.Name {
|
|
// "email" and the ADFS claims URI are what an IdP admin gets
|
|
// by explicitly naming the attribute that way in the SAML
|
|
// app's attribute-statement config. urn:oid:0.9.2342.19200300.100.1.3
|
|
// is the standard LDAP "mail" OID -- what crewjam's own
|
|
// DefaultAssertionMaker (and many real IdPs' default
|
|
// templates) send when nothing more specific was requested,
|
|
// found by tracing the actual assertion-building code this
|
|
// test exercises rather than assuming "email" covers it.
|
|
case "email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "urn:oid:0.9.2342.19200300.100.1.3":
|
|
if len(attr.Values) > 0 {
|
|
claims.Email = attr.Values[0].Value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
func parseLeaf(cert *tls.Certificate) *x509.Certificate {
|
|
if len(cert.Certificate) == 0 {
|
|
return nil
|
|
}
|
|
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return leaf
|
|
}
|
|
|
|
// selfSignedCert generates a throwaway RSA keypair + certificate for
|
|
// development use, per Config.Certificate's doc comment.
|
|
func selfSignedCert() (*tls.Certificate, error) {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
template := x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{CommonName: "sentry-saml-sp-dev"},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().Add(24 * time.Hour * 365),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
BasicConstraintsValid: true,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil
|
|
}
|