Browse Source

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

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.

---------

Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
Signed-off-by: Maksim Nabokikh <maksim.nabokikh@flant.com>
Signed-off-by: Maksim Nabokikh <max.nabokih@gmail.com>
Co-authored-by: Maksim Nabokikh <maksim.nabokikh@flant.com>
Co-authored-by: Maksim Nabokikh <max.nabokih@gmail.com>
pull/4605/head
Mathias Gebbe 2 weeks ago committed by GitHub
parent
commit
fec4f53203
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      cmd/dex/serve.go
  2. 3
      examples/config-dev.yaml
  3. 4
      pkg/featureflags/set.go
  4. 104
      server/handlers.go
  5. 172
      server/handlers_test.go
  6. 1
      server/oauth2.go
  7. 2
      server/server.go
  8. 29
      server/server_test.go

3
cmd/dex/serve.go

@ -622,6 +622,9 @@ func applyConfigOverrides(options serveOptions, config *Config) {
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:token-exchange",
}
if featureflags.ClientCredentialGrantEnabledByDefault.Enabled() {
config.OAuth2.GrantTypes = append(config.OAuth2.GrantTypes, "client_credentials")
}
}
}

3
examples/config-dev.yaml

@ -100,10 +100,11 @@ telemetry:
# format: "text" # can also be "json"
# Default values shown below
# oauth2:
#oauth2:
# grantTypes determines the allowed set of authorization flows.
# grantTypes:
# - "authorization_code"
# - "client_credentials"
# - "refresh_token"
# - "implicit"
# - "password"

4
pkg/featureflags/set.go

@ -17,4 +17,8 @@ var (
// ConfigDisallowUnknownFields enables to forbid unknown fields in the config while unmarshaling.
ConfigDisallowUnknownFields = newFlag("config_disallow_unknown_fields", false)
// ClientCredentialGrantEnabledByDefault enables the client_credentials grant type by default
// without requiring explicit configuration in oauth2.grantTypes.
ClientCredentialGrantEnabledByDefault = newFlag("client_credential_grant_enabled_by_default", false)
)

104
server/handlers.go

