Repository: zitadel/oidc
Pull request #494 (2023-12-04 17:31:22)

feat(rp): client credentials grant
This change adds Client Credentials grant to the Relying Party.
As specified in [RFC 6749, section 4.4](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)

Related to https://github.com/zitadel/zitadel/pull/6999 (required for integration tests)

### Definition of Ready

- [x] I am happy with the code
- [x] Short description of the feature/issue is added in the pr description
- [x] PR is linked to the corresponding user story
- [x] Acceptance criteria are met
- [x] All open todos and follow ups are defined in a new ticket and justified
- [x] Deviations from the acceptance criteria and design are agreed with the PO and documented.
- [x] No debug or dead code
- [x] My code has no repetitions
- [x] Critical parts are tested automatically
- [x] Where possible E2E tests are implemented
- [x] Documentation/examples are up-to-date
- [x] All non-functional requirements are met
- [x] Functionality of the acceptance criteria is checked manually on the dev system.



codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zitadel/oidc/pull/494?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`4d05ead`)](https://app.codecov.io/gh/zitadel/oidc/commit/4d05eade5ec3cab061695ea56d1e6a3e29d66b1b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) 60.26% compared to head [(`172aef5`)](https://app.codecov.io/gh/zitadel/oidc/pull/494?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) 60.33%.


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #494      +/-   ##
==========================================
+ Coverage   60.26%   60.33%   +0.07%     
==========================================
  Files          78       78              
  Lines        6783     6795      +12     
==========================================
+ Hits         4088     4100      +12     
  Misses       2399     2399              
  Partials      296      296              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zitadel/oidc/pull/494?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel).


Patch:
diff --git a/pkg/client/rp/relying_party.go b/pkg/client/rp/relying_party.go
index c6ae2db..5899af0 100644
--- a/pkg/client/rp/relying_party.go
+++ b/pkg/client/rp/relying_party.go
@@ -14,6 +14,7 @@ import (
 	"github.com/zitadel/logging"
 	"golang.org/x/exp/slog"
 	"golang.org/x/oauth2"
+	"golang.org/x/oauth2/clientcredentials"
 
 	"github.com/zitadel/oidc/v3/pkg/client"
 	httphelper "github.com/zitadel/oidc/v3/pkg/http"
@@ -416,12 +417,34 @@ func CodeExchange[C oidc.IDClaims](ctx context.Context, code string, rp RelyingP
 	return verifyTokenResponse[C](ctx, token, rp)
 }
 
+// ClientCredentials requests an access token using the `client_credentials` grant,
+// as defined in [RFC 6749, section 4.4].
+//
+// As there is no user associated to the request an ID Token can never be returned.
+// Client Credentials are undefined in OpenID Connect and is a pure OAuth2 grant.
+// Furthermore the server SHOULD NOT return a refresh token.
+//
+// [RFC 6749, section 4.4]: https://datatracker.ietf.org/doc/html/rfc6749#section-4.4
+func ClientCredentials(ctx context.Context, rp RelyingParty, endpointParams url.Values) (token *oauth2.Token, err error) {
+	ctx = logCtxWithRPData(ctx, rp, "function", "ClientCredentials")
+	ctx = context.WithValue(ctx, oauth2.HTTPClient, rp.HttpClient())
+	config := clientcredentials.Config{
+		ClientID:       rp.OAuthConfig().ClientID,
+		ClientSecret:   rp.OAuthConfig().ClientSecret,
+		TokenURL:       rp.OAuthConfig().Endpoint.TokenURL,
+		Scopes:         rp.OAuthConfig().Scopes,
+		EndpointParams: endpointParams,
+		AuthStyle:      rp.OAuthConfig().Endpoint.AuthStyle,
+	}
+	return config.Token(ctx)
+}
+
 type CodeExchangeCallback[C oidc.IDClaims] func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[C], state string, rp RelyingParty)
 
 // CodeExchangeHandler extends the `CodeExchange` method with a http handler
 // including cookie handling for secure `state` transfer
 // and optional PKCE code verifier checking.
-// Custom paramaters can optionally be set to the token URL.
+// Custom parameters can optionally be set to the token URL.
 func CodeExchangeHandler[C oidc.IDClaims](callback CodeExchangeCallback[C], rp RelyingParty, urlParam ...URLParamOpt) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		state, err := tryReadStateCookie(w, r, rp)


Test patch:
diff --git a/pkg/client/integration_test.go b/pkg/client/integration_test.go
index ec4d57b..7d4cd9e 100644
--- a/pkg/client/integration_test.go
+++ b/pkg/client/integration_test.go
@@ -323,6 +323,31 @@ func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID,
 	return provider, tokens
 }
 
+func TestClientCredentials(t *testing.T) {
+	targetURL := "http://local-site"
+	exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL))
+	var dh deferredHandler
+	opServer := httptest.NewServer(&dh)
+	defer opServer.Close()
+	t.Logf("auth server at %s", opServer.URL)
+	dh.Handler = exampleop.SetupServer(opServer.URL, exampleStorage, Logger, true)
+
+	provider, err := rp.NewRelyingPartyOIDC(
+		CTX,
+		opServer.URL,
+		"sid1",
+		"verysecret",
+		targetURL,
+		[]string{"openid"},
+	)
+	require.NoError(t, err, "new rp")
+
+	token, err := rp.ClientCredentials(CTX, provider, nil)
+	require.NoError(t, err, "ClientCredentials call")
+	require.NotNil(t, token)
+	assert.NotEmpty(t, token.AccessToken)
+}
+
 func TestErrorFromPromptNone(t *testing.T) {
 	jar, err := cookiejar.New(nil)
 	require.NoError(t, err, "create cookie jar")