Skip to content

Commit f54fa8c

Browse files
Add Private Key JWT support for client credentials in Management API (#528)
Co-authored-by: Kunal Dawar <[email protected]> Co-authored-by: Kunal Dawar <[email protected]>
1 parent 52fcddd commit f54fa8c

File tree

10 files changed

+978
-62
lines changed

10 files changed

+978
-62
lines changed

EXAMPLES.md

Lines changed: 93 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,94 @@
11
# Examples
22

3+
- [OAuth Client Credentials](#oauth-client-credentials)
34
- [Request Options](#request-options)
45
- [Pagination](#pagination)
56
- [Page based pagination](#page-based-pagination)
67
- [Checkpoint pagination](#checkpoint-pagination)
78
- [Custom User Structs](#providing-a-custom-user-struct)
89

10+
## OAuth Client Credentials
11+
12+
The SDK provides several ways to authenticate with Auth0 using OAuth client credentials.
13+
14+
### Management API Initialization
15+
16+
When initializing the Management API client, you can use these client credentials options:
17+
18+
```go
19+
// Standard client credentials with client secret
20+
api, err := management.New(
21+
"your-tenant.auth0.com",
22+
management.WithClientCredentials(
23+
context.Background(),
24+
"YOUR_CLIENT_ID",
25+
"YOUR_CLIENT_SECRET"
26+
)
27+
)
28+
29+
// Client credentials with custom audience
30+
api, err := management.New(
31+
"your-tenant.auth0.com",
32+
management.WithClientCredentialsAndAudience(
33+
context.Background(),
34+
"YOUR_CLIENT_ID",
35+
"YOUR_CLIENT_SECRET",
36+
"https://custom-api.example.com"
37+
)
38+
)
39+
40+
// Client credentials with Private Key JWT
41+
api, err := management.New(
42+
"your-tenant.auth0.com",
43+
management.WithClientCredentialsPrivateKeyJwt(
44+
context.Background(),
45+
"YOUR_CLIENT_ID",
46+
privateKey,
47+
"RS256"
48+
)
49+
)
50+
51+
// Private Key JWT with custom audience
52+
api, err := management.New(
53+
"your-tenant.auth0.com",
54+
management.WithClientCredentialsPrivateKeyJwtAndAudience(
55+
context.Background(),
56+
"YOUR_CLIENT_ID",
57+
privateKey,
58+
"RS256",
59+
"https://custom-api.example.com"
60+
)
61+
)
62+
63+
// Using a pre-acquired static token
64+
api, err := management.New(
65+
"your-tenant.auth0.com",
66+
management.WithStaticToken("YOUR_ACCESS_TOKEN")
67+
)
68+
```
69+
70+
### Authentication API Initialization
71+
72+
For the Authentication API, use these initialization patterns:
73+
74+
```go
75+
// With client secret authentication
76+
auth, err := authentication.New(
77+
context.Background(),
78+
"your-tenant.auth0.com",
79+
authentication.WithClientID("YOUR_CLIENT_ID"),
80+
authentication.WithClientSecret("YOUR_CLIENT_SECRET")
81+
)
82+
83+
// With private key JWT authentication
84+
auth, err := authentication.New(
85+
context.Background(),
86+
"your-tenant.auth0.com",
87+
authentication.WithClientID("YOUR_CLIENT_ID"),
88+
authentication.WithClientAssertion(privateKey, "RS256")
89+
)
90+
```
91+
992
## Request Options
1093

1194
Fine-grained configuration can be provided on a per-request basis to enhance the request with specific query params, headers, or to pass it a custom context.
@@ -42,7 +125,7 @@ This SDK supports both offset and checkpoint pagination.
42125

43126
### Page based pagination
44127

45-
When retrieving lists of resources, if no query parameters are set using the `management.PerPage` and `Management.IncludeTotals` helper funcs, then the SDK will default to sending `per_page=50` and `include_totals=true`.
128+
When retrieving lists of resources, if no query parameters are set using the `management.PerPage` and `Management.IncludeTotals` helper funcs, then the SDK will default to sending `per_page=50` and `include_totals=true`.
46129

47130
> **Note**
48131
> The maximum value of the `per_page` query parameter is 100.
@@ -71,16 +154,17 @@ for {
71154
page++
72155
}
73156
```
157+
74158
</details>
75159

76160
### Checkpoint pagination
77161

78162
Checkpoint pagination can be used when you wish to retrieve more than 1000 results from certain APIs. The APIs that support checkpoint based pagination are:
79163

80-
* `Log.List` (`/api/v2/logs`)
81-
* `Organization.List` (`/api/v2/organizations`)
82-
* `Organization.Members` (`/api/v2/organizations/{id}/members`)
83-
* `Role.Users` (`/api/v2/roles/{id}/users`)
164+
- `Log.List` (`/api/v2/logs`)
165+
- `Organization.List` (`/api/v2/organizations`)
166+
- `Organization.Members` (`/api/v2/organizations/{id}/members`)
167+
- `Role.Users` (`/api/v2/roles/{id}/users`)
84168

85169
<details>
86170
<summary>Checkpoint pagination example</summary>
@@ -109,11 +193,11 @@ for {
109193
if err != nil {
110194
log.Fatalf("err :%+v", err)
111195
}
112-
196+
113197
for _, org := range orgList.Organizations {
114198
log.Printf("org %s", org.GetID())
115199
}
116-
200+
117201
// The `HasNext` helper func checks whether
118202
// the API has informed us that there is
119203
// more data to retrieve or not.
@@ -122,6 +206,7 @@ for {
122206
}
123207
}
124208
```
209+
125210
</details>
126211

127212
However, for `Log.List`, the `Next` value is not returned via the API but instead is an ID of a log entry. Determining if there are more logs to retrieved must also be done manually.
@@ -159,6 +244,7 @@ for {
159244
logFromId = logs[len(logs)-1].GetID()
160245
}
161246
```
247+
162248
</details>
163249

164250
## Providing a custom User struct

authentication/authentication.go

Lines changed: 16 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,12 @@ import (
44
"context"
55
"encoding/json"
66
"errors"
7-
"fmt"
87
"net/http"
98
"net/url"
109
"reflect"
1110
"strings"
1211
"time"
1312

14-
"github.com/google/uuid"
15-
"github.com/lestrrat-go/jwx/v2/jwa"
16-
"github.com/lestrrat-go/jwx/v2/jwk"
17-
"github.com/lestrrat-go/jwx/v2/jwt"
18-
1913
"github.com/auth0/go-auth0/authentication/oauth"
2014
"github.com/auth0/go-auth0/internal/client"
2115
"github.com/auth0/go-auth0/internal/idtokenvalidator"
@@ -256,8 +250,14 @@ func (a *Authentication) addClientAuthenticationToURLValues(params oauth.ClientA
256250

257251
switch {
258252
case a.clientAssertionSigningKey != "" && a.clientAssertionSigningAlg != "":
259-
clientAssertion, err := createClientAssertion(
260-
a.clientAssertionSigningAlg,
253+
alg, err := client.DetermineSigningAlgorithm(a.clientAssertionSigningAlg)
254+
if err != nil {
255+
return err
256+
}
257+
258+
// Using the improved createClientAssertion with a standard lifetime
259+
clientAssertion, err := client.CreateClientAssertion(
260+
alg,
261261
a.clientAssertionSigningKey,
262262
clientID,
263263
a.url.JoinPath("/").String(),
@@ -292,8 +292,14 @@ func (a *Authentication) addClientAuthenticationToClientAuthStruct(params *oauth
292292
}
293293

294294
if a.clientAssertionSigningKey != "" && a.clientAssertionSigningAlg != "" {
295-
clientAssertion, err := createClientAssertion(
296-
a.clientAssertionSigningAlg,
295+
alg, err := client.DetermineSigningAlgorithm(a.clientAssertionSigningAlg)
296+
if err != nil {
297+
return err
298+
}
299+
300+
// Using the improved createClientAssertion with a standard lifetime
301+
clientAssertion, err := client.CreateClientAssertion(
302+
alg,
297303
a.clientAssertionSigningKey,
298304
params.ClientID,
299305
a.url.JoinPath("/").String(),
@@ -314,43 +320,3 @@ func (a *Authentication) addClientAuthenticationToClientAuthStruct(params *oauth
314320

315321
return nil
316322
}
317-
318-
func determineAlg(alg string) (jwa.SignatureAlgorithm, error) {
319-
switch alg {
320-
case "RS256":
321-
return jwa.RS256, nil
322-
default:
323-
return "", fmt.Errorf("Unsupported client assertion algorithm \"%s\" provided", alg)
324-
}
325-
}
326-
327-
func createClientAssertion(clientAssertionSigningAlg, clientAssertionSigningKey, clientID, domain string) (string, error) {
328-
alg, err := determineAlg(clientAssertionSigningAlg)
329-
if err != nil {
330-
return "", err
331-
}
332-
333-
key, err := jwk.ParseKey([]byte(clientAssertionSigningKey), jwk.WithPEM(true))
334-
if err != nil {
335-
return "", err
336-
}
337-
338-
token, err := jwt.NewBuilder().
339-
IssuedAt(time.Now()).
340-
Subject(clientID).
341-
JwtID(uuid.New().String()).
342-
Issuer(clientID).
343-
Claim("aud", domain).
344-
Expiration(time.Now().Add(2 * time.Minute)).
345-
Build()
346-
if err != nil {
347-
return "", err
348-
}
349-
350-
b, err := jwt.Sign(token, jwt.WithKey(alg, key))
351-
if err != nil {
352-
return "", err
353-
}
354-
355-
return string(b), nil
356-
}

authentication/oauth_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
"github.com/auth0/go-auth0/authentication/ciba"
14+
"github.com/auth0/go-auth0/internal/client"
1415

1516
"github.com/lestrrat-go/jwx/v2/jwa"
1617
"github.com/lestrrat-go/jwx/v2/jwt"
@@ -286,7 +287,7 @@ func TestLoginWithClientCredentials(t *testing.T) {
286287
skipE2E(t)
287288
configureHTTPTestRecordings(t, authAPI)
288289

289-
auth, err := createClientAssertion("RS256", jwtPrivateKey, clientID, "https://"+domain+"/")
290+
auth, err := client.CreateClientAssertion("RS256", jwtPrivateKey, clientID, "https://"+domain+"/")
290291
require.NoError(t, err)
291292

292293
tokenSet, err := authAPI.OAuth.LoginWithClientCredentials(context.Background(), oauth.LoginWithClientCredentialsRequest{
@@ -317,7 +318,7 @@ func TestLoginWithClientCredentials(t *testing.T) {
317318
Audience: "test-audience",
318319
}, oauth.IDTokenValidationOptions{})
319320

320-
assert.ErrorContains(t, err, "Unsupported client assertion algorithm \"invalid-alg\" provided")
321+
assert.ErrorContains(t, err, "unsupported client assertion algorithm \"invalid-alg\"")
321322
})
322323

323324
t.Run("Should support passing an organization", func(t *testing.T) {

authentication/passwordless_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"github.com/stretchr/testify/assert"
99
"github.com/stretchr/testify/require"
1010

11+
"github.com/auth0/go-auth0/internal/client"
12+
1113
"github.com/auth0/go-auth0/authentication/oauth"
1214
"github.com/auth0/go-auth0/authentication/passwordless"
1315
)
@@ -179,7 +181,7 @@ func TestPasswordlessWithClientAssertion(t *testing.T) {
179181
require.NoError(t, err)
180182
configureHTTPTestRecordings(t, api)
181183

182-
auth, err := createClientAssertion("RS256", jwtPrivateKey, clientID, "https://"+domain+"/")
184+
auth, err := client.CreateClientAssertion("RS256", jwtPrivateKey, clientID, "https://"+domain+"/")
183185
require.NoError(t, err)
184186

185187
r, err := api.Passwordless.SendSMS(context.Background(), passwordless.SendSMSRequest{

internal/client/client.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,27 @@ func OAuth2ClientCredentialsAndAudience(
379379
return cfg.TokenSource(ctx)
380380
}
381381

382+
// OAuth2ClientCredentialsPrivateKeyJwt sets the oauth2
383+
// client credentials with Private Key JWT authentication.
384+
func OAuth2ClientCredentialsPrivateKeyJwt(ctx context.Context, uri, clientID, clientAssertionSigningKey, clientAssertionSigningAlg string) oauth2.TokenSource {
385+
audience := uri + "/api/v2/"
386+
return OAuth2ClientCredentialsPrivateKeyJwtAndAudience(ctx, uri, clientID, clientAssertionSigningKey, clientAssertionSigningAlg, audience)
387+
}
388+
389+
// OAuth2ClientCredentialsPrivateKeyJwtAndAudience sets the oauth2
390+
// client credentials with Private Key JWT authentication
391+
// with a custom audience.
392+
func OAuth2ClientCredentialsPrivateKeyJwtAndAudience(
393+
ctx context.Context,
394+
uri,
395+
clientID,
396+
clientAssertionSigningKey,
397+
clientAssertionSigningAlg,
398+
audience string,
399+
) oauth2.TokenSource {
400+
return newPrivateKeyJwtTokenSource(ctx, uri, clientAssertionSigningAlg, clientAssertionSigningKey, clientID, audience)
401+
}
402+
382403
// StaticToken sets a static token to be used for oauth2.
383404
func StaticToken(token string) oauth2.TokenSource {
384405
return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})

0 commit comments

Comments
 (0)