Skip to content

Commit

Permalink
Support Client Assertion authentication on authentication client (#260)
Browse files Browse the repository at this point in the history
Co-authored-by: Sergiu Ghitea <[email protected]>
  • Loading branch information
ewanharris and sergiught authored Aug 11, 2023
1 parent 4c4f9dc commit a98b898
Show file tree
Hide file tree
Showing 16 changed files with 507 additions and 38 deletions.
26 changes: 14 additions & 12 deletions authentication/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,20 @@ type Authentication struct {
OAuth *OAuth
Passwordless *Passwordless

auth0ClientInfo *client.Auth0ClientInfo
basePath string
common manager
clientID string
clientSecret string
idTokenClockTolerance time.Duration
debug bool
http *http.Client
idTokenSigningAlg string
idTokenValidator *idtokenvalidator.IDTokenValidator
url *url.URL
retryStrategy client.RetryOptions
auth0ClientInfo *client.Auth0ClientInfo
basePath string
common manager
clientID string
clientSecret string
clientAssertionSigningKey string
clientAssertionSigningAlg string
idTokenClockTolerance time.Duration
debug bool
http *http.Client
idTokenSigningAlg string
idTokenValidator *idtokenvalidator.IDTokenValidator
url *url.URL
retryStrategy client.RetryOptions
}

type manager struct {
Expand Down
10 changes: 9 additions & 1 deletion authentication/authentication_option.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,21 @@ func WithClientID(clientID string) Option {
}
}

// WithClientSecret configures the Client secret to be provided with requests if one is not provided.
// WithClientSecret configures the Client Secret to be provided with requests if one is not provided.
func WithClientSecret(clientSecret string) Option {
return func(a *Authentication) {
a.clientSecret = clientSecret
}
}

// WithClientAssertion configures the signing key to be used when performing Private Key JWT Auth.
func WithClientAssertion(signingKey string, signingAlg string) Option {
return func(a *Authentication) {
a.clientAssertionSigningKey = signingKey
a.clientAssertionSigningAlg = signingAlg
}
}

// WithIDTokenSigningAlg configures the signing algorithm used for the ID token.
func WithIDTokenSigningAlg(alg string) Option {
return func(a *Authentication) {
Expand Down
37 changes: 37 additions & 0 deletions authentication/authentication_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,43 @@ var (
httpRecordings = os.Getenv("AUTH0_HTTP_RECORDINGS")
httpRecordingsEnabled = false
authAPI = &Authentication{}
jwtPublicKey = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8foXPIpkeLKAVfVg/W0X
steFas2XwrxAGG0lnLS3mc/cYc/pD/plsR779O8It/2YmHFWIDmCIcW57boDae/K
AhVBLHUa3ato7h5agbY2mKSDUEjqjWilAbdyUZDz8US8ocAmehyVWMuVqeGxunPH
opm4JQ2OGcE31MbtcJN07zCa/R/LUi8KMeuujQ6cceIGupCdOsK6JkoUB2wkvFpU
CiOwqTG51Eq4DSTukDr7tDfe0s5e1MBxVUxLkrw7zBrlDxPgZ+M260FUlRqOKKsk
3IUke/vcQac6t+js1zOs0mapqLybkszGwl2wY0JaOLOtcL5zi4U9w/GeOHOVROdn
6wIDAQAB
-----END PUBLIC KEY-----`
jwtPrivateKey = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDx+hc8imR4soBV
9WD9bRey14VqzZfCvEAYbSWctLeZz9xhz+kP+mWxHvv07wi3/ZiYcVYgOYIhxbnt
ugNp78oCFUEsdRrdq2juHlqBtjaYpINQSOqNaKUBt3JRkPPxRLyhwCZ6HJVYy5Wp
4bG6c8eimbglDY4ZwTfUxu1wk3TvMJr9H8tSLwox666NDpxx4ga6kJ06wromShQH
bCS8WlQKI7CpMbnUSrgNJO6QOvu0N97Szl7UwHFVTEuSvDvMGuUPE+Bn4zbrQVSV
Go4oqyTchSR7+9xBpzq36OzXM6zSZqmovJuSzMbCXbBjQlo4s61wvnOLhT3D8Z44
c5VE52frAgMBAAECggEALCx3qXmqNc6AVzDgb+NGfEOT+5dkqQwst0jVoPHswouL
s998sIoJnngFjwVEFjKZdNrb2i4lb3zlIFzg2qoHurGeoDsQmH7+PNoVs7BL7zm5
LyLgjsgXt2SB3hoULmtZ9D1byNcG/JrNy6GEDIGuZCSj1T/QPStkwdc+6VpB8pgW
E8D7jCt40Tik2neYQkDnY775kGAHGWEqpdPCwm+KOnuE1fHx/jk38lmUgYNjKq0h
JK6Ncjen1X+ZsYfGx4dALWG4cqo3lE0YXXuHuvjJV3aVfzH8t7W4fuZ4+8xvdhhV
F4br5FimWLbTe2qT4lSpadkbLm3aBlSUR7eAP0BlwQKBgQD5ayZpP5OMp1zfa4hA
fM8nVUEaVLkRwFK5NChfjHGiaye2RjrnIorXMsFxXjEscgTn2Ux9CgcBhp1fTBhy
6cmhkp1talAIqLBivNQJT0YTfA+uHrHTTyMfEUgsMzPiiAg7FV7BCG6xd/nsk3yg
ZUfoXefrhq9LIHsJx7cK12VViQKBgQD4XKvwYmX5t7fZFBPd7dv5ZrcMHQnBMHd7
is3QhgyKuEgVDzKQ9SA004I9iSvcI3dE/npj31P39N5bbuvYTh4WR/SR4VvXavNG
AqUR7wm8jTlbiWEPgF9MxC24zaa07Kbxs+P8XT/7wWuijf6+baSFgxQMb80fUArv
7guKikCo0wKBgCUn3DIDoZRrfj9eQo7wyN9gKPGmO2e0kd47MeSCBI+gjOrvbWjv
UWWbjwu3b3Xiim6LhYR/EOoeRqViraW4xCvIrqEVHFUd5CDhZmj4oUTXz3It6mnD
OUUwiuLiwdD2WNuMZHA3NF5FtDqVAhTW4a5xBtKkXsq/TPT5BoCb8+GZAoGAUWAD
0gpbgTuJ2G10qPWDaq8V8Lke9haMP4VWNCmHuHfy3juRhN9cAxL+DG2CWmmgbZG3
xjtpRsgLhwfL7J6DyyceYiHltqpLNTgun7ajiQz4qx5TGAImt39bv75aDdOwS2d2
nrxq93EDdEp0Gi7QhhJRolWLbuQKAV0MmQL9dpMCgYEA5+ug3CDI/jyTHG4ZEVoG
qmIg7QoHrVEmZrvCMiFw8bbuBvoMnvu1o1zfvAkNrDfibZyxYKHzSqgeVPQShvLa
P6JCu67ieCGP8C8CMFiQhJ9n4sYGnkzkz67NpkHSzDPA6DfvG4pYuvBQRIefnhYh
IDGpghhKHMV2DAyzeM4cDU8=
-----END PRIVATE KEY-----`
)

func envVarEnabled(envVar string) bool {
Expand Down
37 changes: 34 additions & 3 deletions authentication/http_recordings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import (
"strings"
"testing"

"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jws"

"github.com/auth0/go-auth0/authentication/oauth"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -65,12 +69,29 @@ func configureHTTPTestRecordings(t *testing.T) {
bodyMatches := false
switch r.Header.Get("Content-Type") {
case "application/json":
bodyMatches = assert.JSONEq(t, i.Body, rb)
v := map[string]string{}
err := json.Unmarshal([]byte(rb), &v)
require.NoError(t, err)

if v["client_assertion"] != "" {
verifyClientAssertion(t, v["client_assertion"])
v["client_assertion"] = "test-client_assertion"
}

body, err := json.Marshal(v)
require.NoError(t, err)

bodyMatches = assert.JSONEq(t, i.Body, string(body))
break
case "application/x-www-form-urlencoded":
err = r.ParseForm()
require.NoError(t, err)

if r.Form.Has("client_assertion") {
verifyClientAssertion(t, r.Form.Get("client_assertion"))
r.Form.Set("client_assertion", "test-client_assertion")
}

bodyMatches = assert.Equal(t, i.Form, r.Form)
break
default:
Expand Down Expand Up @@ -131,8 +152,9 @@ func redactHeaders(i *cassette.Interaction) {
func redactClientAuth(t *testing.T, i *cassette.Interaction) {
contentType := i.Request.Headers.Get("Content-Type")
clientAuthParams := map[string]bool{
"client_id": true,
"client_secret": true,
"client_id": true,
"client_secret": true,
"client_assertion": true,
}

if contentType == "application/x-www-form-urlencoded" {
Expand Down Expand Up @@ -194,3 +216,12 @@ func redactDomain(i *cassette.Interaction, domain string) {
i.Response.Body = strings.ReplaceAll(i.Response.Body, domainParts[0], recordingsDomain)
i.Request.Body = strings.ReplaceAll(i.Request.Body, domainParts[0], recordingsDomain)
}

func verifyClientAssertion(t *testing.T, clientAssertion string) {
key, err := jwk.ParseKey([]byte(jwtPublicKey), jwk.WithPEM(true))
require.NoError(t, err)

_, err = jws.Verify([]byte(clientAssertion), jws.WithKey(jwa.RS256, key))

require.NoError(t, err)
}
94 changes: 84 additions & 10 deletions authentication/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ package authentication
import (
"context"
"errors"
"fmt"
"net/url"
"time"

"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"

"github.com/auth0/go-auth0/authentication/oauth"
"github.com/auth0/go-auth0/internal/idtokenvalidator"
Expand Down Expand Up @@ -62,6 +69,10 @@ func (o *OAuth) LoginWithPassword(ctx context.Context, body oauth.LoginWithPassw
data.Set("scope", body.Scope)
}

if body.Audience != "" {
data.Set("audience", body.Audience)
}

for k, v := range body.ExtraParameters {
data.Set(k, v)
}
Expand Down Expand Up @@ -193,21 +204,84 @@ func (o *OAuth) RevokeRefreshToken(ctx context.Context, body oauth.RevokeRefresh
}

func (o *OAuth) addClientAuthentication(params oauth.ClientAuthentication, body url.Values, required bool) error {
if params.ClientID != "" {
body.Set("client_id", params.ClientID)
} else {
body.Set("client_id", o.authentication.clientID)
clientID := params.ClientID
if params.ClientID == "" {
clientID = o.authentication.clientID
}
body.Set("client_id", clientID)

if params.ClientSecret != "" && body.Get("client_secret") == "" {
body.Set("client_secret", params.ClientSecret)
} else if o.authentication.clientSecret != "" {
body.Set("client_secret", o.authentication.clientSecret)
clientSecret := params.ClientSecret
if params.ClientSecret == "" {
clientSecret = o.authentication.clientSecret
}

if required && body.Get("client_secret") == "" {
return errors.New("client_secret is required but not provided")
switch {
case o.authentication.clientAssertionSigningKey != "" && o.authentication.clientAssertionSigningAlg != "":
clientAssertion, err := createClientAssertion(
o.authentication.clientAssertionSigningAlg,
o.authentication.clientAssertionSigningKey,
clientID,
o.authentication.url.JoinPath("/").String(),
)
if err != nil {
return err
}

body.Set("client_assertion", clientAssertion)
body.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
break
case params.ClientAssertion != "":
body.Set("client_assertion", params.ClientAssertion)
body.Set("client_assertion_type", params.ClientAssertionType)
break
case clientSecret != "":
body.Set("client_secret", clientSecret)
break
}

if required && (body.Get("client_secret") == "" && body.Get("client_assertion") == "") {
return errors.New("client_secret or client_assertion is required but not provided")
}

return nil
}

func determineAlg(alg string) (jwa.SignatureAlgorithm, error) {
switch alg {
case "RS256":
return jwa.RS256, nil
default:
return "", fmt.Errorf("Unsupported client assertion algorithm \"%s\" provided", alg)
}
}

func createClientAssertion(clientAssertionSigningAlg, clientAssertionSigningKey, clientID, domain string) (string, error) {
alg, err := determineAlg(clientAssertionSigningAlg)
if err != nil {
return "", err
}

key, err := jwk.ParseKey([]byte(clientAssertionSigningKey), jwk.WithPEM(true))
if err != nil {
return "", err
}

token, err := jwt.NewBuilder().
IssuedAt(time.Now()).
Subject(clientID).
JwtID(uuid.New().String()).
Issuer(clientID).
Audience([]string{domain}).
Expiration(time.Now().Add(2 * time.Minute)).
Build()
if err != nil {
return "", err
}

b, err := jwt.Sign(token, jwt.WithKey(alg, key))
if err != nil {
return "", err
}

return string(b), nil
}
9 changes: 8 additions & 1 deletion authentication/oauth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ type ClientAuthentication struct {
// ClientSecret to use for the specific request. Required when Client Secret Basic or Client
// Secret Post is the application authentication method.
ClientSecret string `json:"client_secret,omitempty"`
// ClientAssertion to use for the specific request. Required if `Private Key JWT` is the
// authentication method.
ClientAssertion string `json:"client_assertion,omitempty"`
// ClientAssertionType to use for the specific request. Required if you are passing your own
// ClientAssertion.
ClientAssertionType string `json:"client_assertion_type,omitempty"`
}

// TokenSet defines the response of the OAuth endpoints.
Expand All @@ -21,7 +27,8 @@ type TokenSet struct {
IDToken string `json:"id_token,omitempty"`
// The refresh token, only available if `offline_access` scope was provided.
RefreshToken string `json:"refresh_token,omitempty"`
//
// String value of the different scopes the application is asking for.
// Multiple scopes are separated with whitespace.
Scope string `json:"scope,omitempty"`
// The type of the access token.
TokenType string `json:"token_type,omitempty"`
Expand Down
Loading

0 comments on commit a98b898

Please sign in to comment.