diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 2ac9957f..545419dc 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -622,6 +622,9 @@ func applyConfigOverrides(options serveOptions, config *Config) { "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange", } + if featureflags.ClientCredentialGrantEnabledByDefault.Enabled() { + config.OAuth2.GrantTypes = append(config.OAuth2.GrantTypes, "client_credentials") + } } } diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml index 94a40bff..9c25b735 100644 --- a/examples/config-dev.yaml +++ b/examples/config-dev.yaml @@ -100,10 +100,11 @@ telemetry: # format: "text" # can also be "json" # Default values shown below -# oauth2: +#oauth2: # grantTypes determines the allowed set of authorization flows. # grantTypes: # - "authorization_code" +# - "client_credentials" # - "refresh_token" # - "implicit" # - "password" diff --git a/pkg/featureflags/set.go b/pkg/featureflags/set.go index 65a04d8a..dcea1ca7 100644 --- a/pkg/featureflags/set.go +++ b/pkg/featureflags/set.go @@ -17,4 +17,8 @@ var ( // ConfigDisallowUnknownFields enables to forbid unknown fields in the config while unmarshaling. ConfigDisallowUnknownFields = newFlag("config_disallow_unknown_fields", false) + + // ClientCredentialGrantEnabledByDefault enables the client_credentials grant type by default + // without requiring explicit configuration in oauth2.grantTypes. + ClientCredentialGrantEnabledByDefault = newFlag("client_credential_grant_enabled_by_default", false) ) diff --git a/server/handlers.go b/server/handlers.go index b724b8d8..8a20438c 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -889,6 +889,8 @@ func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) { s.withClientFromStorage(w, r, s.handlePasswordGrant) case grantTypeTokenExchange: s.withClientFromStorage(w, r, s.handleTokenExchange) + case grantTypeClientCredentials: + s.withClientFromStorage(w, r, s.handleClientCredentialsGrant) default: s.tokenErrHelper(w, errUnsupportedGrantType, "", http.StatusBadRequest) } @@ -1465,6 +1467,108 @@ func (s *Server) handleTokenExchange(w http.ResponseWriter, r *http.Request, cli json.NewEncoder(w).Encode(resp) } +func (s *Server) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request, client storage.Client) { + ctx := r.Context() + + // client_credentials requires a confidential client. + if client.Public { + s.tokenErrHelper(w, errUnauthorizedClient, "Public clients cannot use client_credentials grant.", http.StatusBadRequest) + return + } + + // Parse scopes from request. + if err := r.ParseForm(); err != nil { + s.tokenErrHelper(w, errInvalidRequest, "Couldn't parse data", http.StatusBadRequest) + return + } + scopes := strings.Fields(r.Form.Get("scope")) + + // Validate scopes. + var ( + unrecognized []string + invalidScopes []string + ) + hasOpenIDScope := false + for _, scope := range scopes { + switch scope { + case scopeOpenID: + hasOpenIDScope = true + case scopeEmail, scopeProfile, scopeGroups: + // allowed + case scopeOfflineAccess: + s.tokenErrHelper(w, errInvalidScope, "client_credentials grant does not support offline_access scope.", http.StatusBadRequest) + return + case scopeFederatedID: + s.tokenErrHelper(w, errInvalidScope, "client_credentials grant does not support federated:id scope.", http.StatusBadRequest) + return + default: + peerID, ok := parseCrossClientScope(scope) + if !ok { + unrecognized = append(unrecognized, scope) + continue + } + + isTrusted, err := s.validateCrossClientTrust(ctx, client.ID, peerID) + if err != nil { + s.logger.ErrorContext(ctx, "error validating cross client trust", "client_id", client.ID, "peer_id", peerID, "err", err) + s.tokenErrHelper(w, errInvalidClient, "Error validating cross client trust.", http.StatusBadRequest) + return + } + if !isTrusted { + invalidScopes = append(invalidScopes, scope) + } + } + } + if len(unrecognized) > 0 { + s.tokenErrHelper(w, errInvalidScope, fmt.Sprintf("Unrecognized scope(s) %q", unrecognized), http.StatusBadRequest) + return + } + if len(invalidScopes) > 0 { + s.tokenErrHelper(w, errInvalidScope, fmt.Sprintf("Client can't request scope(s) %q", invalidScopes), http.StatusBadRequest) + return + } + + // Build claims from the client itself — no user involved. + claims := storage.Claims{ + UserID: client.ID, + } + + // Only populate Username/PreferredUsername when the profile scope is requested. + for _, scope := range scopes { + if scope == scopeProfile { + claims.Username = client.Name + claims.PreferredUsername = client.Name + break + } + } + + nonce := r.Form.Get("nonce") + + // Empty connector ID is unique for cluster credentials grant + // Creating connectors with an empty ID with the config and API is prohibited + connID := "" + + accessToken, expiry, err := s.newAccessToken(ctx, client.ID, claims, scopes, nonce, connID) + if err != nil { + s.logger.ErrorContext(ctx, "client_credentials grant failed to create new access token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + + var idToken string + if hasOpenIDScope { + idToken, expiry, err = s.newIDToken(ctx, client.ID, claims, scopes, nonce, accessToken, "", connID) + if err != nil { + s.logger.ErrorContext(ctx, "client_credentials grant failed to create new ID token", "err", err) + s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) + return + } + } + + resp := s.toAccessTokenResponse(idToken, accessToken, "", expiry) + s.writeAccessToken(w, resp) +} + type accessTokenResponse struct { AccessToken string `json:"access_token"` IssuedTokenType string `json:"issued_token_type,omitempty"` diff --git a/server/handlers_test.go b/server/handlers_test.go index 6ec9b962..14bb3da9 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -22,6 +22,7 @@ import ( "golang.org/x/oauth2" "github.com/dexidp/dex/connector" + "github.com/dexidp/dex/server/internal" "github.com/dexidp/dex/storage" ) @@ -63,6 +64,7 @@ func TestHandleDiscovery(t *testing.T) { Introspect: fmt.Sprintf("%s/token/introspect", httpServer.URL), GrantTypes: []string{ "authorization_code", + "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code", "urn:ietf:params:oauth:grant-type:token-exchange", @@ -646,6 +648,176 @@ func TestHandlePasswordLoginWithSkipApproval(t *testing.T) { } } +func TestHandleClientCredentials(t *testing.T) { + tests := []struct { + name string + clientID string + clientSecret string + scopes string + wantCode int + wantAccessTok bool + wantIDToken bool + wantUsername string + }{ + { + name: "Basic grant, no scopes", + clientID: "test", + clientSecret: "barfoo", + scopes: "", + wantCode: 200, + wantAccessTok: true, + wantIDToken: false, + }, + { + name: "With openid scope", + clientID: "test", + clientSecret: "barfoo", + scopes: "openid", + wantCode: 200, + wantAccessTok: true, + wantIDToken: true, + }, + { + name: "With openid and profile scope includes username", + clientID: "test", + clientSecret: "barfoo", + scopes: "openid profile", + wantCode: 200, + wantAccessTok: true, + wantIDToken: true, + wantUsername: "Test Client", + }, + { + name: "With openid email profile groups", + clientID: "test", + clientSecret: "barfoo", + scopes: "openid email profile groups", + wantCode: 200, + wantAccessTok: true, + wantIDToken: true, + wantUsername: "Test Client", + }, + { + name: "Invalid client secret", + clientID: "test", + clientSecret: "wrong", + scopes: "", + wantCode: 401, + }, + { + name: "Unknown client", + clientID: "nonexistent", + clientSecret: "secret", + scopes: "", + wantCode: 401, + }, + { + name: "offline_access scope rejected", + clientID: "test", + clientSecret: "barfoo", + scopes: "openid offline_access", + wantCode: 400, + }, + { + name: "Unrecognized scope", + clientID: "test", + clientSecret: "barfoo", + scopes: "openid bogus", + wantCode: 400, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + + httpServer, s := newTestServer(t, func(c *Config) { + c.Now = time.Now + }) + defer httpServer.Close() + + // Create a confidential client for testing. + err := s.storage.CreateClient(ctx, storage.Client{ + ID: "test", + Secret: "barfoo", + RedirectURIs: []string{"https://example.com/callback"}, + Name: "Test Client", + }) + require.NoError(t, err) + + u, err := url.Parse(s.issuerURL.String()) + require.NoError(t, err) + u.Path = path.Join(u.Path, "/token") + + v := url.Values{} + v.Add("grant_type", "client_credentials") + if tc.scopes != "" { + v.Add("scope", tc.scopes) + } + + req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(v.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(tc.clientID, tc.clientSecret) + + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + require.Equal(t, tc.wantCode, rr.Code) + + if tc.wantCode == 200 { + var resp struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + } + err := json.Unmarshal(rr.Body.Bytes(), &resp) + require.NoError(t, err) + + if tc.wantAccessTok { + require.NotEmpty(t, resp.AccessToken) + require.Equal(t, "bearer", resp.TokenType) + require.Greater(t, resp.ExpiresIn, 0) + } + if tc.wantIDToken { + require.NotEmpty(t, resp.IDToken) + + // Verify the ID token claims. + provider, err := oidc.NewProvider(ctx, httpServer.URL) + require.NoError(t, err) + verifier := provider.Verifier(&oidc.Config{ClientID: tc.clientID}) + idToken, err := verifier.Verify(ctx, resp.IDToken) + require.NoError(t, err) + + // Decode the subject to verify the connector ID. + var sub internal.IDTokenSubject + require.NoError(t, internal.Unmarshal(idToken.Subject, &sub)) + require.Equal(t, "", sub.ConnId) + require.Equal(t, tc.clientID, sub.UserId) + + var claims struct { + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + } + require.NoError(t, idToken.Claims(&claims)) + + if tc.wantUsername != "" { + require.Equal(t, tc.wantUsername, claims.Name) + require.Equal(t, tc.wantUsername, claims.PreferredUsername) + } else { + require.Empty(t, claims.Name) + require.Empty(t, claims.PreferredUsername) + } + } else { + require.Empty(t, resp.IDToken) + } + // client_credentials must never return a refresh token. + require.Empty(t, resp.RefreshToken) + } + }) + } +} + func TestHandleConnectorCallbackWithSkipApproval(t *testing.T) { ctx := t.Context() diff --git a/server/oauth2.go b/server/oauth2.go index 5821f0ff..8fe75475 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -143,6 +143,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 ea87aa1c..b7c08c64 100644 --- a/server/server.go +++ b/server/server.go @@ -254,6 +254,8 @@ func newServer(ctx context.Context, c Config) (*Server, error) { allSupportedGrants[grantTypePassword] = true } + allSupportedGrants[grantTypeClientCredentials] = true + var supportedGrants []string if len(c.AllowedGrantTypes) > 0 { for _, grant := range c.AllowedGrantTypes { diff --git a/server/server_test.go b/server/server_test.go index e61e21ab..db8f12ce 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -103,6 +103,7 @@ func newTestServer(t *testing.T, updateConfig func(c *Config)) (*httptest.Server AllowedGrantTypes: []string{ // all implemented types grantTypeDeviceCode, grantTypeAuthorizationCode, + grantTypeClientCredentials, grantTypeRefreshToken, grantTypeTokenExchange, grantTypeImplicit, @@ -1774,7 +1775,7 @@ func TestServerSupportedGrants(t *testing.T) { { name: "Simple", config: func(c *Config) {}, - resGrants: []string{grantTypeAuthorizationCode, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, + resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, }, { name: "Minimal", @@ -1782,14 +1783,30 @@ func TestServerSupportedGrants(t *testing.T) { resGrants: []string{grantTypeTokenExchange}, }, { - name: "With password connector", - config: func(c *Config) { c.PasswordConnector = "local" }, - resGrants: []string{grantTypeAuthorizationCode, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, + name: "With password connector", + config: func(c *Config) { + c.PasswordConnector = "local" + }, + resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, }, { - name: "With token response", - config: func(c *Config) { c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken) }, - resGrants: []string{grantTypeAuthorizationCode, grantTypeImplicit, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, + name: "Without client credentials", + config: func(c *Config) { + c.AllowedGrantTypes = []string{ + grantTypeAuthorizationCode, + grantTypeRefreshToken, + grantTypeDeviceCode, + grantTypeTokenExchange, + } + }, + resGrants: []string{grantTypeAuthorizationCode, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, + }, + { + name: "With token response", + config: func(c *Config) { + c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken) + }, + resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypeImplicit, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, }, { name: "All", @@ -1797,7 +1814,7 @@ func TestServerSupportedGrants(t *testing.T) { c.PasswordConnector = "local" c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken) }, - resGrants: []string{grantTypeAuthorizationCode, grantTypeImplicit, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, + resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypeImplicit, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, }, }