From e738058fe862e7f1026d79c58587a16230ff4ecb Mon Sep 17 00:00:00 2001 From: Houssem Ben Mabrouk Date: Fri, 6 Oct 2023 16:10:51 +0200 Subject: [PATCH] add client_credentials grant type Signed-off-by: Houssem Ben Mabrouk --- cmd/dex/serve.go | 1 + connector/oidc/oidc.go | 67 ++++++++++++++++++++++++++++++++++++++---- server/handlers.go | 65 ++++++++++++++++++++++++++++++++++++++++ server/oauth2.go | 1 + server/server.go | 1 + 5 files changed, 130 insertions(+), 5 deletions(-) diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 47b090aeab..ea7854e5fe 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -560,6 +560,7 @@ func applyConfigOverrides(options serveOptions, config *Config) { "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange", + "client_credentials", } } } diff --git a/connector/oidc/oidc.go b/connector/oidc/oidc.go index ff4713c270..2be973f4ae 100644 --- a/connector/oidc/oidc.go +++ b/connector/oidc/oidc.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "strings" @@ -261,6 +262,53 @@ const ( exchangeCaller ) +func (c *oidcConnector) getTokenViaClientCredentials(scopes string) (token *oauth2.Token, err error) { + if scopes == "" { + scopes = strings.Join(c.oauth2Config.Scopes, " ") + } + data := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {c.oauth2Config.ClientID}, + "client_secret": {c.oauth2Config.ClientSecret}, + "scope": {scopes}, + } + resp, err := c.httpClient.PostForm(c.oauth2Config.Endpoint.TokenURL, data) + if err != nil { + return nil, fmt.Errorf("oidc: failed to get token: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("oidc: issuer returned an error: %v", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("oidc: failed to get read token body: %v", err) + } + + type AccessTokenType struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + } + response := AccessTokenType{} + if err = json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("oidc: unable to parse response: %v", err) + } + + token = &oauth2.Token{ + AccessToken: response.AccessToken, + TokenType: "urn:ietf:params:oauth:token-type:id_token", + Expiry: time.Now().Add(time.Second * time.Duration(response.ExpiresIn)), + } + raw := make(map[string]interface{}) + json.Unmarshal(body, &raw) // no error checks for optional fields + token = token.WithExtra(raw) + + return token, nil +} + func (c *oidcConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { q := r.URL.Query() if errType := q.Get("error"); errType != "" { @@ -268,12 +316,21 @@ func (c *oidcConnector) HandleCallback(s connector.Scopes, r *http.Request) (ide } ctx := context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient) - - token, err := c.oauth2Config.Exchange(ctx, q.Get("code")) - if err != nil { - return identity, fmt.Errorf("oidc: failed to get token: %v", err) + if q.Has("code") { + // exchange code to token + token, err := c.oauth2Config.Exchange(ctx, q.Get("code")) + if err != nil { + return identity, fmt.Errorf("oidc: failed to get token: %v", err) + } + return c.createIdentity(ctx, identity, token, createCaller) + } else { + // get token via client_credentials + token, err := c.getTokenViaClientCredentials(r.Form.Get("scope")) + if err != nil { + return identity, err + } + return c.createIdentity(ctx, identity, token, exchangeCaller) } - return c.createIdentity(ctx, identity, token, createCaller) } // Refresh is used to refresh a session with the refresh token provided by the IdP diff --git a/server/handlers.go b/server/handlers.go index 9438d8072b..b7957ffa30 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -840,6 +840,8 @@ func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) { s.handleDeviceToken(w, r) case grantTypeAuthorizationCode: s.withClientFromStorage(w, r, s.handleAuthCode) + case grantTypeClientCredentials: + s.withClientFromStorage(w, r, s.handleClientCredentials) case grantTypeRefreshToken: s.withClientFromStorage(w, r, s.handleRefreshToken) case grantTypePassword: @@ -1096,6 +1098,69 @@ func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) { w.Write(claims) } +func (s *Server) handleClientCredentials(w http.ResponseWriter, r *http.Request, client storage.Client) { + // Parse the fields + if err := r.ParseForm(); err != nil { + s.tokenErrHelper(w, errInvalidRequest, "Couldn't parse data", http.StatusBadRequest) + return + } + q := r.Form + + scopes := strings.Fields(q.Get("scope")) + nonce := "" + connID := q.Get("connector_id") + + // Which connector + conn, err := s.getConnector(connID) + if err != nil { + s.tokenErrHelper(w, errInvalidRequest, "Requested connector does not exist.", http.StatusBadRequest) + return + } + + callbackConnector, ok := conn.Connector.(connector.CallbackConnector) + if !ok { + s.tokenErrHelper(w, errInvalidRequest, "Requested callback connector does not correct type.", http.StatusBadRequest) + return + } + + // Login + identity, err := callbackConnector.HandleCallback(parseScopes(scopes), r) + if err != nil { + s.logger.Errorf("Failed to login user: %v", err) + s.tokenErrHelper(w, errInvalidRequest, "Could not login user", http.StatusBadRequest) + return + } + + // Build the claims to send the id token + claims := storage.Claims{ + UserID: identity.UserID, + Username: identity.Username, + PreferredUsername: identity.PreferredUsername, + Email: identity.Email, + EmailVerified: identity.EmailVerified, + Groups: identity.Groups, + } + + // we can't pass the scope "groups" to the request and so the response doesn't include the scope "groups" + scopes = append(scopes, "groups") // FIXME: tell multipass to fix this? + accessToken, _, err := s.newAccessToken(client.ID, claims, scopes, nonce, connID) + if err != nil { + s.logger.Errorf("client grant failed to create new access token: %v", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + + idToken, expiry, err := s.newIDToken(client.ID, claims, scopes, nonce, accessToken, "", connID) + if err != nil { + s.logger.Errorf("client grant failed to create new ID token: %v", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + + resp := s.toAccessTokenResponse(idToken, accessToken, "", expiry) + s.writeAccessToken(w, resp) +} + func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request, client storage.Client) { // Parse the fields if err := r.ParseForm(); err != nil { diff --git a/server/oauth2.go b/server/oauth2.go index b72431e0e8..0227acb051 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -132,6 +132,7 @@ const ( grantTypePassword = "password" grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" + grantTypeClientCredentials = "client_credentials" ) const ( diff --git a/server/server.go b/server/server.go index bf83dd81f0..093608ae11 100644 --- a/server/server.go +++ b/server/server.go @@ -220,6 +220,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) grantTypeRefreshToken: true, grantTypeDeviceCode: true, grantTypeTokenExchange: true, + grantTypeClientCredentials: true, } supportedRes := make(map[string]bool)