Skip to content

Commit 4fdd0e3

Browse files
feat: add client_credentials from dexidp#4583
Signed-off-by: Houssem Ben Mabrouk <houssem.benmabrouk.ext@orange.com>
1 parent 0301826 commit 4fdd0e3

8 files changed

Lines changed: 305 additions & 4 deletions

File tree

cmd/dex/serve.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,10 @@ func applyConfigOverrides(options serveOptions, config *Config) {
617617
"urn:ietf:params:oauth:grant-type:token-exchange",
618618
}
619619
}
620+
621+
if featureflags.ClientCredentialGrantEnabledByDefault.Enabled() {
622+
config.OAuth2.GrantTypes = append(config.OAuth2.GrantTypes, "client_credentials")
623+
}
620624
}
621625

622626
func pprofHandler(router *http.ServeMux) {

examples/config-dev.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ telemetry:
104104
# grantTypes determines the allowed set of authorization flows.
105105
# grantTypes:
106106
# - "authorization_code"
107+
# - "client_credentials"
107108
# - "refresh_token"
108109
# - "implicit"
109110
# - "password"

pkg/featureflags/set.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,8 @@ var (
1414

1515
// ContinueOnConnectorFailure allows the server to start even if some connectors fail to initialize.
1616
ContinueOnConnectorFailure = newFlag("continue_on_connector_failure", true)
17+
18+
// ClientCredentialGrantEnabledByDefault enables the client_credentials grant type by default
19+
// without requiring explicit configuration in oauth2.grantTypes.
20+
ClientCredentialGrantEnabledByDefault = newFlag("client_credential_grant_enabled_by_default", false)
1721
)

server/handlers.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,8 @@ func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
889889
s.withClientFromStorage(w, r, s.handlePasswordGrant)
890890
case grantTypeTokenExchange:
891891
s.withClientFromStorage(w, r, s.handleTokenExchange)
892+
case grantTypeClientCredentials:
893+
s.withClientFromStorage(w, r, s.handleClientCredentialsGrant)
892894
default:
893895
s.tokenErrHelper(w, errUnsupportedGrantType, "", http.StatusBadRequest)
894896
}
@@ -1461,6 +1463,108 @@ func (s *Server) handleTokenExchange(w http.ResponseWriter, r *http.Request, cli
14611463
json.NewEncoder(w).Encode(resp)
14621464
}
14631465

