Browse Source

feat(oauth2): add client_credentials grant with opt-in config flag

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 #3660

Signed-off-by: Mathias Gebbe <mathias.gebbe@gmail.com>
pull/4583/head
Mathias Gebbe 3 weeks ago committed by Mathias Gebbe
parent
commit
e5c8e6d0ef
No known key found for this signature in database
GPG Key ID: 2A35E2EC75E5438F
  1. 2
      cmd/dex/config.go
  2. 4
      cmd/dex/serve.go
  3. 93
      server/handlers.go
  4. 132
      server/handlers_test.go
  5. 5
      server/oauth2.go
  6. 7
      server/server.go
  7. 27
      server/server_test.go

2
cmd/dex/config.go

@ -161,6 +161,8 @@ type OAuth2 struct {
AlwaysShowLoginScreen bool `json:"alwaysShowLoginScreen"`
// 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"`
}
// Web is the config format for the HTTP server.

4
cmd/dex/serve.go

@ -279,6 +279,9 @@ func runServe(options serveOptions) error {
if c.OAuth2.PasswordConnector != "" {
logger.Info("config using password grant connector", "password_connector", c.OAuth2.PasswordConnector)
}
if c.OAuth2.ClientCredentialsEnabled {
logger.Info("config client credentials grant enabled")
}
if len(c.Web.AllowedOrigins) > 0 {
logger.Info("config allowed origins", "origins", c.Web.AllowedOrigins)
}
@ -356,6 +359,7 @@ func runServe(options serveOptions) error {
SkipApprovalScreen: c.OAuth2.SkipApprovalScreen,
AlwaysShowLoginScreen: c.OAuth2.AlwaysShowLoginScreen,
PasswordConnector: c.OAuth2.PasswordConnector,
ClientCredentialsEnabled: c.OAuth2.ClientCredentialsEnabled,
Headers: c.Web.Headers.ToHTTPHeader(),
AllowedOrigins: c.Web.AllowedOrigins,
AllowedHeaders: c.Web.AllowedHeaders,

93
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)
}
@ -1461,6 +1463,97 @@ 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,
Username: client.Name,
PreferredUsername: client.Name,
}
connID := "client_credentials"
accessToken, expiry, err := s.newAccessToken(ctx, client.ID, claims, scopes, "", 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, "", 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"`

132
server/handlers_test.go

@ -62,6 +62,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",
@ -645,6 +646,137 @@ 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
}{
{
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 email profile groups",
clientID: "test",
clientSecret: "barfoo",
scopes: "openid email profile groups",
wantCode: 200,
wantAccessTok: true,
wantIDToken: true,
},
{
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)
} 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()

5
server/oauth2.go

@ -141,8 +141,9 @@ const (
grantTypeRefreshToken = "refresh_token"
grantTypeImplicit = "implicit"
grantTypePassword = "password"
grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
grantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
grantTypeClientCredentials = "client_credentials"
)
const (

7
server/server.go

@ -106,6 +106,9 @@ type Config struct {
// If set, the server will use this connector to handle password grants
PasswordConnector string
// If enabled, the server will support the client_credentials grant type
ClientCredentialsEnabled bool
GCFrequency time.Duration // Defaults to 5 minutes
// If specified, the server will use this function for determining time.
@ -254,6 +257,10 @@ func newServer(ctx context.Context, c Config) (*Server, error) {
allSupportedGrants[grantTypePassword] = true
}
if c.ClientCredentialsEnabled {
allSupportedGrants[grantTypeClientCredentials] = true
}
var supportedGrants []string
if len(c.AllowedGrantTypes) > 0 {
for _, grant := range c.AllowedGrantTypes {

27
server/server_test.go

@ -99,7 +99,8 @@ func newTestServer(t *testing.T, updateConfig func(c *Config)) (*httptest.Server
Logger: logger,
PrometheusRegistry: prometheus.NewRegistry(),
HealthChecker: gosundheit.New(),
SkipApprovalScreen: true, // Don't prompt for approval, just immediately redirect with code.
SkipApprovalScreen: true, // Don't prompt for approval, just immediately redirect with code.
ClientCredentialsEnabled: true,
AllowedGrantTypes: []string{ // all implemented types
grantTypeDeviceCode,
grantTypeAuthorizationCode,
@ -107,6 +108,7 @@ func newTestServer(t *testing.T, updateConfig func(c *Config)) (*httptest.Server
grantTypeTokenExchange,
grantTypeImplicit,
grantTypePassword,
grantTypeClientCredentials,
},
Signer: sig,
}
@ -1773,7 +1775,7 @@ func TestServerSupportedGrants(t *testing.T) {
}{
{
name: "Simple",
config: func(c *Config) {},
config: func(c *Config) { c.ClientCredentialsEnabled = false },
resGrants: []string{grantTypeAuthorizationCode, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
},
{
@ -1782,13 +1784,24 @@ func TestServerSupportedGrants(t *testing.T) {
resGrants: []string{grantTypeTokenExchange},
},
{
name: "With password connector",
config: func(c *Config) { c.PasswordConnector = "local" },
name: "With password connector",
config: func(c *Config) {
c.ClientCredentialsEnabled = false
c.PasswordConnector = "local"
},
resGrants: []string{grantTypeAuthorizationCode, grantTypePassword, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
},
{
name: "With token response",
config: func(c *Config) { c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken) },
name: "With client credentials",
config: func(c *Config) {},
resGrants: []string{grantTypeAuthorizationCode, grantTypeClientCredentials, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
},
{
name: "With token response",
config: func(c *Config) {
c.ClientCredentialsEnabled = false
c.SupportedResponseTypes = append(c.SupportedResponseTypes, responseTypeToken)
},
resGrants: []string{grantTypeAuthorizationCode, grantTypeImplicit, grantTypeRefreshToken, grantTypeDeviceCode, grantTypeTokenExchange},
},
{
@ -1797,7 +1810,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