@ -889,6 +889,8 @@ func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
s.withClientFromStorage(w, r, s.handlePasswordGrant)
case grantTypeTokenExchange:
s.withClientFromStorage(w, r, s.handleTokenExchange)
case grantTypeClientCredentials:
s.withClientFromStorage(w, r, s.handleClientCredentialsGrant)
default:
s.tokenErrHelper(w, errUnsupportedGrantType, "", http.StatusBadRequest)
}
@ -1465,6 +1467,108 @@ func (s *Server) handleTokenExchange(w http.ResponseWriter, r *http.Request, cli
json.NewEncoder(w).Encode(resp)
}
func (s *Server) handleClientCredentialsGrant(w http.ResponseWriter, r *http.Request, client storage.Client) {
ctx := r.Context()
// client_credentials requires a confidential client.
if client.Public {
s.tokenErrHelper(w, errUnauthorizedClient, "Public clients cannot use client_credentials grant.", http.StatusBadRequest)
return
}
// Parse scopes from request.
if err := r.ParseForm(); err != nil {
s.tokenErrHelper(w, errInvalidRequest, "Couldn't parse data", http.StatusBadRequest)
return
}
scopes := strings.Fields(r.Form.Get("scope"))
// Validate scopes.
var (
unrecognized []string
invalidScopes []string
)
hasOpenIDScope := false
for _, scope := range scopes {
switch scope {
case scopeOpenID:
hasOpenIDScope = true
case scopeEmail, scopeProfile, scopeGroups:
// allowed
case scopeOfflineAccess:
s.tokenErrHelper(w, errInvalidScope, "client_credentials grant does not support offline_access scope.", http.StatusBadRequest)
return
case scopeFederatedID:
s.tokenErrHelper(w, errInvalidScope, "client_credentials grant does not support federated:id scope.", http.StatusBadRequest)
return
default:
peerID, ok := parseCrossClientScope(scope)
if !ok {
unrecognized = append(unrecognized, scope)
continue
}
isTrusted, err := s.validateCrossClientTrust(ctx, client.ID, peerID)
if err != nil {
s.logger.ErrorContext(ctx, "error validating cross client trust", "client_id", client.ID, "peer_id", peerID, "err", err)
s.tokenErrHelper(w, errInvalidClient, "Error validating cross client trust.", http.StatusBadRequest)
return
}
if !isTrusted {
invalidScopes = append(invalidScopes, scope)
}
}
}
if len(unrecognized) > 0 {
s.tokenErrHelper(w, errInvalidScope, fmt.Sprintf("Unrecognized scope(s) %q", unrecognized), http.StatusBadRequest)
return
}
if len(invalidScopes) > 0 {
s.tokenErrHelper(w, errInvalidScope, fmt.Sprintf("Client can't request scope(s) %q", invalidScopes), http.StatusBadRequest)
return
}
// Build claims from the client itself — no user involved.
claims := storage.Claims{
UserID: client.ID,
}
// 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
}
}
nonce := r.Form.Get("nonce")
// Empty connector ID is unique for cluster credentials grant
// Creating connectors with an empty ID with the config and API is prohibited
connID := ""
accessToken, expiry, err := s.newAccessToken(ctx, client.ID, claims, scopes, nonce, connID)
if err != nil {
s.logger.ErrorContext(ctx, "client_credentials grant failed to create new access token", "err", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return
}
var idToken string
if hasOpenIDScope {
idToken, expiry, err = s.newIDToken(ctx, client.ID, claims, scopes, nonce, accessToken, "", connID)
if err != nil {
s.logger.ErrorContext(ctx, "client_credentials grant failed to create new ID token", "err", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return
}
}
resp := s.toAccessTokenResponse(idToken, accessToken, "", expiry)
s.writeAccessToken(w, resp)
}
type accessTokenResponse struct {
AccessToken string `json:"access_token"`
IssuedTokenType string `json:"issued_token_type,omitempty"`

172
server/handlers_test.go

@ -22,6 +22,7 @@ import (
"golang.org/x/oauth2"
"github.com/dexidp/dex/connector"
"github.com/dexidp/dex/server/internal"
"github.com/dexidp/dex/storage"
)
@ -63,6 +64,7 @@ func TestHandleDiscovery(t *testing.T) {
Introspect: fmt.Sprintf("%s/token/introspect", httpServer.URL),
GrantTypes: []string{
"authorization_code",
"client_credentials",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:token-exchange",
@ -646,6 +648,176 @@ 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
wantUsername string
}{
{
name: "Basic grant, no scopes",
clientID: "test",
clientSecret: "barfoo",
scopes: "",
wantCode: 200,
wantAccessTok: true,
wantIDToken: false,
},
{
name: "With openid scope",
clientID: "test",
clientSecret: "barfoo",
scopes: "openid",
wantCode: 200,
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",
clientSecret: "barfoo",
scopes: "openid email profile groups",
wantCode: 200,
wantAccessTok: true,
wantIDToken: true,
wantUsername: "Test Client",
},
{
name: "Invalid client secret",
clientID: "test",
clientSecret: "wrong",
scopes: "",
wantCode: 401,
},
{
name: "Unknown client",
clientID: "nonexistent",
clientSecret: "secret",
scopes: "",
wantCode: 401,
},
{
name: "offline_access scope rejected",
clientID: "test",
clientSecret: "barfoo",
scopes: "openid offline_access",
wantCode: 400,
},
{
name: "Unrecognized scope",
clientID: "test",
clientSecret: "barfoo",
scopes: "openid bogus",
wantCode: 400,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := t.Context()
httpServer, s := newTestServer(t, func(c *Config) {
c.Now = time.Now
})
defer httpServer.Close()
// Create a confidential client for testing.
err := s.storage.CreateClient(ctx, storage.Client{
ID: "test",
Secret: "barfoo",
RedirectURIs: []string{"https://example.com/callback"},
Name: "Test Client",
})
require.NoError(t, err)
u, err := url.Parse(s.issuerURL.String())
require.NoError(t, err)
u.Path = path.Join(u.Path, "/token")
v := url.Values{}
v.Add("grant_type", "client_credentials")
if tc.scopes != "" {
v.Add("scope", tc.scopes)
}
req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(v.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(tc.clientID, tc.clientSecret)
rr := httptest.NewRecorder()
s.ServeHTTP(rr, req)
require.Equal(t, tc.wantCode, rr.Code)
if tc.wantCode == 200 {
var resp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
}
err := json.Unmarshal(rr.Body.Bytes(), &resp)
require.NoError(t, err)
if tc.wantAccessTok {
require.NotEmpty(t, resp.AccessToken)
require.Equal(t, "bearer", resp.TokenType)
require.Greater(t, resp.ExpiresIn, 0)
}
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, "", 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)
}
// client_credentials must never return a refresh token.
require.Empty(t, resp.RefreshToken)
}
})
}
}
func TestHandleConnectorCallbackWithSkipApproval(t *testing.T) {
ctx := t.Context()

1
server/oauth2.go

@ -143,6 +143,7 @@ const (
grantTypePassword = "password"
grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
grantTypeClientCredentials = "client_credentials"
)
const (

2
server/server.go

@ -254,6 +254,8 @@ func newServer(ctx context.Context, c Config) (*Server, error) {
allSupportedGrants[grantTypePassword] = true
}
allSupportedGrants[grantTypeClientCredentials] = true
var supportedGrants []string
if len(c.AllowedGrantTypes) > 0 {
for _, grant := range c.AllowedGrantTypes {

29
server/server_test.go

@ -103,6 +103,7 @@ func newTestServer(t *testing.T, updateConfig func(c *Config)) (*httptest.Server
AllowedGrantTypes: []string{ // all implemented types
grantTypeDeviceCode,
grantTypeAuthorizationCode,
grantTypeClientCredentials,
grantTypeRefreshToken,
grantTypeTokenExchange,
grantTypeImplicit,
@ -1774,7 +1775,7 @@ func TestServerSupportedGrants(t *testing.T) {
{
name: "Simple",
config: func(c *Config) {},
resGrants: []string{grantTypeAuthorizationCode, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
},
{
name: "Minimal",
@ -1783,13 +1784,29 @@ func TestServerSupportedGrants(t *testing.T) {
},
{
name: "With password connector",
config: func(c *Config) { c.PasswordConnector = "local" },
resGrants: []string{grantTypeAuthorizationCode, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
config: func(c *Config) {
c.PasswordConnector = "local"
},
resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
},
{
name: "Without client credentials",
config: func(c *Config) {
c.AllowedGrantTypes = []string{
grantTypeAuthorizationCode,
grantTypeRefreshToken,
grantTypeDeviceCode,
grantTypeTokenExchange,
}
},
resGrants: []string{grantTypeAuthorizationCode, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
},
{
name: "With token response",
config: func(c *Config) { c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken) },
resGrants: []string{grantTypeAuthorizationCode, grantTypeImplicit, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
config: func(c *Config) {
c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken)
},
resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypeImplicit, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
},
{
name: "All",
@ -1797,7 +1814,7 @@ func TestServerSupportedGrants(t *testing.T) {
c.PasswordConnector = "local"
c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken)
},
resGrants: []string{grantTypeAuthorizationCode, grantTypeImplicit, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypeImplicit, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
},
}

Loading…
Cancel
Save