diff --git a/Documentation/connectors/saml.md b/Documentation/connectors/saml.md index 62cf6a7ff5..60c9134d25 100644 --- a/Documentation/connectors/saml.md +++ b/Documentation/connectors/saml.md @@ -2,7 +2,9 @@ ## Overview -The SAML provider allows authentication through the SAML 2.0 HTTP POST binding. The connector maps attribute values in the SAML assertion to user info, such as username, email, and groups. +The SAML provider allows authentication through the SAML 2.0 HTTP POST Binding or HTTP Redirect Binding. +The callback acts as the "Assertion Consumer Service", expecting assertions via the HTTP POST Binding. +The connector maps attribute values in the SAML assertion to user info, such as username, email, and groups. The connector uses the value of the `NameID` element as the user's unique identifier which dex assumes is both unique and never changes. Use the `nameIDPolicyFormat` to ensure this is set to a value which satisfies these requirements. @@ -26,6 +28,7 @@ connectors: config: # SSO URL used for POST value. ssoURL: https://saml.example.com/sso + redirectBinding: true # use HTTP Redirect Binding (defaults to false) # CA to use when validating the signature of the SAML response. ca: /path/to/ca.pem diff --git a/connector/connector.go b/connector/connector.go index edd7fa5706..f1d7f27a23 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -74,12 +74,10 @@ type CallbackConnector interface { // See: https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf // "3.5 HTTP POST Binding" type SAMLConnector interface { - // POSTData returns an encoded SAML request and SSO URL for the server to - // render a POST form with. - // - // POSTData should encode the provided request ID in the returned serialized - // SAML request. - POSTData(s Scopes, requestID string) (ssoURL, samlRequest string, err error) + // AuthnRequest returns an encoded SAML request and SSO URL for the server to + // render a POST form with, or send via the Redirect Binding, depending on the + // connector's configuration. + AuthnRequest(s Scopes, requestID string) (sooURL, samlRequest string, post bool, err error) // HandlePOST decodes, verifies, and maps attributes from the SAML response. // It passes the expected value of the "InResponseTo" response field, which diff --git a/connector/saml/saml.go b/connector/saml/saml.go index 4dac2aca6f..62a7917438 100644 --- a/connector/saml/saml.go +++ b/connector/saml/saml.go @@ -3,6 +3,7 @@ package saml import ( "bytes" + "compress/flate" "crypto/x509" "encoding/base64" "encoding/pem" @@ -84,6 +85,17 @@ type Config struct { SSOIssuer string `json:"ssoIssuer"` SSOURL string `json:"ssoURL"` + // Use HTTP Redirect Binding instead of HTTP Post Binding for AuthnRequest + // See: https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf + // "3.4.4 Message Encoding" for HTTP Redirect Binding + // "3.5.4 Message Encoding" for HTTP POST Binding + // + // If not provided, will use HTTP Post Binding. + // If set to true, this will use the DEFLATE encoding specified in the spec + // referenced above. It will NOT pass SAMLEncoding set to + // urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE + RedirectBinding bool `json:"redirectBinding"` + // X509 CA file or raw data to verify XML signatures. CA string `json:"ca"` CAData []byte `json:"caData"` @@ -154,16 +166,17 @@ func (c *Config) openConnector(logger log.Logger) (*provider, error) { } p := &provider{ - entityIssuer: c.EntityIssuer, - ssoIssuer: c.SSOIssuer, - ssoURL: c.SSOURL, - now: time.Now, - usernameAttr: c.UsernameAttr, - emailAttr: c.EmailAttr, - groupsAttr: c.GroupsAttr, - groupsDelim: c.GroupsDelim, - redirectURI: c.RedirectURI, - logger: logger, + entityIssuer: c.EntityIssuer, + ssoIssuer: c.SSOIssuer, + ssoURL: c.SSOURL, + redirectBinding: c.RedirectBinding, + now: time.Now, + usernameAttr: c.UsernameAttr, + emailAttr: c.EmailAttr, + groupsAttr: c.GroupsAttr, + groupsDelim: c.GroupsDelim, + redirectURI: c.RedirectURI, + logger: logger, nameIDPolicyFormat: c.NameIDPolicyFormat, } @@ -226,6 +239,10 @@ type provider struct { ssoIssuer string ssoURL string + // use HTTP Redirect Binding for AuthnRequest + // if false, HTTP Post Binding is used + redirectBinding bool + now func() time.Time // If nil, don't do signature validation. @@ -244,19 +261,24 @@ type provider struct { logger log.Logger } -func (p *provider) POSTData(s connector.Scopes, id string) (action, value string, err error) { - +func (p *provider) AuthnRequest(s connector.Scopes, id string) (action, value string, post bool, err error) { r := &authnRequest{ - ProtocolBinding: bindingPOST, - ID: id, - IssueInstant: xmlTime(p.now()), - Destination: p.ssoURL, + ID: id, + IssueInstant: xmlTime(p.now()), + Destination: p.ssoURL, NameIDPolicy: &nameIDPolicy{ AllowCreate: true, Format: p.nameIDPolicyFormat, }, AssertionConsumerServiceURL: p.redirectURI, } + + if p.redirectBinding { + r.ProtocolBinding = bindingRedirect + } else { + r.ProtocolBinding = bindingPOST + } + if p.entityIssuer != "" { // Issuer for the request is optional. For example, okta always ignores // this value. @@ -265,12 +287,26 @@ func (p *provider) POSTData(s connector.Scopes, id string) (action, value string data, err := xml.MarshalIndent(r, "", " ") if err != nil { - return "", "", fmt.Errorf("marshal authn request: %v", err) + return "", "", false, fmt.Errorf("marshal authn request: %v", err) } + // Encode data according to specified binding // See: https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf - // "3.5.4 Message Encoding" - return p.ssoURL, base64.StdEncoding.EncodeToString(data), nil + var encoded string + if p.redirectBinding { // "3.4.4 Message Encoding" + b := new(bytes.Buffer) + compressor, err := flate.NewWriter(b, 0) // no compression + if err != nil { + return "", "", false, fmt.Errorf("encode authn request: %v", err) + } + compressor.Write(data) + compressor.Close() + encoded = base64.URLEncoding.EncodeToString(b.Bytes()) + } else { // "3.5.4 Message Encoding" + encoded = base64.StdEncoding.EncodeToString(data) + } + + return p.ssoURL, encoded, !p.redirectBinding, nil } // HandlePOST interprets a request from a SAML provider attempting to verify a diff --git a/server/handlers.go b/server/handlers.go index 9bff36ee84..6e47136ca8 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -23,6 +23,23 @@ import ( "github.com/dexidp/dex/storage" ) +const samlPOSTFormFmt = ` + + + + SAML login + + +
+ + +
+ + +` + // newHealthChecker returns the healthz handler. The handler runs until the // provided context is canceled. func (s *Server) newHealthChecker(ctx context.Context) http.Handler { @@ -333,30 +350,28 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { s.logger.Errorf("Server template error: %v", err) } case connector.SAMLConnector: - action, value, err := conn.POSTData(scopes, authReqID) + ssoURL, authnReq, post, err := conn.AuthnRequest(scopes, authReqID) if err != nil { s.logger.Errorf("Creating SAML data: %v", err) s.renderError(w, http.StatusInternalServerError, "Connector Login Error") return } - // TODO(ericchiang): Don't inline this. - fmt.Fprintf(w, ` - - - - SAML login - - -
- - -
- - - `, action, value, authReqID) + if post { + fmt.Fprintf(w, samlPOSTFormFmt, ssoURL, authnReq, authReqID) + } else { // HTTP Redirect Binding + u, err := url.Parse(ssoURL) + if err != nil { + s.logger.Errorf("Parse SSO URL: %v", err) + s.renderError(w, http.StatusInternalServerError, "Connector Login error.") + return + } + v := url.Values{} + v.Set("SAMLRequest", authnReq) + v.Set("RelayState", authReqID) + u.RawQuery = v.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) + } default: s.renderError(w, http.StatusBadRequest, "Requested resource does not exist.") }