|
| 1 | +# Dex Enhancement Proposal (DEP) 3660 - 2026-03-03 - Client Credentials Grant |
| 2 | + |
| 3 | +## Table of Contents |
| 4 | + |
| 5 | +- [Summary](#summary) |
| 6 | +- [Motivation](#motivation) |
| 7 | + - [Goals](#goals) |
| 8 | + - [Non-Goals](#non-goals) |
| 9 | +- [Proposal](#proposal) |
| 10 | + - [User Experience](#user-experience) |
| 11 | + - [Implementation Details](#implementation-details) |
| 12 | + - [Risks and Mitigations](#risks-and-mitigations) |
| 13 | +- [Future Improvements](#future-improvements) |
| 14 | + |
| 15 | +## Summary |
| 16 | + |
| 17 | +[RFC 6749 Section 4.4] defines the `client_credentials` grant type for service-to-service |
| 18 | +authentication where no end-user is involved. This DEP proposes implementing this grant in Dex, |
| 19 | +gated behind an opt-in configuration flag, so that machine clients can obtain tokens directly |
| 20 | +without requiring a browser-based redirect flow. |
| 21 | + |
| 22 | +[RFC 6749 Section 4.4]: https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 |
| 23 | + |
| 24 | +## Context |
| 25 | + |
| 26 | +This has been a long-standing community request: |
| 27 | + |
| 28 | +- [#3660 Support client_credentials grant type] is the canonical issue tracking this request |
| 29 | +- [#926 Support resource owner password credentials grant] highlights the broader need for |
| 30 | + non-interactive flows in automated environments |
| 31 | +- [#2657 Get OIDC token issued by Dex using a token issued by one of the connectors] solved |
| 32 | + a related problem via token exchange (RFC 8693), but does not cover the pure M2M case |
| 33 | + where no upstream user identity exists |
| 34 | + |
| 35 | +Common use cases reported by the community: |
| 36 | + |
| 37 | +- CI/CD pipelines authenticating against ArgoCD or other Dex-protected APIs |
| 38 | +- Kubernetes operators and controllers making authenticated API calls |
| 39 | +- Service meshes and microservices authenticating without a human in the loop |
| 40 | + |
| 41 | +[#3660 Support client_credentials grant type]: https://github.com/dexidp/dex/issues/3660 |
| 42 | +[#926 Support resource owner password credentials grant]: https://github.com/dexidp/dex/issues/926 |
| 43 | +[#2657 Get OIDC token issued by Dex using a token issued by one of the connectors]: https://github.com/dexidp/dex/issues/2657 |
| 44 | + |
| 45 | +## Motivation |
| 46 | + |
| 47 | +### Goals |
| 48 | + |
| 49 | +- Allow confidential machine clients to authenticate directly against Dex's `/token` endpoint |
| 50 | + using their client ID and secret, without any user interaction |
| 51 | +- Ensure no behavior change for existing deployments by defaulting the feature to disabled |
| 52 | +- Support standard scopes (`openid`, `email`, `profile`, `groups`) so that downstream |
| 53 | + applications can use the resulting token with their existing authorization logic |
| 54 | + |
| 55 | +### Non-Goals |
| 56 | + |
| 57 | +- Support for public clients: the `client_credentials` grant requires a confidential client |
| 58 | + with a non-empty secret |
| 59 | +- Refresh token issuance: M2M clients are expected to re-authenticate rather than hold |
| 60 | + long-lived refresh tokens |
| 61 | +- Custom claim sources: claims are derived from the static client configuration, not from |
| 62 | + a connector backend |
| 63 | + |
| 64 | +## Proposal |
| 65 | + |
| 66 | +### User Experience |
| 67 | + |
| 68 | +Enable the grant by adding `client_credentials` to the `oauth2.grantTypes` list. When |
| 69 | +`grantTypes` is omitted, Dex uses a default list that does not include `client_credentials`, |
| 70 | +so the entry must be explicit: |
| 71 | + |
| 72 | +```yaml |
| 73 | +oauth2: |
| 74 | + grantTypes: |
| 75 | + - authorization_code |
| 76 | + - implicit |
| 77 | + - password |
| 78 | + - refresh_token |
| 79 | + - urn:ietf:params:oauth:grant-type:device_code |
| 80 | + - urn:ietf:params:oauth:grant-type:token-exchange |
| 81 | + - client_credentials |
| 82 | +``` |
| 83 | +
|
| 84 | +Register a confidential static client (non-empty `secret`, no `public: true`): |
| 85 | + |
| 86 | +```yaml |
| 87 | +staticClients: |
| 88 | + - id: my-service |
| 89 | + secret: my-service-secret |
| 90 | + name: My Service |
| 91 | +``` |
| 92 | + |
| 93 | +Request a token via HTTP Basic authentication: |
| 94 | + |
| 95 | +```bash |
| 96 | +curl -X POST https://dex.example.com/token \ |
| 97 | + -u "my-service:my-service-secret" \ |
| 98 | + -d "grant_type=client_credentials" |
| 99 | +``` |
| 100 | + |
| 101 | +To receive an ID token in addition to the access token, include the `openid` scope: |
| 102 | + |
| 103 | +```bash |
| 104 | +curl -X POST https://dex.example.com/token \ |
| 105 | + -u "my-service:my-service-secret" \ |
| 106 | + -d "grant_type=client_credentials&scope=openid+profile" |
| 107 | +``` |
| 108 | + |
| 109 | +The response follows the standard OAuth2 token response format: |
| 110 | + |
| 111 | +```json |
| 112 | +{ |
| 113 | + "access_token": "...", |
| 114 | + "token_type": "bearer", |
| 115 | + "expires_in": 86400 |
| 116 | +} |
| 117 | +``` |
| 118 | + |
| 119 | +With `scope=openid`, an `id_token` is included in the response as well. |
| 120 | + |
| 121 | +**Token claims** are derived from the client itself: |
| 122 | + |
| 123 | +| Claim | Value | |
| 124 | +|---|---| |
| 125 | +| `sub` | base64-encoded protobuf of (client ID, empty connector ID) | |
| 126 | +| `aud` | client ID | |
| 127 | +| `name` / `preferred_username` | client name (requires `profile` scope) | |
| 128 | +| `groups` | groups from `clientCredentialsClaims.groups` on the client (requires `groups` scope) | |
| 129 | + |
| 130 | +**Rejected scopes:** `offline_access` and `federated:id` are not supported; requesting them |
| 131 | +returns an error. |
| 132 | + |
| 133 | +#### ArgoCD example |
| 134 | + |
| 135 | +When using Dex as the OIDC provider for ArgoCD, CI/CD pipelines can authenticate |
| 136 | +programmatically without a user session: |
| 137 | + |
| 138 | +```yaml |
| 139 | +# dex config |
| 140 | +oauth2: |
| 141 | + grantTypes: |
| 142 | + - authorization_code |
| 143 | + - implicit |
| 144 | + - password |
| 145 | + - refresh_token |
| 146 | + - urn:ietf:params:oauth:grant-type:device_code |
| 147 | + - urn:ietf:params:oauth:grant-type:token-exchange |
| 148 | + - client_credentials |
| 149 | +
|
| 150 | +staticClients: |
| 151 | + - id: argocd-pipeline |
| 152 | + secret: pipeline-secret |
| 153 | + name: ArgoCD Pipeline Client |
| 154 | +``` |
| 155 | + |
| 156 | +```bash |
| 157 | +# Obtain a token from Dex |
| 158 | +TOKEN=$(curl -s -X POST https://dex.example.com/token \ |
| 159 | + -u "argocd-pipeline:pipeline-secret" \ |
| 160 | + -d "grant_type=client_credentials&scope=openid" \ |
| 161 | + | jq -r .id_token) |
| 162 | +
|
| 163 | +# Use the token with the ArgoCD API |
| 164 | +argocd app list --auth-token "$TOKEN" --server argocd.example.com |
| 165 | +``` |
| 166 | + |
| 167 | +### Implementation Details |
| 168 | + |
| 169 | +The grant is gated behind `clientCredentialsEnabled` in the `oauth2` config block, |
| 170 | +following the same pattern as `passwordConnector` for the `password` grant. |
| 171 | +When the flag is `false` (the default), the grant type is filtered out in `newServer()` |
| 172 | +and never advertised in the discovery document. |
| 173 | + |
| 174 | +The token endpoint handler authenticates the client via HTTP Basic auth, verifies the |
| 175 | +client is confidential, and issues a signed token. No connector lookup or user session |
| 176 | +is involved. |
| 177 | + |
| 178 | +Implemented in [#4583]. |
| 179 | + |
| 180 | +[#4583]: https://github.com/dexidp/dex/pull/4583 |
| 181 | + |
| 182 | +### Risks and Mitigations |
| 183 | + |
| 184 | +- **Credential exposure:** client secrets used for `client_credentials` must be treated with |
| 185 | + the same care as any long-lived credential. Rotate them regularly and store them in a |
| 186 | + secrets manager (Kubernetes Secret, Vault, etc.). |
| 187 | +- **Over-privileged clients:** because claims come from the client configuration rather than |
| 188 | + a user identity, downstream applications should validate the `sub` claim carefully. Grant |
| 189 | + only the minimum required permissions to each client. |
| 190 | +- **Opt-in only:** the grant is never active unless `client_credentials` is listed in |
| 191 | + `oauth2.grantTypes` explicitly, so existing deployments are unaffected. |
| 192 | + |
| 193 | +## Future Improvements |
| 194 | + |
| 195 | +- Allow per-client scope restrictions to limit what a machine client can request |
0 commit comments