mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 14:07:31 +00:00
b3b60f0632
With allowIdpInitiated off, the default, every SAML login failed: ProcessResponse passed crewjam/saml no request IDs, and crewjam refuses a Response whose InResponseTo is not among them. Only providers that allowed IdP-initiated logins, which skips the check, could sign in. MakeAuthRequest now records each AuthnRequest ID with the login's returnTo and the hash of a random token that /login sets as the HttpOnly SameSite=Lax cookie pulse_saml_login (__Host- over HTTPS), reused across tabs. The IdP posts its Response cross-site, which withholds Lax cookies, so the ACS answers a cookieless POST with a page that re-posts the same fields once from Pulse's origin. The Response must answer an unexpired request bound to the presenting browser; it spends that request and returns to the recorded path. Another browser's request is left for its owner. The request ID is read from the signature covering the assertion crewjam returns (the signed Response, else that assertion's own SubjectConfirmationData), not from crewjam's parsed fields and not from other signed assertions riding in the same unsigned Response. encoding/xml fills an un-namespaced attribute field from a namespace declaration with the same local name, and exclusive canonicalization drops an unused one from the signed form, so xmlns:InResponseTo could rebind a signed Response and xmlns:IssueInstant / xmlns:NotOnOrAfter let a stale one pass crewjam's age checks: an IdP-initiated login could be replayed indefinitely. The signature verifier now refuses signed content declaring a prefix named like an attribute crewjam reads, in both modes. RelayState is now escaped into the redirect query, so a returnTo with its own query string reaches the IdP whole.
152 lines
4.6 KiB
Go
152 lines
4.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/crewjam/saml"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
)
|
|
|
|
func TestParseIDPMetadataXML_EmptyEntities(t *testing.T) {
|
|
wrapped := `<?xml version="1.0"?>
|
|
<EntitiesDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
|
|
</EntitiesDescriptor>`
|
|
if _, err := parseIDPMetadataXML([]byte(wrapped)); err == nil {
|
|
t.Fatal("expected error for empty entities descriptor")
|
|
}
|
|
}
|
|
|
|
func TestExtractAttribute(t *testing.T) {
|
|
service := &SAMLService{}
|
|
attrs := map[string][]string{
|
|
"email": {"user@example.com"},
|
|
}
|
|
|
|
if got := service.extractAttribute(attrs, "", "fallback"); got != "fallback" {
|
|
t.Fatalf("expected fallback value, got %q", got)
|
|
}
|
|
if got := service.extractAttribute(attrs, "email", ""); got != "user@example.com" {
|
|
t.Fatalf("unexpected attribute value: %q", got)
|
|
}
|
|
if got := service.extractAttribute(attrs, "missing", "default"); got != "default" {
|
|
t.Fatalf("unexpected missing attribute value: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestProcessResponse_InvalidResponse(t *testing.T) {
|
|
service := &SAMLService{
|
|
sp: &saml.ServiceProvider{},
|
|
}
|
|
|
|
body := strings.NewReader("RelayState=/infrastructure&SAMLResponse=")
|
|
req := httptest.NewRequest(http.MethodPost, "/acs", body)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
_, relay, err := service.ProcessResponse(req, "")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid response")
|
|
}
|
|
if relay != "/infrastructure" {
|
|
t.Fatalf("unexpected relay state: %q", relay)
|
|
}
|
|
}
|
|
|
|
func TestSAMLServiceIdentifiers(t *testing.T) {
|
|
service := &SAMLService{
|
|
providerID: "provider-1",
|
|
sp: &saml.ServiceProvider{EntityID: "sp-entity"},
|
|
idpMetadata: &saml.EntityDescriptor{
|
|
EntityID: "idp-entity",
|
|
},
|
|
}
|
|
|
|
if service.ProviderID() != "provider-1" {
|
|
t.Fatalf("unexpected provider id")
|
|
}
|
|
if service.GetSPEntityID() != "sp-entity" {
|
|
t.Fatalf("unexpected sp entity id")
|
|
}
|
|
if service.GetIDPEntityID() != "idp-entity" {
|
|
t.Fatalf("unexpected idp entity id")
|
|
}
|
|
|
|
service.sp = nil
|
|
if service.GetSPEntityID() != "" {
|
|
t.Fatalf("expected empty sp entity id when nil")
|
|
}
|
|
service.idpMetadata = nil
|
|
if service.GetIDPEntityID() != "" {
|
|
t.Fatalf("expected empty idp entity id when nil")
|
|
}
|
|
}
|
|
|
|
func TestRefreshMetadata_Success(t *testing.T) {
|
|
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`<?xml version="1.0"?>
|
|
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="idp-refresh">
|
|
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"></IDPSSODescriptor>
|
|
</EntityDescriptor>`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &config.SAMLProviderConfig{IDPMetadataURL: server.URL}
|
|
service := &SAMLService{
|
|
providerID: "idp",
|
|
config: cfg,
|
|
baseURL: "http://localhost",
|
|
httpClient: server.Client(),
|
|
}
|
|
|
|
if err := service.RefreshMetadata(context.Background()); err != nil {
|
|
t.Fatalf("refresh metadata: %v", err)
|
|
}
|
|
if service.sp == nil {
|
|
t.Fatal("expected service provider to be initialized")
|
|
}
|
|
}
|
|
|
|
func TestFetchIDPMetadataFromURL_NonOK(t *testing.T) {
|
|
server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer server.Close()
|
|
|
|
service := &SAMLService{httpClient: server.Client()}
|
|
if _, err := service.fetchIDPMetadataFromURL(context.Background(), server.URL); err == nil {
|
|
t.Fatal("expected error for non-200 status")
|
|
}
|
|
}
|
|
|
|
func TestFetchIDPMetadataFromURL_RejectsCrossOriginRedirect(t *testing.T) {
|
|
target := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(testSAMLMetadata))
|
|
}))
|
|
defer target.Close()
|
|
|
|
origin := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, target.URL+r.URL.Path, http.StatusFound)
|
|
}))
|
|
defer origin.Close()
|
|
|
|
service := &SAMLService{httpClient: newSAMLHTTPClient()}
|
|
if _, err := service.fetchIDPMetadataFromURL(context.Background(), origin.URL+"/metadata"); err == nil || !strings.Contains(err.Error(), "same origin") {
|
|
t.Fatalf("expected same-origin redirect rejection, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAddIDPCertificate_InvalidPEM(t *testing.T) {
|
|
service := &SAMLService{config: &config.SAMLProviderConfig{IDPCertificate: "not-pem"}}
|
|
metadata := &saml.EntityDescriptor{
|
|
IDPSSODescriptors: []saml.IDPSSODescriptor{{}},
|
|
}
|
|
if err := service.addIDPCertificate(metadata); err == nil {
|
|
t.Fatal("expected error for invalid certificate pem")
|
|
}
|
|
}
|