*: wire up SAML POST binding

This commit is contained in:
Eric Chiang 2016-12-20 17:24:32 -08:00
parent 31dfb54b6f
commit 0f4a1f69c5
4 changed files with 138 additions and 19 deletions

View File

@ -0,0 +1,72 @@
# Authentication through SAML 2.0
## Overview
The experimental SAML provider allows authentication through the SAML 2.0 HTTP POST binding.
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.
## Caveats
There are known issues with the XML signature validation for this connector. In addition work is still being done to ensure this connector implements best security practices for SAML 2.0.
The connector doesn't support signed AuthnRequests or encrypted attributes.
The connector doesn't support refresh tokens since the SAML 2.0 protocol doesn't provide a way to requery a provider without interaction.
## Configuration
```yaml
connectors:
- type: samlExperimental # will be changed to "saml" later without support for the "samlExperimental" value
id: saml
config:
# Issuer used for validating the SAML response.
issuer: https://saml.example.com
# SSO URL used for POST value.
ssoURL: https://saml.example.com/sso
# CA to use when validating the SAML response.
ca: /path/to/ca.pem
# CA's can also be provided inline as a base64'd blob.
#
# catData: ( RAW base64'd PEM encoded CA )
# To skip signature validation, uncomment the following field. This should
# only be used during testing and may be removed in the future.
#
# insucreSkipSignatureValidation: true
# Dex's callback URL. Must match the "Destination" attribute of all responses
# exactly.
redirectURI: https://dex.example.com/callback
# Name of attributes in the returned assertions to map to ID token claims.
usernameAttr: name
emailAttr: email
groupsAttr: groups # optional
# By default, multiple groups are assumed to be represented as multiple
# attributes with the same name.
#
# If "groupsDelim" is provided groups are assumed to be represented as a
# single attribute and the delimiter is used to split the attribute's value
# into multiple groups.
#
# groupsDelim: ", "
# Requested format of the NameID. The NameID value is is mapped to the ID Token
# 'sub' claim. This can be an abbreviated form of the full URI with just the last
# component. For example, if this value is set to "emailAddress" the format will
# resolve to:
#
# urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
#
# If no value is specified, this value defaults to:
#
# urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
#
nameIDPolicyFormat: persistent
```

View File

@ -37,6 +37,7 @@ More docs for running dex as a Kubernetes authenticator can be found [here](Docu
* Identity provider logins * Identity provider logins
* [LDAP](Documentation/ldap-connector.md) * [LDAP](Documentation/ldap-connector.md)
* [GitHub](Documentation/github-connector.md) * [GitHub](Documentation/github-connector.md)
* [SAML 2.0 (experimental)](Documentation/saml-connector.md)
* Client libraries * Client libraries
* [Go][go-oidc] * [Go][go-oidc]

View File

@ -14,6 +14,7 @@ import (
"github.com/coreos/dex/connector/ldap" "github.com/coreos/dex/connector/ldap"
"github.com/coreos/dex/connector/mock" "github.com/coreos/dex/connector/mock"
"github.com/coreos/dex/connector/oidc" "github.com/coreos/dex/connector/oidc"
"github.com/coreos/dex/connector/saml"
"github.com/coreos/dex/server" "github.com/coreos/dex/server"
"github.com/coreos/dex/storage" "github.com/coreos/dex/storage"
"github.com/coreos/dex/storage/kubernetes" "github.com/coreos/dex/storage/kubernetes"
@ -177,11 +178,12 @@ type ConnectorConfig interface {
} }
var connectors = map[string]func() ConnectorConfig{ var connectors = map[string]func() ConnectorConfig{
"mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) }, "mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) },
"mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) }, "mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) },
"ldap": func() ConnectorConfig { return new(ldap.Config) }, "ldap": func() ConnectorConfig { return new(ldap.Config) },
"github": func() ConnectorConfig { return new(github.Config) }, "github": func() ConnectorConfig { return new(github.Config) },
"oidc": func() ConnectorConfig { return new(oidc.Config) }, "oidc": func() ConnectorConfig { return new(oidc.Config) },
"samlExperimental": func() ConnectorConfig { return new(saml.Config) },
} }
// UnmarshalJSON allows Connector to implement the unmarshaler interface to // UnmarshalJSON allows Connector to implement the unmarshaler interface to

View File

@ -227,6 +227,31 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) {
if err := s.templates.password(w, authReqID, r.URL.String(), "", false); err != nil { if err := s.templates.password(w, authReqID, r.URL.String(), "", false); err != nil {
s.logger.Errorf("Server template error: %v", err) s.logger.Errorf("Server template error: %v", err)
} }
case connector.SAMLConnector:
action, value, err := conn.POSTData(scopes)
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, `<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<title>SAML login</title>
</head>
<body>
<form method="post" action="%s" >
<input type="hidden" name="SAMLRequest" value="%s" />
<input type="hidden" name="RelayState" value="%s" />
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>`, action, value, authReqID)
default: default:
s.renderError(w, http.StatusBadRequest, "Requested resource does not exist.") s.renderError(w, http.StatusBadRequest, "Requested resource does not exist.")
} }
@ -266,20 +291,24 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) { func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) {
// SAML redirect bindings use the "RelayState" URL query field. When we support var authID string
// SAML, we'll have to check that field too and possibly let callback connectors switch r.Method {
// indicate which field is used to determine the state. case "GET": // OAuth2 callback
// if authID = r.URL.Query().Get("state"); authID == "" {
// See: s.renderError(w, http.StatusBadRequest, "User session error.")
// https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf return
// Section: "3.4.3 RelayState" }
state := r.URL.Query().Get("state") case "POST": // SAML POST binding
if state == "" { if authID = r.PostFormValue("RelayState"); authID == "" {
s.renderError(w, http.StatusBadRequest, "User session error.") s.renderError(w, http.StatusBadRequest, "User session error.")
return
}
default:
s.renderError(w, http.StatusBadRequest, "Method not supported")
return return
} }
authReq, err := s.storage.GetAuthRequest(state) authReq, err := s.storage.GetAuthRequest(authID)
if err != nil { if err != nil {
if err == storage.ErrNotFound { if err == storage.ErrNotFound {
s.logger.Errorf("Invalid 'state' parameter provided: %v", err) s.logger.Errorf("Invalid 'state' parameter provided: %v", err)
@ -296,13 +325,28 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request)
s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.") s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.")
return return
} }
callbackConnector, ok := conn.Connector.(connector.CallbackConnector)
if !ok { var identity connector.Identity
switch conn := conn.Connector.(type) {
case connector.CallbackConnector:
if r.Method != "GET" {
s.logger.Errorf("SAML request mapped to OAuth2 connector")
s.renderError(w, http.StatusBadRequest, "Invalid request")
return
}
identity, err = conn.HandleCallback(parseScopes(authReq.Scopes), r)
case connector.SAMLConnector:
if r.Method != "POST" {
s.logger.Errorf("OAuth2 request mapped to SAML connector")
s.renderError(w, http.StatusBadRequest, "Invalid request")
return
}
identity, err = conn.HandlePOST(parseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse"))
default:
s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.") s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.")
return return
} }
identity, err := callbackConnector.HandleCallback(parseScopes(authReq.Scopes), r)
if err != nil { if err != nil {
s.logger.Errorf("Failed to authenticate: %v", err) s.logger.Errorf("Failed to authenticate: %v", err)
s.renderError(w, http.StatusInternalServerError, "Failed to return user's identity.") s.renderError(w, http.StatusInternalServerError, "Failed to return user's identity.")