diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 8861ddef..3b10335e 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -161,6 +161,8 @@ type OAuth2 struct { AlwaysShowLoginScreen bool `json:"alwaysShowLoginScreen"` // This is the connector that can be used for password grant PasswordConnector string `json:"passwordConnector"` + // If enabled, the server will support the client_credentials grant type + ClientCredentialsEnabled bool `json:"clientCredentialsEnabled"` } // Web is the config format for the HTTP server. diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 54d150af..74141158 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -279,6 +279,9 @@ func runServe(options serveOptions) error { if c.OAuth2.PasswordConnector != "" { logger.Info("config using password grant connector", "password_connector", c.OAuth2.PasswordConnector) } + if c.OAuth2.ClientCredentialsEnabled { + logger.Info("config client credentials grant enabled") + } if len(c.Web.AllowedOrigins) > 0 { logger.Info("config allowed origins", "origins", c.Web.AllowedOrigins) } @@ -356,6 +359,7 @@ func runServe(options serveOptions) error { SkipApprovalScreen: c.OAuth2.SkipApprovalScreen, AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen, PasswordConnector: c.OAuth2.PasswordConnector, + ClientCredentialsEnabled: c.OAuth2.ClientCredentialsEnabled, Headers: c.Web.Headers.ToHTTPHeader(), AllowedOrigins: c.Web.AllowedOrigins, AllowedHeaders: c.Web.AllowedHeaders, diff --git a/server/handlers.go b/server/handlers.go index 62f1650c..f5386201 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) } @@ -1461,6 +1463,97 @@ 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, + Username: client.Name, + PreferredUsername: client.Name, + } + + connID := "client_credentials" + + accessToken, expiry, err := s.newAccessToken(ctx, client.ID, claims, scopes, "", 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, "", 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 0514d85c..7226510b 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -62,6 +62,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", @@ -645,6 +646,137 @@ 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 + }{ + { + 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 email profile groups", + clientID: "test", + clientSecret: "barfoo", + scopes: "openid email profile groups", + wantCode: 200, + wantAccessTok: true, + wantIDToken: true, + }, + { + 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) + } 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..a55affe4 100644 --- a/server/oauth2.go +++ b/server/oauth2.go @@ -141,8 +141,9 @@ const ( grantTypeRefreshToken = "refresh_token" grantTypeImplicit = "implicit" grantTypePassword = "password" - grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" - grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" + 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 e923e3e0..c4b05911 100644 --- a/server/server.go +++ b/server/server.go @@ -106,6 +106,9 @@ type Config struct { // If set, the server will use this connector to handle password grants PasswordConnector string + // If enabled, the server will support the client_credentials grant type + ClientCredentialsEnabled bool + GCFrequency time.Duration // Defaults to 5 minutes // If specified, the server will use this function for determining time. @@ -254,6 +257,10 @@ func newServer(ctx context.Context, c Config) (*Server, error) { allSupportedGrants[grantTypePassword] = true } + if c.ClientCredentialsEnabled { + 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 5a735f1d..ba53a51b 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -99,7 +99,8 @@ func newTestServer(t *testing.T, updateConfig func(c *Config)) (*httptest.Server Logger: logger, PrometheusRegistry: prometheus.NewRegistry(), HealthChecker: gosundheit.New(), - SkipApprovalScreen: true, // Don't prompt for approval, just immediately redirect with code. + SkipApprovalScreen: true, // Don't prompt for approval, just immediately redirect with code. + ClientCredentialsEnabled: true, AllowedGrantTypes: []string{ // all implemented types grantTypeDeviceCode, grantTypeAuthorizationCode, @@ -107,6 +108,7 @@ func newTestServer(t *testing.T, updateConfig func(c *Config)) (*httptest.Server grantTypeTokenExchange, grantTypeImplicit, grantTypePassword, + grantTypeClientCredentials, }, Signer: sig, } @@ -1773,7 +1775,7 @@ func TestServerSupportedGrants(t *testing.T) { }{ { name: "Simple", - config: func(c *Config) {}, + config: func(c *Config) { c.ClientCredentialsEnabled = false }, resGrants: []string{grantTypeAuthorizationCode, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, }, { @@ -1782,13 +1784,24 @@ func TestServerSupportedGrants(t *testing.T) { resGrants: []string{grantTypeTokenExchange}, }, { - name: "With password connector", - config: func(c *Config) { c.PasswordConnector = "local" }, + name: "With password connector", + config: func(c *Config) { + c.ClientCredentialsEnabled = false + c.PasswordConnector = "local" + }, resGrants: []string{grantTypeAuthorizationCode, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, }, { - name: "With token response", - config: func(c *Config) { c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken) }, + name: "With client credentials", + config: func(c *Config) {}, + resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, + }, + { + name: "With token response", + config: func(c *Config) { + c.ClientCredentialsEnabled = false + c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken) + }, resGrants: []string{grantTypeAuthorizationCode, grantTypeImplicit, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange}, }, { @@ -1797,7 +1810,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}, }, }