Merge pull request #869 from ericchiang/saml-response-to

*: validate InResponseTo SAML response field and make issuer optional
This commit is contained in:
Eric Chiang 2017-03-22 13:04:41 -07:00 committed by GitHub
commit b112aa2ecd
7 changed files with 114 additions and 31 deletions

View file

@ -93,3 +93,46 @@ objectClass: groupOfNames
member: cn=Test1,dc=example,dc=org member: cn=Test1,dc=example,dc=org
cn: tstgrp cn: tstgrp
``` ```
## SAML
### Okta
The Okta identity provider supports free accounts for developers to test their implementation against. This document describes configuring an Okta application to test dex's SAML connector.
First, [sign up for a developer account][okta-sign-up]. Then, to create a SAML application:
* Go to the admin screen.
* Click "Add application"
* Click "Create New App"
* Choose "SAML 2.0" and press "Create"
* Configure SAML
* Enter `http://127.0.0.1:5556/dex/callback` for "Single sign on URL"
* Enter `http://127.0.0.1:5556/dex/callback` for "Audience URI (SP Entity ID)"
* Under "ATTRIBUTE STATEMENTS (OPTIONAL)" add an "email" and "name" attribute. The values should be something like `user:email` and `user:firstName`, respectively.
* Under "GROUP ATTRIBUTE STATEMENTS (OPTIONAL)" add a "groups" attribute. Use the "Regexp" filter `.*`.
After the application's created, assign yourself to the app.
* "Applications" > "Applications"
* Click on your application then under the "People" tab press the "Assign to People" button and add yourself.
At the app, go to the "Sign On" tab and then click "View Setup Instructions". Use those values to fill out the following connector in `examples/config-dev.yaml`.
```yaml
connectors:
- type: samlExperimental
id: saml
name: Okta
config:
ssoURL: ( "Identity Provider Single Sign-On URL" )
caData: ( base64'd value of "X.509 Certificate" )
redirectURI: http://127.0.0.1:5556/dex/callback
usernameAttr: name
emailAttr: email
groupsAttr: groups
```
Start both dex and the example app, and try logging in (requires not requesting a refresh token).
[okta-sign-up]: https://www.okta.com/developer/signup/

View file

@ -24,8 +24,6 @@ connectors:
# Required field for connector name. # Required field for connector name.
name: SAML name: SAML
config: config:
# Issuer used for validating the SAML response.
issuer: https://saml.example.com
# SSO URL used for POST value. # SSO URL used for POST value.
ssoURL: https://saml.example.com/sso ssoURL: https://saml.example.com/sso
@ -72,4 +70,8 @@ connectors:
# urn:oasis:names:tc:SAML:2.0:nameid-format:persistent # urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
# #
nameIDPolicyFormat: persistent nameIDPolicyFormat: persistent
# Optional issuer used for validating the SAML response. If provided the
# connector will validate the Issuer in the response.
# issuer: https://saml.example.com
``` ```

View file

@ -66,20 +66,25 @@ type CallbackConnector interface {
} }
// SAMLConnector represents SAML connectors which implement the HTTP POST binding. // SAMLConnector represents SAML connectors which implement the HTTP POST binding.
// RelayState is handled by the server.
// //
// RelayState is handled by the server. // See: https://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
// "3.5 HTTP POST Binding"
type SAMLConnector interface { type SAMLConnector interface {
// POSTData returns an encoded SAML request and SSO URL for the server to // POSTData returns an encoded SAML request and SSO URL for the server to
// render a POST form with. // render a POST form with.
POSTData(s Scopes) (sooURL, samlRequest string, err error) //
// POSTData should encode the provided request ID in the returned serialized
// SAML request.
POSTData(s Scopes, requestID string) (sooURL, samlRequest string, err error)
// TODO(ericchiang): Provide expected "InResponseTo" ID value. // HandlePOST decodes, verifies, and maps attributes from the SAML response.
// It passes the expected value of the "InResponseTo" response field, which
// the connector must ensure matches the response value.
// //
// See: https://www.oasis-open.org/committees/download.php/35711/sstc-saml-core-errata-2.0-wd-06-diff.pdf // See: https://www.oasis-open.org/committees/download.php/35711/sstc-saml-core-errata-2.0-wd-06-diff.pdf
// "3.2.2 Complex Type StatusResponseType" // "3.2.2 Complex Type StatusResponseType"
HandlePOST(s Scopes, samlResponse, inResponseTo string) (identity Identity, err error)
// HandlePOST decodes, verifies, and maps attributes from the SAML response.
HandlePOST(s Scopes, samlResponse string) (identity Identity, err error)
} }
// RefreshConnector is a connector that can update the client claims. // RefreshConnector is a connector that can update the client claims.

