From 41a315c3031f9328060a5f142d034a43de6bcd94 Mon Sep 17 00:00:00 2001 From: Herman Date: Wed, 20 Aug 2025 13:09:58 +0200 Subject: [PATCH] support Scoping --- schema.go | 43 +++++++++++++++++++++++++++++++++++++++---- schema_test.go | 17 +++++++++++++++++ service_provider.go | 10 ++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/schema.go b/schema.go index 1a543de2..e252a584 100644 --- a/schema.go +++ b/schema.go @@ -48,7 +48,7 @@ type AuthnRequest struct { NameIDPolicy *NameIDPolicy `xml:"urn:oasis:names:tc:SAML:2.0:protocol NameIDPolicy"` Conditions *Conditions RequestedAuthnContext *RequestedAuthnContext - // Scoping *Scoping // TODO + Scoping *Scoping ForceAuthn *bool `xml:",attr"` IsPassive *bool `xml:",attr"` @@ -209,9 +209,9 @@ func (r *AuthnRequest) Element() *etree.Element { if r.RequestedAuthnContext != nil { el.AddChild(r.RequestedAuthnContext.Element()) } - // if r.Scoping != nil { - // el.AddChild(r.Scoping.Element()) - // } + if r.Scoping != nil { + el.AddChild(r.Scoping.Element()) + } if r.ForceAuthn != nil { el.CreateAttr("ForceAuthn", strconv.FormatBool(*r.ForceAuthn)) } @@ -321,6 +321,41 @@ func (a *NameIDPolicy) Element() *etree.Element { return el } +// Scoping represents the SAML object of the same name. +// +// See http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf ยง 3.4.1.2 +type Scoping struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol Scoping"` + ProxyCount *int `xml:",attr"` + IDPList []string `xml:"urn:oasis:names:tc:SAML:2.0:protocol IDPList"` // Only supports IDEntry, TODO support GetComplete{uri} + RequesterIDs []string `xml:"urn:oasis:names:tc:SAML:2.0:protocol RequesterID"` +} + +// Element returns an etree.Element representing the object in XML form. +func (a *Scoping) Element() *etree.Element { + el := etree.NewElement("samlp:Scoping") + if a.ProxyCount != nil { + el.CreateAttr("ProxyCount", strconv.Itoa(*a.ProxyCount)) + } + if len(a.IDPList) > 0 { + idpList := etree.NewElement("samlp:IDPList") + for _, idp := range a.IDPList { + idpEntry := etree.NewElement("samlp:IDPEntry") + idpEntry.CreateAttr("ProviderID", idp) + idpList.AddChild(idpEntry) + } + el.AddChild(idpList) + } + if len(a.RequesterIDs) > 0 { + for _, requesterID := range a.RequesterIDs { + requesterIDEntry := etree.NewElement("samlp:RequesterIDEntry") + requesterIDEntry.CreateAttr("ProviderID", requesterID) + el.AddChild(requesterIDEntry) + } + } + return el +} + // ArtifactResolve represents the SAML object of the same name. type ArtifactResolve struct { XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol ArtifactResolve"` diff --git a/schema_test.go b/schema_test.go index 3d9f793a..c5acb59b 100644 --- a/schema_test.go +++ b/schema_test.go @@ -277,3 +277,20 @@ func TestLogoutRequestMarshalWithoutNotOnOrAfter(t *testing.T) { assert.Check(t, err) assert.Check(t, is.DeepEqual(expected, actual)) } + +func TestScopingElement(t *testing.T) { + proxyCount := 2 + expected := AuthnRequest{Scoping: &Scoping{ + XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:protocol", Local: "Scoping"}, + IDPList: []string{"idp1", "idp2"}, + ProxyCount: &proxyCount, + RequesterIDs: []string{"http://uri"}, + }} + + doc := etree.NewDocument() + doc.SetRoot(expected.Element()) + x, err := doc.WriteToBytes() + assert.Check(t, err) + assert.Check(t, is.Equal(``, + string(x))) +} diff --git a/service_provider.go b/service_provider.go index c97886d0..03147fe2 100644 --- a/service_provider.go +++ b/service_provider.go @@ -113,6 +113,10 @@ type ServiceProvider struct { // authentication requests AuthnNameIDFormat NameIDFormat + // IDPList is a list of identity providers that are allowed to authenticate users. Send as part of AuthnRequest.Scoping. + // If empty, any delegate IDP can be used. + IDPList []string + // MetadataValidDuration is a duration used to calculate validUntil // attribute in the metadata endpoint MetadataValidDuration time.Duration @@ -536,6 +540,7 @@ func (sp *ServiceProvider) MakeAuthenticationRequest(idpURL string, binding stri Format: "urn:oasis:names:tc:SAML:2.0:nameid-format:entity", Value: firstSet(sp.EntityID, sp.MetadataURL.String()), }, + NameIDPolicy: &NameIDPolicy{ AllowCreate: &allowCreate, // TODO(ross): figure out exactly policy we need @@ -546,6 +551,11 @@ func (sp *ServiceProvider) MakeAuthenticationRequest(idpURL string, binding stri ForceAuthn: sp.ForceAuthn, RequestedAuthnContext: sp.RequestedAuthnContext, } + if len(sp.IDPList) > 0 { + req.Scoping = &Scoping{ + IDPList: sp.IDPList, + } + } // We don't need to sign the XML document if the IDP uses HTTP-Redirect binding if len(sp.SignatureMethod) > 0 && binding == HTTPPostBinding { if err := sp.SignAuthnRequest(&req); err != nil {