mirror of
https://github.com/netbirdio/dex.git
synced 2026-05-22 18:43:53 -07:00
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>
This commit is contained in:
co-authored by
Maksim Nabokikh
Maksim Nabokikh
parent
e79638db52
commit
fec4f53203
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+25
-8
@@ -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",
|
||||
@@ -1782,14 +1783,30 @@ func TestServerSupportedGrants(t *testing.T) {
|
||||
resGrants: []string{grantTypeTokenExchange},
|
||||
},
|
||||
{
|
||||
name: "With password connector",
|
||||
config: func(c *Config) { c.PasswordConnector = "local" },
|
||||
resGrants: []string{grantTypeAuthorizationCode, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
|
||||
name: "With password connector",
|
||||
config: func(c *Config) {
|
||||
c.PasswordConnector = "local"
|
||||
},
|
||||
resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
|
||||
},
|
||||
{
|
||||
name: "With token response",
|
||||
config: func(c *Config) { c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken) },
|
||||
resGrants: []string{grantTypeAuthorizationCode, grantTypeImplicit, 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, 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},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user