View file

@ -135,7 +135,6 @@ func (c *Config) openConnector(logger logrus.FieldLogger) (interface {
requiredFields := []struct { requiredFields := []struct {
name, val string name, val string
}{ }{
{"issuer", c.Issuer},
{"ssoURL", c.SSOURL}, {"ssoURL", c.SSOURL},
{"usernameAttr", c.UsernameAttr}, {"usernameAttr", c.UsernameAttr},
{"emailAttr", c.EmailAttr}, {"emailAttr", c.EmailAttr},
@ -240,7 +239,7 @@ type provider struct {
logger logrus.FieldLogger logger logrus.FieldLogger
} }
func (p *provider) POSTData(s connector.Scopes) (action, value string, err error) { func (p *provider) POSTData(s connector.Scopes, id string) (action, value string, err error) {
// NOTE(ericchiang): If we can't follow up with the identity provider, can we // NOTE(ericchiang): If we can't follow up with the identity provider, can we
// support refresh tokens? // support refresh tokens?
@ -250,28 +249,32 @@ func (p *provider) POSTData(s connector.Scopes) (action, value string, err error
r := &authnRequest{ r := &authnRequest{
ProtocolBinding: bindingPOST, ProtocolBinding: bindingPOST,
ID: "_" + uuidv4(), ID: id,
IssueInstant: xmlTime(p.now()), IssueInstant: xmlTime(p.now()),
Destination: p.ssoURL, Destination: p.ssoURL,
Issuer: &issuer{
Issuer: p.issuer,
},
NameIDPolicy: &nameIDPolicy{ NameIDPolicy: &nameIDPolicy{
AllowCreate: true, AllowCreate: true,
Format: p.nameIDPolicyFormat, Format: p.nameIDPolicyFormat,
}, },
AssertionConsumerServiceURL: p.redirectURI, AssertionConsumerServiceURL: p.redirectURI,
} }
if p.issuer != "" {
// Issuer for the request is optional. For example, okta always ignores
// this value.
r.Issuer = &issuer{Issuer: p.issuer}
}
data, err := xml.MarshalIndent(r, "", " ") data, err := xml.MarshalIndent(r, "", " ")
if err != nil { if err != nil {
return "", "", fmt.Errorf("marshal authn request: %v", err) return "", "", fmt.Errorf("marshal authn request: %v", err)
} }
// 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 return p.ssoURL, base64.StdEncoding.EncodeToString(data), nil
} }
func (p *provider) HandlePOST(s connector.Scopes, samlResponse string) (ident connector.Identity, err error) { func (p *provider) HandlePOST(s connector.Scopes, samlResponse, inResponseTo string) (ident connector.Identity, err error) {
rawResp, err := base64.StdEncoding.DecodeString(samlResponse) rawResp, err := base64.StdEncoding.DecodeString(samlResponse)
if err != nil { if err != nil {
return ident, fmt.Errorf("decode response: %v", err) return ident, fmt.Errorf("decode response: %v", err)
@ -287,6 +290,17 @@ func (p *provider) HandlePOST(s connector.Scopes, samlResponse string) (ident co
return ident, fmt.Errorf("unmarshal response: %v", err) return ident, fmt.Errorf("unmarshal response: %v", err)
} }
if p.issuer != "" && resp.Issuer != nil && resp.Issuer.Issuer != p.issuer {
return ident, fmt.Errorf("expected Issuer value %s, got %s", p.issuer, resp.Issuer.Issuer)
}
// Verify InResponseTo value matches the expected ID associated with
// the RelayState.
if resp.InResponseTo != inResponseTo {
return ident, fmt.Errorf("expected InResponseTo value %s, got %s", inResponseTo, resp.InResponseTo)
}
// Destination is optional.
if resp.Destination != "" && resp.Destination != p.redirectURI { if resp.Destination != "" && resp.Destination != p.redirectURI {
return ident, fmt.Errorf("expected destination %q got %q", p.redirectURI, resp.Destination) return ident, fmt.Errorf("expected destination %q got %q", p.redirectURI, resp.Destination)
@ -327,26 +341,26 @@ func (p *provider) HandlePOST(s connector.Scopes, samlResponse string) (ident co
} }
if ident.Email, _ = attributes.get(p.emailAttr); ident.Email == "" { if ident.Email, _ = attributes.get(p.emailAttr); ident.Email == "" {
return ident, fmt.Errorf("no attribute with name %q", p.emailAttr) return ident, fmt.Errorf("no attribute with name %q: %s", p.emailAttr, attributes.names())
} }
ident.EmailVerified = true ident.EmailVerified = true
if ident.Username, _ = attributes.get(p.usernameAttr); ident.Username == "" { if ident.Username, _ = attributes.get(p.usernameAttr); ident.Username == "" {
return ident, fmt.Errorf("no attribute with name %q", p.usernameAttr) return ident, fmt.Errorf("no attribute with name %q: %s", p.usernameAttr, attributes.names())
} }
if s.Groups && p.groupsAttr != "" { if s.Groups && p.groupsAttr != "" {
if p.groupsDelim != "" { if p.groupsDelim != "" {
groupsStr, ok := attributes.get(p.groupsAttr) groupsStr, ok := attributes.get(p.groupsAttr)
if !ok { if !ok {
return ident, fmt.Errorf("no attribute with name %q", p.groupsAttr) return ident, fmt.Errorf("no attribute with name %q: %s", p.groupsAttr, attributes.names())
} }
// TODO(ericchiang): Do we need to further trim whitespace? // TODO(ericchiang): Do we need to further trim whitespace?
ident.Groups = strings.Split(groupsStr, p.groupsDelim) ident.Groups = strings.Split(groupsStr, p.groupsDelim)
} else { } else {
groups, ok := attributes.all(p.groupsAttr) groups, ok := attributes.all(p.groupsAttr)
if !ok { if !ok {
return ident, fmt.Errorf("no attribute with name %q", p.groupsAttr) return ident, fmt.Errorf("no attribute with name %q: %s", p.groupsAttr, attributes.names())
} }
ident.Groups = groups ident.Groups = groups
} }
@ -427,6 +441,9 @@ func (p *provider) validateSubjectConfirmation(subject *subject) error {
} }
// Validates the Conditions element and all of it's content // Validates the Conditions element and all of it's content
//
// See: https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf
// "2.3.3 Element <Assertion>"
func (p *provider) validateConditions(assertion *assertion) error { func (p *provider) validateConditions(assertion *assertion) error {
// Checks if a Conditions element exists // Checks if a Conditions element exists
conditions := assertion.Conditions conditions := assertion.Conditions
@ -452,15 +469,17 @@ func (p *provider) validateConditions(assertion *assertion) error {
if audienceRestriction != nil { if audienceRestriction != nil {
audiences := audienceRestriction.Audiences audiences := audienceRestriction.Audiences
if audiences != nil && len(audiences) > 0 { if audiences != nil && len(audiences) > 0 {
values := make([]string, len(audiences))
issuerInAudiences := false issuerInAudiences := false
for _, audience := range audiences { for i, audience := range audiences {
if audience.Value == p.issuer { if audience.Value == p.redirectURI {
issuerInAudiences = true issuerInAudiences = true
break break
} }
values[i] = audience.Value
} }
if !issuerInAudiences { if !issuerInAudiences {
return fmt.Errorf("required audience %s was not in Response audiences %s", p.issuer, audiences) return fmt.Errorf("required audience %s was not in Response audiences %s", p.redirectURI, values)
} }
} }
} }

View file

@ -18,8 +18,11 @@ import (
) )
const ( const (
defaultIssuer = "http://localhost:5556/dex/callback" defaultIssuer = "http://www.okta.com/exk91cb99lKkKSYoy0h7"
defaultRedirectURI = "http://localhost:5556/dex/callback" defaultRedirectURI = "http://localhost:5556/dex/callback"
// Response ID embedded in our testdata.
testDataResponseID = "_fd1b3ef9-ec09-44a7-a66b-0d39c250f6a0"
) )
func loadCert(ca string) (*x509.Certificate, error) { func loadCert(ca string) (*x509.Certificate, error) {
@ -109,7 +112,7 @@ func TestHandlePOST(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
ident, err := p.HandlePOST(scopes, base64.StdEncoding.EncodeToString(data)) ident, err := p.HandlePOST(scopes, base64.StdEncoding.EncodeToString(data), testDataResponseID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -254,12 +257,12 @@ func TestValidateConditions(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("validation of %q should succeed", "Conditions where notBefore is 15 seconds after now") t.Fatalf("validation of %q should succeed", "Conditions where notBefore is 15 seconds after now")
} }
// Audiences contains the issuer // Audiences contains the redirectURI
validAudience := audience{Value: p.issuer} validAudience := audience{Value: p.redirectURI}
cond.AudienceRestriction.Audiences = []audience{validAudience} cond.AudienceRestriction.Audiences = []audience{validAudience}
err = p.validateConditions(assert) err = p.validateConditions(assert)
if err != nil { if err != nil {
t.Fatalf("validation of %q should succeed", "Audiences contains the issuer") t.Fatalf("validation of %q should succeed: %v", "Audiences contains the redirectURI", err)
} }
// Audiences is not empty and not contains the issuer // Audiences is not empty and not contains the issuer
invalidAudience := audience{Value: "invalid"} invalidAudience := audience{Value: "invalid"}

View file

@ -162,8 +162,9 @@ type authnContextClassRef struct {
type response struct { type response struct {
XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol Response"` XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol Response"`
ID string `xml:"ID,attr"` ID string `xml:"ID,attr"`
Version samlVersion `xml:"Version,attr"` InResponseTo string `xml:"InResponseTo,attr"`
Version samlVersion `xml:"Version,attr"`
Destination string `xml:"Destination,attr,omitempty"` Destination string `xml:"Destination,attr,omitempty"`
@ -221,6 +222,16 @@ func (a *attributeStatement) all(name string) (s []string, ok bool) {
return return
} }
// names list the names of all attributes in the attribute statement.
func (a *attributeStatement) names() []string {
s := make([]string, len(a.Attributes))
for i, attr := range a.Attributes {
s[i] = attr.Name
}
return s
}
type attribute struct { type attribute struct {
XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Attribute"` XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Attribute"`

View file

@ -247,7 +247,7 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) {
s.logger.Errorf("Server template error: %v", err) s.logger.Errorf("Server template error: %v", err)
} }
case connector.SAMLConnector: case connector.SAMLConnector:
action, value, err := conn.POSTData(scopes) action, value, err := conn.POSTData(scopes, authReqID)
if err != nil { if err != nil {
s.logger.Errorf("Creating SAML data: %v", err) s.logger.Errorf("Creating SAML data: %v", err)
s.renderError(w, http.StatusInternalServerError, "Connector Login Error") s.renderError(w, http.StatusInternalServerError, "Connector Login Error")
@ -360,7 +360,7 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request)
s.renderError(w, http.StatusBadRequest, "Invalid request") s.renderError(w, http.StatusBadRequest, "Invalid request")
return return
} }
identity, err = conn.HandlePOST(parseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse")) identity, err = conn.HandlePOST(parseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse"), authReq.ID)
default: default:
s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.") s.renderError(w, http.StatusInternalServerError, "Requested resource does not exist.")
return return