Skip to content

feat(oauth2): add client credentials flow with opt-in config flag#4583

Open
matzegebbe wants to merge 15 commits intodexidp:masterfrom
matzegebbe:feat/add-client-credential-flow
Open

feat(oauth2): add client credentials flow with opt-in config flag#4583
matzegebbe wants to merge 15 commits intodexidp:masterfrom
matzegebbe:feat/add-client-credential-flow

Conversation

@matzegebbe
Copy link

Overview

Add OAuth2 client_credentials grant type support, gated behind an opt-in clientCredentialsEnabled config flag.

What this PR does / why we need it

Dex has no built-in support for the client_credentials grant, which is the standard OAuth2 flow (RFC 6749 Section 4.4) for service-to-service authentication where no end-user is involved. This has been a long-standing community request.

This PR implements the grant and gates it behind a new clientCredentialsEnabled config flag that defaults to false, following the same pattern used by passwordConnector for the password grant. This ensures no behavior change for existing deployments.

Grant behavior:

  • Requires a confidential client (public clients are rejected)
  • Authenticates via client ID + secret (HTTP Basic auth)
  • Supports openid, email, profile, groups scopes
  • Returns an ID token when openid scope is requested
  • Rejects offline_access and federated:id scopes (no refresh tokens for M2M)
  • Claims are derived from the client itself: aud = client ID, sub = encoded client ID + connector ID, name/preferred_username = client name (with profile scope)

Configuration:

oauth2:
  clientCredentialsEnabled: true

Security considerations:

  • Opt-in only: the grant is never exposed unless explicitly enabled
  • Confidential clients only: public clients cannot use this grant
  • No refresh tokens: offline_access scope is rejected, limiting token lifetime
  • Service identity: the sub claim is the client ID, not a user

Testing:

  • go build ./... compiles cleanly
  • go test ./server/... -run TestHandleClientCredentials — 7 cases pass
  • go test ./server/... -run TestServerSupportedGrants — 6 cases pass
  • go test ./server/... -run TestHandleDiscovery — passes
  • go test ./server/... — full server suite passes
  • Manual test: start dex with clientCredentialsEnabled: true, request token via curl

Manual verification:

  1. Enable the flag in examples/config-dev.yaml:
oauth2:
  clientCredentialsEnabled: true
  1. Build and run dex:
go run ./cmd/dex serve examples/config-dev.yaml
  1. Request an access token:
curl -X POST http://127.0.0.1:5556/dex/token \
  -u "example-app:ZXhhbXBsZS1hcHAtc2VjcmV0" \
  -d "grant_type=client_credentials"
  1. Request an access token + ID token:
curl -X POST http://127.0.0.1:5556/dex/token \
  -u "example-app:ZXhhbXBsZS1hcHAtc2VjcmV0" \
  -d "grant_type=client_credentials&scope=openid+profile"

Closes #3660

The gating mechanism works the same way as the password grant: client_credentials is included in the default allowed grant types list, but only gets added to allSupportedGrants (and thus passes the intersection filter in newServer()) when ClientCredentialsEnabled is true. Without the flag, the grant type is silently filtered out.

@matzegebbe matzegebbe force-pushed the feat/add-client-credential-flow branch 3 times, most recently from 370999f to fab39be Compare February 25, 2026 13:43
Copy link
Member

@nabokihms nabokihms left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello! There are some points to resolve before merging.

// 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"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have GrantTypes option. How is this option different? How do they coexist?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. Having both is redundant. I initially considered adding a dedicated feature flag (DEX_CLIENT_CREDENTIALS_ENABLED) as a hard kill-switch, but that would mean operators need to configure two things (env var + grantTypes), which is unnecessarily complex.

Instead I have removed the ClientCredentialsEnabled config option entirely and now rely on the existing grantTypes mechanism. client_credentials is always supported by the server (added to allSupportedGrants), but it's not included in the default grantTypes list. Operators opt in by explicitly adding "client_credentials" to their grantTypes config - the same way configure any other grant type.

This keeps gating simple and consistent with how other grants work.

