Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support for saml idp initiated flow #1514

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions Documentation/connectors/saml.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ The 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.

Unlike some clients which will process unprompted AuthnResponses, dex must send the initial AuthnRequest and validates the response's InResponseTo value.

## Caveats

__The connector doesn't support refresh tokens__ since the SAML 2.0 protocol doesn't provide a way to requery a provider without interaction. If the "offline_access" scope is requested, it will be ignored.
Expand Down Expand Up @@ -111,3 +109,44 @@ connectors:
emailAttr: email
groupsAttr: groups
```

## Identity Provider Initiated Login

The SAML connector can support the identity provider (IdP) initiated flow with some special configuration. This flow allows a SAML user to initiate the login from a portal on their provider without needing to redirect back to the provider for further authentication.

Because this flow does not follow the normal OAuth flow (App -> Auth Provider -> App), dex requires some configuration on the client to know how to handle an unprompted AuthnResponse. For each client that you would like to support SAML's IdP initiated flow, you must configure the `samlInitiated` section.

As a static client:

```yaml
staticClients:
- id: example-app
name: "Example App"
secret: ZXhhbXBsZS1hcHAtc2VjcmV0
redirectURIs:
- http://127.0.0.1:5555/callback
samlInitiated:
redirecURI: http://127.0.0.1:5555/callback
scopes: ["openid", "profile", "email"]
```

Or via the API:

```golang
client := &api.Client{
Id: "example-app",
Name: "Example App",
Secret: "ZXhhbXBsZS1hcHAtc2VjcmV0",
RedirectUris: []string{"http://127.0.0.1:5555/callback"},
SamlInitiated: &api.SamlInitiatedConfig{
RedirectURI: "http://127.0.0.1:5555/callback",
Scopes: []string{"openid", "profile", "email"}
},
}
```

The scopes above are optional. If they are not provided the default set `openid profile email groups` will be used. However, without `samlInitiated.redirecURI` dex will reject any IdP initiated login for that client.

When configuring an application on your SAML provider, you must provide the `client_id` as the Default Relay Sate. This is how dex will match the AuthnResponse to the client, decide what claims to build, and where to redirect the user after handling the response.

After handling the AuthnResponse dex will redirect back to your application with a `code` and an empty `state`. Your application must be willing to accept the empty `state` and redeem the `code` for user tokens. The implicit flow is not supported since there is no way of generating a verifiable `nonce` in this setup.
269 changes: 169 additions & 100 deletions api/api.pb.go

Large diffs are not rendered by default.

18 changes: 13 additions & 5 deletions api/api.proto
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ message Client {
bool public = 5;
string name = 6;
string logo_url = 7;
SamlInitiatedConfig saml_initiated = 8;
}

// SamlInitiatedConfig provides config options for SAML initiated login on a client
message SamlInitiatedConfig {
string redirect_uri = 1;
repeated string scopes =2;
}

// CreateClientReq is a request to make a client.
Expand All @@ -22,7 +29,7 @@ message CreateClientReq {
// CreateClientResp returns the response from creating a client.
message CreateClientResp {
bool already_exists = 1;
Client client = 2;
Client client = 2;
}

// DeleteClientReq is a request to delete a client.
Expand All @@ -31,7 +38,7 @@ message DeleteClientReq {
string id = 1;
}

// DeleteClientResp determines if the client is deleted successfully.
// DeleteClientResp determines if the client is deleted successfully.
message DeleteClientResp {
bool not_found = 1;
}
Expand All @@ -43,6 +50,7 @@ message UpdateClientReq {
repeated string trusted_peers = 3;
string name = 4;
string logo_url = 5;
SamlInitiatedConfig saml_initiated = 6;
}

// UpdateClientResp returns the reponse form updating a client.
Expand Down Expand Up @@ -80,7 +88,7 @@ message UpdatePasswordReq {
string new_username = 3;
}

// UpdatePasswordResp returns the response from modifying an existing password.
// UpdatePasswordResp returns the response from modifying an existing password.
message UpdatePasswordResp {
bool not_found = 1;
}
Expand All @@ -90,7 +98,7 @@ message DeletePasswordReq {
string email = 1;
}

// DeletePasswordResp returns the response from deleting a password.
// DeletePasswordResp returns the response from deleting a password.
message DeletePasswordResp {
bool not_found = 1;
}
Expand Down Expand Up @@ -142,7 +150,7 @@ message RevokeRefreshReq {
string client_id = 2;
}

// RevokeRefreshResp determines if the refresh token is revoked successfully.
// RevokeRefreshResp determines if the refresh token is revoked successfully.
message RevokeRefreshResp {
// Set to true is refresh token was not found and token could not be revoked.
bool not_found = 1;
Expand Down
23 changes: 23 additions & 0 deletions connector/saml/saml.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ const (
)

var (
// DefaultIDPInitiatedScopes specifies scopes to use for IdP initiated flows
// if the target client does not have any configured
DefaultIDPInitiatedScopes = []string{"openid", "profile", "email", "groups"}

nameIDFormats = []string{
nameIDFormatEmailAddress,
nameIDFormatUnspecified,
Expand Down Expand Up @@ -434,6 +438,25 @@ func (p *provider) HandlePOST(s connector.Scopes, samlResponse, inResponseTo str
return ident, nil
}

// GetSAMLIssuer parses a SAML response and returns its 'Issuer' field.
func GetSAMLIssuer(samlResponse string) (string, error) {
rawResp, err := base64.StdEncoding.DecodeString(samlResponse)
if err != nil {
return "", err
}

var resp response
if err := xml.Unmarshal(rawResp, &resp); err != nil {
return "", err
}

if resp.Issuer == nil {
return "", errors.New("response is missing issuer")
}

return resp.Issuer.Issuer, nil
scotthew1 marked this conversation as resolved.
Show resolved Hide resolved
}

// validateStatus verifies that the response has a good status code or
// formats a human readble error based on the bad status.
func (p *provider) validateStatus(status *status) error {
Expand Down
12 changes: 12 additions & 0 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ func (d dexAPI) CreateClient(ctx context.Context, req *api.CreateClientReq) (*ap
Name: req.Client.Name,
LogoURL: req.Client.LogoUrl,
}
if req.Client.SamlInitiated != nil {
c.SAMLInitiated.RedirectURI = req.Client.SamlInitiated.RedirectUri
c.SAMLInitiated.Scopes = req.Client.SamlInitiated.Scopes
}
if err := d.s.CreateClient(c); err != nil {
if err == storage.ErrAlreadyExists {
return &api.CreateClientResp{AlreadyExists: true}, nil
Expand Down Expand Up @@ -94,6 +98,14 @@ func (d dexAPI) UpdateClient(ctx context.Context, req *api.UpdateClientReq) (*ap
if req.LogoUrl != "" {
old.LogoURL = req.LogoUrl
}
if req.SamlInitiated != nil {
if req.SamlInitiated.RedirectUri != "" {
old.SAMLInitiated.RedirectURI = req.SamlInitiated.RedirectUri
}
if len(req.SamlInitiated.Scopes) > 0 {
old.SAMLInitiated.Scopes = req.SamlInitiated.Scopes
}
}
return old, nil
})

Expand Down
15 changes: 15 additions & 0 deletions server/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,10 @@ func TestUpdateClient(t *testing.T) {
TrustedPeers: []string{"test"},
Name: "test",
LogoUrl: "https://logout",
SamlInitiated: &api.SamlInitiatedConfig{
RedirectUri: "https://redirect",
Scopes: []string{"idtoken"},
},
},
wantErr: false,
want: &api.UpdateClientResp{
Expand Down Expand Up @@ -489,6 +493,17 @@ func TestUpdateClient(t *testing.T) {
t.Errorf("expected trusted peer: %s", peer)
}
}
if tc.req.SamlInitiated != nil {
if tc.req.SamlInitiated.RedirectUri != client.SAMLInitiated.RedirectURI {
t.Errorf("expected stored client with SAML initiated redirectURI: %s, found %s", tc.req.SamlInitiated.RedirectUri, client.SAMLInitiated.RedirectURI)
}
for _, scope := range tc.req.SamlInitiated.Scopes {
found := find(scope, client.SAMLInitiated.Scopes)
if !found {
t.Errorf("expected SAML initiated scope: %s", scope)
}
}
}
}

if tc.cleanup != nil {
Expand Down
99 changes: 95 additions & 4 deletions server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
jose "gopkg.in/square/go-jose.v2"

"github.com/dexidp/dex/connector"
"github.com/dexidp/dex/connector/saml"
"github.com/dexidp/dex/server/internal"
"github.com/dexidp/dex/storage"
)
Expand Down Expand Up @@ -395,16 +396,19 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) {
}

func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request) {
var authID string
var authID, samlInResponseTo string
switch r.Method {
case http.MethodGet: // OAuth2 callback
if authID = r.URL.Query().Get("state"); authID == "" {
s.renderError(r, w, http.StatusBadRequest, "User session error.")
return
}
case http.MethodPost: // SAML POST binding
if authID = r.PostFormValue("RelayState"); authID == "" {
s.renderError(r, w, http.StatusBadRequest, "User session error.")
var code int
var err error
authID, samlInResponseTo, code, err = s.handleSAMLCallback(r)
if err != nil {
s.renderError(r, w, code, fmt.Sprintf("Error processing SAML callback: %s.", err))
return
}
default:
Expand Down Expand Up @@ -452,7 +456,7 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request)
s.renderError(r, w, http.StatusBadRequest, "Invalid request")
return
}
identity, err = conn.HandlePOST(parseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse"), authReq.ID)
identity, err = conn.HandlePOST(parseScopes(authReq.Scopes), r.PostFormValue("SAMLResponse"), samlInResponseTo)
default:
s.renderError(r, w, http.StatusInternalServerError, "Requested resource does not exist.")
return
Expand All @@ -474,6 +478,93 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request)
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}

// handleSamlCallback handles a saml callback response with support for the IdP initiated flow.
// if the RelayState is not one of our auth requests, we check to see if it is a valid redirectURI
// for one of our clients. then, if the SAMLResponse is from a registered connector, we create a
// new auth request in the database so we can continue with our regular callback flow.
func (s *Server) handleSAMLCallback(r *http.Request) (string, string, int, error) {
relayState := r.PostFormValue("RelayState")
if relayState == "" {
return "", "", http.StatusBadRequest, errors.New("user session error")
}
// if our relaySate is a valid authId, this is not IdP initiated
if _, err := s.storage.GetAuthRequest(relayState); err == nil {
return relayState, relayState, http.StatusOK, nil
} else if err != storage.ErrNotFound {
s.logger.Errorf("Failed to get auth request: %v", err)
return "", "", http.StatusInternalServerError, errors.New("database error")
}
// if relayState is a valid client_id, we'll check to see if that has config to
// allow the IdP initiated flow.
client, err := s.storage.GetClient(relayState)
if err == storage.ErrNotFound {
// this is not a valid client_id, we have a bogus relayState
s.logger.Warnf("SAML authentication recieved with unknown RelaySate %q", relayState)
return "", "", http.StatusBadRequest, errors.New("user session error")
} else if err != nil {
s.logger.Errorf("Failed to get client: %v", err)
return "", "", http.StatusInternalServerError, errors.New("database error")
}
redirectURI := client.SAMLInitiated.RedirectURI
if redirectURI == "" {
// this client does not have support for IdP initiated login configured
s.logger.Warnf("SAML IdP initiated login attempt made to client %q, but support for this flow is not configured. Update the client config to support this login flow.", relayState)
return "", "", http.StatusBadRequest, errors.New("user session error")
}
// TODO: should we check that 'openid' is one of the scopes? or just put the
// burden on the user to configure these scopes correctly?
scopes := client.SAMLInitiated.Scopes
if len(scopes) == 0 {
scopes = saml.DefaultIDPInitiatedScopes
}
// find the issuer attribute in the SAMLResponse, use it to find the associated connector-id.
ssoIssuer, err := saml.GetSAMLIssuer(r.PostFormValue("SAMLResponse"))
if err != nil {
return "", "", http.StatusBadRequest, errors.New("bad saml response")
}
connectors, err := s.storage.ListConnectors()
if err != nil {
s.logger.Errorf("Failed to list connectors: %v", err)
return "", "", http.StatusInternalServerError, errors.New("database error")
}
var connID string
for _, c := range connectors {
if c.Type != "saml" {
continue
}
var cfg saml.Config
if err := json.Unmarshal(c.Config, &cfg); err != nil {
s.logger.Errorf("Cannot parse saml config for connector %s: %s", c.ID, err)
return "", "", http.StatusPreconditionFailed, errors.New("config error")
}
if cfg.SSOIssuer == ssoIssuer {
connID = c.ID
break
}
}
if connID == "" {
s.logger.Errorf("Cannot find the connector id associated with the issuer %s", ssoIssuer)
return "", "", http.StatusInternalServerError, errors.New("bad saml response")
}
// build our auth request (fake it til ya make it)
authID := storage.NewID()
req := storage.AuthRequest{
ID: authID,
ClientID: client.ID,
Scopes: scopes,
RedirectURI: redirectURI,
ResponseTypes: []string{"code"},
ConnectorID: connID,
Expiry: s.now().Add(s.authRequestsValidFor),
}
if err := s.storage.CreateAuthRequest(req); err != nil {
s.logger.Errorf("Could not create SAML IDP initiated request: %v", err)
return "", "", http.StatusInternalServerError, errors.New("database error")
}
// empty InResponseTo value because this is IdP initiated, not in response to any request
return authID, "", http.StatusOK, nil
}

// finalizeLogin associates the user's identity with the current AuthRequest, then returns
// the approval page's path.
func (s *Server) finalizeLogin(identity connector.Identity, authReq storage.AuthRequest, conn connector.Connector) (string, error) {
Expand Down
8 changes: 8 additions & 0 deletions storage/conformance/conformance.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ func testClientCRUD(t *testing.T, s storage.Storage) {
RedirectURIs: []string{"foo://bar.com/", "https://auth.example.com"},
Name: "dex client",
LogoURL: "https://goo.gl/JIyzIC",
SAMLInitiated: storage.SAMLInitiatedConfig{
RedirectURI: "foo://bar.com/",
Scopes: []string{"idtoken", "profile", "email"},
},
}
err := s.DeleteClient(id1)
mustBeErrNotFound(t, "client", err)
Expand All @@ -265,6 +269,10 @@ func testClientCRUD(t *testing.T, s storage.Storage) {
RedirectURIs: []string{"foo://bar.com/", "https://auth.example.com"},
Name: "dex client",
LogoURL: "https://goo.gl/JIyzIC",
SAMLInitiated: storage.SAMLInitiatedConfig{
RedirectURI: "foo://bar.com/",
Scopes: []string{"idtoken", "profile", "email"},
},
}

if err := s.CreateClient(c2); err != nil {
Expand Down
Loading