feat(oauth2): add client credentials flow with opt-in config flag#4583
feat(oauth2): add client credentials flow with opt-in config flag#4583matzegebbe wants to merge 15 commits intodexidp:masterfrom
Conversation
370999f to
fab39be
Compare
nabokihms
left a comment
There was a problem hiding this comment.
Hello! There are some points to resolve before merging.
cmd/dex/config.go
Outdated
| // 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"` |
There was a problem hiding this comment.
We already have GrantTypes option. How is this option different? How do they coexist?
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
We can disable this by default and add a feature flag to enable it by default instead of a new option.
There was a problem hiding this comment.
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
server/handlers.go
Outdated
| claims := storage.Claims{ | ||
| UserID: client.ID, | ||
| Username: client.Name, | ||
| PreferredUsername: client.Name, |
There was a problem hiding this comment.
Is this valid for client credentials?
There was a problem hiding this comment.
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:
UserIDis always set toclient.ID(this is the service identity).UsernameandPreferredUsernameare only set toclient.Namewhen theprofilescope 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
server/handlers.go
Outdated
| PreferredUsername: client.Name, | ||
| } | ||
|
|
||
| connID := "client_credentials" |
There was a problem hiding this comment.
This is a weak spot. The value must be reserved. Otherwise it will overlap with connectors.
There was a problem hiding this comment.
Whoopsie. True. A user could create a connector with ID "client_credentials" and break the assumption. I've addressed this in two ways:
-
Changed the connector ID to
"__client_credentials"(double-underscore prefix). This is a reserved namespace for internal use. -
Added validation in
CreateConnector(gRPC API) that rejects any connector ID starting with__
| allSupportedGrants[grantTypePassword] = true | ||
| } | ||
|
|
||
| allSupportedGrants[grantTypeClientCredentials] = true |
There was a problem hiding this comment.
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:
- Introduce the feature flag smth like
client_credential_grant_enabled_by_defaultwith the false default in the next dex release - 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).
- Remove the feature flag.
It gives users a grace period between 2 and 3.
There was a problem hiding this comment.
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.
-
Start dex without the feature flag (default behavior):
go run ./cmd/dex/ serve examples/config-dev.yaml
Check the discovery endpoint —
client_credentialsshould not appear ingrant_types_supported:curl -s http://127.0.0.1:5556/dex/.well-known/openid-configuration | jq .grant_types_supported -
Start dex with the feature flag:
DEX_CLIENT_CREDENTIAL_GRANT_ENABLED_BY_DEFAULT=true go run ./cmd/dex/ serve examples/config-dev.yaml
client_credentials
should 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" ]
server/handlers.go
Outdated
|
|
||
| connID := "__client_credentials" | ||
|
|
||
| accessToken, expiry, err := s.newAccessToken(ctx, client.ID, claims, scopes, "", connID) |
There was a problem hiding this comment.
nonce is missed here, should be retrieved from the form, I guess
Somthing like this nonce := q.Get("nonce")
server/api.go
Outdated
| return nil, errors.New("no id supplied") | ||
| } | ||
|
|
||
| if strings.HasPrefix(req.Connector.Id, "__") { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
0befb94 to
0b64c08
Compare
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>
40f46d7 to
2dcd9b9
Compare

Overview
Add OAuth2
client_credentialsgrant type support, gated behind an opt-inclientCredentialsEnabledconfig flag.What this PR does / why we need it
Dex has no built-in support for the
client_credentialsgrant, 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
clientCredentialsEnabledconfig flag that defaults tofalse, following the same pattern used bypasswordConnectorfor thepasswordgrant. This ensures no behavior change for existing deployments.Grant behavior:
openid,email,profile,groupsscopesopenidscope is requestedoffline_accessandfederated:idscopes (no refresh tokens for M2M)aud= client ID,sub= encoded client ID + connector ID,name/preferred_username= client name (withprofilescope)Configuration:
Security considerations:
offline_accessscope is rejected, limiting token lifetimesubclaim is the client ID, not a userTesting:
go build ./...compiles cleanlygo test ./server/... -run TestHandleClientCredentials— 7 cases passgo test ./server/... -run TestServerSupportedGrants— 6 cases passgo test ./server/... -run TestHandleDiscovery— passesgo test ./server/...— full server suite passesclientCredentialsEnabled: true, request token viacurlManual verification:
examples/config-dev.yaml:Closes #3660
The gating mechanism works the same way as the
passwordgrant:client_credentialsis included in the default allowed grant types list, but only gets added toallSupportedGrants(and thus passes the intersection filter innewServer()) whenClientCredentialsEnabledistrue. Without the flag, the grant type is silently filtered out.