From b652d5b2a0177a91790c5812d72b026463fc2a78 Mon Sep 17 00:00:00 2001 From: Mathias Gebbe Date: Wed, 25 Feb 2026 17:49:31 +0100 Subject: [PATCH] fix(oauth2): scope-conditional claims and reserved connector ID for client_credentials Signed-off-by: Mathias Gebbe --- server/handlers.go | 15 +++++++++--- server/handlers_test.go | 54 +++++++++++++++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/server/handlers.go b/server/handlers.go index f5386201..eac2c945 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -1526,12 +1526,19 @@ func (s *Server) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Req // Build claims from the client itself — no user involved. claims := storage.Claims{ - UserID: client.ID, - Username: client.Name, - PreferredUsername: client.Name, + UserID: client.ID, } - connID := "client_credentials" + // 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 + } + } + + connID := "__client_credentials" accessToken, expiry, err := s.newAccessToken(ctx, client.ID, claims, scopes, "", connID) if err != nil { diff --git a/server/handlers_test.go b/server/handlers_test.go index 7226510b..58018920 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -21,6 +21,7 @@ import ( "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" + "github.com/dexidp/dex/server/internal" "github.com/dexidp/dex/storage" ) @@ -648,13 +649,14 @@ 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 string + clientID string + clientSecret string + scopes string + wantCode int + wantAccessTok bool + wantIDToken bool + wantUsername string }{ { name: "Basic grant, no scopes", @@ -674,6 +676,16 @@ func TestHandleClientCredentials(t *testing.T) { 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", @@ -682,6 +694,7 @@ func TestHandleClientCredentials(t *testing.T) { wantCode: 200, wantAccessTok: true, wantIDToken: true, + wantUsername: "Test Client", }, { name: "Invalid client secret", @@ -767,6 +780,33 @@ func TestHandleClientCredentials(t *testing.T) { } 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, "__client_credentials", 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) }