Files
pulse/internal/api/saml_service_additional_test.go
T
rcourtman b3b60f0632 Bind SAML logins to the browser that started them
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.
2026-10-07 23:02:13 +01:00

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")
}
}