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.
62 lines
1.7 KiB
Go
62 lines
1.7 KiB
Go
package saml
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/crewjam/saml"
|
|
)
|
|
|
|
func fakeIDPMetadata() *saml.EntityDescriptor {
|
|
return &saml.EntityDescriptor{
|
|
EntityID: "https://idp.example.com/metadata",
|
|
IDPSSODescriptors: []saml.IDPSSODescriptor{
|
|
{
|
|
SingleSignOnServices: []saml.Endpoint{
|
|
{Binding: saml.HTTPRedirectBinding, Location: "https://idp.example.com/sso"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsMissingConfig(t *testing.T) {
|
|
_, err := New(Config{})
|
|
if err == nil {
|
|
t.Fatalf("expected an error for an empty config")
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsMissingIDPMetadata(t *testing.T) {
|
|
_, err := New(Config{EntityID: "https://sentry.example.com/saml/metadata", ACSURL: "https://sentry.example.com/saml/acs"})
|
|
if err == nil {
|
|
t.Fatalf("expected an error when IDPMetadata is missing")
|
|
}
|
|
}
|
|
|
|
// TestLoginURLBuildsAgainstRealIDPMetadata exercises the actual
|
|
// crewjam/saml AuthnRequest-building and redirect-encoding path (deflate
|
|
// + base64 + query-string construction) against IdP metadata shaped like
|
|
// what a real IdP publishes, confirming the wiring produces a usable
|
|
// redirect rather than just "the code compiles."
|
|
func TestLoginURLBuildsAgainstRealIDPMetadata(t *testing.T) {
|
|
sp, err := New(Config{
|
|
EntityID: "https://sentry.example.com/saml/metadata",
|
|
ACSURL: "https://sentry.example.com/saml/acs",
|
|
IDPMetadata: fakeIDPMetadata(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
redirectURL, requestID, err := sp.LoginURL("relay-state-123")
|
|
if err != nil {
|
|
t.Fatalf("LoginURL: %v", err)
|
|
}
|
|
if redirectURL == "" {
|
|
t.Fatalf("expected a non-empty redirect URL")
|
|
}
|
|
if requestID == "" {
|
|
t.Fatalf("expected a non-empty AuthnRequest ID -- callers need this for ParseResponse's possibleRequestIDs")
|
|
}
|
|
}
|