// Mirrors loginhandler_test.go's OIDC approach: exercise the full SAML // login flow against a real fake IdP rather than mocking anything. // crewjam/saml ships samlidp, a genuine SAML identity provider (real XML // signing, real assertion construction) meant for exactly this kind of // testing. To avoid driving its HTML login form, a valid saml.Session is // seeded directly into the IdP's session store and presented via the // `session` cookie GetSession already accepts -- confirmed by reading // samlidp's own GetSession implementation, the same "skip the UI, keep // the crypto real" shortcut oidctest gives the OIDC tests above. package loginhandler import ( "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/xml" "fmt" "html" "io" "log/slog" "math/big" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "testing" "time" "github.com/crewjam/saml" "github.com/crewjam/saml/samlidp" "github.com/sentry/sentry/enterprise/internal/rbacstore" samlpkg "github.com/sentry/sentry/enterprise/internal/saml" ) const ( testSAMLEntityID = "https://sentry-test.example.com/saml/metadata" testSAMLACSURL = "https://sentry-test.example.com/auth/saml/acs" ) // testSAMLIdP bundles a real samlidp.Server with the SP key/cert it was // registered against, enough to drive a full SP-initiated login. type testSAMLIdP struct { server *samlidp.Server store *samlidp.MemoryStore spKey *rsa.PrivateKey spCert *x509.Certificate } func genSelfSignedCert(t *testing.T, commonName string) (*rsa.PrivateKey, *x509.Certificate) { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("generating RSA key: %v", err) } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { t.Fatalf("generating serial number: %v", err) } template := x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{CommonName: commonName}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, BasicConstraintsValid: true, } der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) if err != nil { t.Fatalf("creating certificate: %v", err) } cert, err := x509.ParseCertificate(der) if err != nil { t.Fatalf("parsing certificate: %v", err) } return key, cert } // newTestSAMLIdP starts a real samlidp.Server and registers Sentry's SP // metadata with it directly via the IdP's own PUT /services/{id} // endpoint -- the same mechanism a real IdP admin uses, not a shortcut // that reaches into samlidp's unexported state. func newTestSAMLIdP(t *testing.T) *testSAMLIdP { t.Helper() idpKey, idpCert := genSelfSignedCert(t, "sentry-test-idp") spKey, spCert := genSelfSignedCert(t, "sentry-test-sp") store := &samlidp.MemoryStore{} idpServer, err := samlidp.New(samlidp.Options{ Key: idpKey, Certificate: idpCert, Store: store, URL: url.URL{Scheme: "http", Host: "idp.example.com"}, }) if err != nil { t.Fatalf("samlidp.New: %v", err) } entityIDURL, err := url.Parse(testSAMLEntityID) if err != nil { t.Fatalf("parsing test entity id: %v", err) } acsURL, err := url.Parse(testSAMLACSURL) if err != nil { t.Fatalf("parsing test acs url: %v", err) } // A throwaway saml.ServiceProvider built from the same // EntityID/ACSURL/cert samlpkg.New below uses -- Metadata() is a // pure function of those exported fields, so this stays consistent // with the real samlpkg.ServiceProvider without needing access to // its unexported inner sp field. spForRegistration := saml.ServiceProvider{ Key: spKey, Certificate: spCert, MetadataURL: *entityIDURL, AcsURL: *acsURL, } spMetadataXML, err := xml.Marshal(spForRegistration.Metadata()) if err != nil { t.Fatalf("marshaling sp metadata: %v", err) } putReq := httptest.NewRequest(http.MethodPut, "/services/sentry-test-sp", strings.NewReader(string(spMetadataXML))) putRec := httptest.NewRecorder() idpServer.ServeHTTP(putRec, putReq) if putRec.Code != http.StatusNoContent { t.Fatalf("registering sp metadata with fake idp: status = %d, body = %s", putRec.Code, putRec.Body.String()) } return &testSAMLIdP{server: idpServer, store: store, spKey: spKey, spCert: spCert} } // serviceProvider builds the samlpkg.ServiceProvider loginhandler uses, // trusting idp's metadata. func (idp *testSAMLIdP) serviceProvider(t *testing.T) *samlpkg.ServiceProvider { t.Helper() sp, err := samlpkg.New(samlpkg.Config{ EntityID: testSAMLEntityID, ACSURL: testSAMLACSURL, IDPMetadata: idp.server.IDP.Metadata(), Certificate: &tls.Certificate{ Certificate: [][]byte{idp.spCert.Raw}, PrivateKey: idp.spKey, }, }) if err != nil { t.Fatalf("samlpkg.New: %v", err) } return sp } // seedSession pre-authenticates a user directly in the fake IdP's store, // bypassing its login-form HTML entirely. Confirmed viable by reading // samlidp's GetSession: a valid, non-expired saml.Session at // /sessions/ plus a matching `session` cookie is exactly what a real // login-form POST would have produced -- this is the IdP's own supported // shortcut, not an abuse of internals. func (idp *testSAMLIdP) seedSession(t *testing.T, nameID, email string) *http.Cookie { t.Helper() sessionID := fmt.Sprintf("test-session-%d", time.Now().UnixNano()) session := &saml.Session{ ID: sessionID, NameID: nameID, CreateTime: saml.TimeNow(), ExpireTime: saml.TimeNow().Add(time.Hour), Index: sessionID, UserEmail: email, } if err := idp.store.Put(fmt.Sprintf("/sessions/%s", sessionID), session); err != nil { t.Fatalf("seeding idp session: %v", err) } return &http.Cookie{Name: "session", Value: sessionID} } var samlResponseFieldRe = regexp.MustCompile(`name="(SAMLResponse|RelayState)" value="([^"]*)"`) // extractSAMLResponseForm pulls the hidden form fields out of the IdP's // auto-submitting HTML response -- what a real browser's inline