diff --git a/README.md b/README.md index 8b1c713..b2b3486 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,51 @@ if result.Success { // user successfully authenticated... if len(result.Groups) > 0 { // we found some groups associated with the authenticated user... - } + } } ``` + +### [`saml package`](./saml) + +A package for writing clients that integrate with SAML Providers. + +The SAML library orients mainly on the implementation profile for +[federation interoperability](https://kantarainitiative.github.io/SAMLprofiles/fedinterop.html) +(also known as interoperable SAML), a set of software conformance requirements +intended to facilitate interoperability within the context of full mesh identity +federations. It supports the Web Browser SSO profile with HTTP-Post and +HTTP-Redirect as supported service bindings. The default SAML settings follow +the requirements of the interoperable SAML +[deployment profile](https://kantarainitiative.github.io/SAMLprofiles/saml2int.html#_service_provider_requirements). + +#### Example usage + +```go + // Create a new saml config providing the necessary provider information: + cfg, err := saml.NewConfig(, , , options...) + // handle error + + // Use the config to create the service provider: + sp, err := saml.NewServiceProvider(cfg) + // handle error + + // With the service provider you can create saml authentication requests: + + // Generate a saml auth request with HTTP Post-Binding + template, err := sp.AuthRequestPost("relay state", options...) + // handle error + + // Generate a saml auth request with HTTP Request-Binding + redirectURL, err := sp.AuthRequestRedirect("relay state", options...) + // handle error + + // Parsing a SAML response: + r.ParseForm() + samlResp := r.PostForm.Get("SAMLResponse") + + response, err := sp.ParseResponse(samlResp, "Response ID", options...) + // handle error +``` + +You can find the full demo code in the [`saml/demo`](./saml/demo/main.go) +package. diff --git a/saml/authn_request.go b/saml/authn_request.go new file mode 100644 index 0000000..acfb4ff --- /dev/null +++ b/saml/authn_request.go @@ -0,0 +1,395 @@ +package saml + +import ( + "bytes" + "compress/flate" + "encoding/base64" + "encoding/xml" + "fmt" + "net/http" + "net/url" + "strings" + "text/template" + + "github.com/jonboulle/clockwork" + + "github.com/hashicorp/cap/saml/models/core" +) + +const ( + // postBindingScriptSha256 is a base64 encoded sha256 hash generated from the javascript within the script tag in ./authn_request.gohtml. + // The hash is set in the Content-Security-Policy header when using the SAML HTTP POST-Binding Authentication Request. + // You can read more about the header and how the hash is generated here: https://content-security-policy.com/hash/ + // As the POST-Binding script is static, this value is static as well and shouldn't change. + postBindingScriptSha256 = "sha256-T8Q9GZiIVtYoNIdF6UW5hDNgJudFDijQM/usO+xUkes=" +) + +type authnRequestOptions struct { + clock clockwork.Clock + allowCreate bool + nameIDFormat core.NameIDFormat + forceAuthn bool + protocolBinding core.ServiceBinding + authnContextClassRefs []string + indent int + assertionConsumerServiceURL string +} + +func authnRequestOptionsDefault() authnRequestOptions { + return authnRequestOptions{ + allowCreate: false, + clock: clockwork.NewRealClock(), + nameIDFormat: core.NameIDFormat(""), + forceAuthn: false, + protocolBinding: core.ServiceBindingHTTPPost, + } +} + +func getAuthnRequestOptions(opt ...Option) authnRequestOptions { + opts := authnRequestOptionsDefault() + ApplyOpts(&opts, opt...) + return opts +} + +// AllowCreate is a Boolean value used to indicate whether the identity provider is allowed, in the course +// of fulfilling the request, to create a new identifier to represent the principal. +func AllowCreate() Option { + return func(o interface{}) { + if o, ok := o.(*authnRequestOptions); ok { + o.allowCreate = true + } + } +} + +// WithNameIDFormat will set an NameIDPolicy object with the +// given NameIDFormat. It implies allowCreate=true as recommended by +// the SAML 2.0 spec, which says: +// "Requesters that do not make specific use of this (AllowCreate) attribute SHOULD generally set it to “true” +// to maximize interoperability." +// See https://www.oasis-open.org/committees/download.php/56776/sstc-saml-core-errata-2.0-wd-07.pdf +func WithNameIDFormat(f core.NameIDFormat) Option { + return func(o interface{}) { + if o, ok := o.(*authnRequestOptions); ok { + o.nameIDFormat = f + o.allowCreate = true + } + } +} + +// ForceAuthentication is a boolean value that tells the identity provider it MUST authenticate the presenter +// directly rather than rely on a previous security context. +func ForceAuthn() Option { + return func(o interface{}) { + if o, ok := o.(*authnRequestOptions); ok { + o.forceAuthn = true + } + } +} + +// WithProtocolBinding defines the ProtocolBinding to be used. It defaults to HTTP-Post. +// The ProtocolBinding is a URI reference that identifies a SAML protocol binding to be used +// when returning the message. +func WithProtocolBinding(binding core.ServiceBinding) Option { + return func(o interface{}) { + if o, ok := o.(*authnRequestOptions); ok { + o.protocolBinding = binding + } + } +} + +// WithAuthContextClassRefs defines AuthnContextClassRefs. +// An AuthContextClassRef Specifies the requirements, if any, that the requester places on the +// authentication context that applies to the responding provider's authentication of the presenter. +func WithAuthContextClassRefs(cfs []string) Option { + return func(o interface{}) { + if o, ok := o.(*authnRequestOptions); ok { + o.authnContextClassRefs = cfs + } + } +} + +// WithIndent indent the XML document when marshalling it. +func WithIndent(indent int) Option { + return func(o interface{}) { + if o, ok := o.(*authnRequestOptions); ok { + o.indent = indent + } + } +} + +// WithClock changes the clock used when generating requests. +func WithClock(clock clockwork.Clock) Option { + return func(o interface{}) { + switch opts := o.(type) { + case *authnRequestOptions: + opts.clock = clock + case *parseResponseOptions: + opts.clock = clock + case *idpMetadataOptions: + opts.clock = clock + } + } +} + +// WithAssertionConsumerServiceURL changes the Assertion Consumer Service URL +// to use in the Auth Request or during the response validation +func WithAssertionConsumerServiceURL(url string) Option { + return func(o interface{}) { + switch opts := o.(type) { + case *authnRequestOptions: + opts.assertionConsumerServiceURL = url + case *parseResponseOptions: + opts.assertionConsumerServiceURL = url + } + } +} + +// CreateAuthnRequest creates an Authentication Request object. +// The defaults follow the deployment profile for federation interoperability. +// See: 3.1.1 https://kantarainitiative.github.io/SAMLprofiles/saml2int.html#_service_provider_requirements [INT_SAML] +// +// Options: +// - WithClock +// - ForceAuthn +// - AllowCreate +// - WithIDFormat +// - WithProtocolBinding +// - WithAuthContextClassRefs +// - WithAssertionConsumerServiceURL +func (sp *ServiceProvider) CreateAuthnRequest( + id string, + binding core.ServiceBinding, + opt ...Option, +) (*core.AuthnRequest, error) { + const op = "saml.ServiceProvider.CreateAuthnRequest" + + if id == "" { + return nil, fmt.Errorf("%s: no ID provided: %w", op, ErrInvalidParameter) + } + + if binding == "" { + return nil, fmt.Errorf("%s: no binding provided: %w", op, ErrInvalidParameter) + } + + opts := getAuthnRequestOptions(opt...) + + destination, err := sp.destination(binding) + if err != nil { + return nil, fmt.Errorf( + "%s: failed to get destination for given service binding (%s): %w", + op, + binding, + err, + ) + } + + ar := &core.AuthnRequest{} + + ar.ID = id + ar.Version = core.SAMLVersion2 + ar.ProtocolBinding = opts.protocolBinding + + // [INT_SAML][SDP-SP05][SDP-SP06] + // "The message SHOULD contain an AssertionConsumerServiceURL attribute and MUST NOT contain an + // AssertionConsumerServiceIndex attribute (i.e., the desired endpoint MUST be the default, + // or identified via the AssertionConsumerServiceURL attribute)." + ar.AssertionConsumerServiceURL = sp.cfg.AssertionConsumerServiceURL + if opts.assertionConsumerServiceURL != "" { + ar.AssertionConsumerServiceURL = opts.assertionConsumerServiceURL + } + + ar.IssueInstant = opts.clock.Now().UTC() + ar.Destination = destination + + ar.Issuer = &core.Issuer{} + ar.Issuer.Value = sp.cfg.EntityID + + // [INT_SAML][SDP-SP04] + // "The message MUST either omit the element (RECOMMENDED), + // or the element MUST contain an AllowCreate attribute of "true" and MUST NOT contain a Format attribute." + if opts.allowCreate || opts.nameIDFormat != "" { + ar.NameIDPolicy = &core.NameIDPolicy{ + AllowCreate: opts.allowCreate, + } + + // This will only be set if the option WithNameIDFormat is set. + if opts.nameIDFormat != "" { + ar.NameIDPolicy.Format = opts.nameIDFormat + } + } + + // [INT_SAML][SDP-SP07] + // "An SP that does not require a specific value MUST NOT include a + // element in its requests. + // An SP that requires specific values MUST specify the allowable values + // in a element in its requests, with the Comparison attribute set to exact." + if len(opts.authnContextClassRefs) > 0 { + ar.RequestedAuthContext = &core.RequestedAuthnContext{ + AuthnContextClassRef: opts.authnContextClassRefs, + Comparison: core.ComparisonExact, + } + } + + ar.ForceAuthn = opts.forceAuthn + + return ar, nil +} + +// AuthnRequestPost creates an AuthRequest with HTTP-Post binding. +func (sp *ServiceProvider) AuthnRequestPost( + relayState string, opt ...Option, +) ([]byte, *core.AuthnRequest, error) { + const op = "saml.ServiceProvider.AuthnRequestPost" + + requestID, err := sp.cfg.GenerateAuthRequestID() + if err != nil { + return nil, nil, fmt.Errorf( + "%s: failed to generate authentication request ID: %w", + op, + ErrInternal, + ) + } + + authN, err := sp.CreateAuthnRequest(requestID, core.ServiceBindingHTTPPost) + if err != nil { + return nil, nil, fmt.Errorf( + "%s: failed to create authentication request: %w", + op, + ErrInternal, + ) + } + + opts := getAuthnRequestOptions(opt...) + payload, err := authN.CreateXMLDocument(opts.indent) + if err != nil { + return nil, nil, fmt.Errorf( + "%s: failed to create request XML: %w", + op, + ErrInternal, + ) + } + + b64Payload := base64.StdEncoding.EncodeToString(payload) + + tmpl := template.Must( + template.New("post-binding").Parse(postBindingTempl), + ) + + buf := bytes.Buffer{} + + if err := tmpl.Execute(&buf, map[string]string{ + "Destination": authN.Destination, + "SAMLRequest": b64Payload, + "RelayState": relayState, + }); err != nil { + return nil, nil, fmt.Errorf( + "%s: failed to execute POST binding template: %w", + op, + ErrInternal, + ) + } + + return buf.Bytes(), authN, nil +} + +// WritePostBindingRequestHeader writes recommended content headers when using the SAML HTTP POST binding. +func WritePostBindingRequestHeader(w http.ResponseWriter) error { + const op = "saml.WritePostBindingHeader" + + if w == nil { + return fmt.Errorf("%s: response writer is nil", op) + } + + w.Header(). + Add("Content-Security-Policy", fmt.Sprintf("script-src '%s'", postBindingScriptSha256)) + w.Header().Add("Content-type", "text/html") + + return nil +} + +// AuthRequestRedirect creates a SAML authentication request with HTTP redirect binding. +func (sp *ServiceProvider) AuthnRequestRedirect( + relayState string, opts ...Option, +) (*url.URL, *core.AuthnRequest, error) { + const op = "saml.ServiceProvider.AuthnRequestRedirect" + + requestID, err := sp.cfg.GenerateAuthRequestID() + if err != nil { + return nil, nil, fmt.Errorf( + "%s: failed to generate authentication request ID: %w", + op, + err, + ) + } + + authN, err := sp.CreateAuthnRequest(requestID, core.ServiceBindingHTTPRedirect, opts...) + if err != nil { + return nil, nil, fmt.Errorf( + "%s: failed to create SAML auth request: %w", + op, + err, + ) + } + + payload, err := Deflate(authN, opts...) + if err != nil { + return nil, nil, fmt.Errorf("%s: failed to deflate/compress request: %w", op, err) + } + + b64Payload := base64.StdEncoding.EncodeToString(payload) + + redirect, err := url.Parse(authN.Destination) + if err != nil { + return nil, nil, fmt.Errorf("%s: failed to parse destination URL: %w", op, err) + } + + // if sp.SignRequest { + // ctx := sp.SigningContext() + // qs.Add("SigAlg", ctx.GetSignatureMethodIdentifier()) + // var rawSignature []byte + // if rawSignature, err = ctx.SignString(signatureInputString(qs.Get("SAMLRequest"), qs.Get("RelayState"), qs.Get("SigAlg"))); err != nil { + // return "", fmt.Errorf("unable to sign query string of redirect URL: %v", err) + // } + + // // Now add base64 encoded Signature + // qs.Add("Signature", base64.StdEncoding.EncodeToString(rawSignature)) + // } + + vals := redirect.Query() + vals.Set("SAMLRequest", b64Payload) + + if relayState != "" { + vals.Set("RelayState", relayState) + } + + redirect.RawQuery = vals.Encode() + + return redirect, authN, nil +} + +// Deflate returns an AuthnRequest in the Deflate file format, applying default +// compression. +func Deflate(authn *core.AuthnRequest, opt ...Option) ([]byte, error) { + const op = "saml.Deflate" + + buf := bytes.Buffer{} + opts := getAuthnRequestOptions(opt...) + + fw, err := flate.NewWriter(&buf, flate.DefaultCompression) + if err != nil { + return nil, fmt.Errorf("%s: failed to create new flate writer: %w", op, err) + } + + encoder := xml.NewEncoder(fw) + encoder.Indent("", strings.Repeat(" ", opts.indent)) + err = encoder.Encode(authn) + if err != nil { + return nil, fmt.Errorf("%s: failed to XML encode SAML authn request: %w", op, err) + } + + if err := fw.Close(); err != nil { + return nil, fmt.Errorf("%s: failed to close flate writer: %w", op, err) + } + + return buf.Bytes(), nil +} diff --git a/saml/authn_request.gohtml b/saml/authn_request.gohtml new file mode 100644 index 0000000..914708f --- /dev/null +++ b/saml/authn_request.gohtml @@ -0,0 +1,11 @@ + + + +
+ + + +
+ + + diff --git a/saml/authn_request_test.go b/saml/authn_request_test.go new file mode 100644 index 0000000..0d961d2 --- /dev/null +++ b/saml/authn_request_test.go @@ -0,0 +1,257 @@ +package saml_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/saml" + "github.com/hashicorp/cap/saml/models/core" + testprovider "github.com/hashicorp/cap/saml/test" +) + +func Test_CreateAuthnRequest(t *testing.T) { + t.Parallel() + r := require.New(t) + + tp := testprovider.StartTestProvider(t) + defer tp.Close() + + cfg, err := saml.NewConfig( + "http://test.me/entity", + "http://test.me/saml/acs", + fmt.Sprintf("%s/saml/metadata", tp.ServerURL()), + ) + r.NoError(err) + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + cases := []struct { + name string + id string + binding core.ServiceBinding + opts []saml.Option + expectedACS string + err string + }{ + { + name: "With service binding post", + id: "abc123", + binding: core.ServiceBindingHTTPPost, + expectedACS: "http://test.me/saml/acs", + err: "", + }, + { + name: "With service binding redirect", + id: "abc123", + binding: core.ServiceBindingHTTPRedirect, + expectedACS: "http://test.me/saml/acs", + err: "", + }, + { + name: "With service binding redirect and custom acs", + id: "abc123", + binding: core.ServiceBindingHTTPRedirect, + opts: []saml.Option{saml.WithAssertionConsumerServiceURL("http://secondary.me/saml/acs")}, + expectedACS: "http://secondary.me/saml/acs", + err: "", + }, + { + name: "When there is no ID provided", + id: "", + binding: core.ServiceBindingHTTPRedirect, + err: "saml.ServiceProvider.CreateAuthnRequest: no ID provided: invalid parameter", + }, + { + name: "When there is no binding provided", + id: "abc123", + binding: "", + err: "saml.ServiceProvider.CreateAuthnRequest: no binding provided: invalid parameter", + }, + { + name: "When there there is no destination for the given binding", + id: "abc123", + binding: core.ServiceBinding("non-existing"), + err: "saml.ServiceProvider.CreateAuthnRequest: failed to get destination for given service binding (non-existing):", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest(c.id, c.binding, c.opts...) + if c.err != "" { + r.Error(err) + r.ErrorContains(err, c.err) + return + } + r.NoError(err) + + switch c.binding { + case core.ServiceBindingHTTPPost: + loc := fmt.Sprintf("%s/saml/login/post", tp.ServerURL()) + r.Equal(loc, got.Destination) + case core.ServiceBindingHTTPRedirect: + loc := fmt.Sprintf("%s/saml/login/redirect", tp.ServerURL()) + r.Equal(loc, got.Destination) + } + + r.Equal(c.id, got.ID) + r.Equal("2.0", got.Version) + r.Equal(core.ServiceBindingHTTPPost, got.ProtocolBinding) + r.Equal(c.expectedACS, got.AssertionConsumerServiceURL) + r.Equal("http://test.me/entity", got.Issuer.Value) + r.Nil(got.NameIDPolicy) + r.Nil(got.RequestedAuthContext) + r.False(got.ForceAuthn) + }) + } +} + +func Test_CreateAuthnRequest_Options(t *testing.T) { + t.Parallel() + r := require.New(t) + + tp := testprovider.StartTestProvider(t) + defer tp.Close() + + cfg, err := saml.NewConfig( + "http://test.me/entity", + "http://test.me/saml/acs", + fmt.Sprintf("%s/saml/metadata", tp.ServerURL()), + ) + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + t.Run("When option AllowCreate is set", func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest( + "abc123", + core.ServiceBindingHTTPPost, + saml.AllowCreate(), + ) + + r.NoError(err) + + r.NotNil(got.NameIDPolicy) + r.True(got.NameIDPolicy.AllowCreate) + }) + + t.Run("When option WithNameIDFormat is set", func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest( + "abc123", + core.ServiceBindingHTTPPost, + saml.WithNameIDFormat(core.NameIDFormatEmail), + ) + + r.NoError(err) + + r.NotNil(got.NameIDPolicy) + r.True(got.NameIDPolicy.AllowCreate) + r.Equal(core.NameIDFormatEmail, got.NameIDPolicy.Format) + }) + + t.Run("When option ForceAuthn is set", func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest( + "abc123", + core.ServiceBindingHTTPPost, + saml.ForceAuthn(), + ) + + r.NoError(err) + r.True(got.ForceAuthn) + }) + + t.Run("When option WithProtocolBinding is set", func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest( + "abc123", + core.ServiceBindingHTTPPost, + saml.WithProtocolBinding(core.ServiceBindingHTTPRedirect), + ) + + r.NoError(err) + r.Equal(core.ServiceBindingHTTPRedirect, got.ProtocolBinding) + }) + + t.Run("When option WithAuthnContextRefs is set", func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest( + "abc123", + core.ServiceBindingHTTPPost, + saml.WithAuthContextClassRefs([]string{ + "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", + }), + ) + + r.NoError(err) + r.Contains( + got.RequestedAuthContext.AuthnContextClassRef, + "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", + ) + r.Equal(core.ComparisonExact, got.RequestedAuthContext.Comparison) + }) + + t.Run("When more than one option is set", func(t *testing.T) { + r := require.New(t) + got, err := provider.CreateAuthnRequest( + "abc123", + core.ServiceBindingHTTPPost, + saml.ForceAuthn(), + saml.WithProtocolBinding(core.ServiceBindingHTTPRedirect), + ) + + r.NoError(err) + r.True(got.ForceAuthn) + r.Equal(core.ServiceBindingHTTPRedirect, got.ProtocolBinding) + }) + + r.NoError(err) +} + +func Test_ServiceProvider_AuthnRequestRedirect(t *testing.T) { + t.Parallel() + r := require.New(t) + + tp := testprovider.StartTestProvider(t) + defer tp.Close() + + cfg, err := saml.NewConfig( + "http://test.me/entity", + "http://test.me/saml/acs", + fmt.Sprintf("%s/saml/metadata", tp.ServerURL()), + ) + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + redirectURL, _, err := provider.AuthnRequestRedirect("relayState") + r.NoError(err) + + tp.SetExpectedIssuer("http://test.me/entity") + tp.SetExpectedACSURL("http://test.me/saml/acs") + tp.SetExpectedRelayState("relayState") + + // The test server validates the request. So we don't have to do it here. + resp, err := http.Get(redirectURL.String()) + r.NoError(err) + r.NotNil(resp) + + body, err := io.ReadAll(resp.Body) + r.NoError(err) + + samlRespPostData := &testprovider.SAMLResponsePostData{} + err = json.Unmarshal(body, samlRespPostData) + r.NoError(err) + + r.Equal("http://test.me/saml/acs", samlRespPostData.Destination) + r.Equal("relayState", samlRespPostData.RelayState) +} diff --git a/saml/config.go b/saml/config.go new file mode 100644 index 0000000..689e504 --- /dev/null +++ b/saml/config.go @@ -0,0 +1,271 @@ +package saml + +import ( + "fmt" + "net/url" + "time" + + "github.com/hashicorp/go-uuid" + + "github.com/hashicorp/cap/saml/models/core" +) + +// ValidUntilFunc represents a function that sets a time until a service provider metadata +// document is valid. +type ValidUntilFunc func() time.Time + +// GenerateAuthRequestIDFunc represents a function that generates the +// SAML authentication request ID. +type GenerateAuthRequestIDFunc func() (string, error) + +// Config contains configuraiton parameters that are required for a service provider +// to successfully federate with an identity provider and execute a SAML authentication flow. +type Config struct { + // AssertionConsumerServiceURL defines the endpoint at the service provider where + // the identity provider will redirect to with its authentication response. Must be + // a valid URL. Required. + AssertionConsumerServiceURL string + + // EntityID is a globally unique identifier of the service provider. Must be a + // valid URL. Required. + EntityID string + + // MetadataURL is the endpoint an identity provider serves its metadata XML document. + // Must be a valid URL. Takes precedence over MetadataXML and MetadataParameters. + // Required if MetadataXML or MetadataParameters not set. + MetadataURL string + + // MetadataXML is the XML-formatted metadata an identity provider provides to + // configure a service provider. Takes precedence over MetadataParameters. Optional. + MetadataXML string + + // MetadataParameters are the individual parameters an identity provider provides + // to configure a service provider. Optional. + MetadataParameters *MetadataParameters + + // ValidUntil is a function that defines the time after which the service provider + // metadata document is considered invalid. Optional. + ValidUntil ValidUntilFunc + + // GenerateAuthRequestID generates an XSD:ID conforming ID. + GenerateAuthRequestID GenerateAuthRequestIDFunc +} + +// MetadataParameters are parameters that are required for SAML federation. +// This can be used when the IDP doesn't support a Metadata URL. +type MetadataParameters struct { + // Issuer is a globally unique identifier of the identity provider. + // Must be a valid URL. Required. + Issuer string + + // SingleSignOnURL is the single sign-on service URL of the identity provider. + // Must be a valid URL. Required. + SingleSignOnURL string + + // IDPCertificate is the PEM-encoded public key certificate provided by the identity + // provider. Used to verify response and assertion signatures. Required. + IDPCertificate string + + // Binding defines the binding that will be used for authentication requests. Defaults + // to HTTP-POST binding. Optional. + Binding core.ServiceBinding +} + +// Validate validates the provided metadata parameters. +func (c *MetadataParameters) Validate() error { + if c.Issuer == "" { + return fmt.Errorf("issuer not set") + } + if _, err := url.Parse(c.Issuer); err != nil { + return fmt.Errorf("provided Issuer is not a valid URL: %w", err) + } + + if c.SingleSignOnURL == "" { + return fmt.Errorf("SSO URL not set") + } + if _, err := url.Parse(c.SingleSignOnURL); err != nil { + return fmt.Errorf("provided SSO URL is not a valid URL: %w", err) + } + + if _, err := parsePEMCertificate([]byte(c.IDPCertificate)); err != nil { + return fmt.Errorf("failed to parse IDP certificate: %w", err) + } + + return nil +} + +// WithMetadataXML provides optional identity provider metadata in the form of an XML +// document that can be used to configure the service provider. +func WithMetadataXML(metadata string) Option { + return func(o interface{}) { + if o, ok := o.(*configOptions); ok { + o.withMetadataXML = metadata + } + } +} + +// WithMetadataParameters provides optional static metadata from an identity provider +// that can be used to configure the service provider. +func WithMetadataParameters(metadata MetadataParameters) Option { + return func(o interface{}) { + if o, ok := o.(*configOptions); ok { + if metadata.Binding == "" { + metadata.Binding = core.ServiceBindingHTTPPost + } + o.withMetadataParameters = &metadata + } + } +} + +// WithValidUntil provides the time after which the service provider metadata +// document is considered invalid +func WithValidUntil(validUntil ValidUntilFunc) Option { + return func(o interface{}) { + if o, ok := o.(*configOptions); ok { + o.withValidUntil = validUntil + } + } +} + +// WithGenerateAuthRequestID provides an XSD:ID conforming ID for authentication requests +func WithGenerateAuthRequestID(generateAuthRequestID GenerateAuthRequestIDFunc) Option { + return func(o interface{}) { + if o, ok := o.(*configOptions); ok { + o.withGenerateAuthRequestID = generateAuthRequestID + } + } +} + +// NewConfig creates a new configuration for a service provider. Identity provider +// metadata can be provided via the metadataURL parameter or the WithMetadataXML +// and WithMetadataParameters options. The metadataURL will always take precedence +// if options are provided. +// +// Options: +// - WithValidUntil +// - WithMetadataXML +// - WithMetadataParameters +// - WithGenerateAuthRequestID +func NewConfig(entityID, acs, metadataURL string, opt ...Option) (*Config, error) { + const op = "saml.NewConfig" + + opts := getConfigOptions(opt...) + + cfg := &Config{ + EntityID: entityID, + AssertionConsumerServiceURL: acs, + MetadataURL: metadataURL, + MetadataXML: opts.withMetadataXML, + MetadataParameters: opts.withMetadataParameters, + ValidUntil: opts.withValidUntil, + GenerateAuthRequestID: opts.withGenerateAuthRequestID, + } + + err := cfg.Validate() + if err != nil { + return nil, fmt.Errorf("%s: invalid provider config: %w", op, err) + } + + return cfg, nil +} + +// Validate validates the Config fields. +func (c *Config) Validate() error { + const op = "saml.Config.Validate" + + if c.AssertionConsumerServiceURL == "" { + return fmt.Errorf("%s: ACS URL not set: %w", op, ErrInvalidParameter) + } + if _, err := url.Parse(c.AssertionConsumerServiceURL); err != nil { + return fmt.Errorf("%s: provided ACS URL is not a valid URL: %w", op, ErrInvalidParameter) + } + + if c.EntityID == "" { + return fmt.Errorf("%s: EntityID not set: %w", op, ErrInvalidParameter) + } + if _, err := url.Parse(c.EntityID); err != nil { + return fmt.Errorf("%s: provided Entity ID is not a valid URL: %w", op, ErrInvalidParameter) + } + + if c.MetadataURL == "" && c.MetadataXML == "" && c.MetadataParameters == nil { + return fmt.Errorf("%s: One of MetadataURL, MetadataXML, or MetadataParameters "+ + "must be set: %w", op, ErrInvalidParameter) + } + if c.MetadataURL != "" { + if _, err := url.Parse(c.MetadataURL); err != nil { + return fmt.Errorf( + "%s: provided Metadata URL is not a valid URL: %w", + op, + ErrInvalidParameter, + ) + } + } + if c.MetadataXML != "" { + if _, err := parseIDPMetadata([]byte(c.MetadataXML)); err != nil { + return fmt.Errorf("%s: %s: %w", op, err.Error(), ErrInvalidParameter) + } + } + + if c.MetadataParameters != nil { + if err := c.MetadataParameters.Validate(); err != nil { + return fmt.Errorf("%s: %s: %w", op, err.Error(), ErrInvalidParameter) + } + } + + if c.GenerateAuthRequestID == nil { + return fmt.Errorf( + "%s: GenerateAuthRequestID func not provided: %w", + op, + ErrInvalidParameter, + ) + } + + return nil +} + +type configOptions struct { + withMetadataXML string + withMetadataParameters *MetadataParameters + withValidUntil ValidUntilFunc + withGenerateAuthRequestID GenerateAuthRequestIDFunc +} + +func configOptionsDefault() configOptions { + return configOptions{ + withValidUntil: defaultValidUntil, + } +} + +func getConfigOptions(opt ...Option) configOptions { + opts := configOptionsDefault() + ApplyOpts(&opts, opt...) + + // Apply defaults to options + if opts.withGenerateAuthRequestID == nil { + opts.withGenerateAuthRequestID = DefaultGenerateAuthRequestID + } + if opts.withValidUntil == nil { + opts.withValidUntil = defaultValidUntil + } + + return opts +} + +// DefaultGenerateAuthRequestID generates an auth XSD:ID conform ID. +// A UUID prefixed with an underscore. +func DefaultGenerateAuthRequestID() (string, error) { + newID, err := uuid.GenerateUUID() + if err != nil { + return "", err + } + + // Request IDs have to be xsd:ID, which means they need to start with an underscore or letter, + // which is not always given for UUIDs. + return fmt.Sprintf("_%s", newID), nil +} + +// defaultValidUntil returns a timestamp with one year +// added to the time when this function is called. +func defaultValidUntil() time.Time { + return time.Now().Add(time.Hour * 24 * 365) +} diff --git a/saml/config_test.go b/saml/config_test.go new file mode 100644 index 0000000..ca8cb01 --- /dev/null +++ b/saml/config_test.go @@ -0,0 +1,256 @@ +package saml_test + +import ( + "strings" + "testing" + + "github.com/hashicorp/go-uuid" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/saml" + "github.com/hashicorp/cap/saml/models/core" +) + +func Test_NewConfig(t *testing.T) { + t.Parallel() + const ( + entityID = "http://test.me/entity" + acs = "http://test.me/sso/acs" + metadata = "http://test.me/sso/metadata" + ) + + cases := []struct { + name string + entityID string + acs string + issuer string + metadata string + opts []saml.Option + cfgOverride func(*saml.Config) + expectedErr string + }{ + { + name: "When all URLs are provided", + entityID: entityID, + acs: acs, + metadata: metadata, + expectedErr: "", + }, + { + name: "When there is no entity ID provided", + acs: acs, + metadata: metadata, + expectedErr: "saml.NewConfig: invalid provider config: saml.Config.Validate: EntityID not set: invalid parameter", + }, + { + name: "When there is no ACS URL provided", + entityID: entityID, + metadata: metadata, + expectedErr: "saml.NewConfig: invalid provider config: saml.Config.Validate: ACS URL not set: invalid parameter", + }, + { + name: "When there is no metadata URL provided", + acs: acs, + entityID: entityID, + expectedErr: "saml.NewConfig: invalid provider config: saml.Config.Validate: One of MetadataURL, MetadataXML, or MetadataParameters must be set: invalid parameter", + }, + { + name: "valid-WithMetadataParameters", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: "https://samltest.id/idp", + SingleSignOnURL: "https://samltest.id/idp/profile/Shibboleth/SSO", + IDPCertificate: testEncodedMetadataCert, + Binding: core.ServiceBindingHTTPPost, + }), + }, + }, + { + name: "err-WithMetadataParameters-empty", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{}), + }, + expectedErr: "saml.Config.Validate: issuer not set", + }, + { + name: "err-WithMetadataParameters-invalid-issuer", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: " https://samltest.id/idp", // extra space at the start makes it invalid + SingleSignOnURL: "https://samltest.id/idp/profile/Shibboleth/SSO", + IDPCertificate: testEncodedMetadataCert, + Binding: core.ServiceBindingHTTPPost, + }), + }, + expectedErr: "provided Issuer is not a valid URL", + }, + { + name: "err-WithMetadataParameters-missing-sso-url", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: "https://samltest.id/idp", + SingleSignOnURL: "", + IDPCertificate: testEncodedMetadataCert, + Binding: core.ServiceBindingHTTPPost, + }), + }, + expectedErr: "SSO URL not set", + }, + { + name: "err-WithMetadataParameters-invalid-sso-url", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: "https://samltest.id/idp", + SingleSignOnURL: " https://samltest.id/idp/profile/Shibboleth/SSO", // extra space at the start makes it invalid + IDPCertificate: testEncodedMetadataCert, + Binding: core.ServiceBindingHTTPPost, + }), + }, + expectedErr: "provided SSO URL is not a valid URL", + }, + { + name: "err-WithMetadataParameters-missing-cert", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: "https://samltest.id/idp", + SingleSignOnURL: "https://samltest.id/idp/profile/Shibboleth/SSO", + IDPCertificate: "", + Binding: core.ServiceBindingHTTPPost, + }), + }, + expectedErr: "no certificate found", + }, + { + name: "err-WithMetadataParameters-extra-data", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: "https://samltest.id/idp", + SingleSignOnURL: "https://samltest.id/idp/profile/Shibboleth/SSO", + IDPCertificate: testEncodedMetadataCert + "\nextra bits", + Binding: core.ServiceBindingHTTPPost, + }), + }, + expectedErr: "extra data found after certificate", + }, + { + name: "err-WithMetadataParameters-invalid-block-identifier", + entityID: entityID, + acs: acs, + metadata: metadata, + opts: []saml.Option{ + saml.WithMetadataParameters(saml.MetadataParameters{ + Issuer: "https://samltest.id/idp", + SingleSignOnURL: "https://samltest.id/idp/profile/Shibboleth/SSO", + IDPCertificate: testEncodedMetadataCertWithInvalidBlockIdentifier, + Binding: core.ServiceBindingHTTPPost, + }), + }, + expectedErr: `wrong block type found: "PRIVATE KEY"`, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := require.New(t) + got, err := saml.NewConfig( + c.entityID, + c.acs, + c.metadata, + c.opts..., + ) + + if c.expectedErr != "" { + r.ErrorContains(err, c.expectedErr) + return + } + r.NoError(err) + + r.Equal(got.EntityID, "http://test.me/entity") + r.Equal(got.AssertionConsumerServiceURL, "http://test.me/sso/acs") + r.Equal(got.MetadataURL, "http://test.me/sso/metadata") + + r.NotNil(got.GenerateAuthRequestID) + r.NotNil(got.ValidUntil) + }) + } +} + +func Test_GenerateAuthRequestID(t *testing.T) { + t.Parallel() + r := require.New(t) + + id, err := saml.DefaultGenerateAuthRequestID() + r.NoError(err) + + r.Contains(id, "_") + + splitted := strings.Split(id, "_") + + _, err = uuid.ParseUUID(splitted[1]) + r.NoError(err) +} + +const testEncodedMetadataCert = ` +-----BEGIN CERTIFICATE----- +MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEB +CwUAMBYxFDASBgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4 +MDgyNDIxMTQwOVowFjEUMBIGA1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFKs71ufbQwoQoW7qkNAJRIANGA4iM0 +ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyjxj0uJ4lArgkr4AOE +jj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVNc1kl +bN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF +/cL5fOpdVa54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8n +spXiH/MZW8o2cqWRkrw3MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0G +A1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE4k2ZNTA0BgNVHREELTArggtzYW1sdGVz +dC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lkcDANBgkqhkiG9w0BAQsF +AAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3YaMb2RSn +7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHT +TNiLArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nbl +D1JJKSQ3AdhxK/weP3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcU +ZOpx4swtgGdeoSpeRyrtMvRwdcciNBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu +3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== +-----END CERTIFICATE----- +` + +const testEncodedMetadataCertWithInvalidBlockIdentifier = ` +-----BEGIN PRIVATE KEY----- +MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEB +CwUAMBYxFDASBgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4 +MDgyNDIxMTQwOVowFjEUMBIGA1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFKs71ufbQwoQoW7qkNAJRIANGA4iM0 +ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyjxj0uJ4lArgkr4AOE +jj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVNc1kl +bN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF +/cL5fOpdVa54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8n +spXiH/MZW8o2cqWRkrw3MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0G +A1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE4k2ZNTA0BgNVHREELTArggtzYW1sdGVz +dC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lkcDANBgkqhkiG9w0BAQsF +AAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3YaMb2RSn +7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHT +TNiLArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nbl +D1JJKSQ3AdhxK/weP3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcU +ZOpx4swtgGdeoSpeRyrtMvRwdcciNBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu +3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== +-----END PRIVATE KEY----- +` diff --git a/saml/demo/.gitignore b/saml/demo/.gitignore new file mode 100644 index 0000000..1549b67 --- /dev/null +++ b/saml/demo/.gitignore @@ -0,0 +1 @@ +demo diff --git a/saml/demo/main.go b/saml/demo/main.go new file mode 100644 index 0000000..99f99fb --- /dev/null +++ b/saml/demo/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "html/template" + "net/http" + "os" + + "github.com/hashicorp/cap/saml" + "github.com/hashicorp/cap/saml/handler" +) + +func main() { + envs := map[string]string{ + "entityID": os.Getenv("CAP_SAML_ENTITY_ID"), + "acs": os.Getenv("CAP_SAML_ACS"), + "metadata": os.Getenv("CAP_SAML_METADATA"), + "metadata_xml": os.Getenv("CAP_SAML_METADATA_XML"), + } + + var options []saml.Option + if metaXML, ok := envs["metadata_xml"]; ok { + options = append(options, saml.WithMetadataXML(metaXML)) + } + + cfg, err := saml.NewConfig(envs["entityID"], envs["acs"], envs["metadata"], options...) + exitOnError(err) + + sp, err := saml.NewServiceProvider(cfg) + exitOnError(err) + + acsHandler, err := handler.ACSHandlerFunc(sp) + exitOnError(err) + + redirectHandler, err := handler.RedirectBindingHandlerFunc(sp) + exitOnError(err) + + postBindHandler, err := handler.PostBindingHandlerFunc(sp) + exitOnError(err) + + metadataHandler, err := handler.MetadataHandlerFunc(sp) + exitOnError(err) + + http.HandleFunc("/saml/acs", acsHandler) + http.HandleFunc("/saml/auth/redirect", redirectHandler) + http.HandleFunc("/saml/auth/post", postBindHandler) + http.HandleFunc("/metadata", metadataHandler) + http.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) { + ts, _ := template.New("sso").Parse( + `
+
`, + ) + + ts.Execute(w, nil) + }) + + fmt.Println("Visit http://localhost:8000/login") + + err = http.ListenAndServe(":8000", nil) + exitOnError(err) +} + +func exitOnError(err error) { + if err != nil { + fmt.Printf("failed to run demo: %s", err.Error()) + os.Exit(1) + } +} diff --git a/saml/error.go b/saml/error.go new file mode 100644 index 0000000..519533f --- /dev/null +++ b/saml/error.go @@ -0,0 +1,15 @@ +package saml + +import "errors" + +var ( + ErrInternal = errors.New("internal error") + ErrBindingUnsupported = errors.New("Configured binding unsupported by the IDP") + ErrInvalidTLSCert = errors.New("invalid tls certificate") + ErrInvalidParameter = errors.New("invalid parameter") + ErrMissingAssertions = errors.New("missing assertions") + ErrInvalidTime = errors.New("invalid time") + ErrInvalidAudience = errors.New("invalid audience") + ErrMissingSubject = errors.New("subject missing") + ErrMissingAttributeStmt = errors.New("attribute statement missing") +) diff --git a/saml/go.mod b/saml/go.mod new file mode 100644 index 0000000..684679b --- /dev/null +++ b/saml/go.mod @@ -0,0 +1,27 @@ +module github.com/hashicorp/cap/saml + +go 1.19 + +require ( + github.com/beevik/etree v1.2.0 + github.com/crewjam/go-xmlsec v0.0.0-20200414151428-d2b1a58f7262 + github.com/crewjam/saml v0.4.13 + github.com/hashicorp/go-uuid v1.0.3 + github.com/jonboulle/clockwork v0.4.0 + github.com/russellhaering/gosaml2 v0.9.1 + github.com/russellhaering/goxmldsig v1.4.0 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/crewjam/errset v0.0.0-20160219153700-f78d65de925c // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/ma314smith/signedxml v1.1.1 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.10.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/ma314smith/signedxml v1.1.1 => github.com/moov-io/signedxml v1.1.1 diff --git a/saml/go.sum b/saml/go.sum new file mode 100644 index 0000000..003b56e --- /dev/null +++ b/saml/go.sum @@ -0,0 +1,87 @@ +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/beevik/etree v1.2.0 h1:l7WETslUG/T+xOPs47dtd6jov2Ii/8/OjCldk5fYfQw= +github.com/beevik/etree v1.2.0/go.mod h1:aiPf89g/1k3AShMVAzriilpcE4R/Vuor90y83zVZWFc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/errset v0.0.0-20160219153700-f78d65de925c h1:dCJ9oZ0VgnzJHR5BjkSrwkXA1USu483qlxBd0u29P8s= +github.com/crewjam/errset v0.0.0-20160219153700-f78d65de925c/go.mod h1:XhiWL7J86xoqJ8+x2OA+AM2l9skQP2DZ0UOXQYVg7uI= +github.com/crewjam/go-xmlsec v0.0.0-20200414151428-d2b1a58f7262 h1:3V8RSsB1mxeAfxMb7lGSd0HlCHhc/ElJj1peaJMAkyk= +github.com/crewjam/go-xmlsec v0.0.0-20200414151428-d2b1a58f7262/go.mod h1:M9eHnKpImgRwzOFdlFQnbgJRqFwW/eX1cKAVobv03uE= +github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= +github.com/crewjam/saml v0.4.13 h1:TYHggH/hwP7eArqiXSJUvtOPNzQDyQ7vwmwEqlFWhMc= +github.com/crewjam/saml v0.4.13/go.mod h1:igEejV+fihTIlHXYP8zOec3V5A8y3lws5bQBFsTm4gA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= +github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= +github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= +github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/moov-io/signedxml v1.1.1 h1:TQ2fK4DRCYv7agH+z6RjtnBTmEyYMAztFzuHIPtUJpg= +github.com/moov-io/signedxml v1.1.1/go.mod h1:p+b4f/Wo/qKyew8fHW8VZOgsILWylyvvjdE68egzbwc= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russellhaering/gosaml2 v0.9.1 h1:H/whrl8NuSoxyW46Ww5lKPskm+5K+qYLw9afqJ/Zef0= +github.com/russellhaering/gosaml2 v0.9.1/go.mod h1:ja+qgbayxm+0mxBRLMSUuX3COqy+sb0RRhIGun/W2kc= +github.com/russellhaering/goxmldsig v1.2.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= +github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= +golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/saml/handler/acs.go b/saml/handler/acs.go new file mode 100644 index 0000000..d0693e2 --- /dev/null +++ b/saml/handler/acs.go @@ -0,0 +1,31 @@ +package handler + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/cap/saml" +) + +// ACSHandlerFunc creates a handler function that handles a SAML +// ACS request +func ACSHandlerFunc(sp *saml.ServiceProvider) (http.HandlerFunc, error) { + const op = "handler.ACSHandler" + switch { + case sp == nil: + return nil, fmt.Errorf("%s: missing service provider", op) + } + return func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + samlResp := r.PostForm.Get("SAMLResponse") + + res, err := sp.ParseResponse(samlResp, "responseID", saml.InsecureSkipRequestIDValidation()) + if err != nil { + fmt.Println("failed to handle SAML response:", err.Error()) + http.Error(w, "failed to handle SAML response", http.StatusUnauthorized) + return + } + + fmt.Fprintf(w, "Authenticated! %+v", res) + }, nil +} diff --git a/saml/handler/metadata.go b/saml/handler/metadata.go new file mode 100644 index 0000000..324b28b --- /dev/null +++ b/saml/handler/metadata.go @@ -0,0 +1,26 @@ +package handler + +import ( + "encoding/xml" + "fmt" + "net/http" + + "github.com/hashicorp/cap/saml" +) + +// MetadataHandlerFunc creates a handler function that handles a SAML +// metadata request +func MetadataHandlerFunc(sp *saml.ServiceProvider) (http.HandlerFunc, error) { + const op = "handler.MetadataHandlerFunc" + switch { + case sp == nil: + return nil, fmt.Errorf("%s: missing service provider", op) + } + return func(w http.ResponseWriter, _ *http.Request) { + meta := sp.CreateMetadata() + err := xml.NewEncoder(w).Encode(meta) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }, nil +} diff --git a/saml/handler/post_binding.go b/saml/handler/post_binding.go new file mode 100644 index 0000000..2f789d5 --- /dev/null +++ b/saml/handler/post_binding.go @@ -0,0 +1,54 @@ +package handler + +import ( + _ "embed" + "fmt" + "net/http" + + "github.com/hashicorp/cap/saml" +) + +// PostBindingHandlerFunc creates a handler function that handles a HTTP-POST binding SAML request. +func PostBindingHandlerFunc(sp *saml.ServiceProvider) (http.HandlerFunc, error) { + const op = "handler.PostBindingHandlerFunc" + switch { + case sp == nil: + return nil, fmt.Errorf("%s: missing service provider", op) + } + return func(w http.ResponseWriter, _ *http.Request) { + templ, _, err := sp.AuthnRequestPost("") + if err != nil { + http.Error( + w, + fmt.Sprintf("Failed to do SAML POST authentication request: %s", err.Error()), + http.StatusInternalServerError, + ) + return + } + + err = saml.WritePostBindingRequestHeader(w) + if err != nil { + http.Error( + w, + fmt.Sprintf( + "failed to write content headers: %s", + err.Error(), + ), + http.StatusInternalServerError, + ) + } + + _, err = w.Write(templ) + if err != nil { + http.Error( + w, + fmt.Sprintf( + "failed to serve post binding request: %s", + err.Error(), + ), + http.StatusInternalServerError, + ) + return + } + }, nil +} diff --git a/saml/handler/redirect_binding.go b/saml/handler/redirect_binding.go new file mode 100644 index 0000000..9b95ed1 --- /dev/null +++ b/saml/handler/redirect_binding.go @@ -0,0 +1,35 @@ +package handler + +import ( + "fmt" + "net/http" + + "github.com/hashicorp/cap/saml" +) + +// RedirectBindingHandlerFunc creates a handler function that handles a SAML +// redirect request. +func RedirectBindingHandlerFunc(sp *saml.ServiceProvider) (http.HandlerFunc, error) { + const op = "handler.RedirectBindingHandlerFunc" + switch { + case sp == nil: + return nil, fmt.Errorf("%s: missing service provider", op) + } + return func(w http.ResponseWriter, r *http.Request) { + redirectURL, _, err := sp.AuthnRequestRedirect("relayState") + if err != nil { + http.Error( + w, + fmt.Sprintf("failed to create SAML Authn Request: %s", err.Error()), + http.StatusInternalServerError, + ) + return + } + + redirect := redirectURL.String() + + fmt.Printf("Redirect URL: %s\n", redirect) + + http.Redirect(w, r, redirect, http.StatusFound) + }, nil +} diff --git a/saml/is_nil.go b/saml/is_nil.go new file mode 100644 index 0000000..c54654e --- /dev/null +++ b/saml/is_nil.go @@ -0,0 +1,15 @@ +package saml + +import "reflect" + +// isNil reports if a is nil +func isNil(a any) bool { + if a == nil { + return true + } + switch reflect.TypeOf(a).Kind() { + case reflect.Ptr, reflect.Map, reflect.Chan, reflect.Slice, reflect.Func: + return reflect.ValueOf(a).IsNil() + } + return false +} diff --git a/saml/is_nil_test.go b/saml/is_nil_test.go new file mode 100644 index 0000000..ff9fd40 --- /dev/null +++ b/saml/is_nil_test.go @@ -0,0 +1,56 @@ +package saml + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_isNil(t *testing.T) { + t.Parallel() + + var testErrNilPtr *testError + var testMapNilPtr map[string]struct{} + var testArrayNilPtr *[1]string + var testChanNilPtr *chan string + var testSliceNilPtr *[]string + var testFuncNil func() + + var testChanString chan string + + tc := []struct { + i any + want bool + }{ + {i: &testError{}, want: false}, + {i: testError{}, want: false}, + {i: &map[string]struct{}{}, want: false}, + {i: map[string]struct{}{}, want: false}, + {i: [1]string{}, want: false}, + {i: &[1]string{}, want: false}, + {i: &testChanString, want: false}, + {i: "string", want: false}, + {i: []string{}, want: false}, + {i: func() {}, want: false}, + {i: nil, want: true}, + {i: testErrNilPtr, want: true}, + {i: testMapNilPtr, want: true}, + {i: testArrayNilPtr, want: true}, + {i: testChanNilPtr, want: true}, + {i: testChanString, want: true}, + {i: testSliceNilPtr, want: true}, + {i: testFuncNil, want: true}, + } + + for i, tc := range tc { + t.Run(fmt.Sprintf("test #%d", i+1), func(t *testing.T) { + assert := assert.New(t) + assert.Equal(tc.want, isNil(tc.i)) + }) + } +} + +type testError struct{} + +func (*testError) Error() string { return "error" } diff --git a/saml/models/core/common.go b/saml/models/core/common.go new file mode 100644 index 0000000..921d9eb --- /dev/null +++ b/saml/models/core/common.go @@ -0,0 +1,221 @@ +package core + +import ( + "encoding/xml" + "time" + + "github.com/crewjam/go-xmlsec/xmlenc" +) + +const ( + SAMLVersion2 = "2.0" +) + +type ServiceBinding string + +const ( + ServiceBindingHTTPPost ServiceBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + ServiceBindingHTTPRedirect ServiceBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + ServiceBindingSOAP ServiceBinding = "urn:oasis:names:tc:SAML:2.0:bindings:SOAP" +) + +// See 8.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type NameIDFormat string + +const ( + // See 8.3.1 - 8.3.8 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf + NameIDFormatUnspecified NameIDFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" + NameIDFormatEmail NameIDFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" + NameIDFormatX509SubjectName NameIDFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName" + NameIDFormatWindowsDomainQualifiedName NameIDFormat = "urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName" + NameIDFormatKerberos NameIDFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos" + NameIDFormatEntity NameIDFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity" + NameIDFormatPersistent NameIDFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" + NameIDFormatTransient NameIDFormat = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" +) + +type NameFormat string + +const ( + NameFormatURI NameFormat = "urn:oasis:names:tc:SAML:2.0:attrname-format:uri" +) + +// StatusCodeType defines the possible status codes in a SAML Response. +// The possible status codes are defined in: +// 3.2.2.2 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type StatusCodeType string + +const ( + // StatusCodeSuccess indicates that the request succeeded. + StatusCodeSuccess StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:Success" + + // StatusCodeRequester indicates that the request could not be performed due to + // an error on the part of the requester. + StatusCodeRequester StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:Requester" + + // StatusCodeResponder indicatest that the request could not be performed due to + // an error on the part of the SAML responder or SAML authority. + StatusCodeResponder StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:Responder" + + // StatusCodeVersionMismatch indicates that the SAML responder could not process the + // request because the version of the request message was incorrect. + StatusCodeVersionMismatch StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:VersionMismatch" + + // StatusCodeAuthnFailed indicates that the responding provider was unable to successfully + // authenticate the principal. + StatusCodeAuthnFailed StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:AuthnFailed" + + // StatusCodeInvalidAttrNameOrValue indicates that an unexpected or invalid content was + // encountered within a or element. + StatusCodeInvalidAttrNameOrValue StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:InvalidAttrNameOrValue" + + // StatusCodeInvalidNameIDPolicy indicates that the responding provider cannot or will not support the + // requested name identifier policy. + StatusCodeInvalidNameIDPolicy StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:InvalidNameIDPolicy" + + // StatusCodeNoAuthnContext indicates that the specified authentication context requirements cannot + // be met by the responder. + StatusCodeNoAuthnContext StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext" + + // StatusCodeNoAvailableIDP indicates that the Used by an intermediary to indicate that none of the + // supported identity provider elements in an can be resolved or that none of the + // supported identity providers are available. + StatusCodeNoAvailableIDP StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:NoAvailableIDP" + + // StatusCodeNoPassive indicates that the responding provider cannot authenticate the principal passively, + // as has been requested. + StatusCodeNoPassive StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:NoPassive" + + // StatusCodeNoSupportedIDP is used by an intermediary to indicate that none of the identity providers in an + // are supported by the intermediary. + StatusCodeNoSupportedIDP StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:NoSupportedIDP" + + // StatusCodePartialLogout is used by a session authority to indicate to a session participant that it + // was not able to propagate logout to all other session participants. + StatusCodePartialLogout StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:PartialLogout" + + // StatusCodeProxyCountExceeded indicates that a responding provider cannot authenticate the principal + // directly and is not permitted to proxy the request further. + StatusCodeProxyCountExceeded StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:ProxyCountExceeded" + + // StatusCodeRequestDenied indicates that the SAML responder or SAML authority is able to process the + // request but has chosen not to respond. This status code MAY be used when there is concern about the + // security context of the request message or the sequence of request messages received from a particular + // requester. + StatusCodeRequestDenied StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:RequestDenied" + + // StatusCodeRequestUnsupported indicates that the SAML responder or SAML authority does not support the + // request. + StatusCodeRequestUnsupported StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:RequestUnsupported" + + // StatusCodeRequestVersionDeprecated indicates that the SAML responder cannot process any requests with + // the protocol version specified in the request. + StatusCodeRequestVersionDeprecated StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:RequestVersionDeprecated" + + // StatusCodeRequestRequestVersionTooHigh indicates that the SAML responder cannot process the request because + // the protocol version specified in the request message is a major upgrade from the highest protocol version + // supported by the responder. + StatusCodeRequestRequestVersionTooHigh StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:RequestVersionTooHigh" + + // StatusCodeRequestRequestVersionTooLow indicates that the SAML responder cannot process the request because + // the protocol version specified in the request message is too low. + StatusCodeRequestVersionTooLow StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:RequestVersionTooLow" + + // StatusCodeRequestResourceNotRecognized indicates that the resource value provided in the request message is + // invalid or unrecognized. + StatusCodeResourceNotRecognized StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:ResourceNotRecognized" + + // StatusCodeTooManyResponses indicates that the response message would contain more elements than the SAML + // responder is able to return. + StatusCodeTooManyResponses StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:TooManyResponses" + + // StatusCodeUnknownAttrProfile indicates that an entity that has no knowledge of a particular attribute + // profile has been presented with an attribute drawn from that profile. + StatusCodeUnknownAttrProfile StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:UnknownAttrProfile" + + // StatusCodeUnknownPrincipal indicates that the responding provider does not recognize the principal + // specified or implied by the request. + StatusCodeUnknownPrincipal StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:UnknownPrincipal" + + // StatusCodeUnsupportedBinding indicates that the SAML responder cannot properly fulfill the request using + // the protocol binding specified in the request. + StatusCodeUnsupportedBinding StatusCodeType = "urn:oasis:names:tc:SAML:2.0:status:UnsupportedBinding" +) + +// ConfirmationMethod indicates the sepcific method to be used by the relying parte to determine +// that the request or message came from a system entity that is associated with the subject of +// the assertion, within the context of a particular profile. +// +// See 3. http://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf +type ConfirmationMethod string + +const ( + // ConfirmationMethodHolderOfKey indicates that the key holder itself can confirm + // itself as the subject. If this method is given, the SubjectConfirmationData MUST + // contain one or more KeyInfo elements, where KeyInfo identifies a cryptographic key. + // + // See 3.1 http://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf + ConfirmationMethodHolderOfKey ConfirmationMethod = "urn:oasis:names:tc:SAML:2.0:cm:holder-of-key" + + // ConfirmationMethodSenderVouches indicates that no other information is available about + // the context of use of the assertion. + // + // See 3.2 http://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf + ConfirmationMethodSenderVouches ConfirmationMethod = "urn:oasis:names:tc:SAML:2.0:cm:sender-vouches" + + // ConfirmationMethodBearer indicates that the bearer can confirm itself as the subject. + // + // See 3.3 http://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf + ConfirmationMethodBearer ConfirmationMethod = "urn:oasis:names:tc:SAML:2.0:cm:bearer" +) + +// See 3.2 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type RequestResponseCommon struct { + ID string `xml:",attr"` // required + Version string `xml:",attr"` // required + + // The time instant of issue of the request. + IssueInstant time.Time `xml:",attr"` // required + Consent string `xml:",attr,omitempty"` // optional TODO: define constants + Issuer *Issuer // recommended + Singature string `xml:",omitempty"` // recommended + Extensions *Extensions // optional + Destination string `xml:",attr"` +} + +// See 2.2.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type BaseID struct { + NameQualifier string `xml:",attr,omitempty"` + SPNameQualifier string `xml:",attr,omitempty"` +} + +// See 2.2.2 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type NameIDType struct { + NameQualifier string `xml:",attr,omitempty"` + SPNameQualifier string `xml:",attr,omitempty"` + Format NameIDFormat `xml:",attr,omitempty"` + SPProvidedID string `xml:",attr,omitempty"` + + Value string `xml:",chardata"` +} + +// See 2.2.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type NameID = NameIDType + +// See 2.2.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type EncryptedID struct { + EncryptedData xmlenc.EncryptedData + EncryptedKey xmlenc.EncryptedKey +} + +// Issuer, with type NameIDType, provides information about the issuer of a SAML assertion. +// See 2.2.5 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type Issuer struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Issuer"` + + NameIDType +} + +// Indicates that an attribute is yet to be defined. +// It is only used to for development purposes. +type TBD struct{} diff --git a/saml/models/core/fixtures/response.xml.go b/saml/models/core/fixtures/response.xml.go new file mode 100644 index 0000000..2731c98 --- /dev/null +++ b/saml/models/core/fixtures/response.xml.go @@ -0,0 +1,92 @@ +package fixtures + +var ResponseXML = ` + + https://samltest.id/saml/idp + + + + + + + + + + + + + Hs5IUzabpy3X7gqpi0FbyGQoqgVaNwfAQvHymdEHJtE= + + + jgRgXKmIhn/OGcScnKC2zkg/kIEnThE8CzxqkG1cM2UHgkjB+zB2CkxJ/TmjYL+qljjJmeijgkabwhiDMwVJ62tEYv2Ck5OliRyF2mvO+lV0XIFjbXIvJm20R3xP3US23Vj6UpFX/kqlgD//K/v8uS4KENVok0UCQgqXT8JtDTCSmg6aV+boE8KrgFsKXX75zH7ZpUDOIDakmNXDXsS/y7xTtu23YNHLCiP99Px22kJ+cDk30I7/w2DN85si6dvmfbV4jSwFQHyf4ZT6RRk0TkOjTCEkN6qDdEOsbUPDYurUXeDUD2WU2YMCE0JDaymPedh1JtNoQS64UQssjTduFA== + + + MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDAS BgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIG A1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFK s71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyj xj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVN c1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpd Va54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3 MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE 4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lk cDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3 YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiL ArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/we P3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcci NBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== + + + + + + + + https://samltest.id/saml/idp + + rsanchez@samltest.id + + + + + + + http://saml.julz/example + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + urn:mace:dir:entitlement:common-lib-terms + + + rick + + + rsanchez@samltest.id + + + +1-555-555-5515 + + + manager@Samltest.id + + + rsanchez@samltest.id + + + Sanchez + + + Rick Sanchez + + + Rick + + + +` + +var ResponseXMLIssuer = ` + + https://samltest.id/saml/idp +` + +var ResponseXMLStatus = ` + + + + +` diff --git a/saml/models/core/request.go b/saml/models/core/request.go new file mode 100644 index 0000000..732de1f --- /dev/null +++ b/saml/models/core/request.go @@ -0,0 +1,170 @@ +package core + +import ( + "encoding/xml" + "strings" + "time" +) + +// See 3.2.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type StatusRequestType struct { + RequestResponseCommon +} + +// See 3.4.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +// TODO Finish this +type AuthnRequest struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol AuthnRequest"` + + StatusRequestType + + Subject *Subject + NameIDPolicy *NameIDPolicy `xml:",omitempty"` + Conditions *Conditions + RequestedAuthContext *RequestedAuthnContext + Scoping *Scoping + + ForceAuthn bool `xml:",attr,omitempty"` + IsPassive bool `xml:",attr,omitempty"` + + AssertionConsumerServiceIndex string `xml:",attr,omitempty"` + AssertionConsumerServiceURL string `xml:",attr"` + + // A URI reference that identifies a SAML protocol binding to be used when + // returning the Response message. + ProtocolBinding ServiceBinding `xml:",attr"` + + AttributeConsumingServiceIndex string `xml:",attr,omitempty"` + ProviderName string `xml:",attr,omitempty"` +} + +// Subject specifies the requested subject of the resulting assertion(s). +// If entirely omitted or if no identifier is included, the presenter of +// the message is presumed to be the requested subject. +// +// See 2.4 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type Subject struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Subject"` + + SubjectConfirmation []*SubjectConfirmation + + BaseID *BaseID // optional + NameID *NameID // optional + EncryptedID *EncryptedID // optional +} + +// See 2.4.1.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type SubjectConfirmation struct { + Method ConfirmationMethod `xml:",attr"` // required + + SubjectConfirmationData *SubjectConfirmationData // optional + + BaseID *BaseID // optional + NameID *NameID // optional + EncryptedID *EncryptedID // optional +} + +// See 2.4.1.2 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type SubjectConfirmationData struct { + NotBefore time.Time `xml:",attr"` // optional + NotOnOrAfter time.Time `xml:",attr"` // optional + Recipient string `xml:",attr"` // optional + InResponseTo string `xml:",attr"` // optional + Address string `xml:",attr"` // optional +} + +/* TODO: Create a function to validate this: +Note that the time period specified by the optional NotBefore and NotOnOrAfter attributes, if present, +SHOULD fall within the overall assertion validity period as specified by the element's +NotBefore and NotOnOrAfter attributes. If both attributes are present, the value for NotBefore +MUST be less than (earlier than) the value for NotOnOrAfter. +*/ + +// NameIDPolicy specifies constraints on the name identifier to be used to represent +// the requested subject. +// See 3.4.1.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type NameIDPolicy struct { + Format NameIDFormat `xml:",omitempty"` + SPNameQualifier string `xml:",attr,omitempty"` + AllowCreate bool `xml:",attr"` +} + +// Scoping ... (TODO: not important for the first MVP) +// See 3.4.1.2 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type Scoping struct { + // ProxyCount specifies the number of proxying indirections permissible between the + // identity provider that receives this AuthnRequest and the identity provider who + // ultimately authenticates the principal. + ProxyCount int `xml:",attr"` + + IDPList *IDPList + + RequesterID []string +} + +// IDPList specifies the identity providers trusted by the requester to authenticate the +// presenter. +// See 3.4.1.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type IDPList struct { + IDPEntry []*IDPEntry + GetComplete []string // TODO is this correct? +} + +// IDPEntry specifies a single identity provider trusted by the requester to authenticate the +// presenter. +// See 3.4.1.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type IDPEntry struct { + // ProviderID is the unique identifier of the identity provider. + ProviderID string `xml:",attr"` + + // Name is a human-readable name for the identity provider. + Name string + + // Loc is a URI reference representing the location of a profile-specific endpoint + // supporting the authentication request protocol. + Loc string +} + +type Conditions struct{} + +// Comparison specifies the comparison method used to evaluate the requested context classes or statements. +// Possible values: "exact", "minimum", "maximum", "better" +type Comparison string + +const ( + // ComparisonExact requires that the resulting authentication context in the authentication + // statement MUST be the exact match of at least one of the authentication contexts specified. + ComparisonExact Comparison = "exact" // default + + // ComparisonMin requires that the resulting authentication context in the authentication + // statement MUST be at least as strong (as deemed by the responder) as one of the authentication + // contexts specified. + ComparsionMin Comparison = "minimum" + + // ComparisonMax requires that the resulting authentication context in the authentication + // statement MUST be stronger (as deemed by the responder) than any one of the authentication contexts + // specified. + ComparsionMax Comparison = "maximum" + + // ComparisonBetter requires that the resulting authentication context in the authentication + // statement MUST be as strong as possible (as deemed by the responder) without exceeding the strength + // of at least one of the authentication contexts specified. + ComparisonBetter Comparison = "better" +) + +// RequestedAuthnContext specifies the authentication context requirements of +// authentication statements returned in response to a request or query. +// See 3.3.2.2.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type RequestedAuthnContext struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol RequestedAuthnContext"` + + AuthnContextClassRef []string `xml:"urn:oasis:names:tc:SAML:2.0:assertion AuthnContextClassRef"` + Comparison Comparison `xml:",attr"` +} + +type Extensions struct{} + +// CreateXMLDocument creates an AuthnRequest XML document. +func (a *AuthnRequest) CreateXMLDocument(indent int) ([]byte, error) { + return xml.MarshalIndent(a, "", strings.Repeat(" ", indent)) +} diff --git a/saml/models/core/response.go b/saml/models/core/response.go new file mode 100644 index 0000000..7c5ad9b --- /dev/null +++ b/saml/models/core/response.go @@ -0,0 +1,78 @@ +package core + +import ( + "github.com/russellhaering/gosaml2/types" +) + +// Response is a SAML Response element. +// See 3.3.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type Response struct { + types.Response +} + +// Assertions returns the assertions in the Response. +func (r *Response) Assertions() []Assertion { + assertions := make([]Assertion, 0, len(r.Response.Assertions)) + for _, assertion := range r.Response.Assertions { + assertions = append(assertions, Assertion{Assertion: assertion}) + } + + return assertions +} + +// Issuer returns the issuer of the Response if it exists. +// Otherwise, it returns an empty string. +func (r *Response) Issuer() string { + if r.Response.Issuer == nil { + return "" + } + + return r.Response.Issuer.Value +} + +// Assertion is a SAML Assertion element. +// See 2.3.3 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type Assertion struct { + types.Assertion +} + +// Attribute is a SAML Attribute element. +// See 2.7.3.1 http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf +type Attribute struct { + types.Attribute +} + +// Issuer returns the issuer of the Assertion if it exists. +// Otherwise, it returns an empty string. +func (a *Assertion) Issuer() string { + if a.Assertion.Issuer == nil { + return "" + } + + return a.Assertion.Issuer.Value +} + +// SubjectNameID returns the value of the NameID element if it exists in +// the Subject of the Assertion. Otherwise, it returns an empty string. +func (a *Assertion) SubjectNameID() string { + if a.Subject == nil || a.Subject.NameID == nil { + return "" + } + + return a.Subject.NameID.Value +} + +// Attributes returns the attributes of the Assertion. If there is no +// AttributeStatement or no contained Attributes, an empty list is returned. +func (a *Assertion) Attributes() []Attribute { + if a.AttributeStatement == nil { + return []Attribute{} + } + + attributes := make([]Attribute, 0, len(a.AttributeStatement.Attributes)) + for _, attribute := range a.AttributeStatement.Attributes { + attributes = append(attributes, Attribute{Attribute: attribute}) + } + + return attributes +} diff --git a/saml/models/core/response_test.go b/saml/models/core/response_test.go new file mode 100644 index 0000000..8adf99c --- /dev/null +++ b/saml/models/core/response_test.go @@ -0,0 +1,176 @@ +package core_test + +import ( + "encoding/xml" + "testing" + + "github.com/hashicorp/cap/saml/models/core" + "github.com/stretchr/testify/require" +) + +func TestResponse(t *testing.T) { + tests := []struct { + name string + responseXML string + assertions func(*testing.T, core.Response) + }{ + { + name: "response container", + responseXML: responseXMLContainer, + assertions: func(t *testing.T, response core.Response) { + require.Equal(t, response.Destination, "http://localhost:8000/saml/acs") + require.Equal(t, response.ID, "saml-response-id") + require.Equal(t, response.IssueInstant.String(), "2023-03-31 06:55:44.494 +0000 UTC") + require.Equal(t, response.Version, "2.0") + }, + }, + { + name: "assertions helper", + responseXML: responseXMLAssertion, + assertions: func(t *testing.T, response core.Response) { + assertions := response.Assertions() + require.Len(t, assertions, 1) + assertion := assertions[0] + + require.Equal(t, "assertion-id", assertion.ID) + require.Equal(t, "2023-03-31 06:55:44.494 +0000 UTC", assertion.IssueInstant.String()) + require.Equal(t, "2.0", assertion.Version) + }, + }, + { + name: "assertion subject helper", + responseXML: responseXMLAssertionSubject, + assertions: func(t *testing.T, response core.Response) { + assertions := response.Assertions() + require.Len(t, assertions, 1) + assertion := assertions[0] + + require.Equal(t, "someone@samltest.id", assertion.SubjectNameID()) + require.EqualValues(t, core.ConfirmationMethodBearer, assertion.Subject.SubjectConfirmation.Method) + require.Equal(t, "http://localhost:8000/saml/acs", assertion.Subject.SubjectConfirmation.SubjectConfirmationData.Recipient) + require.Equal(t, "request-id", assertion.Subject.SubjectConfirmation.SubjectConfirmationData.InResponseTo) + }, + }, + { + name: "assertion issuer helper", + responseXML: responseXMLAssertionIssuer, + assertions: func(t *testing.T, response core.Response) { + assertions := response.Assertions() + require.Len(t, assertions, 1) + assertion := assertions[0] + + require.Equal(t, "https://samltest.id/saml/idp", assertion.Issuer()) + }, + }, + { + name: "response issuer helper", + responseXML: responseXMLIssuer, + assertions: func(t *testing.T, response core.Response) { + require.Equal(t, "https://samltest.id/saml/idp2", response.Issuer()) + }, + }, + { + name: "response status code", + responseXML: responseXMLStatus, + assertions: func(t *testing.T, response core.Response) { + require.Equal(t, string(core.StatusCodeSuccess), response.Status.StatusCode.Value) + }, + }, + { + name: "assertion attributes helper", + responseXML: responseXMLAssertionAttributes, + assertions: func(t *testing.T, response core.Response) { + assertions := response.Assertions() + require.Len(t, assertions, 1) + assertion := assertions[0] + attributes := assertion.Attributes() + require.Len(t, attributes, 3) + require.Equal(t, "telephoneNumber", attributes[0].FriendlyName) + require.Equal(t, "+1-555-555-5555", attributes[0].Values[0].Value) + require.Equal(t, "+1-777-777-7777", attributes[0].Values[1].Value) + require.Equal(t, "email", attributes[1].FriendlyName) + require.Equal(t, "rsanchez@samltest.id", attributes[1].Values[0].Value) + require.Equal(t, "givenName", attributes[2].FriendlyName) + require.Equal(t, "Rick", attributes[2].Values[0].Value) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + response := responseXML(t, tt.responseXML) + tt.assertions(t, response) + }) + } +} + +func responseXML(t *testing.T, ssoRes string) core.Response { + t.Helper() + + res := core.Response{} + err := xml.Unmarshal([]byte(ssoRes), &res) + require.NoError(t, err) + return res +} + +const ( + responseXMLContainer = ` + +` + + responseXMLIssuer = ` + + https://samltest.id/saml/idp2 +` + + responseXMLStatus = ` + + + + +` + + responseXMLAssertion = ` + + + +` + + responseXMLAssertionIssuer = ` + + + https://samltest.id/saml/idp + +` + + responseXMLAssertionSubject = ` + + + + someone@samltest.id + + + + + +` + + responseXMLAssertionAttributes = ` + + + + + +1-555-555-5555 + +1-777-777-7777 + + + rsanchez@samltest.id + + + Rick + + + +` +) diff --git a/saml/models/metadata/common.go b/saml/models/metadata/common.go new file mode 100644 index 0000000..8c2d236 --- /dev/null +++ b/saml/models/metadata/common.go @@ -0,0 +1,36 @@ +package metadata + +import "github.com/hashicorp/cap/saml/models/core" + +/* + This file defines common types used in defining SAML v2.0 Metadata elements and + Attributes. + See 2.2 Common Types - http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +*/ + +// EndpointType describes a SAML protocol binding endpoint at which a SAML entity can +// be sent protocol messages. +// See 2.2.2 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type Endpoint struct { + Binding core.ServiceBinding `xml:",attr"` + Location string `xml:",attr"` + ResponseLocation string `xml:",attr,omitempty"` +} + +// IndexedEndpointType extends EndpointType with a pair of attributes to permit the +// indexing of otherwise identical endpoints so that they can be referenced by protocol messages. +// See 2.2.3 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type IndexedEndpoint struct { + Endpoint + Index int `xml:"index,attr"` + IsDefault bool `xml:"isDefault,attr,omitempty"` +} + +// Localized is used to represent the SAML types: +// - localizedName +// - localizedURI +// See 2.2.4 & 2.2.5 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type Localized struct { + Lang string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"` + Value string `xml:",chardata"` +} diff --git a/saml/models/metadata/duration.go b/saml/models/metadata/duration.go new file mode 100644 index 0000000..f28aa35 --- /dev/null +++ b/saml/models/metadata/duration.go @@ -0,0 +1,22 @@ +package metadata + +import ( + "time" + + crewjamSaml "github.com/crewjam/saml" +) + +// Duration is a time.Duration that uses the xsd:duration format for text +// marshalling and unmarshalling. +type Duration time.Duration + +// MarshalText implements the encoding.TextMarshaler interface. +func (d Duration) MarshalText() ([]byte, error) { + return crewjamSaml.Duration(d).MarshalText() +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface. +func (d *Duration) UnmarshalText(text []byte) error { + cp := (*crewjamSaml.Duration)(d) + return cp.UnmarshalText(text) +} diff --git a/saml/models/metadata/duration_test.go b/saml/models/metadata/duration_test.go new file mode 100644 index 0000000..9ae0853 --- /dev/null +++ b/saml/models/metadata/duration_test.go @@ -0,0 +1,56 @@ +package metadata + +import ( + "errors" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var durationMarshalTests = []struct { + in time.Duration + expected []byte +}{ + {0, nil}, + {time.Hour, []byte("PT1H")}, + {-time.Hour, []byte("-PT1H")}, +} + +func TestDuration(t *testing.T) { + for i, testCase := range durationMarshalTests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + actual, err := Duration(testCase.in).MarshalText() + require.NoError(t, err) + require.Equal(t, testCase.expected, actual) + }) + } +} + +var durationUnmarshalTests = []struct { + in []byte + expected time.Duration + err error +}{ + {nil, 0, nil}, + {[]byte("-PT1H"), -time.Hour, nil}, + {[]byte("P1D"), 24 * time.Hour, nil}, + {[]byte("P1M"), 720 * time.Hour, nil}, + {[]byte("PT1.S"), 0, errors.New("invalid duration (PT1.S)")}, +} + +func TestDurationUnmarshal(t *testing.T) { + for i, testCase := range durationUnmarshalTests { + t.Run(strconv.Itoa(i), func(t *testing.T) { + var actual Duration + err := actual.UnmarshalText(testCase.in) + if testCase.err == nil { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, testCase.err.Error()) + } + require.Equal(t, Duration(testCase.expected), actual) + }) + } +} diff --git a/saml/models/metadata/entity_descriptor.go b/saml/models/metadata/entity_descriptor.go new file mode 100644 index 0000000..8ac8985 --- /dev/null +++ b/saml/models/metadata/entity_descriptor.go @@ -0,0 +1,172 @@ +package metadata + +import ( + "time" + + "github.com/beevik/etree" + dsig "github.com/russellhaering/goxmldsig/types" + + "github.com/hashicorp/cap/saml/models/core" +) + +type ContactType string + +const ( + ContactTypeTechnical ContactType = "technical" + ContactTypeSupport ContactType = "support" + ContactTypeAdministrative ContactType = "administrative" + ContactTypeBilling ContactType = "billing" + ContactTypeOther ContactType = "other" +) + +type ProtocolSupportEnumeration string + +const ( + ProtocolSupportEnumerationProtocol ProtocolSupportEnumeration = "urn:oasis:names:tc:SAML:2.0:protocol" +) + +// KeyType defines what the key is used for. +// Possible values are "encryption" and "signing". +// See 2.4.1.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type KeyType string + +const ( + KeyTypeEncryption KeyType = "encryption" + KeyTypeSigning KeyType = "signing" +) + +// DescriptorCommon defines common fields used in Entity- and EntitiesDescriptor. +type DescriptorCommon struct { + ID string `xml:",attr,omitempty"` + ValidUntil *time.Time `xml:"validUntil,attr,omitempty"` + CacheDuration *Duration `xml:"cacheDuration,attr,omitempty"` + Signature *dsig.Signature +} + +// EntitiesDescriptor is a container that wraps one or more elements of +// EntityDiscriptor. +// See 2.3.1 in http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type EntitiesDescriptor struct { + DescriptorCommon + + Name string + + EntitiesDescriptor []*EntitiesDescriptor + EntityDescriptor []*EntityDescriptor +} + +// EntityDescriptor represents a system entity (IdP or SP) in metadata. +// See 2.3.2 in http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type EntityDescriptor struct { + DescriptorCommon + + EntityID string `xml:"entityID,attr"` + + AffiliationDescriptor *AffiliationDescriptor + Organization *Organization + ContactPerson *ContactPerson + AdditionalMetadataLocation []string +} + +// Organization specifies basic information about an organization responsible for a SAML +// entity or role. +// See 2.3.2.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type Organization struct { + Extensions []*etree.Element + OrganizationName []Localized + OrganizationDisplayName []Localized + OrganizationURL []Localized +} + +// ContactPerson specifies basic contact information about a person responsible in some +// capacity for a SAML entity or role. +// See 2.3.2.2 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type ContactPerson struct { + ContactType ContactType `xml:",attr"` + Extensions []*etree.Element + Company string + GivenName string + SurName string + EmailAddress []string + TelephoneNumber []string +} + +// RoleDescriptor is an abstract extension point that contains common descriptive +// information intended to provide processing commonality across different roles. +// See 2.4.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type RoleDescriptor struct { + DescriptorCommon + + ProtocolSupportEnumeration ProtocolSupportEnumeration `xml:"protocolSupportEnumeration,attr,omitempty"` + ErrorURL string `xml:"errorURL,attr,omitempty"` + KeyDescriptor []KeyDescriptor + Organization *Organization + ContactPerson []ContactPerson +} + +// KeyDescriptor provides information about the cryptographic key(s) that an entity uses +// to sign data or receive encrypted keys, along with additional cryptographic details. +// See 2.4.1.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type KeyDescriptor struct { + Use KeyType `xml:"use,attr"` + KeyInfo KeyInfo + EncryptionMethod []EncryptionMethod +} + +// KeyInfo directly or indireclty identifies a key. It defines the usage of the +// XML Signature element. +// See https://www.w3.org/TR/xmldsig-core1/#sec-KeyInfo +type KeyInfo struct { + dsig.KeyInfo + KeyName string +} + +// EncyrptionMethod describes the encryption algorithm applied to the cipher data. +// See https://www.w3.org/TR/2002/REC-xmlenc-core-20021210/Overview.html#sec-EncryptionMethod +type EncryptionMethod struct { + Algorithm string `xml:"Algorithm,attr"` +} + +// SSODescriptor is the common base type for concrete types such as +// IDPSSODescriptor and SPSSODescriptor. +// See 2.4.2 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type SSODescriptor struct { + RoleDescriptor + + ArtifactResolutionService []IndexedEndpoint + SingleLogoutService []Endpoint + ManageNameIDService []Endpoint + NameIDFormat []core.NameIDFormat +} + +// AuthnAuthorityDescriptor ... ??? TODO +// See 2.4.5 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type AuthnAuthorityDescriptor struct { + RoleDescriptor + + AuthnQueryService []Endpoint + AssertionIDRequestService []Endpoint + NameIDFormats []core.NameIDFormat +} + +type PDPDescriptor struct { +} + +// AttributeAuthorityDescriptor is a compatibiity requirement +// for supporting legacy or other SPs that rely on queries for +// attributes. +type AttributeAuthorityDescriptor struct { +} + +// AffiliationDescriptor represents a group of other +// entitites, such as related service providers that +// share a persistent NameID. +type AffiliationDescriptor struct { +} + +// X509Data contains one ore more identifiers of keys or X509 certifactes. +// See https://www.w3.org/TR/xmldsig-core1/#sec-X509Data +// type X509Data struct { +// XMLName xml.Name `xml:"http://www.w3.org/2000/09/xmldsig# X509Certificate"` +// Data string `xml:",chardata"` +// } diff --git a/saml/models/metadata/idp_sso_descriptor.go b/saml/models/metadata/idp_sso_descriptor.go new file mode 100644 index 0000000..c1f6536 --- /dev/null +++ b/saml/models/metadata/idp_sso_descriptor.go @@ -0,0 +1,43 @@ +package metadata + +import ( + "encoding/xml" + + "github.com/hashicorp/cap/saml/models/core" +) + +// IDPSSODescriptor contains profiles specific to identity providers supporting SSO. +// It extends the SSODescriptor type. +// See 2.4.3 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type IDPSSODescriptor struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata IDPSSODescriptor"` + + SSODescriptor + + WantAuthnRequestsSigned bool `xml:",attr"` + SingleSignOnService []Endpoint + NameIDMappingService []Endpoint // TODO test missing! + AssertionIDRequestService []Endpoint // TODO test missing! + AttributeProfile []string // TODO test missing! + Attribute []Attribute +} + +// EntityDescriptorIDPSSO is an EntityDescriptor that accommodates the IDPSSODescriptor +// as descriptor field only. +type EntityDescriptorIDPSSO struct { + EntityDescriptor + + IDPSSODescriptor []*IDPSSODescriptor +} + +func (e *EntityDescriptorIDPSSO) GetLocationForBinding(b core.ServiceBinding) (string, bool) { + for _, isd := range e.IDPSSODescriptor { + for _, ssos := range isd.SingleSignOnService { + if ssos.Binding == b { + return ssos.Location, true + } + } + } + + return "", false +} diff --git a/saml/models/metadata/idp_sso_descriptor_test.go b/saml/models/metadata/idp_sso_descriptor_test.go new file mode 100644 index 0000000..1dc2018 --- /dev/null +++ b/saml/models/metadata/idp_sso_descriptor_test.go @@ -0,0 +1,224 @@ +package metadata_test + +import ( + "encoding/xml" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/saml/models/core" + "github.com/hashicorp/cap/saml/models/metadata" +) + +var exampleIDPSSODescriptorX = ` + + ... + + + + IdentityProvider.com AA Key + + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + member + student + faculty + employee + staff + + + + Identity Providers R US + Identity Providers R US, a Division of Lerxst Corp. + https://IdentityProvider.com + +` + +var exampleIDPSSODescriptor = ` + + + +` + +func Test_IDPSSODescriptor(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorIDPSSO{} + + err := xml.Unmarshal([]byte(exampleIDPSSODescriptor), ed) + r.NoError(err) + + r.Len(ed.IDPSSODescriptor, 1) + + idp := ed.IDPSSODescriptor[0] + + r.True(idp.WantAuthnRequestsSigned) + r.Equal(idp.ProtocolSupportEnumeration, metadata.ProtocolSupportEnumerationProtocol) +} + +var exampleIDPSSOKeyDescriptor = ` + + + + + IdentityProvider.com SSO Key + + + +` + +func Test_IDPSSODescriptor_KeyDescriptor(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorIDPSSO{} + + err := xml.Unmarshal([]byte(exampleIDPSSOKeyDescriptor), ed) + r.NoError(err) + + r.Len(ed.IDPSSODescriptor, 1) + + idp := ed.IDPSSODescriptor[0] + + r.Len(idp.KeyDescriptor, 1) + r.Equal(idp.KeyDescriptor[0].Use, metadata.KeyTypeSigning) + r.Equal(idp.KeyDescriptor[0].KeyInfo.KeyName, "IdentityProvider.com SSO Key") +} + +var exampleIDPSSODescriptorArtifactResolutionService = ` + + + + +` + +func Test_IDPSSODescriptor_ArtifactResolutionService(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorIDPSSO{} + + err := xml.Unmarshal([]byte(exampleIDPSSODescriptorArtifactResolutionService), ed) + r.NoError(err) + + r.Len(ed.IDPSSODescriptor, 1) + + ars := ed.IDPSSODescriptor[0].ArtifactResolutionService + + r.Len(ars, 1) + + r.True(ars[0].IsDefault) + r.Equal(ars[0].Index, 0) + r.Equal(ars[0].Binding, core.ServiceBindingSOAP) + r.Equal(ars[0].Location, "https://hashicorp-idp.com/SAML/Artifact") +} + +var exampleIDPSSODescriptorSLO = ` + + + + + +` + +func Test_IDPSSODescriptor_SLO(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorIDPSSO{} + + err := xml.Unmarshal([]byte(exampleIDPSSODescriptorSLO), ed) + r.NoError(err) + + r.Len(ed.IDPSSODescriptor, 1) + + slo := ed.IDPSSODescriptor[0].SingleLogoutService + + r.Len(slo, 2) + + r.Equal(slo[0].Binding, core.ServiceBindingSOAP) + r.Equal(slo[0].Location, "https://hashicorp.com/SAML/SLO/SOAP") + + r.Equal(slo[1].Binding, core.ServiceBindingHTTPRedirect) + r.Equal(slo[1].Location, "https://hashicorp.com/SAML/SLO/Browser") +} + +var exampleIDPSSODescriptorSSO = ` + + + + + +` + +func Test_IDPSSODescriptor_SSO(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorIDPSSO{} + + err := xml.Unmarshal([]byte(exampleIDPSSODescriptorSSO), ed) + r.NoError(err) + + r.Len(ed.IDPSSODescriptor, 1) + + sso := ed.IDPSSODescriptor[0].SingleSignOnService + + r.Len(sso, 2) + + r.Equal(sso[0].Binding, core.ServiceBindingHTTPRedirect) + r.Equal(sso[0].Location, "https://hashicorp.com/SAML/SSO/Browser") + + r.Equal(sso[1].Binding, core.ServiceBindingHTTPPost) + r.Equal(sso[1].Location, "https://hashicorp.com/SAML/SSO/Browser") +} + +var exampleIDPSSODescriptorAttributes = ` + + + + + member + student + faculty + employee + staff + + +` + +func Test_IDPSSODescriptor_Attributes(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorIDPSSO{} + + err := xml.Unmarshal([]byte(exampleIDPSSODescriptorAttributes), ed) + r.NoError(err) + + r.Len(ed.IDPSSODescriptor, 1) + + attr := ed.IDPSSODescriptor[0].Attribute + + r.Len(attr, 2) + + r.Equal(attr[0].NameFormat, string(core.NameFormatURI)) + r.Equal(attr[0].Name, "urn:oid:1.3.6.1.4.1.5923.1.1.1.6") + + r.Equal(attr[1].NameFormat, string(core.NameFormatURI)) + r.Equal(attr[1].Name, "urn:oid:1.3.6.1.4.1.5923.1.1.1.1") + + r.Len(attr[1].AttributeValue, 5) + r.Equal(attr[1].AttributeValue[0].Value, "member") + r.Equal(attr[1].AttributeValue[1].Value, "student") + r.Equal(attr[1].AttributeValue[2].Value, "faculty") + r.Equal(attr[1].AttributeValue[3].Value, "employee") + r.Equal(attr[1].AttributeValue[4].Value, "staff") +} diff --git a/saml/models/metadata/sp_sso_descriptor.go b/saml/models/metadata/sp_sso_descriptor.go new file mode 100644 index 0000000..69b7b68 --- /dev/null +++ b/saml/models/metadata/sp_sso_descriptor.go @@ -0,0 +1,72 @@ +package metadata + +import "encoding/xml" + +// EntityDescriptorSPSSO defines an EntityDescriptor type +// that can accommodate an SPSSODescriptor. +// This type can be usued specifically to describe SPSSO profiles. +type EntityDescriptorSPSSO struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata EntityDescriptor"` + + EntityDescriptor + + SPSSODescriptor []*SPSSODescriptor +} + +// SPSSODescriptor contains profiles specific to service providers. +// It extends the SSODescriptor type. +// See 2.4.4 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type SPSSODescriptor struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata SPSSODescriptor"` + + SSODescriptor + + AuthnRequestsSigned bool `xml:",attr"` + WantAssertionsSigned bool `xml:",attr"` + AssertionConsumerService []IndexedEndpoint + AttributeConsumingService []*AttributeConsumingService + Attribute []Attribute +} + +// AttributeConsumingService (ACS) is the location where an IdP will eventually send +// the user at the SP. +// See 2.4.4.1 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type AttributeConsumingService struct { + Index int `xml:",attr"` + IsDefault bool `xml:"isDefault,attr"` + ServiceName []Localized + ServiceDescription []Localized + RequestedAttribute []RequestedAttribute +} + +// RequestedAttribute specifies a service providers interest in a specific +// SAML attribute, including specific values. +// See 2.4.4.2 http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf +type RequestedAttribute struct { + Attribute + IsRequired bool `xml:"isRequired,attr"` +} + +// TODO: CORE This needs to be part of core? +type Attribute struct { + FriendlyName string `xml:",attr"` + Name string `xml:",attr"` + NameFormat string `xml:",attr"` + AttributeValue []AttributeValue +} + +// TODO: CORE +type AttributeValue struct { + Type string `xml:"http://www.w3.org/2001/XMLSchema-instance type,attr"` + Value string `xml:",chardata"` + NameID *NameID +} + +// TODO: CORE +type NameID struct { + NameQualifier string `xml:",attr"` + SPNameQualifier string `xml:",attr"` + Format string `xml:",attr"` + SPProvidedID string `xml:",attr"` + Value string `xml:",chardata"` +} diff --git a/saml/models/metadata/sp_sso_descriptor_test.go b/saml/models/metadata/sp_sso_descriptor_test.go new file mode 100644 index 0000000..b1dc0b6 --- /dev/null +++ b/saml/models/metadata/sp_sso_descriptor_test.go @@ -0,0 +1,340 @@ +package metadata_test + +import ( + "encoding/xml" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/saml/models/core" + "github.com/hashicorp/cap/saml/models/metadata" +) + +var exampleSPSSODescriptorA = ` + + signature + + + + ServiceProvider.com SSO Key + + + + + ServiceProvider.com Encrypt Key + + + + + + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + + + + Academic Journals R US + + https://ServiceProvider.com/entitlements/123456789 + + + + + Academic Journals R US + Academic Journals R US, a Division of Dirk Corp. + https://ServiceProvider.com + +` + +var exampleSPSSODescriptor = ` + + +` + +func Test_SPSSODescriptor(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorSPSSO{} + + err := xml.Unmarshal([]byte(exampleSPSSODescriptor), ed) + r.NoError(err) + + r.Len(ed.SPSSODescriptor, 1) + + spSSO := ed.SPSSODescriptor[0] + + r.True(spSSO.AuthnRequestsSigned) + r.True(spSSO.WantAssertionsSigned) + r.Equal(spSSO.ProtocolSupportEnumeration, metadata.ProtocolSupportEnumerationProtocol) +} + +var exampleSLOService = ` + + + + +` + +func Test_SPSSODescriptor_SLOService(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorSPSSO{} + + err := xml.Unmarshal([]byte(exampleSLOService), ed) + r.NoError(err) + + slo := ed.SPSSODescriptor[0].SingleLogoutService + + r.Len(slo, 2) + + r.Equal(slo[0].Binding, core.ServiceBindingHTTPRedirect) + r.Equal(slo[0].Location, "https://hashicorp.com/slo/endpoint") + r.Equal(slo[0].ResponseLocation, "https://hashicorp.com/slo/endpoint") + + r.Equal(slo[1].Binding, core.ServiceBindingSOAP) + r.Equal(slo[1].Location, "https://hashicorp.com/slo/endpoint") + r.Equal(slo[1].ResponseLocation, "") +} + +var exampleNameIDService = ` + + + + +` + +func Test_SPSSODescriptor_ManageNameIDService(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorSPSSO{} + + err := xml.Unmarshal([]byte(exampleNameIDService), ed) + r.NoError(err) + + nameIDSvc := ed.SPSSODescriptor[0].ManageNameIDService + + r.Len(nameIDSvc, 2) + + r.Equal(nameIDSvc[0].Binding, core.ServiceBindingHTTPRedirect) + r.Equal(nameIDSvc[0].Location, "https://hashicorp.com/nameid/endpoint") + r.Equal(nameIDSvc[0].ResponseLocation, "https://hashicorp.com/nameid/endpoint") + + r.Equal(nameIDSvc[1].Binding, core.ServiceBindingSOAP) + r.Equal(nameIDSvc[1].Location, "https://hashicorp.com/nameid/endpoint") + r.Equal(nameIDSvc[1].ResponseLocation, "https://hashicorp.com/nameid/endpoint") +} + +var exampleNameIDFormats = ` + + urn:oasis:names:tc:SAML:2.0:nameid-format:persistent + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + urn:oasis:names:tc:SAML:2.0:nameid-format:transient + +` + +func Test_SPSSODescriptor_NameIDFormats(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorSPSSO{} + + err := xml.Unmarshal([]byte(exampleNameIDFormats), ed) + r.NoError(err) + + nameIDFormats := ed.SPSSODescriptor[0].NameIDFormat + + r.Len(nameIDFormats, 3) + + r.Equal(nameIDFormats[0], core.NameIDFormatPersistent) + r.Equal(nameIDFormats[1], core.NameIDFormatEmail) + r.Equal(nameIDFormats[2], core.NameIDFormatTransient) +} + +var exampleACS = ` + + + + +` + +func Test_SPSSODescriptor_ACS(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorSPSSO{} + + err := xml.Unmarshal([]byte(exampleACS), ed) + r.NoError(err) + + acs := ed.SPSSODescriptor[0].AssertionConsumerService + + r.Len(acs, 2) + + r.True(acs[0].IsDefault) + r.Equal(acs[0].Binding, core.ServiceBindingHTTPRedirect) + r.Equal(acs[0].Index, 0) + r.Equal(acs[0].Location, "https://hashicorp.com/acs/endpoint") + + r.False(acs[1].IsDefault) + r.Equal(acs[1].Binding, core.ServiceBindingHTTPPost) + r.Equal(acs[1].Index, 1) + r.Equal(acs[1].Location, "https://hashicorp.com/acs/endpoint") +} + +var exampleAttributeConsumingService = ` + + + Academic Journals R US + Wir sind Akademische Zeitungen + + https://hashicorp.com/entitlements/123456789 + + + + Academic Journals R US + + https://hashicorp.com/entitlements/987654321 + + + +` + +// TODO: Check on Attributes & AttributeValues +// +// By-Tor +// +// By-Tor + +func Test_SPSSODescriptor_AttributeConsumingService(t *testing.T) { + t.Parallel() + r := require.New(t) + + ed := &metadata.EntityDescriptorSPSSO{} + + err := xml.Unmarshal([]byte(exampleAttributeConsumingService), ed) + r.NoError(err) + + acs := ed.SPSSODescriptor[0].AttributeConsumingService + + r.Len(acs, 2) + + r.Equal(acs[0].Index, 0) + r.True(acs[0].IsDefault) + + r.Equal(acs[0].ServiceName[0].Lang, "en") + r.Equal(acs[0].ServiceName[0].Value, "Academic Journals R US") + r.Equal(acs[0].ServiceName[1].Lang, "de") + r.Equal(acs[0].ServiceName[1].Value, "Wir sind Akademische Zeitungen") + + r.Equal(acs[0].RequestedAttribute[0].Name, "urn:oid:1.3.6.1.4.1.5923.1.1.1.7") + r.Equal(acs[0].RequestedAttribute[0].FriendlyName, "eduPersonEntitlement") + r.Equal(acs[0].RequestedAttribute[0].NameFormat, "urn:oasis:names:tc:SAML:2.0:attrname-format:uri") + r.True(acs[0].RequestedAttribute[0].IsRequired) + r.Len(acs[0].RequestedAttribute[0].AttributeValue, 1) + r.Equal(acs[0].RequestedAttribute[0].AttributeValue[0].Value, "https://hashicorp.com/entitlements/123456789") + + r.Equal(acs[1].ServiceName[0].Lang, "en") + r.Equal(acs[1].ServiceName[0].Value, "Academic Journals R US") + + r.Equal(acs[1].RequestedAttribute[0].Name, "urn:oid:1.3.6.1.4.1.5923.1.1.1.8") + r.Equal(acs[1].RequestedAttribute[0].FriendlyName, "eduPersonEntitlement") + r.Equal(acs[1].RequestedAttribute[0].NameFormat, "urn:oasis:names:tc:SAML:2.0:attrname-format:uri") + r.Len(acs[1].RequestedAttribute[0].AttributeValue, 1) + r.Equal(acs[1].RequestedAttribute[0].AttributeValue[0].Value, "https://hashicorp.com/entitlements/987654321") +} + +var exampleKeyDescriptor = ` + + + + + +MIICYDCCAgqgAwIBAgICBoowDQYJKoZIhvcNAQEEBQAwgZIxCzAJBgNVBAYTAlVTMRMwEQYDVQQI +EwpDYWxpZm9ybmlhMRQwEgYDVQQHEwtTYW50YSBDbGFyYTEeMBwGA1UEChMVU3VuIE1pY3Jvc3lz +dGVtcyBJbmMuMRowGAYDVQQLExFJZGVudGl0eSBTZXJ2aWNlczEcMBoGA1UEAxMTQ2VydGlmaWNh +dGUgTWFuYWdlcjAeFw0wNjExMDIxOTExMzRaFw0xMDA3MjkxOTExMzRaMDcxEjAQBgNVBAoTCXNp +cm9lLmNvbTEhMB8GA1UEAxMYbG9hZGJhbGFuY2VyLTkuc2lyb2UuY29tMIGfMA0GCSqGSIb3DQEB +AQUAA4GNADCBiQKBgQCjOwa5qoaUuVnknqf5pdgAJSEoWlvx/jnUYbkSDpXLzraEiy2UhvwpoBgB +EeTSUaPPBvboCItchakPI6Z/aFdH3Wmjuij9XD8r1C+q//7sUO0IGn0ORycddHhoo0aSdnnxGf9V +tREaqKm9dJ7Yn7kQHjo2eryMgYxtr/Z5Il5F+wIDAQABo2AwXjARBglghkgBhvhCAQEEBAMCBkAw +DgYDVR0PAQH/BAQDAgTwMB8GA1UdIwQYMBaAFDugITflTCfsWyNLTXDl7cMDUKuuMBgGA1UdEQQR +MA+BDW1hbGxhQHN1bi5jb20wDQYJKoZIhvcNAQEEBQADQQB/6DOB6sRqCZu2OenM9eQR0gube85e +nTTxU4a7x1naFxzYXK1iQ1vMARKMjDb19QEJIEJKZlDK4uS7yMlf1nFS + + + + + + + + +MIICTDCCAfagAwIBAgICBo8wDQYJKoZIhvcNAQEEBQAwgZIxCzAJBgNVBAYTAlVTMRMwEQYDVQQI +EwpDYWxpZm9ybmlhMRQwEgYDVQQHEwtTYW50YSBDbGFyYTEeMBwGA1UEChMVU3VuIE1pY3Jvc3lz +dGVtcyBJbmMuMRowGAYDVQQLExFJZGVudGl0eSBTZXJ2aWNlczEcMBoGA1UEAxMTQ2VydGlmaWNh +dGUgTWFuYWdlcjAeFw0wNjExMDcyMzU2MTdaFw0xMDA4MDMyMzU2MTdaMCMxITAfBgNVBAMTGGxv +YWRiYWxhbmNlci05LnNpcm9lLmNvbTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAw574iRU6 +HsSO4LXW/OGTXyfsbGv6XRVOoy3v+J1pZ51KKejcDjDJXNkKGn3/356AwIaqbcymWd59T0zSqYfR +Hn+45uyjYxRBmVJseLpVnOXLub9jsjULfGx0yjH4w+KsZSZCXatoCHbj/RJtkzuZY6V9to/hkH3S +InQB4a3UAgMCAwEAAaNgMF4wEQYJYIZIAYb4QgEBBAQDAgZAMA4GA1UdDwEB/wQEAwIE8DAfBgNV +HSMEGDAWgBQ7oCE35Uwn7FsjS01w5e3DA1CrrjAYBgNVHREEETAPgQ1tYWxsYUBzdW4uY29tMA0G +CSqGSIb3DQEBBAUAA0EAMlbfBg/ff0Xkv4DOR5LEqmfTZKqgdlD81cXynfzlF7XfnOqI6hPIA90I +x5Ql0ejivIJAYcMGUyA+/YwJg2FGoA== + + + + + 128 + + + +` diff --git a/saml/options.go b/saml/options.go new file mode 100644 index 0000000..32ad499 --- /dev/null +++ b/saml/options.go @@ -0,0 +1,16 @@ +package saml + +// Option defines a common functional options type which can be used in a +// variadic parameter pattern. +type Option func(interface{}) + +// ApplyOpts takes a pointer to the options struct as a set of default options +// and applies the slice of opts as overrides. +func ApplyOpts(opts interface{}, opt ...Option) { + for _, o := range opt { + if o == nil { // ignore any nil Options + continue + } + o(opts) + } +} diff --git a/saml/response.go b/saml/response.go new file mode 100644 index 0000000..e43c3f8 --- /dev/null +++ b/saml/response.go @@ -0,0 +1,244 @@ +package saml + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "regexp" + + "github.com/jonboulle/clockwork" + saml2 "github.com/russellhaering/gosaml2" + dsig "github.com/russellhaering/goxmldsig" + + "github.com/hashicorp/cap/saml/models/core" + "github.com/hashicorp/cap/saml/models/metadata" +) + +type parseResponseOptions struct { + clock clockwork.Clock + skipRequestIDValidation bool + skipAssertionConditionValidation bool + skipSignatureValidation bool + assertionConsumerServiceURL string +} + +func parseResponseOptionsDefault() parseResponseOptions { + return parseResponseOptions{ + clock: clockwork.NewRealClock(), + skipRequestIDValidation: false, + skipAssertionConditionValidation: false, + skipSignatureValidation: false, + } +} + +func getParseResponseOptions(opt ...Option) parseResponseOptions { + opts := parseResponseOptionsDefault() + ApplyOpts(&opts, opt...) + return opts +} + +// InsecureSkipRequestIDValidation disables/skips if the given requestID matches +// the InResponseTo parameter in the SAML response. This options should only +// be used for testing purposes. +func InsecureSkipRequestIDValidation() Option { + return func(o interface{}) { + if o, ok := o.(*parseResponseOptions); ok { + o.skipRequestIDValidation = true + } + } +} + +// InsecureSkipAssertionConditionValidation disables/skips validation of the assertion +// conditions within the SAML response. This options should only be used for +// testing purposes. +func InsecureSkipAssertionConditionValidation() Option { + return func(o interface{}) { + if o, ok := o.(*parseResponseOptions); ok { + o.skipAssertionConditionValidation = true + } + } +} + +// InsecureSkipSignatureValidation disables/skips validation of the SAML Response and its assertions. +// This options should only be used for testing purposes. +func InsecureSkipSignatureValidation() Option { + return func(o interface{}) { + if o, ok := o.(*parseResponseOptions); ok { + o.skipSignatureValidation = true + } + } +} + +// ParseResponse parses and validates a SAML Reponse. +// +// Options: +// - InsecureSkipRequestIDValidation +// - InsecureSkipAssertionConditionValidation +// - InsecureSkipSignatureValidation +// - WithAssertionConsumerServiceURL +// - WithClock +func (sp *ServiceProvider) ParseResponse( + samlResp string, + requestID string, + opt ...Option, +) (*core.Response, error) { + const op = "saml.(ServiceProvider).ParseResponse" + switch { + case sp == nil: + return nil, fmt.Errorf("%s: missing service provider %w", op, ErrInternal) + case samlResp == "": + return nil, fmt.Errorf("%s: missing saml response: %w", op, ErrInvalidParameter) + case requestID == "": + return nil, fmt.Errorf("%s: missing request ID: %w", op, ErrInvalidParameter) + } + opts := getParseResponseOptions(opt...) + + // We use github.com/russellhaering/gosaml2 for SAMLResponse signature and condition validation. + ip, err := sp.internalParser( + opts.skipSignatureValidation, + opts.assertionConsumerServiceURL, + opts.clock, + ) + if err != nil { + return nil, fmt.Errorf("%s: error initializing parser: %w", op, err) + } + + // This will validate the response and all assertions. + response, err := ip.ValidateEncodedResponse(samlResp) + switch { + case err != nil: + return nil, fmt.Errorf("%s: unable to validate encoded response: %w", op, err) + case len(response.Assertions) == 0: + // note: this is currently unreachable since the call to + // ip.ValidateEncodedResponse(...) above will return an err if there are + // no assertions, but we've left this here since it's a required for our + // implementation as well. + return nil, fmt.Errorf("%s: %w", op, ErrMissingAssertions) + case !opts.skipRequestIDValidation && response.InResponseTo != requestID: + return nil, fmt.Errorf( + "InResponseTo (%s) doesn't match the expected requestID (%s)", + response.InResponseTo, + requestID, + ) + case !opts.skipAssertionConditionValidation: + // Verify conditions for all assertions + for _, assert := range response.Assertions { + warnings, err := ip.VerifyAssertionConditions(&assert) + switch { + case err != nil: + return nil, fmt.Errorf("%s: %w", op, err) + case warnings.InvalidTime: + // note: this is currently unreachable since the call to + // ip.ValidateEncodedResponse(...) above will return an err if + // the time is invalid, but we've left this here since it's a + // required for our implementation as well. + return nil, fmt.Errorf("%s: %w", op, ErrInvalidTime) + case warnings.NotInAudience: + return nil, fmt.Errorf("%s: %w", op, ErrInvalidAudience) + case assert.Subject == nil || assert.Subject.NameID == nil: + // note: this is currently unreachable since the call to + // ip.ValidateEncodedResponse(...) above will return an err if + // there isn't a subject, but we've left this here since it's a + // required for our implementation as well. + return nil, fmt.Errorf("%s: %w", op, ErrMissingSubject) + case assert.AttributeStatement == nil: + return nil, fmt.Errorf("%s: %w", op, ErrMissingAttributeStmt) + } + } + } + + return &core.Response{Response: *response}, nil +} + +func (sp *ServiceProvider) internalParser( + skipSignatureValidation bool, + assertionConsumerServiceURL string, + clock clockwork.Clock, +) (*saml2.SAMLServiceProvider, error) { + const op = "saml.(ServiceProvider).internalParser" + switch { + case isNil(clock): + return nil, fmt.Errorf("%s: missing clock: %w", op, ErrInvalidParameter) + } + idpMetadata, err := sp.IDPMetadata() + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + switch { + case err != nil: + return nil, fmt.Errorf("%s: %w", op, err) + case len(idpMetadata.IDPSSODescriptor) != 1: + return nil, fmt.Errorf("%s: expected one IdP descriptor and got %d: %w", op, len(idpMetadata.IDPSSODescriptor), ErrInternal) + } + + var certStore dsig.MemoryX509CertificateStore + for _, kd := range idpMetadata.IDPSSODescriptor[0].KeyDescriptor { + switch kd.Use { + case "", metadata.KeyTypeSigning: + for _, xcert := range kd.KeyInfo.X509Data.X509Certificates { + parsed, err := parseX509Certificate(xcert.Data) + if err != nil { + return nil, fmt.Errorf("%s: unable to parse cert: %w", op, err) + } + certStore.Roots = append(certStore.Roots, parsed) // append works just fine with a nil slice + } + } + } + + if assertionConsumerServiceURL == "" { + assertionConsumerServiceURL = sp.cfg.AssertionConsumerServiceURL + } + + return &saml2.SAMLServiceProvider{ + IdentityProviderIssuer: idpMetadata.EntityID, + IDPCertificateStore: &certStore, + ServiceProviderIssuer: sp.cfg.EntityID, + AudienceURI: sp.cfg.EntityID, + AssertionConsumerServiceURL: assertionConsumerServiceURL, + SkipSignatureValidation: skipSignatureValidation, + Clock: dsig.NewFakeClock(clock), + }, nil +} + +// parseX509Certificate parses the contents of a which is a +// base64-encoded ASN.1 DER certificate. It does not parse PEM-encoded certificates. +func parseX509Certificate(cert string) (*x509.Certificate, error) { + const op = "saml.parseCert" + switch { + case cert == "": + return nil, fmt.Errorf("%s: missing certificate: %w", op, ErrInvalidParameter) + default: + regex := regexp.MustCompile(`\s+`) + cert = regex.ReplaceAllString(cert, "") + if cert == "" { + return nil, fmt.Errorf("%s: certificate was only whitespace: %w", op, ErrInvalidParameter) + } + } + certBytes, err := base64.StdEncoding.DecodeString(cert) + if err != nil { + return nil, fmt.Errorf("cannot decode certificate: %s", err) + } + parsedCert, err := x509.ParseCertificate(certBytes) + if err != nil { + return nil, fmt.Errorf("cannot parse certificate: %s", err) + } + + return parsedCert, nil +} + +func parsePEMCertificate(cert []byte) (*x509.Certificate, error) { + block, rest := pem.Decode(cert) + if block == nil { + return nil, fmt.Errorf("no certificate found") + } + if len(rest) != 0 { + return nil, fmt.Errorf("extra data found after certificate: %s", rest) + } + + if block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("wrong block type found: %q", block.Type) + } + + return x509.ParseCertificate(block.Bytes) +} diff --git a/saml/response_test.go b/saml/response_test.go new file mode 100644 index 0000000..57baccd --- /dev/null +++ b/saml/response_test.go @@ -0,0 +1,749 @@ +package saml_test + +import ( + "encoding/base64" + "fmt" + "testing" + "time" + + "github.com/hashicorp/cap/saml" + "github.com/hashicorp/cap/saml/models/core" + testprovider "github.com/hashicorp/cap/saml/test" + "github.com/jonboulle/clockwork" + saml2 "github.com/russellhaering/gosaml2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testExpiredResp = `PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHNhbWwycDpSZXNwb25zZSBEZXN0aW5hdGlvbj0iaHR0cDovL2xvY2FsaG9zdDo4MDAwL3NhbWwvYWNzIiBJRD0iXzg4NDljMmVlNTMyZmNkYjc4MWYyYTE3NzZlYWMzNzQxIiBJblJlc3BvbnNlVG89ImJjNWE1YmFhLTk0ZTAtNThhOC04NzJjLWU1MTQ5MWQyYjNlZSIgSXNzdWVJbnN0YW50PSIyMDIzLTA4LTI1VDE0OjMyOjUzLjY4MFoiIFZlcnNpb249IjIuMCIgeG1sbnM6c2FtbDJwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiIHhtbG5zOnhzZD0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiPjxzYW1sMjpJc3N1ZXIgeG1sbnM6c2FtbDI9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iPmh0dHBzOi8vc2FtbHRlc3QuaWQvc2FtbC9pZHA8L3NhbWwyOklzc3Vlcj48ZHM6U2lnbmF0dXJlIHhtbG5zOmRzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIj48ZHM6U2lnbmVkSW5mbz48ZHM6Q2Fub25pY2FsaXphdGlvbk1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPjxkczpTaWduYXR1cmVNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNyc2Etc2hhMjU2Ii8+PGRzOlJlZmVyZW5jZSBVUkk9IiNfODg0OWMyZWU1MzJmY2RiNzgxZjJhMTc3NmVhYzM3NDEiPjxkczpUcmFuc2Zvcm1zPjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjZW52ZWxvcGVkLXNpZ25hdHVyZSIvPjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiPjxlYzpJbmNsdXNpdmVOYW1lc3BhY2VzIFByZWZpeExpc3Q9InhzZCIgeG1sbnM6ZWM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPjwvZHM6VHJhbnNmb3JtPjwvZHM6VHJhbnNmb3Jtcz48ZHM6RGlnZXN0TWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjc2hhMjU2Ii8+PGRzOkRpZ2VzdFZhbHVlPlJWNDg1dUtHSlptTkExbzU2Z3h4aytWWmt2eE1xdGxIWkEyaUhIOFpVMVE9PC9kczpEaWdlc3RWYWx1ZT48L2RzOlJlZmVyZW5jZT48L2RzOlNpZ25lZEluZm8+PGRzOlNpZ25hdHVyZVZhbHVlPmQzTHBjNmhjU0I3YndDek1yTzN3ZlpyTmlHazVnWjhyS1JLT1FFTkRQMnErcDMrTGtEbVNCdDZ6enl4bjMzTUNTSnQrZFBIcEYxNFlNQUsvTjNQbld3U1NVcDBqNWt6T2M5S2E1TmRpYW5FME5nWW5VMHFqaEZKYlRoQVF6N2hSb3dTNEo0OWhTLzZNdVNRMFo3bkJCQ2VEZ2VENlBZUkFwS012bE90a0JHUEphTFQybVJ5L2duUStDQzZ1ZFVkSnl2U2diOW40M2x2eGRhYVpXckRLM1dnYTk4WWxrY1JITHJtUEFBTThLeFlXbmtvcGlvNllJTlU0RDVtWmpzRXNuVWtINDFXZ2N3Z21TMnh6UDNJQ25OYzNXSDlOSHJWS3A5YXQyREJ3cllESXNlczZGWGdZcStpVVdLMjE5MWpXcElDM3FWQUIwY09pbG1SWHd0RUg3Zz09PC9kczpTaWduYXR1cmVWYWx1ZT48ZHM6S2V5SW5mbz48ZHM6WDUwOURhdGE+PGRzOlg1MDlDZXJ0aWZpY2F0ZT5NSUlERWpDQ0FmcWdBd0lCQWdJVkFNRUNRMXRqZ2hhZm01T3hXRGg5aHdaZnh0aFdNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1CWXhGREFTCkJnTlZCQU1NQzNOaGJXeDBaWE4wTG1sa01CNFhEVEU0TURneU5ESXhNVFF3T1ZvWERUTTRNRGd5TkRJeE1UUXdPVm93RmpFVU1CSUcKQTFVRUF3d0xjMkZ0YkhSbGMzUXVhV1F3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRQzBaNFFYMU5GSwpzNzF1ZmJRd29Rb1c3cWtOQUpSSUFOR0E0aU0wVGhZZ2h1bDNwQytGd3JHdjM3YVR4V1hmQTFVRzluaktiYkRyZWlEQVpLbmdDZ3lqCnhqMHVKNGxBcmdrcjRBT0VqajV6WEE4MXVHSEFSZlVCY3R2UWNzWnBCSXhET3ZVVUltQWwrM05xTGdNR0YyZmt0eE1HN2tYM0dFVk4KYzFrbGJOM2RmWXNhdzVkVXJ3MjVEaGVMOW5wN0cvKzI4R3dIUHZMYjRhcHRPaU9OYkNhVnZoOVVNSEVBOUY3YzB6ZkYvY0w1Zk9wZApWYTU0d1RJMHUxMkNzRkt0NzhoNmxFR0c1alVzL3FYOWNsWm5jSk03RUZrTjNpbVBQeSswSEM4bnNwWGlIL01aVzhvMmNxV1JrcnczCk16QlpXM09qazVuUWo0MFY2TlViamI3a2ZlanpBZ01CQUFHalZ6QlZNQjBHQTFVZERnUVdCQlFUNlk5SjNUdy9oT0djOFBOVjdKRUUKNGsyWk5UQTBCZ05WSFJFRUxUQXJnZ3R6WVcxc2RHVnpkQzVwWklZY2FIUjBjSE02THk5ellXMXNkR1Z6ZEM1cFpDOXpZVzFzTDJsawpjREFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBU2szZ3VLZlRrVmhFYUlWdnhFUE5SMnczdld0M2Z3bXdKQ2NjVzk4WFhMV2dOYnUzCllhTWIyUlNuN1RoNHAzaCttZnlrMmRvbjZhdTdVeXpjMUpkMzlSTnY4MFRHNWlRb3hmQ2dwaHkxRlltbWRhU2ZPOHd2RHRIVFROaUwKQXJBeE9ZdHpmWWJ6YjVRck5OSC9nUUVOOFJKYUVmL2cvMUdUdzl4LzEwM2RTTUswUlh0bCtmUnMybmJsRDFKSktTUTNBZGh4Sy93ZQpQM2FVUHRMeFZWSjl3TU9RT2ZjeTAybCtoSE1iNnVBanNQT3BPVktxaTNNOFhtY1VaT3B4NHN3dGdHZGVvU3BlUnlydE12UndkY2NpCk5CcDlVWm9tZTQ0cVpBWUgxaXFycG1tanNmSTlwSkl0c2dXdTNrWFBqaFNmajFBSkdSMWw5Skd2SnJIa2kxaUhUQT09PC9kczpYNTA5Q2VydGlmaWNhdGU+PC9kczpYNTA5RGF0YT48L2RzOktleUluZm8+PC9kczpTaWduYXR1cmU+PHNhbWwycDpTdGF0dXM+PHNhbWwycDpTdGF0dXNDb2RlIFZhbHVlPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6c3RhdHVzOlN1Y2Nlc3MiLz48L3NhbWwycDpTdGF0dXM+PHNhbWwyOkFzc2VydGlvbiBJRD0iXzM1ZWE5MGI3MTFkNmYzODUzNDVmMGRiZGQ3ZDBlZDViIiBJc3N1ZUluc3RhbnQ9IjIwMjMtMDgtMjVUMTQ6MzI6NTMuNjgwWiIgVmVyc2lvbj0iMi4wIiB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiI+PHNhbWwyOklzc3Vlcj5odHRwczovL3NhbWx0ZXN0LmlkL3NhbWwvaWRwPC9zYW1sMjpJc3N1ZXI+PHNhbWwyOlN1YmplY3Q+PHNhbWwyOk5hbWVJRCBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjEuMTpuYW1laWQtZm9ybWF0OmVtYWlsQWRkcmVzcyIgTmFtZVF1YWxpZmllcj0iaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lkcCIgU1BOYW1lUXVhbGlmaWVyPSJodHRwOi8vc2FtbC5qdWx6L2V4YW1wbGUiIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj5tc21pdGhAc2FtbHRlc3QuaWQ8L3NhbWwyOk5hbWVJRD48c2FtbDI6U3ViamVjdENvbmZpcm1hdGlvbiBNZXRob2Q9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpjbTpiZWFyZXIiPjxzYW1sMjpTdWJqZWN0Q29uZmlybWF0aW9uRGF0YSBBZGRyZXNzPSIxMDQuMjguMzkuMzQiIEluUmVzcG9uc2VUbz0iYmM1YTViYWEtOTRlMC01OGE4LTg3MmMtZTUxNDkxZDJiM2VlIiBOb3RPbk9yQWZ0ZXI9IjIwMjMtMDgtMjVUMTQ6Mzc6NTMuNjkzWiIgUmVjaXBpZW50PSJodHRwOi8vbG9jYWxob3N0OjgwMDAvc2FtbC9hY3MiLz48L3NhbWwyOlN1YmplY3RDb25maXJtYXRpb24+PC9zYW1sMjpTdWJqZWN0PjxzYW1sMjpDb25kaXRpb25zIE5vdEJlZm9yZT0iMjAyMy0wOC0yNVQxNDozMjo1My42ODBaIiBOb3RPbk9yQWZ0ZXI9IjIwMjMtMDgtMjVUMTQ6Mzc6NTMuNjgwWiI+PHNhbWwyOkF1ZGllbmNlUmVzdHJpY3Rpb24+PHNhbWwyOkF1ZGllbmNlPmh0dHA6Ly9zYW1sLmp1bHovZXhhbXBsZTwvc2FtbDI6QXVkaWVuY2U+PC9zYW1sMjpBdWRpZW5jZVJlc3RyaWN0aW9uPjwvc2FtbDI6Q29uZGl0aW9ucz48c2FtbDI6QXV0aG5TdGF0ZW1lbnQgQXV0aG5JbnN0YW50PSIyMDIzLTA4LTI1VDE0OjMxOjU2LjA2NFoiIFNlc3Npb25JbmRleD0iX2Y3MmE2M2VlMzc4MmI0N2M4OWY2MGU4MWFkZGUwYWIwIj48c2FtbDI6U3ViamVjdExvY2FsaXR5IEFkZHJlc3M9IjEwNC4yOC4zOS4zNCIvPjxzYW1sMjpBdXRobkNvbnRleHQ+PHNhbWwyOkF1dGhuQ29udGV4dENsYXNzUmVmPnVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphYzpjbGFzc2VzOlBhc3N3b3JkUHJvdGVjdGVkVHJhbnNwb3J0PC9zYW1sMjpBdXRobkNvbnRleHRDbGFzc1JlZj48L3NhbWwyOkF1dGhuQ29udGV4dD48L3NhbWwyOkF1dGhuU3RhdGVtZW50PjxzYW1sMjpBdHRyaWJ1dGVTdGF0ZW1lbnQ+PHNhbWwyOkF0dHJpYnV0ZSBGcmllbmRseU5hbWU9ImVkdVBlcnNvbkVudGl0bGVtZW50IiBOYW1lPSJ1cm46b2lkOjEuMy42LjEuNC4xLjU5MjMuMS4xLjEuNyIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1cmkiPjxzYW1sMjpBdHRyaWJ1dGVWYWx1ZT5BbWJhc3NhZG9yPC9zYW1sMjpBdHRyaWJ1dGVWYWx1ZT48c2FtbDI6QXR0cmlidXRlVmFsdWU+Tm9uZTwvc2FtbDI6QXR0cmlidXRlVmFsdWU+PC9zYW1sMjpBdHRyaWJ1dGU+PHNhbWwyOkF0dHJpYnV0ZSBOYW1lPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDphdHRyaWJ1dGU6c3ViamVjdC1pZCIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1cmkiPjxzYW1sMjpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHNkOnN0cmluZyI+bXNtaXRoQHNhbWx0ZXN0LmlkPC9zYW1sMjpBdHRyaWJ1dGVWYWx1ZT48L3NhbWwyOkF0dHJpYnV0ZT48c2FtbDI6QXR0cmlidXRlIEZyaWVuZGx5TmFtZT0idWlkIiBOYW1lPSJ1cm46b2lkOjAuOS4yMzQyLjE5MjAwMzAwLjEwMC4xLjEiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDI6QXR0cmlidXRlVmFsdWU+bW9ydHk8L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDI6QXR0cmlidXRlPjxzYW1sMjpBdHRyaWJ1dGUgRnJpZW5kbHlOYW1lPSJ0ZWxlcGhvbmVOdW1iZXIiIE5hbWU9InVybjpvaWQ6Mi41LjQuMjAiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDI6QXR0cmlidXRlVmFsdWU+KzEtNTU1LTU1NS01NTA1PC9zYW1sMjpBdHRyaWJ1dGVWYWx1ZT48L3NhbWwyOkF0dHJpYnV0ZT48c2FtbDI6QXR0cmlidXRlIEZyaWVuZGx5TmFtZT0icm9sZSIgTmFtZT0iaHR0cHM6Ly9zYW1sdGVzdC5pZC9hdHRyaWJ1dGVzL3JvbGUiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDI6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzZDpzdHJpbmciPmphbml0b3JAc2FtbHRlc3QuaWQ8L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDI6QXR0cmlidXRlPjxzYW1sMjpBdHRyaWJ1dGUgRnJpZW5kbHlOYW1lPSJtYWlsIiBOYW1lPSJ1cm46b2lkOjAuOS4yMzQyLjE5MjAwMzAwLjEwMC4xLjMiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDI6QXR0cmlidXRlVmFsdWU+bXNtaXRoQHNhbWx0ZXN0LmlkPC9zYW1sMjpBdHRyaWJ1dGVWYWx1ZT48L3NhbWwyOkF0dHJpYnV0ZT48c2FtbDI6QXR0cmlidXRlIEZyaWVuZGx5TmFtZT0ic24iIE5hbWU9InVybjpvaWQ6Mi41LjQuNCIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1cmkiPjxzYW1sMjpBdHRyaWJ1dGVWYWx1ZT5TbWl0aDwvc2FtbDI6QXR0cmlidXRlVmFsdWU+PC9zYW1sMjpBdHRyaWJ1dGU+PHNhbWwyOkF0dHJpYnV0ZSBGcmllbmRseU5hbWU9ImRpc3BsYXlOYW1lIiBOYW1lPSJ1cm46b2lkOjIuMTYuODQwLjEuMTEzNzMwLjMuMS4yNDEiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDI6QXR0cmlidXRlVmFsdWU+TW9ydHkgU21pdGg8L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDI6QXR0cmlidXRlPjxzYW1sMjpBdHRyaWJ1dGUgRnJpZW5kbHlOYW1lPSJnaXZlbk5hbWUiIE5hbWU9InVybjpvaWQ6Mi41LjQuNDIiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dXJpIj48c2FtbDI6QXR0cmlidXRlVmFsdWU+TW9ydGltZXI8L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDI6QXR0cmlidXRlPjwvc2FtbDI6QXR0cmlidXRlU3RhdGVtZW50Pjwvc2FtbDI6QXNzZXJ0aW9uPjwvc2FtbDJwOlJlc3BvbnNlPg==` + +// TODO: add the ability to sign requests, so we can write more complete unit tests +func TestServiceProvider_ParseResponse(t *testing.T) { + t.Parallel() + const ( + testRequestId = "bc5a5baa-94e0-58a8-872c-e51491d2b3ee" + testEntityID = "http://saml.julz/example" + testAcs = "http://localhost:8000/saml/acs" + metadataURL = "https://samltest.id/saml/idp" + ) + + testCfg, err := saml.NewConfig(testEntityID, testAcs, metadataURL) + require.NoError(t, err) + testSp, err := saml.NewServiceProvider(testCfg) + require.NoError(t, err) + + fakeTime, err := time.Parse("2006-01-02 15:04:05", "2023-08-25 14:33:53") + require.NoError(t, err) + + testCfgWithBadMetadata, err := saml.NewConfig(testEntityID, testAcs, "https://samltest.id/saml/idp-invalid") + require.NoError(t, err) + testSpWithInvalidMetadataURL, err := saml.NewServiceProvider(testCfgWithBadMetadata) + require.NoError(t, err) + + tests := []struct { + name string + sp *saml.ServiceProvider + samlResp string + requestID string + opts []saml.Option + want *core.Response + wantErrContains string + wantErrIs error + wantErrAs error + }{ + { + name: "success", + sp: testSp, + samlResp: testExpiredResp, + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + }, + requestID: testRequestId, + }, + { + name: "err-assertion-missing-attribute-stmt", + sp: testSp, + samlResp: base64.StdEncoding.EncodeToString([]byte(testRespInvalidAssertionMissingAttributeStmt)), + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + }, + requestID: testRequestId, + wantErrContains: "attribute statement missing", + }, + { + name: "err-assertion-missing-subject", + sp: testSp, + samlResp: base64.StdEncoding.EncodeToString([]byte(testRespInvalidAssertionMissingSubject)), + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + }, + requestID: testRequestId, + wantErrContains: "unable to validate encoded response: missing Subject element", + }, + { + name: "err-assertion-missing-not-before", + sp: testSp, + samlResp: base64.StdEncoding.EncodeToString([]byte(testRespInvalidAssertionMissingNotBefore)), + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + }, + requestID: testRequestId, + wantErrContains: "missing NotBefore attribute on Conditions element", + }, + { + name: "err-assertion-invalid-audience", + sp: testSp, + samlResp: base64.StdEncoding.EncodeToString([]byte(testRespInvalidAssertionAudience)), + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + }, + requestID: testRequestId, + wantErrContains: "invalid audience", + }, + { + name: "err-no-assertions", + sp: testSp, + samlResp: base64.StdEncoding.EncodeToString([]byte(testRespNoAssertions)), + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + }, + requestID: testRequestId, + wantErrContains: "unable to validate encoded response: missing Assertion element", + }, + { + name: "err-bad-metatdata-url", + sp: testSpWithInvalidMetadataURL, + samlResp: testExpiredResp, + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + }, + requestID: "invalid-request-id", + wantErrContains: "error initializing parser: saml.(ServiceProvider).internalParser: saml.ServiceProvider.FetchIDPMetadata: failed to parse identity provider XML metadata", + }, + { + name: "err-unable-to-parse-resp", + sp: testSp, + samlResp: "unable-to-parse-resp", + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + }, + requestID: testRequestId, + wantErrContains: "unable to validate encoded response: illegal base64 data", + }, + { + name: "err-in-response-to", + sp: testSp, + samlResp: testExpiredResp, + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + }, + requestID: "invalid-request-id", + wantErrContains: "doesn't match the expected requestID (invalid-request-id)", + }, + { + name: "expired", + sp: testSp, + samlResp: testExpiredResp, + requestID: "request-id", + wantErrAs: &saml2.ErrInvalidValue{}, + wantErrContains: "unable to validate encoded response: Expired NotOnOrAfter value", + }, + { + name: "nil-sp", + wantErrIs: saml.ErrInternal, + wantErrContains: "missing service provider", + }, + { + name: "missing-saml-response", + sp: testSp, + wantErrIs: saml.ErrInvalidParameter, + wantErrContains: "missing saml response", + }, + { + name: "missing-request-id", + sp: testSp, + samlResp: testExpiredResp, + wantErrIs: saml.ErrInvalidParameter, + wantErrContains: "missing request ID", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := tc.sp.ParseResponse(tc.samlResp, tc.requestID, tc.opts...) + if tc.wantErrContains != "" { + require.Error(err) + assert.Empty(got) + assert.ErrorContains(err, tc.wantErrContains) + if tc.wantErrIs != nil { + assert.ErrorIs(err, tc.wantErrIs) + } + if tc.wantErrAs != nil { + assert.ErrorAs(err, tc.wantErrAs) + } + return + } + require.NoError(err) + assert.Equal(testRequestId, got.InResponseTo) + assert.Equal("http://localhost:8000/saml/acs", got.Destination) + assert.Equal("urn:oasis:names:tc:SAML:2.0:status:Success", got.Status.StatusCode.Value) + assert.Equal(metadataURL, got.Issuer()) + assert.Equal("msmith@samltest.id", got.Assertions()[0].Subject.NameID.Value) + assert.Equal("_35ea90b711d6f385345f0dbdd7d0ed5b", got.Assertions()[0].ID) + }) + } +} + +func TestServiceProvider_ParseResponseCustomACS(t *testing.T) { + t.Parallel() + r := require.New(t) + + fakeTime, err := time.Parse("2006-01-02", "2015-07-15") + r.NoError(err) + + tp := testprovider.StartTestProvider(t) + defer tp.Close() + + cfg, err := saml.NewConfig( + "http://test.me/entity", + "http://test.me/saml/acs", + fmt.Sprintf("%s/saml/metadata", tp.ServerURL()), + ) + r.NoError(err) + + sp, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + encodedResponse := base64.StdEncoding.EncodeToString([]byte(responseUnsigned)) + + type testCase struct { + name string + opts []saml.Option + err string + } + + for _, c := range []testCase{ + { + name: "default url", + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + }, + }, + { + name: "valid acs url", + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + saml.WithAssertionConsumerServiceURL("http://test.me/saml/acs"), + }, + }, + { + name: "invalid acs url", + opts: []saml.Option{ + saml.WithClock(clockwork.NewFakeClockAt(fakeTime)), + saml.InsecureSkipSignatureValidation(), + saml.WithAssertionConsumerServiceURL("http://badurl.me"), + }, + err: "Unrecognized Destination value, Expected: http://badurl.me, Actual: http://test.me/saml/acs", + }, + } { + t.Run(c.name, func(t *testing.T) { + _, err = sp.ParseResponse( + encodedResponse, + "ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685", + c.opts..., + ) + if c.err == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, c.err) + }) + } + +} + +// From https://www.samltool.com/generic_sso_res.php +const responseUnsigned = ` + + http://test.idp + + + + + http://test.idp + + _ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7 + + + + + + + http://test.me/entity + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + test + + + test@example.com + + + users + examplerole1 + + + +` + +const testRespNoAssertions = ` + + + https://samltest.id/saml/idp + + + + + + + + + + + + + RV485uKGJZmNA1o56gxxk+VZkvxMqtlHZA2iHH8ZU1Q= + + + d3Lpc6hcSB7bwCzMrO3wfZrNiGk5gZ8rKRKOQENDP2q+p3+LkDmSBt6zzyxn33MCSJt+dPHpF14YMAK/N3PnWwSSUp0j5kzOc9Ka5NdianE0NgYnU0qjhFJbThAQz7hRowS4J49hS/6MuSQ0Z7nBBCeDgeD6PYRApKMvlOtkBGPJaLT2mRy/gnQ+CC6udUdJyvSgb9n43lvxdaaZWrDK3Wga98YlkcRHLrmPAAM8KxYWnkopio6YINU4D5mZjsEsnUkH41WgcwgmS2xzP3ICnNc3WH9NHrVKp9at2DBwrYDIses6FXgYq+iUWK2191jWpIC3qVAB0cOilmRXwtEH7g== + + + MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDAS +BgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIG +A1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFK +s71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyj +xj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVN +c1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpd +Va54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3 +MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE +4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lk +cDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3 +YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiL +ArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/we +P3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcci +NBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== + + + + + + + +` + +const testRespInvalidAssertionAudience = ` + + + https://samltest.id/saml/idp + + + + + + + + + + + + + RV485uKGJZmNA1o56gxxk+VZkvxMqtlHZA2iHH8ZU1Q= + + + d3Lpc6hcSB7bwCzMrO3wfZrNiGk5gZ8rKRKOQENDP2q+p3+LkDmSBt6zzyxn33MCSJt+dPHpF14YMAK/N3PnWwSSUp0j5kzOc9Ka5NdianE0NgYnU0qjhFJbThAQz7hRowS4J49hS/6MuSQ0Z7nBBCeDgeD6PYRApKMvlOtkBGPJaLT2mRy/gnQ+CC6udUdJyvSgb9n43lvxdaaZWrDK3Wga98YlkcRHLrmPAAM8KxYWnkopio6YINU4D5mZjsEsnUkH41WgcwgmS2xzP3ICnNc3WH9NHrVKp9at2DBwrYDIses6FXgYq+iUWK2191jWpIC3qVAB0cOilmRXwtEH7g== + + + MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDAS +BgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIG +A1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFK +s71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyj +xj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVN +c1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpd +Va54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3 +MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE +4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lk +cDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3 +YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiL +ArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/we +P3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcci +NBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== + + + + + + + + https://samltest.id/saml/idp + + msmith@samltest.id + + + + + + + + . + http://saml.julz/invalid-audience + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + Ambassador + None + + + msmith@samltest.id + + + + morty + + + +1-555-555-5505 + + + janitor@samltest.id + + + + msmith@samltest.id + + + Smith + + + Morty Smith + + + Mortimer + + + + ` + +const testRespInvalidAssertionMissingNotBefore = ` + + + https://samltest.id/saml/idp + + + + + + + + + + + + + RV485uKGJZmNA1o56gxxk+VZkvxMqtlHZA2iHH8ZU1Q= + + + d3Lpc6hcSB7bwCzMrO3wfZrNiGk5gZ8rKRKOQENDP2q+p3+LkDmSBt6zzyxn33MCSJt+dPHpF14YMAK/N3PnWwSSUp0j5kzOc9Ka5NdianE0NgYnU0qjhFJbThAQz7hRowS4J49hS/6MuSQ0Z7nBBCeDgeD6PYRApKMvlOtkBGPJaLT2mRy/gnQ+CC6udUdJyvSgb9n43lvxdaaZWrDK3Wga98YlkcRHLrmPAAM8KxYWnkopio6YINU4D5mZjsEsnUkH41WgcwgmS2xzP3ICnNc3WH9NHrVKp9at2DBwrYDIses6FXgYq+iUWK2191jWpIC3qVAB0cOilmRXwtEH7g== + + + MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDAS +BgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIG +A1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFK +s71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyj +xj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVN +c1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpd +Va54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3 +MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE +4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lk +cDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3 +YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiL +ArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/we +P3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcci +NBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== + + + + + + + + https://samltest.id/saml/idp + + msmith@samltest.id + + + + + + + + + http://saml.julz/example + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + Ambassador + None + + + msmith@samltest.id + + + + morty + + + +1-555-555-5505 + + + janitor@samltest.id + + + + msmith@samltest.id + + + Smith + + + Morty Smith + + + Mortimer + + + + +` + +const testRespInvalidAssertionMissingSubject = ` + + + https://samltest.id/saml/idp + + + + + + + + + + + + + RV485uKGJZmNA1o56gxxk+VZkvxMqtlHZA2iHH8ZU1Q= + + + d3Lpc6hcSB7bwCzMrO3wfZrNiGk5gZ8rKRKOQENDP2q+p3+LkDmSBt6zzyxn33MCSJt+dPHpF14YMAK/N3PnWwSSUp0j5kzOc9Ka5NdianE0NgYnU0qjhFJbThAQz7hRowS4J49hS/6MuSQ0Z7nBBCeDgeD6PYRApKMvlOtkBGPJaLT2mRy/gnQ+CC6udUdJyvSgb9n43lvxdaaZWrDK3Wga98YlkcRHLrmPAAM8KxYWnkopio6YINU4D5mZjsEsnUkH41WgcwgmS2xzP3ICnNc3WH9NHrVKp9at2DBwrYDIses6FXgYq+iUWK2191jWpIC3qVAB0cOilmRXwtEH7g== + + + MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDAS +BgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIG +A1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFK +s71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyj +xj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVN +c1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpd +Va54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3 +MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE +4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lk +cDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3 +YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiL +ArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/we +P3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcci +NBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== + + + + + + + + https://samltest.id/saml/idp + + + + http://saml.julz/example + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + Ambassador + None + + + msmith@samltest.id + + + + morty + + + +1-555-555-5505 + + + janitor@samltest.id + + + + msmith@samltest.id + + + Smith + + + Morty Smith + + + Mortimer + + + + +` + +const testRespInvalidAssertionMissingAttributeStmt = ` + + + https://samltest.id/saml/idp + + + + + + + + + + + + + RV485uKGJZmNA1o56gxxk+VZkvxMqtlHZA2iHH8ZU1Q= + + + d3Lpc6hcSB7bwCzMrO3wfZrNiGk5gZ8rKRKOQENDP2q+p3+LkDmSBt6zzyxn33MCSJt+dPHpF14YMAK/N3PnWwSSUp0j5kzOc9Ka5NdianE0NgYnU0qjhFJbThAQz7hRowS4J49hS/6MuSQ0Z7nBBCeDgeD6PYRApKMvlOtkBGPJaLT2mRy/gnQ+CC6udUdJyvSgb9n43lvxdaaZWrDK3Wga98YlkcRHLrmPAAM8KxYWnkopio6YINU4D5mZjsEsnUkH41WgcwgmS2xzP3ICnNc3WH9NHrVKp9at2DBwrYDIses6FXgYq+iUWK2191jWpIC3qVAB0cOilmRXwtEH7g== + + + MIIDEjCCAfqgAwIBAgIVAMECQ1tjghafm5OxWDh9hwZfxthWMA0GCSqGSIb3DQEBCwUAMBYxFDAS +BgNVBAMMC3NhbWx0ZXN0LmlkMB4XDTE4MDgyNDIxMTQwOVoXDTM4MDgyNDIxMTQwOVowFjEUMBIG +A1UEAwwLc2FtbHRlc3QuaWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0Z4QX1NFK +s71ufbQwoQoW7qkNAJRIANGA4iM0ThYghul3pC+FwrGv37aTxWXfA1UG9njKbbDreiDAZKngCgyj +xj0uJ4lArgkr4AOEjj5zXA81uGHARfUBctvQcsZpBIxDOvUUImAl+3NqLgMGF2fktxMG7kX3GEVN +c1klbN3dfYsaw5dUrw25DheL9np7G/+28GwHPvLb4aptOiONbCaVvh9UMHEA9F7c0zfF/cL5fOpd +Va54wTI0u12CsFKt78h6lEGG5jUs/qX9clZncJM7EFkN3imPPy+0HC8nspXiH/MZW8o2cqWRkrw3 +MzBZW3Ojk5nQj40V6NUbjb7kfejzAgMBAAGjVzBVMB0GA1UdDgQWBBQT6Y9J3Tw/hOGc8PNV7JEE +4k2ZNTA0BgNVHREELTArggtzYW1sdGVzdC5pZIYcaHR0cHM6Ly9zYW1sdGVzdC5pZC9zYW1sL2lk +cDANBgkqhkiG9w0BAQsFAAOCAQEASk3guKfTkVhEaIVvxEPNR2w3vWt3fwmwJCccW98XXLWgNbu3 +YaMb2RSn7Th4p3h+mfyk2don6au7Uyzc1Jd39RNv80TG5iQoxfCgphy1FYmmdaSfO8wvDtHTTNiL +ArAxOYtzfYbzb5QrNNH/gQEN8RJaEf/g/1GTw9x/103dSMK0RXtl+fRs2nblD1JJKSQ3AdhxK/we +P3aUPtLxVVJ9wMOQOfcy02l+hHMb6uAjsPOpOVKqi3M8XmcUZOpx4swtgGdeoSpeRyrtMvRwdcci +NBp9UZome44qZAYH1iqrpmmjsfI9pJItsgWu3kXPjhSfj1AJGR1l9JGvJrHki1iHTA== + + + + + + + + https://samltest.id/saml/idp + + msmith@samltest.id + + + + + + + + . + http://saml.julz/example + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + ` diff --git a/saml/sp.go b/saml/sp.go new file mode 100644 index 0000000..957402c --- /dev/null +++ b/saml/sp.go @@ -0,0 +1,388 @@ +package saml + +import ( + _ "embed" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + "github.com/hashicorp/cap/saml/models/core" + "github.com/hashicorp/cap/saml/models/metadata" + "github.com/jonboulle/clockwork" + dsig "github.com/russellhaering/goxmldsig/types" +) + +//go:embed authn_request.gohtml +var postBindingTempl string + +type metadataOptions struct { + wantAssertionsSigned bool + nameIDFormats []core.NameIDFormat + acsServiceBinding core.ServiceBinding + additionalACSs []metadata.Endpoint +} + +func metadataOptionsDefault() metadataOptions { + return metadataOptions{ + wantAssertionsSigned: true, + acsServiceBinding: core.ServiceBindingHTTPPost, + } +} + +func getMetadataOptions(opt ...Option) metadataOptions { + opts := metadataOptionsDefault() + ApplyOpts(&opts, opt...) + return opts +} + +// InsecureWantAssertionsUnsigned provides a way to optionally request that you +// want insecure/unsigned assertions. +func InsecureWantAssertionsUnsigned() Option { + return func(o interface{}) { + if o, ok := o.(*metadataOptions); ok { + o.wantAssertionsSigned = false + } + } +} + +// WithMetadataNameIDFormat provides an optional name ID formats, which are +// added to the existing set. +func WithMetadataNameIDFormat(format ...core.NameIDFormat) Option { + return func(o interface{}) { + if o, ok := o.(*metadataOptions); ok { + o.nameIDFormats = append(o.nameIDFormats, format...) + } + } +} + +// WithACSServiceBinding provides an optional service binding. +func WithACSServiceBinding(b core.ServiceBinding) Option { + return func(o interface{}) { + if o, ok := o.(*metadataOptions); ok { + o.acsServiceBinding = b + } + } +} + +// WithAdditionalACSEndpoint provides an optional additional ACS endpoint +func WithAdditionalACSEndpoint(b core.ServiceBinding, location url.URL) Option { + return func(o interface{}) { + if o, ok := o.(*metadataOptions); ok { + o.additionalACSs = append(o.additionalACSs, metadata.Endpoint{ + Binding: b, + Location: location.String(), + }) + } + } +} + +// ServiceProvider defines a type for service providers +type ServiceProvider struct { + cfg *Config + + metadata *metadata.EntityDescriptorIDPSSO + metadataCachedUntil *time.Time + metadataLock sync.Mutex +} + +// NewServiceProvider creates a new ServiceProvider. +func NewServiceProvider(cfg *Config) (*ServiceProvider, error) { + const op = "saml.NewServiceProvider" + + if cfg == nil { + return nil, fmt.Errorf( + "%s: no provider config provided", + op, + ) + } + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf( + "%s: insufficient provider config: %w", + op, err, + ) + } + + return &ServiceProvider{ + cfg: cfg, + }, nil +} + +// Config returns the service provider config. +func (sp *ServiceProvider) Config() *Config { + return sp.cfg +} + +// CreateMetadata creates the metadata XML for the service provider. +// +// Options: +// - InsecureWantAssertionsUnsigned +// - WithNameIDFormats +// - WithACSServiceBinding +// - WithAdditonalACSEndpoint +func (sp *ServiceProvider) CreateMetadata(opt ...Option) *metadata.EntityDescriptorSPSSO { + validUntil := sp.cfg.ValidUntil() + + opts := getMetadataOptions(opt...) + + spsso := metadata.EntityDescriptorSPSSO{} + spsso.EntityID = sp.cfg.EntityID + spsso.ValidUntil = &validUntil + + spssoDescriptor := &metadata.SPSSODescriptor{} + spssoDescriptor.ProtocolSupportEnumeration = metadata.ProtocolSupportEnumerationProtocol + spssoDescriptor.NameIDFormat = opts.nameIDFormats + spssoDescriptor.AuthnRequestsSigned = false // always false for now until request signing is supported. + spssoDescriptor.WantAssertionsSigned = opts.wantAssertionsSigned + spssoDescriptor.AssertionConsumerService = []metadata.IndexedEndpoint{ + { + Endpoint: metadata.Endpoint{ + Binding: opts.acsServiceBinding, + Location: sp.cfg.AssertionConsumerServiceURL, + }, + Index: 1, + }, + } + + for i, a := range opts.additionalACSs { + spssoDescriptor.AssertionConsumerService = append( + spssoDescriptor.AssertionConsumerService, + metadata.IndexedEndpoint{ + Endpoint: a, + Index: i + 2, // The first index is already taken. + }, + ) + } + + spsso.SPSSODescriptor = []*metadata.SPSSODescriptor{spssoDescriptor} + + return &spsso +} + +type idpMetadataOptions struct { + cache bool + useStale bool + clock clockwork.Clock +} + +func idpMetadataOptionsDefault() idpMetadataOptions { + return idpMetadataOptions{ + cache: true, + useStale: false, + clock: clockwork.NewRealClock(), + } +} + +func getIDPMetadataOptions(opt ...Option) idpMetadataOptions { + opts := idpMetadataOptionsDefault() + ApplyOpts(&opts, opt...) + return opts +} + +// WithCache control whether we should cache IDP Metadata. +func WithCache(cache bool) Option { + return func(o interface{}) { + if o, ok := o.(*idpMetadataOptions); ok { + o.cache = cache + } + } +} + +// WithStale control whether we should use a stale IDP Metadata document if +// refreshing it fails. +func WithStale(stale bool) Option { + return func(o interface{}) { + if o, ok := o.(*idpMetadataOptions); ok { + o.useStale = stale + } + } +} + +// IDPMetadata fetches the metadata XML document from the configured identity provider. +// Options: +// - WithClock +// - WithCache +// - WithStale +func (sp *ServiceProvider) IDPMetadata(opt ...Option) (*metadata.EntityDescriptorIDPSSO, error) { + const op = "saml.ServiceProvider.FetchIDPMetadata" + + opts := getIDPMetadataOptions(opt...) + + var err error + var ed *metadata.EntityDescriptorIDPSSO + + isValid := func(md *metadata.EntityDescriptorIDPSSO) bool { + if md == nil { + return false + } + if md.ValidUntil == nil { + return true + } + return opts.clock.Now().Before(*md.ValidUntil) + } + + isAlive := func(md *metadata.EntityDescriptorIDPSSO, expireAt *time.Time) bool { + if md == nil || !opts.cache || expireAt == nil { + return false + } + + return opts.clock.Now().Before(*expireAt) + } + + if opts.cache { + // We only take the lock when caching is enabled so that requests can be + // done concurrently when it is not + sp.metadataLock.Lock() + defer sp.metadataLock.Unlock() + + switch { + case !isValid(sp.metadata): + sp.metadata = nil + sp.metadataCachedUntil = nil + case isValid(sp.metadata) && isAlive(sp.metadata, sp.metadataCachedUntil): + return sp.metadata, nil + } + } + + // Order of switch case determines IDP metadata config precedence + switch { + case sp.cfg.MetadataURL != "": + ed, err = fetchIDPMetadata(sp.cfg.MetadataURL) + switch { + case err != nil && opts.useStale && isValid(sp.metadata): + // An error occurred but we have a cached metadata document that + // we can use + return sp.metadata, nil + case err != nil: + return nil, fmt.Errorf("%s: %w", op, err) + } + + case sp.cfg.MetadataXML != "": + ed, err = parseIDPMetadata([]byte(sp.cfg.MetadataXML)) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + case sp.cfg.MetadataParameters != nil: + ed, err = constructIDPMetadata(sp.cfg.MetadataParameters) + if err != nil { + return nil, fmt.Errorf("%s: %w", op, err) + } + + default: + return nil, fmt.Errorf("%s: no IDP metadata configuration set: %w", op, ErrInvalidParameter) + } + + if !isValid(ed) { + return nil, fmt.Errorf("the IDP configuration was only valid until %s", ed.ValidUntil.Format(time.RFC3339)) + } + + sp.metadata = ed + sp.metadataCachedUntil = nil + if sp.metadata.CacheDuration != nil { + cachedUntil := opts.clock.Now().Add(time.Duration(*sp.metadata.CacheDuration)) + sp.metadataCachedUntil = &cachedUntil + } + + return ed, err +} + +func (sp *ServiceProvider) destination(binding core.ServiceBinding) (string, error) { + const op = "saml.ServiceProvider.destination" + + meta, err := sp.IDPMetadata() + if err != nil { + return "", fmt.Errorf("%s: failed to fetch metadata: %w", op, err) + } + + destination, ok := meta.GetLocationForBinding(binding) + if !ok { + return "", fmt.Errorf( + "%s: no location for provided binding (%s) found: %w", + op, binding, ErrBindingUnsupported, + ) + } + + return destination, nil +} + +func fetchIDPMetadata(metadataURL string) (*metadata.EntityDescriptorIDPSSO, error) { + res, err := http.Get(metadataURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch identity provider metadata: %w", err) + } + + raw, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read http body: %w", err) + } + + meta, err := parseIDPMetadata(raw) + if err != nil { + return nil, err + } + + return meta, err +} + +func parseIDPMetadata(rawXML []byte) (*metadata.EntityDescriptorIDPSSO, error) { + var ed metadata.EntityDescriptorIDPSSO + if err := xml.Unmarshal(rawXML, &ed); err != nil { + return nil, fmt.Errorf("failed to parse identity provider XML metadata: %w", err) + } + + // [SDP-MD03] https://kantarainitiative.github.io/SAMLprofiles/saml2int.html#_metadata_and_trust_management + // IDPMetadata without a validUntil attribute on its root element MUST be rejected. IDPMetadata whose root element’s validUntil + // attribute extends beyond a deployer- or community-imposed threshold MUST be rejected. + // TODO: VALIDATE + + return &ed, nil +} + +func constructIDPMetadata(params *MetadataParameters) (*metadata.EntityDescriptorIDPSSO, error) { + cert, err := parsePEMCertificate([]byte(params.IDPCertificate)) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate: %w", err) + } + + keyDescriptor := metadata.KeyDescriptor{ + Use: metadata.KeyTypeSigning, + KeyInfo: metadata.KeyInfo{ + KeyInfo: dsig.KeyInfo{ + X509Data: dsig.X509Data{ + X509Certificates: []dsig.X509Certificate{ + { + Data: base64.StdEncoding.EncodeToString(cert.Raw), + }, + }, + }, + }, + }, + } + + idpSSODescriptor := &metadata.IDPSSODescriptor{ + SSODescriptor: metadata.SSODescriptor{ + RoleDescriptor: metadata.RoleDescriptor{ + KeyDescriptor: []metadata.KeyDescriptor{keyDescriptor}, + }, + }, + WantAuthnRequestsSigned: false, + SingleSignOnService: []metadata.Endpoint{ + { + Binding: params.Binding, + Location: params.SingleSignOnURL, + }, + }, + } + + return &metadata.EntityDescriptorIDPSSO{ + EntityDescriptor: metadata.EntityDescriptor{ + EntityID: params.Issuer, + }, + IDPSSODescriptor: []*metadata.IDPSSODescriptor{idpSSODescriptor}, + }, nil +} diff --git a/saml/sp_test.go b/saml/sp_test.go new file mode 100644 index 0000000..9c74bba --- /dev/null +++ b/saml/sp_test.go @@ -0,0 +1,427 @@ +package saml_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/saml" + "github.com/hashicorp/cap/saml/models/core" + "github.com/hashicorp/cap/saml/models/metadata" +) + +func Test_NewServiceProvider(t *testing.T) { + t.Parallel() + r := require.New(t) + exampleURL := "http://test.me" + + validConfig, err := saml.NewConfig( + exampleURL, + exampleURL, + exampleURL, + ) + r.NoError(err) + + cases := []struct { + name string + cfg *saml.Config + err string + }{ + { + name: "When a valid config is provided", + cfg: validConfig, + err: "", + }, + { + name: "When an invalid config is provided", + cfg: &saml.Config{}, + err: "saml.NewServiceProvider: insufficient provider config:", + }, + { + name: "When no config is provided", + cfg: nil, + err: "saml.NewServiceProvider: no provider config provided", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := require.New(t) + got, err := saml.NewServiceProvider(c.cfg) + + if c.err != "" { + r.Error(err) + r.ErrorContains(err, c.err) + return + } + r.NoError(err) + r.NotNil(got) + r.NotNil(got.Config()) + }) + } +} + +func Test_ServiceProvider_FetchMetadata_ErrorCases(t *testing.T) { + t.Parallel() + r := require.New(t) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("")) + })) + defer s.Close() + + fakeURL := "http://cap.saml.fake" + metaURL := fmt.Sprintf("%s/saml/metadata", s.URL) + + cfg, err := saml.NewConfig( + fakeURL, + fakeURL, + fakeURL, + ) + r.NoError(err) + + cases := []struct { + name string + metadata string + wantErr string + }{ + { + name: "When the metadata can't be fetched", + metadata: fakeURL, + wantErr: "saml.ServiceProvider.FetchIDPMetadata: failed to fetch identity provider metadata:", + }, + { + name: "When the metadata XML can't be parsed", + metadata: metaURL, + wantErr: "saml.ServiceProvider.FetchIDPMetadata: failed to parse identity provider XML metadata:", + }, + } + + for _, c := range cases { + cfg.MetadataURL = c.metadata + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + t.Run(c.name, func(t *testing.T) { + r := require.New(t) + got, err := provider.IDPMetadata() + r.Nil(got) + r.Error(err) + r.ErrorContains(err, c.wantErr) + }) + } +} + +func Test_ServiceProvider_FetchMetadata_Cache(t *testing.T) { + type testServer struct { + fail bool + failOnRefresh bool + } + + newTestServer := func(t *testing.T, failOnRefresh bool) string { + t.Helper() + + ts := &testServer{false, failOnRefresh} + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if !ts.fail { + w.Write([]byte(exampleIDPSSODescriptorX)) + } + ts.fail = ts.fail || ts.failOnRefresh + })) + t.Cleanup(s.Close) + + return s.URL + } + + cases := []struct { + name string + newTime string + shouldBeCached bool + opts []saml.Option + failOnRefresh bool + expectErrorOnRefresh bool + }{ + { + name: "is cached", + shouldBeCached: true, + }, + { + name: "cache is disabled", + opts: []saml.Option{saml.WithCache(false)}, + shouldBeCached: false, + }, + { + name: "stale cached document should not be used", + newTime: "2017-07-26", + shouldBeCached: false, + }, + { + name: "is not cached once validUntil is reached", + newTime: "2018-07-25", + expectErrorOnRefresh: true, + }, + { + name: "a stale document should not be used if refreshing fails", + newTime: "2017-07-26", + failOnRefresh: true, + expectErrorOnRefresh: true, + }, + { + name: "use stale document", + opts: []saml.Option{saml.WithStale(true)}, + newTime: "2017-07-26", + failOnRefresh: true, + shouldBeCached: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + r := require.New(t) + + url := newTestServer(t, tt.failOnRefresh) + metaURL := fmt.Sprintf("%s/saml/metadata", url) + cfg, err := saml.NewConfig( + metaURL, + metaURL, + metaURL, + ) + r.NoError(err) + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + newTime, err := time.Parse("2006-01-02", "2017-07-25") + r.NoError(err) + + opts := append([]saml.Option{saml.WithClock(clockwork.NewFakeClockAt(newTime))}, tt.opts...) + + got1, err := provider.IDPMetadata(opts...) + r.NoError(err) + r.NotNil(got1) + + if tt.newTime != "" { + newTime, err = time.Parse("2006-01-02", tt.newTime) + r.NoError(err) + opts = append(opts, saml.WithClock(clockwork.NewFakeClockAt(newTime))) + } + + got2, err := provider.IDPMetadata(opts...) + if tt.expectErrorOnRefresh { + r.Error(err) + return + } + r.NoError(err) + r.NotNil(got2) + + if tt.shouldBeCached { + r.True(got1 == got2) + } else { + r.False(got1 == got2) + } + }) + } +} + +func Test_ServiceProvider_CreateMetadata(t *testing.T) { + t.Parallel() + r := require.New(t) + + entityID := "http://test.me/entity" + acs := "http://test.me/saml/acs" + meta := "http://test.me/sso/metadata" + + now := time.Now() + validUntil := func() time.Time { + return now + } + + cfg, err := saml.NewConfig( + entityID, + acs, + meta, + ) + r.NoError(err) + + cfg.ValidUntil = validUntil + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + cases := []struct { + name string + nameIDFormats []core.NameIDFormat + }{ + { + name: "", + }, + { + name: "email", + nameIDFormats: []core.NameIDFormat{core.NameIDFormatEmail}, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := require.New(t) + opts := []saml.Option{} + if c.nameIDFormats != nil { + opts = append(opts, saml.WithMetadataNameIDFormat(c.nameIDFormats...)) + } + got := provider.CreateMetadata(opts...) + + r.Equal(&now, got.ValidUntil) + r.Equal("http://test.me/entity", got.EntityID) + + r.Len(got.SPSSODescriptor, 1) + r.True(got.SPSSODescriptor[0].WantAssertionsSigned) + r.False(got.SPSSODescriptor[0].AuthnRequestsSigned) + r.Equal( + metadata.ProtocolSupportEnumerationProtocol, + got.SPSSODescriptor[0].ProtocolSupportEnumeration, + ) + r.Equal( + core.ServiceBindingHTTPPost, + got.SPSSODescriptor[0].AssertionConsumerService[0].Binding, + ) + r.Equal(1, got.SPSSODescriptor[0].AssertionConsumerService[0].Index) + r.Equal( + "http://test.me/saml/acs", + got.SPSSODescriptor[0].AssertionConsumerService[0].Location, + ) + r.Equal(got.SPSSODescriptor[0].NameIDFormat, c.nameIDFormats) + }) + } +} + +func Test_CreateMetadata_Options(t *testing.T) { + t.Parallel() + r := require.New(t) + + fakeURL := "http://fake.test.url" + + cfg, err := saml.NewConfig( + fakeURL, + fakeURL, + fakeURL, + ) + + provider, err := saml.NewServiceProvider(cfg) + r.NoError(err) + + t.Run("When option InsecureWantAssertionsUnsigned is set", func(t *testing.T) { + r := require.New(t) + got := provider.CreateMetadata( + saml.InsecureWantAssertionsUnsigned(), + ) + + r.False(got.SPSSODescriptor[0].WantAssertionsSigned) + }) + + t.Run("When option WithAdditionalNameIDFormat is set", func(t *testing.T) { + r := require.New(t) + got := provider.CreateMetadata( + saml.WithMetadataNameIDFormat(core.NameIDFormatTransient), + ) + + r.Equal(got.SPSSODescriptor[0].NameIDFormat, []core.NameIDFormat{core.NameIDFormatTransient}) + }) + + t.Run("When option WithNameIDFormats is set", func(t *testing.T) { + r := require.New(t) + got := provider.CreateMetadata( + saml.WithMetadataNameIDFormat(core.NameIDFormatEntity, core.NameIDFormatUnspecified), + ) + + r.Len(got.SPSSODescriptor[0].NameIDFormat, 2) + r.Equal(got.SPSSODescriptor[0].NameIDFormat, []core.NameIDFormat{ + core.NameIDFormatEntity, + core.NameIDFormatUnspecified, + }) + }) + + t.Run("When option WithACSServiceBinding is set", func(t *testing.T) { + r := require.New(t) + got := provider.CreateMetadata( + saml.WithACSServiceBinding(core.ServiceBindingHTTPRedirect), + ) + + r.Len(got.SPSSODescriptor[0].AssertionConsumerService, 1) + r.Equal( + got.SPSSODescriptor[0].AssertionConsumerService[0].Binding, + core.ServiceBindingHTTPRedirect, + ) + }) + + t.Run("When option WithAdditionalACSEndpoint is set", func(t *testing.T) { + r := require.New(t) + redirectEndpoint, err := url.Parse("http://cap.saml.test/acs/redirect") + r.NoError(err) + + got := provider.CreateMetadata( + saml.WithAdditionalACSEndpoint( + core.ServiceBindingHTTPRedirect, + *redirectEndpoint, + ), + ) + + r.Len(got.SPSSODescriptor[0].AssertionConsumerService, 2) + r.Equal( + got.SPSSODescriptor[0].AssertionConsumerService[0], + metadata.IndexedEndpoint{ + Endpoint: metadata.Endpoint{ + Binding: core.ServiceBindingHTTPPost, + Location: fakeURL, + }, + Index: 1, + }, + ) + + r.Equal( + got.SPSSODescriptor[0].AssertionConsumerService[1], + metadata.IndexedEndpoint{ + Endpoint: metadata.Endpoint{ + Binding: core.ServiceBindingHTTPRedirect, + Location: redirectEndpoint.String(), + }, + Index: 2, + }, + ) + }) +} + +var exampleIDPSSODescriptorX = ` + + + + + + + https://registrar.example.net/category/self-certified + + + + + + ... + ... + https://www.example.info/ + + + SAML Technical Support + mailto:technical-support@example.info + + +` diff --git a/saml/test/provider.go b/saml/test/provider.go new file mode 100644 index 0000000..592f5ea --- /dev/null +++ b/saml/test/provider.go @@ -0,0 +1,416 @@ +package testprovider + +import ( + "bytes" + "compress/flate" + "encoding/base64" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/hashicorp/cap/saml/models/core" + "github.com/hashicorp/cap/saml/models/metadata" +) + +// ID must start with a letter or underscore. +var idRegexp = regexp.MustCompile(`\A[a-zA-Z_]`) + +const meta = ` + + + + + + MIICajCCAdOgAwIBAgIBADANBgkqhkiG9w0BAQ0FADBSMQswCQYDVQQGEwJ1czETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMT25lbG9naW4gSW5jMRcwFQYDVQQDDA5zcC5leGFtcGxlLmNvbTAeFw0xNDA3MTcxNDEyNTZaFw0xNTA3MTcxNDEyNTZaMFIxCzAJBgNVBAYTAnVzMRMwEQYDVQQIDApDYWxpZm9ybmlhMRUwEwYDVQQKDAxPbmVsb2dpbiBJbmMxFzAVBgNVBAMMDnNwLmV4YW1wbGUuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDZx+ON4IUoIWxgukTb1tOiX3bMYzYQiwWPUNMp+Fq82xoNogso2bykZG0yiJm5o8zv/sd6pGouayMgkx/2FSOdc36T0jGbCHuRSbtia0PEzNIRtmViMrt3AeoWBidRXmZsxCNLwgIV6dn2WpuE5Az0bHgpZnQxTKFek0BMKU/d8wIDAQABo1AwTjAdBgNVHQ4EFgQUGHxYqZYyX7cTxKVODVgZwSTdCnwwHwYDVR0jBBgwFoAUGHxYqZYyX7cTxKVODVgZwSTdCnwwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQ0FAAOBgQByFOl+hMFICbd3DJfnp2Rgd/dqttsZG/tyhILWvErbio/DEe98mXpowhTkC04ENprOyXi7ZbUqiicF89uAGyt1oqgTUCD1VsLahqIcmrzgumNyTwLGWo17WDAa1/usDhetWAMhgzF/Cnf5ek0nK00m0YZGyc4LzgD0CROMASTWNg== + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + + +` + +// From https://www.samltool.com/generic_sso_res.php +const responseSigned = ` + + http://idp.example.com/metadata.php + + + + + http://idp.example.com/metadata.php + + + CtIHbEceX42xKr7zJ/642uXWROg=ALoS5nPK3X14WITy+5W/GYbdfpBBfqYugw3R69+QQa0pu7hy0VG2nr5LzEe4n1YbLd0rA2q5N6jtCuicv9Mfvk9SatkNhuP1TDnIeX4muOx/tu7hkCyaR9IeLfIVa9kohi1uGLqffGTBNUlIO0PpCPxwlmKCiio4zOUa/Dln8vs= +MIICajCCAdOgAwIBAgIBADANBgkqhkiG9w0BAQ0FADBSMQswCQYDVQQGEwJ1czETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMT25lbG9naW4gSW5jMRcwFQYDVQQDDA5zcC5leGFtcGxlLmNvbTAeFw0xNDA3MTcxNDEyNTZaFw0xNTA3MTcxNDEyNTZaMFIxCzAJBgNVBAYTAnVzMRMwEQYDVQQIDApDYWxpZm9ybmlhMRUwEwYDVQQKDAxPbmVsb2dpbiBJbmMxFzAVBgNVBAMMDnNwLmV4YW1wbGUuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDZx+ON4IUoIWxgukTb1tOiX3bMYzYQiwWPUNMp+Fq82xoNogso2bykZG0yiJm5o8zv/sd6pGouayMgkx/2FSOdc36T0jGbCHuRSbtia0PEzNIRtmViMrt3AeoWBidRXmZsxCNLwgIV6dn2WpuE5Az0bHgpZnQxTKFek0BMKU/d8wIDAQABo1AwTjAdBgNVHQ4EFgQUGHxYqZYyX7cTxKVODVgZwSTdCnwwHwYDVR0jBBgwFoAUGHxYqZYyX7cTxKVODVgZwSTdCnwwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQ0FAAOBgQByFOl+hMFICbd3DJfnp2Rgd/dqttsZG/tyhILWvErbio/DEe98mXpowhTkC04ENprOyXi7ZbUqiicF89uAGyt1oqgTUCD1VsLahqIcmrzgumNyTwLGWo17WDAa1/usDhetWAMhgzF/Cnf5ek0nK00m0YZGyc4LzgD0CROMASTWNg== + + _ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7 + + + + + + + http://sp.example.com/demo1/metadata.php + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + test + + + test@example.com + + + users + examplerole1 + + + +` + +// SAMLResponsePostData represents the SAML response data that is expected +// in the form data of a POST request. +type SAMLResponsePostData struct { + SAMLResponse string `json:"saml_response"` + RelayState string `json:"relay_state"` + Destination string `json:"destination"` +} + +// PostRequest creates an http POST request with the SAML response and relay state +// included as form data. +func (s *SAMLResponsePostData) PostRequest(t *testing.T) *http.Request { + t.Helper() + r := require.New(t) + + form := url.Values{} + form.Add("SAMLResponse", s.SAMLResponse) + form.Add("RelayState", s.RelayState) + + req, err := http.NewRequest(http.MethodPost, s.Destination, strings.NewReader(form.Encode())) + r.NoError(err) + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + return req +} + +// TestProvider is an identity provider that can be used for testing +// SAML federeation and authentication flows. +type TestProvider struct { + t *testing.T + server *httptest.Server + + metadata *metadata.EntityDescriptorIDPSSO + recorder *httptest.ResponseRecorder + result *http.Response + + expectedRelayState string + expectedVersion string + expectedIssuer string + expectedProtocolBinding string + expectedACSURL string + expectedRequestID string + expectInvalidRequestID bool + expectedIssueInstant time.Time + + expectedB64EncSAMLRequest string +} + +func (p *TestProvider) defaults() { + p.expectedVersion = "2.0" + p.expectedProtocolBinding = string(core.ServiceBindingHTTPPost) +} + +// SetExpectedRelayState sets the expected RelayState value. +func (p *TestProvider) SetExpectedRelayState(rs string) { + p.expectedRelayState = rs +} + +// SetExpectedIssuer sets the in the SAML request expected issuer value. +func (p *TestProvider) SetExpectedIssuer(sr string) { + p.expectedIssuer = sr +} + +// SetExpectedProtocolBinding sets the in the SAML request expected protocol binding. +// Defaults to the HTTP-POST binding. +func (p *TestProvider) SetExpectedProtocolBinding(pb string) { + p.expectedProtocolBinding = pb +} + +// SetExpectedACSURL sets the in the SAML request expected assertion consumer service URL. +func (p *TestProvider) SetExpectedACSURL(acs string) { + p.expectedACSURL = acs +} + +// SetExpectedRequestID sets the in the SAML request expected request ID. +func (p *TestProvider) SetExpectedRequestID(id string) { + p.expectedRequestID = id +} + +// ExpectInvalidRequestID expects that the ID isn't XSD:ID conform. +func (p *TestProvider) ExpectedInvalidRequestID() { + p.expectInvalidRequestID = true +} + +// SetExpectedACSURL sets the in the SAML request expected issue instant value. +func (p *TestProvider) SetExpectedIssueInstant(ii time.Time) { + p.expectedIssueInstant = ii +} + +// SetExpectedSAMLRequest sets the expected SAML request. +func (p *TestProvider) SetExpectedBase64EncodedSAMLRequest(sr string) { + p.expectedB64EncSAMLRequest = sr +} + +// StartTestProvider starts a new identity provider for testing. +// The metadata XML is served at the "/saml/metadata" path. +// The server URL can be obtained by calling the ServerURL() method. +// +// The metadata XML contains the HTTP-Post and Redirect sign-on endpoints. +// The sign-on endpoints will validate the incoming requests on their correctness. +// The SAMLResponse, RelayState, and Destination URL will be returned in a JSON file, +// that can be unmarshalled into testprovider.SAMLResponsePostData. +func StartTestProvider(t *testing.T) *TestProvider { + t.Helper() + r := require.New(t) + + var m metadata.EntityDescriptorIDPSSO + err := xml.Unmarshal([]byte(meta), &m) + r.NoError(err) + + provider := &TestProvider{ + t: t, + metadata: &m, + } + + provider.defaults() + + mux := http.NewServeMux() + mux.HandleFunc("/saml/metadata", provider.metadataHandler) + mux.HandleFunc("/saml/login/post", provider.loginHandlerPost) + mux.HandleFunc("/saml/login/redirect", provider.loginHandlerRedirect) + + server := httptest.NewUnstartedServer(mux) + provider.server = server + + server.Start() + + overrideSSOLocations(server.URL, &m) + + return provider +} + +func overrideSSOLocations(serverURL string, metadata *metadata.EntityDescriptorIDPSSO) { + ssoDescriptor := metadata.IDPSSODescriptor[0] + for i, sso := range ssoDescriptor.SingleSignOnService { + if sso.Binding == core.ServiceBindingHTTPPost { + sso.Location = fmt.Sprintf("%s/saml/login/post", serverURL) + ssoDescriptor.SingleSignOnService[i] = sso + } + + if sso.Binding == core.ServiceBindingHTTPRedirect { + sso.Location = fmt.Sprintf("%s/saml/login/redirect", serverURL) + ssoDescriptor.SingleSignOnService[i] = sso + } + } +} + +// Close shut downs the server and waits for all requests to complete. +func (p *TestProvider) Close() { + p.server.Close() +} + +// ServerURL returns the test server URL. +func (p *TestProvider) ServerURL() string { + return p.server.URL +} + +func (p *TestProvider) metadataHandler(w http.ResponseWriter, _ *http.Request) { + p.t.Helper() + r := require.New(p.t) + + err := xml.NewEncoder(w).Encode(p.metadata) + r.NoError(err) +} + +func (p *TestProvider) loginHandlerPost(w http.ResponseWriter, req *http.Request) { + p.t.Helper() + r := require.New(p.t) + + err := req.ParseForm() + r.NoError(err) + + rawReq := req.FormValue("SAMLRequest") + r.NotEmpty(rawReq) + + // do not check the base64 encoded saml request if not explicitly set. + if p.expectedB64EncSAMLRequest != "" { + r.Equal(p.expectedB64EncSAMLRequest, rawReq) + } + + relayState := req.FormValue("RelayState") + + r.Equal(p.expectedRelayState, relayState, "relay state doesn't match") + http.Error(w, "not implemented", http.StatusNotImplemented) + + samlReq := p.parseRequestPost(rawReq) + + p.validateRequest(samlReq) + + samlResponseData := &SAMLResponsePostData{ + SAMLResponse: responseSigned, + RelayState: relayState, + Destination: samlReq.AssertionConsumerServiceURL, + } + + w.Header().Set("Content-Type", "application/json") + + err = json.NewEncoder(w).Encode(samlResponseData) + r.NoError(err, "failed to encode SAML response data") +} + +func (p *TestProvider) loginHandlerRedirect(w http.ResponseWriter, req *http.Request) { + p.t.Helper() + r := require.New(p.t) + + rawReq := req.URL.Query().Get("SAMLRequest") + r.NotEmpty(rawReq) + + // do not check the base64 encoded saml request if not explicitly set. + if p.expectedB64EncSAMLRequest != "" { + r.Equal(p.expectedB64EncSAMLRequest, rawReq) + } + + relayState := req.URL.Query().Get("RelayState") + + r.Equal(p.expectedRelayState, relayState, "relay state doesn't match") + + samlReq := p.parseRequestRedirect(rawReq) + r.NotNil(samlReq, "the saml request must not be nil") + + p.validateRequest(samlReq) + + samlResponseData := &SAMLResponsePostData{ + SAMLResponse: responseSigned, + RelayState: relayState, + Destination: samlReq.AssertionConsumerServiceURL, + } + + w.Header().Set("Content-Type", "application/json") + + err := json.NewEncoder(w).Encode(samlResponseData) + r.NoError(err, "failed to encode SAML response data") +} + +func (p *TestProvider) validateRequest(samlReq *core.AuthnRequest) { + p.t.Helper() + r := require.New(p.t) + + r.Equal( + p.expectedVersion, + samlReq.Version, + fmt.Sprintf("the SAML version doesn't match. Got: %s", samlReq.Version), + ) + + expectedDestination := fmt.Sprintf("%s/saml/login/redirect", p.server.URL) + r.Equal( + expectedDestination, + samlReq.Destination, + "the destination must match the HTTP redirect location from the IDP metadata", + ) + + if p.expectInvalidRequestID { + r.False( + idRegexp.MatchString(samlReq.ID), + "expected an invalid SAML request ID but it's valid", + ) + } else { + r.True( + idRegexp.MatchString(samlReq.ID), + fmt.Sprintf( + "first letter of the SAML request ID must be a letter or underscore. Got: %s", + samlReq.ID, + ), + ) + } + + r.Equal( + p.expectedIssuer, + samlReq.Issuer.Value, + "the issuer value doesn't match the expected issuer", + ) + + r.Equal( + p.expectedProtocolBinding, + string(samlReq.ProtocolBinding), + "SAML protocol binding doesn't match", + ) + + r.Equal( + p.expectedACSURL, + samlReq.AssertionConsumerServiceURL, + "ACS URL doesn't match", + ) + + // TODO: Add an option to set an issue instant + // r.Equal( + // p.expectedIssueInstant, samlReq.IssueInstant, "issue instant doesn't match", + // ) + + if p.expectedRequestID != "" { + r.Equal( + p.expectedRequestID, + samlReq.ID, + "expected request ID doesn't match the ID in the SAML request", + ) + } +} + +func (p *TestProvider) parseRequestRedirect(request string) *core.AuthnRequest { + p.t.Helper() + r := require.New(p.t) + + deflated, err := base64.StdEncoding.DecodeString(request) + r.NoError(err, "couldn't base64 decode SAML request") + + raw, err := io.ReadAll(flate.NewReader(bytes.NewReader(deflated))) + r.NoError(err, "couldn't uncompress (deflated) SAML request") + + req := core.AuthnRequest{} + err = xml.Unmarshal(raw, &req) + r.NoError(err, "couldn't unmarshal SAML request") + + return &req +} + +func (p *TestProvider) parseRequestPost(request string) *core.AuthnRequest { + p.t.Helper() + r := require.New(p.t) + + raw, err := base64.StdEncoding.DecodeString(request) + r.NoError(err, "couldn't base64 decode SAML request") + + req := core.AuthnRequest{} + err = xml.Unmarshal(raw, &req) + r.NoError(err, "couldn't unmarshal SAML request") + + return &req +}