1466+
func (s *Server) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request, client storage.Client) {
1467+
ctx := r.Context()
1468+
1469+
// client_credentials requires a confidential client.
1470+
if client.Public {
1471+
s.tokenErrHelper(w, errUnauthorizedClient, "Public clients cannot use client_credentials grant.", http.StatusBadRequest)
1472+
return
1473+
}
1474+
1475+
// Parse scopes from request.
1476+
if err := r.ParseForm(); err != nil {
1477+
s.tokenErrHelper(w, errInvalidRequest, "Couldn't parse data", http.StatusBadRequest)
1478+
return
1479+
}
1480+
scopes := strings.Fields(r.Form.Get("scope"))
1481+
1482+
// Validate scopes.
1483+
var (
1484+
unrecognized []string
1485+
invalidScopes []string
1486+
)
1487+
hasOpenIDScope := false
1488+
for _, scope := range scopes {
1489+
switch scope {
1490+
case scopeOpenID:
1491+
hasOpenIDScope = true
1492+
case scopeEmail, scopeProfile, scopeGroups:
1493+
// allowed
1494+
case scopeOfflineAccess:
1495+
s.tokenErrHelper(w, errInvalidScope, "client_credentials grant does not support offline_access scope.", http.StatusBadRequest)
1496+
return
1497+
case scopeFederatedID:
1498+
s.tokenErrHelper(w, errInvalidScope, "client_credentials grant does not support federated:id scope.", http.StatusBadRequest)
1499+
return
1500+
default:
1501+
peerID, ok := parseCrossClientScope(scope)
1502+
if !ok {
1503+
unrecognized = append(unrecognized, scope)
1504+
continue
1505+
}
1506+
1507+
isTrusted, err := s.validateCrossClientTrust(ctx, client.ID, peerID)
1508+
if err != nil {
1509+
s.logger.ErrorContext(ctx, "error validating cross client trust", "client_id", client.ID, "peer_id", peerID, "err", err)
1510+
s.tokenErrHelper(w, errInvalidClient, "Error validating cross client trust.", http.StatusBadRequest)
1511+
return
1512+
}
1513+
if !isTrusted {
1514+
invalidScopes = append(invalidScopes, scope)
1515+
}
1516+
}
1517+
}
1518+
if len(unrecognized) > 0 {
1519+
s.tokenErrHelper(w, errInvalidScope, fmt.Sprintf("Unrecognized scope(s) %q", unrecognized), http.StatusBadRequest)
1520+
return
1521+
}
1522+
if len(invalidScopes) > 0 {
1523+
s.tokenErrHelper(w, errInvalidScope, fmt.Sprintf("Client can't request scope(s) %q", invalidScopes), http.StatusBadRequest)
1524+
return
1525+
}
1526+
1527+
// Build claims from the client itself — no user involved.
1528+
claims := storage.Claims{
1529+
UserID: client.ID,
1530+
}
1531+
1532+
// Only populate Username/PreferredUsername when the profile scope is requested.
1533+
for _, scope := range scopes {
1534+
if scope == scopeProfile {
1535+
claims.Username = client.Name
1536+
claims.PreferredUsername = client.Name
1537+
break
1538+
}
1539+
}
1540+
1541+
nonce := r.Form.Get("nonce")
1542+
1543+
// Empty connector ID is unique for cluster credentials grant
1544+
// Creating connectors with an empty ID with the config and API is prohibited
1545+
connID := ""
1546+
1547+
accessToken, expiry, err := s.newAccessToken(ctx, client.ID, claims, scopes, nonce, connID)
1548+
if err != nil {
1549+
s.logger.ErrorContext(ctx, "client_credentials grant failed to create new access token", "err", err)
1550+
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
1551+
return
1552+
}
1553+
1554+
var idToken string
1555+
if hasOpenIDScope {
1556+
idToken, expiry, err = s.newIDToken(ctx, client.ID, claims, scopes, nonce, accessToken, "", connID)
1557+
if err != nil {
1558+
s.logger.ErrorContext(ctx, "client_credentials grant failed to create new ID token", "err", err)
1559+
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
1560+
return
1561+
}
1562+
}
1563+
1564+
resp := s.toAccessTokenResponse(idToken, accessToken, "", expiry)
1565+
s.writeAccessToken(w, resp)
1566+
}
1567+
14641568
type accessTokenResponse struct {
14651569
AccessToken string `json:"access_token"`
14661570
IssuedTokenType string `json:"issued_token_type,omitempty"`

server/handlers_test.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"golang.org/x/crypto/bcrypt"
2222
"golang.org/x/oauth2"
2323

24+
"github.com/dexidp/dex/server/internal"
2425
"github.com/dexidp/dex/storage"
2526
)
2627

