Merge pull request #869 from ericchiang/saml-response-to
*: validate InResponseTo SAML response field and make issuer optional
This commit is contained in:
commit
b112aa2ecd
7 changed files with 114 additions and 31 deletions
|
@ -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/
|
||||||
|
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"}
|
||||||
|
|
|
@ -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"`
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Reference in a new issue