cmd/dex/serve.go Outdated
if len(config.OAuth2.GrantTypes) == 0 {
config.OAuth2.GrantTypes = []string{
"authorization_code",
"client_credentials",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can disable this by default and add a feature flag to enable it by default instead of a new option.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I have removed client_credentials from the default grantTypes list in applyConfigOverrides(). The grant is now always supported but never active unless the operator explicitly lists it in their config

claims := storage.Claims{
UserID: client.ID,
Username: client.Name,
PreferredUsername: client.Name,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this valid for client credentials?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point for a pure M2M flow there is no "user", so unconditionally populating Username and PreferredUsername is misleading. I've changed this to be scope-conditional:

  • UserID is always set to client.ID (this is the service identity).
  • Username and PreferredUsername are only set to client.Name when the profile scope is explicitly requested.

This follows the OIDC spec pattern where profile claims are tied to the profile scope. If a client just requests openid, they get sub (the client ID encoded with the connector ID) but no name claims.

If you do not like it we can remove it; During my testing I though it can be handy to have the name of the client

PreferredUsername: client.Name,
}

connID := "client_credentials"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a weak spot. The value must be reserved. Otherwise it will overlap with connectors.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoopsie. True. A user could create a connector with ID "client_credentials" and break the assumption. I've addressed this in two ways:

  1. Changed the connector ID to "__client_credentials" (double-underscore prefix). This is a reserved namespace for internal use.

  2. Added validation in CreateConnector (gRPC API) that rejects any connector ID starting with __

@nabokihms nabokihms added the release-note/new-feature Release note: Exciting New Features label Feb 25, 2026
allSupportedGrants[grantTypePassword] = true
}

allSupportedGrants[grantTypeClientCredentials] = true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My idea to wrap it with a feature gate is that we open an attack surface by introducing the new grant.
I'd like to do this in three steps:

  1. Introduce the feature flag smth like client_credential_grant_enabled_by_default with the false default in the next dex release
  2. After one or two releases, set the default to true (which allows users to still make it false easily via the feature flag without changing their configs).
  3. Remove the feature flag.

It gives users a grace period between 2 and 3.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fully agree on the phased rollout and I can sleep way better :D

Added DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT (default false) in pkg/featureflags/set.go. The client_credentials grant is always registered in allSupportedGrants so it can be explicitly enabled via oauth2.grantTypes in config, but the default code path (no explicit grant config) now skips it unless the feature flag is set to true.

  1. Start dex without the feature flag (default behavior):

    go run ./cmd/dex/ serve examples/config-dev.yaml

    Check the discovery endpoint — client_credentials should not appear in grant_types_supported:

    curl -s http://127.0.0.1:5556/dex/.well-known/openid-configuration | jq .grant_types_supported
  2. Start dex with the feature flag:

    DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true go run ./cmd/dex/ serve examples/config-dev.yaml

    client_credentialsshould now appear ingrant_types_supported`.

     curl -s http://127.0.0.1:5556/dex/.well-known/openid-configuration | jq .grant_types_supported
     [
     "authorization_code",
     "client_credentials",
     "refresh_token",
     "urn:ietf:params:oauth:grant-type:device_code",
     "urn:ietf:params:oauth:grant-type:token-exchange"
     ]


connID := "__client_credentials"

accessToken, expiry, err := s.newAccessToken(ctx, client.ID, claims, scopes, "", connID)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nonce is missed here, should be retrieved from the form, I guess

Somthing like this nonce := q.Get("nonce")

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

Good catch, fixed. The nonce is now read from the form via r.Form.Get("nonce") and passed to both newAccessToken and newIDToken.

server/api.go Outdated
return nil, errors.New("no id supplied")
}

if strings.HasPrefix(req.Connector.Id, "__") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Connectors can be registered through the config file, so we also need a validation on config parsing.

		if c.ID == "" || c.Name == "" || c.Type == "" {
			return fmt.Errorf("invalid config: ID, Type and Name fields are required for a connector")
		}

I started thinking that maybe leaving the connector ID empty is the better way instead of using a special value.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, using an empty connector ID is cleaner and avoids the need for reserved prefix validation entirely. Changed connID from "__client_credentials" to "" in the handler, removed the __ prefix check from CreateConnector in api.go (along with the now-unused strings import), and removed the associated test.

@matzegebbe matzegebbe force-pushed the feat/add-client-credential-flow branch 2 times, most recently from 0befb94 to 0b64c08 Compare February 25, 2026 20:11
matzegebbe and others added 15 commits February 25, 2026 22:52
Implement the OAuth2 client_credentials grant type for
machine-to-machine authentication. The grant is gated behind a new
clientCredentialsEnabled config flag (defaults to false), following
the same pattern as passwordConnector for the password grant.

Closes dexidp#3660

Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
The gating happens via allSupportedGrants in server.go, not via the
allowed list. Without client_credentials in the defaults, the
intersection filter always excluded it even with the flag enabled.
This matches how the password grant works: present in defaults but
only activated when the corresponding config flag is set.

Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
…onfig flag

Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
…lient_credentials

Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
The __client_credentials connector ID is no longer used since the
client_credentials grant now uses an empty connector ID. Remove the
__ prefix validation from CreateConnector and its associated test.

Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
…ials

Use an empty connector ID instead of __client_credentials to avoid
requiring reserved ID validation. Read the nonce parameter from the
token request and forward it to newAccessToken and newIDToken.

Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
Add DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT feature flag
(default false) so client_credentials is not advertised by default.
Users can still explicitly enable it via oauth2.grantTypes config.
The flag will be flipped to true in a future release before removal.

Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
…lag is enabled

The serve command sets a default grantTypes list when none is configured,
which meant AllowedGrantTypes was never empty and the feature flag check
in server.go was bypassed. Append client_credentials to the default list
when DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT is true.

Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
The feature flag check in the else branch of server.go is dead code
since serve.go always sets a default AllowedGrantTypes list. Move the
gate entirely to cmd/dex/serve.go and remove the unused featureflags
import. Restore server_test.go to match server.go behavior directly.

Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
@matzegebbe matzegebbe force-pushed the feat/add-client-credential-flow branch from 40f46d7 to 2dcd9b9 Compare February 25, 2026 21:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-note/new-feature Release note: Exciting New Features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement client credentials flow

2 participants