@@ -62,6 +63,7 @@ func TestHandleDiscovery(t *testing.T) {
6263
Introspect: fmt.Sprintf("%s/token/introspect", httpServer.URL),
6364
GrantTypes: []string{
6465
"authorization_code",
66+
"client_credentials",
6567
"refresh_token",
6668
"urn:ietf:params:oauth:grant-type:device_code",
6769
"urn:ietf:params:oauth:grant-type:token-exchange",
@@ -645,6 +647,176 @@ func TestHandlePasswordLoginWithSkipApproval(t *testing.T) {
645647
}
646648
}
647649

650+
func TestHandleClientCredentials(t *testing.T) {
651+
tests := []struct {
652+
name string
653+
clientID string
654+
clientSecret string
655+
scopes string
656+
wantCode int
657+
wantAccessTok bool
658+
wantIDToken bool
659+
wantUsername string
660+
}{
661+
{
662+
name: "Basic grant, no scopes",
663+
clientID: "test",
664+
clientSecret: "barfoo",
665+
scopes: "",
666+
wantCode: 200,
667+
wantAccessTok: true,
668+
wantIDToken: false,
669+
},
670+
{
671+
name: "With openid scope",
672+
clientID: "test",
673+
clientSecret: "barfoo",
674+
scopes: "openid",
675+
wantCode: 200,
676+
wantAccessTok: true,
677+
wantIDToken: true,
678+
},
679+
{
680+
name: "With openid and profile scope includes username",
681+
clientID: "test",
682+
clientSecret: "barfoo",
683+
scopes: "openid profile",
684+
wantCode: 200,
685+
wantAccessTok: true,
686+
wantIDToken: true,
687+
wantUsername: "Test Client",
688+
},
689+
{
690+
name: "With openid email profile groups",
691+
clientID: "test",
692+
clientSecret: "barfoo",
693+
scopes: "openid email profile groups",
694+
wantCode: 200,
695+
wantAccessTok: true,
696+
wantIDToken: true,
697+
wantUsername: "Test Client",
698+
},
699+
{
700+
name: "Invalid client secret",
701+
clientID: "test",
702+
clientSecret: "wrong",
703+
scopes: "",
704+
wantCode: 401,
705+
},
706+
{
707+
name: "Unknown client",
708+
clientID: "nonexistent",
709+
clientSecret: "secret",
710+
scopes: "",
711+
wantCode: 401,
712+
},
713+
{
714+
name: "offline_access scope rejected",
715+
clientID: "test",
716+
clientSecret: "barfoo",
717+
scopes: "openid offline_access",
718+
wantCode: 400,
719+
},
720+
{
721+
name: "Unrecognized scope",
722+
clientID: "test",
723+
clientSecret: "barfoo",
724+
scopes: "openid bogus",
725+
wantCode: 400,
726+
},
727+
}
728+
for _, tc := range tests {
729+
t.Run(tc.name, func(t *testing.T) {
730+
ctx := t.Context()
731+
732+
httpServer, s := newTestServer(t, func(c *Config) {
733+
c.Now = time.Now
734+
})
735+
defer httpServer.Close()
736+
737+
// Create a confidential client for testing.
738+
err := s.storage.CreateClient(ctx, storage.Client{
739+
ID: "test",
740+
Secret: "barfoo",
741+
RedirectURIs: []string{"https://example.com/callback"},
742+
Name: "Test Client",
743+
})
744+
require.NoError(t, err)
745+
746+
u, err := url.Parse(s.issuerURL.String())
747+
require.NoError(t, err)
748+
u.Path = path.Join(u.Path, "/token")
749+
750+
v := url.Values{}
751+
v.Add("grant_type", "client_credentials")
752+
if tc.scopes != "" {
753+
v.Add("scope", tc.scopes)
754+
}
755+
756+
req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(v.Encode()))
757+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
758+
req.SetBasicAuth(tc.clientID, tc.clientSecret)
759+
760+
rr := httptest.NewRecorder()
761+
s.ServeHTTP(rr, req)
762+
763+
require.Equal(t, tc.wantCode, rr.Code)
764+
765+
if tc.wantCode == 200 {
766+
var resp struct {
767+
AccessToken string `json:"access_token"`
768+
TokenType string `json:"token_type"`
769+
ExpiresIn int `json:"expires_in"`
770+
IDToken string `json:"id_token"`
771+
RefreshToken string `json:"refresh_token"`
772+
}
773+
err := json.Unmarshal(rr.Body.Bytes(), &resp)
774+
require.NoError(t, err)
775+
776+
if tc.wantAccessTok {
777+
require.NotEmpty(t, resp.AccessToken)
778+
require.Equal(t, "bearer", resp.TokenType)
779+
require.Greater(t, resp.ExpiresIn, 0)
780+
}
781+
if tc.wantIDToken {
782+
require.NotEmpty(t, resp.IDToken)
783+
784+
// Verify the ID token claims.
785+
provider, err := oidc.NewProvider(ctx, httpServer.URL)
786+
require.NoError(t, err)
787+
verifier := provider.Verifier(&oidc.Config{ClientID: tc.clientID})
788+
idToken, err := verifier.Verify(ctx, resp.IDToken)
789+
require.NoError(t, err)
790+
791+
// Decode the subject to verify the connector ID.
792+
var sub internal.IDTokenSubject
793+
require.NoError(t, internal.Unmarshal(idToken.Subject, &sub))
794+
require.Equal(t, "", sub.ConnId)
795+
require.Equal(t, tc.clientID, sub.UserId)
796+
797+
var claims struct {
798+
Name string `json:"name"`
799+
PreferredUsername string `json:"preferred_username"`
800+
}
801+
require.NoError(t, idToken.Claims(&claims))
802+
803+
if tc.wantUsername != "" {
804+
require.Equal(t, tc.wantUsername, claims.Name)
805+
require.Equal(t, tc.wantUsername, claims.PreferredUsername)
806+
} else {
807+
require.Empty(t, claims.Name)
808+
require.Empty(t, claims.PreferredUsername)
809+
}
810+
} else {
811+
require.Empty(t, resp.IDToken)
812+
}
813+
// client_credentials must never return a refresh token.
814+
require.Empty(t, resp.RefreshToken)
815+
}
816+
})
817+
}
818+
}
819+
648820
func TestHandleConnectorCallbackWithSkipApproval(t *testing.T) {
649821
ctx := t.Context()
650822

server/oauth2.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ const (
143143
grantTypePassword = "password"
144144
grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
145145
grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
146+
grantTypeClientCredentials = "client_credentials"
146147
)
147148

148149
const (

server/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ func newServer(ctx context.Context, c Config) (*Server, error) {
254254
allSupportedGrants[grantTypePassword] = true
255255
}
256256

257+
allSupportedGrants[grantTypeClientCredentials] = true
258+
257259
var supportedGrants []string
258260
if len(c.AllowedGrantTypes) > 0 {
259261
for _, grant := range c.AllowedGrantTypes {

0 commit comments

Comments
 (0)