diff --git a/authentication/authentication.go b/authentication/authentication.go index a465b167..97087684 100644 --- a/authentication/authentication.go +++ b/authentication/authentication.go @@ -3,12 +3,20 @@ package authentication import ( "context" "encoding/json" + "errors" + "fmt" "net/http" "net/url" "reflect" "strings" "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/client" "github.com/auth0/go-auth0/internal/idtokenvalidator" ) @@ -112,6 +120,7 @@ func (u *UserInfoResponse) UnmarshalJSON(b []byte) error { // Authentication is the auth client. type Authentication struct { Database *Database + MFA *MFA OAuth *OAuth Passwordless *Passwordless @@ -172,6 +181,7 @@ func New(ctx context.Context, domain string, options ...Option) (*Authentication a.common.authentication = a a.Database = (*Database)(&a.common) + a.MFA = (*MFA)(&a.common) a.OAuth = (*OAuth)(&a.common) a.Passwordless = (*Passwordless)(&a.common) @@ -214,3 +224,131 @@ func (a *Authentication) UserInfo(ctx context.Context, accessToken string, opts err = a.Request(ctx, "GET", a.URI("userinfo"), nil, &user, opts...) return } + +// Helper for adding values to a url.Values instance if they are not empty. +func addIfNotEmpty(key string, value string, qs url.Values) { + if value != "" { + qs.Set(key, value) + } +} + +// Helper for enforcing that required values are set. +func check(errors *[]string, key string, c bool) { + if !c { + *errors = append(*errors, key) + } +} + +// Helper for adding client authentication into a url.Values instance. +func (a *Authentication) addClientAuthenticationToURLValues(params oauth.ClientAuthentication, body url.Values, required bool) error { + clientID := params.ClientID + if params.ClientID == "" { + clientID = a.clientID + } + body.Set("client_id", clientID) + + clientSecret := params.ClientSecret + if params.ClientSecret == "" { + clientSecret = a.clientSecret + } + + switch { + case a.clientAssertionSigningKey != "" && a.clientAssertionSigningAlg != "": + clientAssertion, err := createClientAssertion( + a.clientAssertionSigningAlg, + a.clientAssertionSigningKey, + clientID, + a.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 != "" && params.ClientAssertionType != "": + 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 +} + +// Helper for adding client authentication to an oauth.ClientAuthentication struct. +func (a *Authentication) addClientAuthenticationToClientAuthStruct(params *oauth.ClientAuthentication, required bool) error { + if params.ClientID == "" { + params.ClientID = a.clientID + } + + if a.clientAssertionSigningKey != "" && a.clientAssertionSigningAlg != "" { + clientAssertion, err := createClientAssertion( + a.clientAssertionSigningAlg, + a.clientAssertionSigningKey, + params.ClientID, + a.url.JoinPath("/").String(), + ) + if err != nil { + return err + } + + params.ClientAssertion = clientAssertion + params.ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + } else if params.ClientSecret == "" && a.clientSecret != "" { + params.ClientSecret = a.clientSecret + } + + if required && (params.ClientSecret == "" && params.ClientAssertion == "") { + 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 +} diff --git a/authentication/authentication_error.go b/authentication/authentication_error.go index 7d47a1a8..8c24446c 100644 --- a/authentication/authentication_error.go +++ b/authentication/authentication_error.go @@ -29,7 +29,9 @@ func newError(response *http.Response) error { // If that happens we still want to display the correct code. if apiError.Status() == 0 { apiError.StatusCode = response.StatusCode - apiError.Err = http.StatusText(response.StatusCode) + if apiError.Err == "" { + apiError.Err = http.StatusText(response.StatusCode) + } } return apiError diff --git a/authentication/http_recordings_test.go b/authentication/http_recordings_test.go index 44fb975e..f7a3cfff 100644 --- a/authentication/http_recordings_test.go +++ b/authentication/http_recordings_test.go @@ -12,7 +12,7 @@ import ( "github.com/lestrrat-go/jwx/v2/jwk" "github.com/lestrrat-go/jwx/v2/jws" - "github.com/auth0/go-auth0/authentication/oauth" + "github.com/auth0/go-auth0/authentication/mfa" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -189,7 +189,13 @@ func redactTokens(t *testing.T, i *cassette.Interaction) { return } - tokenSet := &oauth.TokenSet{} + if i.Response.Code >= http.StatusBadRequest { + return + } + + // We use mfa.VerifyWithRecoveryCodeResponse here as we don't want to lose the RecoveryCode + // property when anonymizing the tokenset + tokenSet := &mfa.VerifyWithRecoveryCodeResponse{} err := json.Unmarshal([]byte(i.Response.Body), tokenSet) require.NoError(t, err) diff --git a/authentication/mfa.go b/authentication/mfa.go new file mode 100644 index 00000000..1ab29c22 --- /dev/null +++ b/authentication/mfa.go @@ -0,0 +1,137 @@ +package authentication + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/auth0/go-auth0/authentication/mfa" + "github.com/auth0/go-auth0/authentication/oauth" +) + +// MFA exposes requesting an MFA challenge and verifying MFA methods. +type MFA manager + +// Challenge requests a challenge for multi-factor authentication (MFA) based on the challenge types supported by the application and user. +// +// See: https://auth0.com/docs/api/authentication#challenge-request +func (m *MFA) Challenge(ctx context.Context, body mfa.ChallengeRequest, opts ...RequestOption) (c *mfa.ChallengeResponse, err error) { + missing := []string{} + check(&missing, "ClientID", (body.ClientID != "" || m.authentication.clientID != "")) + check(&missing, "MFAToken", body.MFAToken != "") + check(&missing, "ChallengeType", body.ChallengeType != "") + + if len(missing) > 0 { + return nil, fmt.Errorf("Missing required fields: %s", strings.Join(missing, ", ")) + } + + err = m.authentication.addClientAuthenticationToClientAuthStruct(&body.ClientAuthentication, false) + + if err != nil { + return nil, err + } + + err = m.authentication.Request(ctx, "POST", m.authentication.URI("mfa", "challenge"), body, &c, opts...) + + if err != nil { + return nil, err + } + + return +} + +// VerifyWithOTP verifies an MFA challenge using a one-time password (OTP). +// +// See: https://auth0.com/docs/api/authentication#verify-with-one-time-password-otp- +func (m *MFA) VerifyWithOTP(ctx context.Context, body mfa.VerifyWithOTPRequest, opts ...RequestOption) (t *oauth.TokenSet, err error) { + missing := []string{} + check(&missing, "ClientID", (body.ClientID != "" || m.authentication.clientID != "")) + check(&missing, "MFAToken", body.MFAToken != "") + check(&missing, "OTP", body.OTP != "") + + if len(missing) > 0 { + return nil, fmt.Errorf("Missing required fields: %s", strings.Join(missing, ", ")) + } + + data := url.Values{ + "mfa_token": []string{body.MFAToken}, + "grant_type": []string{"http://auth0.com/oauth/grant-type/mfa-otp"}, + "otp": []string{body.OTP}, + } + + err = m.authentication.addClientAuthenticationToURLValues(body.ClientAuthentication, data, true) + + if err != nil { + return nil, err + } + + err = m.authentication.Request(ctx, "POST", m.authentication.URI("oauth", "token"), data, &t, opts...) + + return +} + +// VerifyWithOOB verifies an MFA challenge using an out-of-band challenge (OOB), either push notification, +// SMS, or voice. +// +// See: https://auth0.com/docs/api/authentication#verify-with-out-of-band-oob- +func (m *MFA) VerifyWithOOB(ctx context.Context, body mfa.VerifyWithOOBRequest, opts ...RequestOption) (t *oauth.TokenSet, err error) { + missing := []string{} + check(&missing, "ClientID", (body.ClientID != "" || m.authentication.clientID != "")) + check(&missing, "MFAToken", body.MFAToken != "") + check(&missing, "OOBCode", body.OOBCode != "") + + if len(missing) > 0 { + return nil, fmt.Errorf("Missing required fields: %s", strings.Join(missing, ", ")) + } + + data := url.Values{ + "mfa_token": []string{body.MFAToken}, + "grant_type": []string{"http://auth0.com/oauth/grant-type/mfa-oob"}, + "oob_code": []string{body.OOBCode}, + } + + if body.BindingCode != "" { + data.Set("binding_code", body.BindingCode) + } + + err = m.authentication.addClientAuthenticationToURLValues(body.ClientAuthentication, data, true) + + if err != nil { + return nil, err + } + + err = m.authentication.Request(ctx, "POST", m.authentication.URI("oauth", "token"), data, &t, opts...) + + return +} + +// VerifyWithRecoveryCode verifies an MFA challenge using a recovery code. +// +// See: https://auth0.com/docs/api/authentication#verify-with-recovery-code +func (m *MFA) VerifyWithRecoveryCode(ctx context.Context, body mfa.VerifyWithRecoveryCodeRequest, opts ...RequestOption) (t *mfa.VerifyWithRecoveryCodeResponse, err error) { + missing := []string{} + check(&missing, "ClientID", (body.ClientID != "" || m.authentication.clientID != "")) + check(&missing, "MFAToken", body.MFAToken != "") + check(&missing, "RecoveryCode", body.RecoveryCode != "") + + if len(missing) > 0 { + return nil, fmt.Errorf("Missing required fields: %s", strings.Join(missing, ", ")) + } + + data := url.Values{ + "mfa_token": []string{body.MFAToken}, + "grant_type": []string{"http://auth0.com/oauth/grant-type/mfa-recovery-code"}, + "recovery_code": []string{body.RecoveryCode}, + } + + err = m.authentication.addClientAuthenticationToURLValues(body.ClientAuthentication, data, true) + + if err != nil { + return nil, err + } + + err = m.authentication.Request(ctx, "POST", m.authentication.URI("oauth", "token"), data, &t, opts...) + + return +} diff --git a/authentication/mfa/mfa.go b/authentication/mfa/mfa.go new file mode 100644 index 00000000..5dac9fc4 --- /dev/null +++ b/authentication/mfa/mfa.go @@ -0,0 +1,62 @@ +package mfa + +import ( + "github.com/auth0/go-auth0/authentication/oauth" +) + +// ChallengeRequest defines the request body for requesting an MFA challenge. +type ChallengeRequest struct { + oauth.ClientAuthentication + // The token received from the `mfa_required` error. + MFAToken string `json:"mfa_token,omitempty"` + // A whitespace-separated list of the challenges types accepted by your application. + // Accepted challenge types are "oob" or "otp". Excluding this parameter means that your + // client application accepts all supported challenge types. + ChallengeType string `json:"challenge_type,omitempty"` + // The ID of the authenticator to challenge. You can get the ID by querying the list of + // available authenticators for the user using `management.User.ListAuthenticationMethods`. + AuthenticatorID string `json:"authenticator_id,omitempty"` +} + +// ChallengeResponse defines the response body when requesting an MFA challenge. +type ChallengeResponse struct { + // The type of challenge requested. + ChallengeType string `json:"challenge_type,omitempty"` + // The OOB code to use when calling `VerifyWithOOBRequest` + // Only present when `ChallengeType` is "oob". + OOBCode string `json:"oob_code,omitempty"` + /// If included, then the user should be prompted for a `BindingCode` which should be included + // in the `VerifyWithOOBRequest` provided to `VerifyWithOOB`. + // Only present when `ChallengeType` is "oob". + BindingMethod string `json:"binding_method,omitempty"` +} + +// VerifyWithOTPRequest defines the request body for verifying an MFA challenge with OTP. +type VerifyWithOTPRequest struct { + oauth.ClientAuthentication + MFAToken string + OTP string +} + +// VerifyWithOOBRequest defines the request body for verifying an MFA challenge with an OOB challenge. +type VerifyWithOOBRequest struct { + oauth.ClientAuthentication + MFAToken string + OOBCode string + BindingCode string +} + +// VerifyWithRecoveryCodeRequest defines the request body for verifying an MFA challenge with a +// recovery code. +type VerifyWithRecoveryCodeRequest struct { + oauth.ClientAuthentication + MFAToken string + RecoveryCode string +} + +// VerifyWithRecoveryCodeResponse defines the response when verifying with a recovery code. +type VerifyWithRecoveryCodeResponse struct { + oauth.TokenSet + // If present, a new recovery code that should be presented to the user to store. + RecoveryCode string `json:"recovery_code,omitempty"` +} diff --git a/authentication/mfa_test.go b/authentication/mfa_test.go new file mode 100644 index 00000000..f7f27013 --- /dev/null +++ b/authentication/mfa_test.go @@ -0,0 +1,177 @@ +package authentication + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/go-auth0/authentication/mfa" + "github.com/auth0/go-auth0/authentication/oauth" +) + +func TestMFAChallenge(t *testing.T) { + t.Run("Should require ClientID, MFAToken, and ChallengeType", func(t *testing.T) { + auth, err := New( + context.Background(), + domain, + ) + require.NoError(t, err) + + _, err = auth.MFA.Challenge(context.Background(), mfa.ChallengeRequest{}) + assert.ErrorContains(t, err, "Missing required fields: ClientID, MFAToken, ChallengeType") + }) + + t.Run("Should make a challenge request using OTP", func(t *testing.T) { + skipE2E(t) + configureHTTPTestRecordings(t, authAPI) + + r, err := authAPI.MFA.Challenge(context.Background(), mfa.ChallengeRequest{ + ClientAuthentication: oauth.ClientAuthentication{ + ClientSecret: clientSecret, + }, + MFAToken: "mfa-token", + ChallengeType: "otp", + AuthenticatorID: "totp|dev_id", + }) + + require.NoError(t, err) + assert.Equal(t, "otp", r.ChallengeType) + }) + + t.Run("Should make a challenge request using OOB", func(t *testing.T) { + skipE2E(t) + configureHTTPTestRecordings(t, authAPI) + + r, err := authAPI.MFA.Challenge(context.Background(), mfa.ChallengeRequest{ + ClientAuthentication: oauth.ClientAuthentication{ + ClientSecret: clientSecret, + }, + MFAToken: "mfa-token", + ChallengeType: "oob", + AuthenticatorID: "push|dev_ADbB4Y2ozSOwynKu", + }) + + require.NoError(t, err) + assert.Equal(t, "oob", r.ChallengeType) + }) +} + +func TestMFAVerifyWithOTP(t *testing.T) { + t.Run("Should require ClientID, MFAToken, and OTP", func(t *testing.T) { + auth, err := New( + context.Background(), + domain, + ) + require.NoError(t, err) + + _, err = auth.MFA.VerifyWithOTP(context.Background(), mfa.VerifyWithOTPRequest{}) + assert.ErrorContains(t, err, "Missing required fields: ClientID, MFAToken, OTP") + }) + + t.Run("Should return tokens for a valid request", func(t *testing.T) { + skipE2E(t) + configureHTTPTestRecordings(t, authAPI) + + tokenset, err := authAPI.MFA.VerifyWithOTP(context.Background(), mfa.VerifyWithOTPRequest{ + ClientAuthentication: oauth.ClientAuthentication{ + ClientSecret: clientSecret, + }, + MFAToken: "mfa-token", + OTP: "853860", + }) + + require.NoError(t, err) + assert.NotEmpty(t, tokenset) + }) +} + +func TestMFAVerifyWithOOB(t *testing.T) { + t.Run("Should require ClientID, MFAToken, and OOB", func(t *testing.T) { + auth, err := New( + context.Background(), + domain, + ) + require.NoError(t, err) + + _, err = auth.MFA.VerifyWithOOB(context.Background(), mfa.VerifyWithOOBRequest{}) + assert.ErrorContains(t, err, "Missing required fields: ClientID, MFAToken, OOB") + }) + + t.Run("Should return an error when requesting before authorizing", func(t *testing.T) { + skipE2E(t) + configureHTTPTestRecordings(t, authAPI) + + _, err := authAPI.MFA.VerifyWithOOB(context.Background(), mfa.VerifyWithOOBRequest{ + ClientAuthentication: oauth.ClientAuthentication{ + ClientSecret: clientSecret, + }, + MFAToken: "mfa-token", + OOBCode: "oob-token", + }) + + assert.ErrorContains(t, err, "Authorization pending: please repeat the request in a few seconds.") + }) + + t.Run("Should return an error when requesting when authorization has been denied", func(t *testing.T) { + skipE2E(t) + configureHTTPTestRecordings(t, authAPI) + + _, err := authAPI.MFA.VerifyWithOOB(context.Background(), mfa.VerifyWithOOBRequest{ + ClientAuthentication: oauth.ClientAuthentication{ + ClientSecret: clientSecret, + }, + MFAToken: "mfa-token", + OOBCode: "oob-token", + }) + + assert.ErrorContains(t, err, "MFA Authorization rejected") + }) + + t.Run("Should return tokens when authorization is approved", func(t *testing.T) { + skipE2E(t) + configureHTTPTestRecordings(t, authAPI) + + tokenset, err := authAPI.MFA.VerifyWithOOB(context.Background(), mfa.VerifyWithOOBRequest{ + ClientAuthentication: oauth.ClientAuthentication{ + ClientSecret: clientSecret, + }, + MFAToken: "mfa-token", + OOBCode: "oob-token", + }) + + require.NoError(t, err) + assert.NotEmpty(t, tokenset) + }) +} + +func TestMFAVerifyWithRecoveryCode(t *testing.T) { + t.Run("Should require ClientID, MFAToken, and OOB", func(t *testing.T) { + auth, err := New( + context.Background(), + domain, + ) + require.NoError(t, err) + + _, err = auth.MFA.VerifyWithRecoveryCode(context.Background(), mfa.VerifyWithRecoveryCodeRequest{}) + assert.ErrorContains(t, err, "Missing required fields: ClientID, MFAToken, RecoveryCode") + }) + + t.Run("Should return tokens for a valid request", func(t *testing.T) { + skipE2E(t) + configureHTTPTestRecordings(t, authAPI) + + tokenset, err := authAPI.MFA.VerifyWithRecoveryCode(context.Background(), mfa.VerifyWithRecoveryCodeRequest{ + ClientAuthentication: oauth.ClientAuthentication{ + ClientSecret: clientSecret, + }, + MFAToken: "mfa-token", + RecoveryCode: "M67LWV6ZJGNDUGH43BADW46N", + }) + + require.NoError(t, err) + assert.NotEmpty(t, tokenset) + assert.NotEmpty(t, tokenset.RecoveryCode) + }) +} diff --git a/authentication/oauth.go b/authentication/oauth.go index 1d4ffec1..236a43ee 100644 --- a/authentication/oauth.go +++ b/authentication/oauth.go @@ -2,16 +2,9 @@ package authentication import ( "context" - "errors" "fmt" "net/url" "strings" - "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" @@ -78,7 +71,7 @@ func (o *OAuth) LoginWithPassword(ctx context.Context, body oauth.LoginWithPassw data.Set(k, v) } - err = o.addClientAuthentication(body.ClientAuthentication, data, false) + err = o.authentication.addClientAuthenticationToURLValues(body.ClientAuthentication, data, false) if err != nil { return @@ -103,7 +96,7 @@ func (o *OAuth) LoginWithAuthCode(ctx context.Context, body oauth.LoginWithAuthC data.Set("redirect_uri", body.RedirectURI) } - err = o.addClientAuthentication(body.ClientAuthentication, data, true) + err = o.authentication.addClientAuthenticationToURLValues(body.ClientAuthentication, data, true) if err != nil { return @@ -130,7 +123,7 @@ func (o *OAuth) LoginWithAuthCodeWithPKCE(ctx context.Context, body oauth.LoginW data.Set("redirect_uri", body.RedirectURI) } - err = o.addClientAuthentication(body.ClientAuthentication, data, false) + err = o.authentication.addClientAuthenticationToURLValues(body.ClientAuthentication, data, false) if err != nil { return @@ -155,7 +148,7 @@ func (o *OAuth) LoginWithClientCredentials(ctx context.Context, body oauth.Login data.Set("organization", body.Organization) } - err = o.addClientAuthentication(body.ClientAuthentication, data, true) + err = o.authentication.addClientAuthenticationToURLValues(body.ClientAuthentication, data, true) if err != nil { return @@ -177,7 +170,7 @@ func (o *OAuth) RefreshToken(ctx context.Context, body oauth.RefreshTokenRequest data.Set("scope", body.Scope) } - err = o.addClientAuthentication(body.ClientAuthentication, data, false) + err = o.authentication.addClientAuthenticationToURLValues(body.ClientAuthentication, data, false) if err != nil { return @@ -240,7 +233,7 @@ func (o *OAuth) PushedAuthorization(ctx context.Context, body oauth.PushedAuthor data.Set(key, value) } - err = o.addClientAuthentication(body.ClientAuthentication, data, true) + err = o.authentication.addClientAuthenticationToURLValues(body.ClientAuthentication, data, true) if err != nil { return nil, err @@ -254,98 +247,3 @@ func (o *OAuth) PushedAuthorization(ctx context.Context, body oauth.PushedAuthor return } - -func (o *OAuth) addClientAuthentication(params oauth.ClientAuthentication, body url.Values, required bool) error { - clientID := params.ClientID - if params.ClientID == "" { - clientID = o.authentication.clientID - } - body.Set("client_id", clientID) - - clientSecret := params.ClientSecret - if params.ClientSecret == "" { - clientSecret = o.authentication.clientSecret - } - - 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 -} - -func addIfNotEmpty(key string, value string, qs url.Values) { - if value != "" { - qs.Set(key, value) - } -} - -func check(errors *[]string, key string, c bool) { - if !c { - *errors = append(*errors, key) - } -} diff --git a/authentication/passwordless.go b/authentication/passwordless.go index c9e3d535..07937a55 100644 --- a/authentication/passwordless.go +++ b/authentication/passwordless.go @@ -18,7 +18,7 @@ type Passwordless manager // // See: https://auth0.com/docs/api/authentication?http#get-code-or-link func (p *Passwordless) SendEmail(ctx context.Context, params passwordless.SendEmailRequest, opts ...RequestOption) (r *passwordless.SendEmailResponse, err error) { - err = p.addClientAuthentication(¶ms.ClientAuthentication) + err = p.authentication.addClientAuthenticationToClientAuthStruct(¶ms.ClientAuthentication, false) if err != nil { return nil, err } @@ -33,7 +33,7 @@ func (p *Passwordless) SendEmail(ctx context.Context, params passwordless.SendEm // // See: https://auth0.com/docs/api/authentication?http#authenticate-user func (p *Passwordless) LoginWithEmail(ctx context.Context, params passwordless.LoginWithEmailRequest, validationOptions oauth.IDTokenValidationOptions, opts ...RequestOption) (t *oauth.TokenSet, err error) { - err = p.addClientAuthentication(¶ms.ClientAuthentication) + err = p.authentication.addClientAuthenticationToClientAuthStruct(¶ms.ClientAuthentication, false) if err != nil { return nil, err } @@ -64,7 +64,7 @@ func (p *Passwordless) LoginWithEmail(ctx context.Context, params passwordless.L // // See: https://auth0.com/docs/api/authentication?http#get-code-or-link func (p *Passwordless) SendSMS(ctx context.Context, params passwordless.SendSMSRequest, opts ...RequestOption) (r *passwordless.SendSMSResponse, err error) { - err = p.addClientAuthentication(¶ms.ClientAuthentication) + err = p.authentication.addClientAuthenticationToClientAuthStruct(¶ms.ClientAuthentication, false) if err != nil { return nil, err } @@ -79,7 +79,7 @@ func (p *Passwordless) SendSMS(ctx context.Context, params passwordless.SendSMSR // // See: https://auth0.com/docs/api/authentication?http#authenticate-user func (p *Passwordless) LoginWithSMS(ctx context.Context, params passwordless.LoginWithSMSRequest, validationOptions oauth.IDTokenValidationOptions, opts ...RequestOption) (t *oauth.TokenSet, err error) { - err = p.addClientAuthentication(¶ms.ClientAuthentication) + err = p.authentication.addClientAuthenticationToClientAuthStruct(¶ms.ClientAuthentication, false) if err != nil { return nil, err @@ -104,28 +104,3 @@ func (p *Passwordless) LoginWithSMS(ctx context.Context, params passwordless.Log return } - -func (p *Passwordless) addClientAuthentication(params *oauth.ClientAuthentication) error { - if params.ClientID == "" { - params.ClientID = p.authentication.clientID - } - - if p.authentication.clientAssertionSigningKey != "" && p.authentication.clientAssertionSigningAlg != "" { - clientAssertion, err := createClientAssertion( - p.authentication.clientAssertionSigningAlg, - p.authentication.clientAssertionSigningKey, - params.ClientID, - p.authentication.url.JoinPath("/").String(), - ) - if err != nil { - return err - } - - params.ClientAssertion = clientAssertion - params.ClientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - } else if params.ClientSecret == "" && p.authentication.clientSecret != "" { - params.ClientSecret = p.authentication.clientSecret - } - - return nil -} diff --git a/test/data/recordings/authentication/TestMFAChallenge/Should_make_a_challenge_request_using_OOB.yaml b/test/data/recordings/authentication/TestMFAChallenge/Should_make_a_challenge_request_using_OOB.yaml new file mode 100644 index 00000000..b6fabf09 --- /dev/null +++ b/test/data/recordings/authentication/TestMFAChallenge/Should_make_a_challenge_request_using_OOB.yaml @@ -0,0 +1,36 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 2203 + transfer_encoding: [] + trailer: {} + host: go-auth0-dev.eu.auth0.com + remote_addr: "" + request_uri: "" + body: '{"authenticator_id":"push|dev_ADbB4Y2ozSOwynKu","challenge_type":"oob","client_id":"test-client_id","client_secret":"test-client_secret","mfa_token":"mfa-token"}' + form: {} + headers: + Content-Type: + - application/json + url: https://go-auth0-dev.eu.auth0.com/mfa/challenge + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: false + body: '{"challenge_type":"oob","oob_code":"mfa-token"}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: 200 OK + code: 200 + duration: 395.86025ms diff --git a/test/data/recordings/authentication/TestMFAChallenge/Should_make_a_challenge_request_using_OTP.yaml b/test/data/recordings/authentication/TestMFAChallenge/Should_make_a_challenge_request_using_OTP.yaml new file mode 100644 index 00000000..e8886bbf --- /dev/null +++ b/test/data/recordings/authentication/TestMFAChallenge/Should_make_a_challenge_request_using_OTP.yaml @@ -0,0 +1,36 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 2203 + transfer_encoding: [] + trailer: {} + host: go-auth0-dev.eu.auth0.com + remote_addr: "" + request_uri: "" + body: '{"authenticator_id":"totp|dev_id","challenge_type":"otp","client_id":"test-client_id","client_secret":"test-client_secret","mfa_token":"mfa-token"}' + form: {} + headers: + Content-Type: + - application/json + url: https://go-auth0-dev.eu.auth0.com/mfa/challenge + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: 24 + uncompressed: false + body: '{"challenge_type":"otp"}' + headers: + Content-Type: + - application/json; charset=utf-8 + status: 200 OK + code: 200 + duration: 331.64075ms diff --git a/test/data/recordings/authentication/TestMFAVerifyWithOOB/Should_return_an_error_when_requesting_before_authorizing.yaml b/test/data/recordings/authentication/TestMFAVerifyWithOOB/Should_return_an_error_when_requesting_before_authorizing.yaml new file mode 100644 index 00000000..3c434a27 --- /dev/null +++ b/test/data/recordings/authentication/TestMFAVerifyWithOOB/Should_return_an_error_when_requesting_before_authorizing.yaml @@ -0,0 +1,46 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 2841 + transfer_encoding: [] + trailer: {} + host: go-auth0-dev.eu.auth0.com + remote_addr: "" + request_uri: "" + body: client_id=test-client_id&client_secret=test-client_secret&grant_type=http%3A%2F%2Fauth0.com%2Foauth%2Fgrant-type%2Fmfa-oob&mfa_token=mfa-token + form: + client_id: + - test-client_id + client_secret: + - test-client_secret + grant_type: + - http://auth0.com/oauth/grant-type/mfa-oob + mfa_token: + - mfa-token + oob_code: + - oob-token + headers: + Content-Type: + - application/x-www-form-urlencoded + url: https://go-auth0-dev.eu.auth0.com/oauth/token + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: 122 + uncompressed: false + body: '{"error":"authorization_pending","error_description":"Authorization pending: please repeat the request in a few seconds."}' + headers: + Content-Type: + - application/json + status: 400 Bad Request + code: 400 + duration: 321.844959ms diff --git a/test/data/recordings/authentication/TestMFAVerifyWithOOB/Should_return_an_error_when_requesting_when_authorization_has_been_denied.yaml b/test/data/recordings/authentication/TestMFAVerifyWithOOB/Should_return_an_error_when_requesting_when_authorization_has_been_denied.yaml new file mode 100644 index 00000000..227a116c --- /dev/null +++ b/test/data/recordings/authentication/TestMFAVerifyWithOOB/Should_return_an_error_when_requesting_when_authorization_has_been_denied.yaml @@ -0,0 +1,46 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 2841 + transfer_encoding: [] + trailer: {} + host: go-auth0-dev.eu.auth0.com + remote_addr: "" + request_uri: "" + body: client_id=test-client_id&client_secret=test-client_secret&grant_type=http%3A%2F%2Fauth0.com%2Foauth%2Fgrant-type%2Fmfa-oob&mfa_token=mfa-token + form: + client_id: + - test-client_id + client_secret: + - test-client_secret + grant_type: + - http://auth0.com/oauth/grant-type/mfa-oob + mfa_token: + - mfa-token + oob_code: + - oob-token + headers: + Content-Type: + - application/x-www-form-urlencoded + url: https://go-auth0-dev.eu.auth0.com/oauth/token + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: 75 + uncompressed: false + body: '{"error":"access_denied","error_description":"MFA Authorization rejected."}' + headers: + Content-Type: + - application/json + status: 400 Bad Request + code: 400 + duration: 312.522583ms diff --git a/test/data/recordings/authentication/TestMFAVerifyWithOOB/Should_return_tokens_when_authorization_is_approved.yaml b/test/data/recordings/authentication/TestMFAVerifyWithOOB/Should_return_tokens_when_authorization_is_approved.yaml new file mode 100644 index 00000000..b23e1947 --- /dev/null +++ b/test/data/recordings/authentication/TestMFAVerifyWithOOB/Should_return_tokens_when_authorization_is_approved.yaml @@ -0,0 +1,46 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 2841 + transfer_encoding: [] + trailer: {} + host: go-auth0-dev.eu.auth0.com + remote_addr: "" + request_uri: "" + body: client_id=test-client_id&client_secret=test-client_secret&grant_type=http%3A%2F%2Fauth0.com%2Foauth%2Fgrant-type%2Fmfa-oob&mfa_token=mfa-token + form: + client_id: + - test-client_id + client_secret: + - test-client_secret + grant_type: + - http://auth0.com/oauth/grant-type/mfa-oob + mfa_token: + - mfa-token + oob_code: + - oob-token + headers: + Content-Type: + - application/x-www-form-urlencoded + url: https://go-auth0-dev.eu.auth0.com/oauth/token + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: true + body: '{"access_token":"test-access-token","expires_in":86400,"scope":"openid profile","token_type":"Bearer"}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 430.221333ms diff --git a/test/data/recordings/authentication/TestMFAVerifyWithOTP/Should_return_tokens_for_a_valid_request.yaml b/test/data/recordings/authentication/TestMFAVerifyWithOTP/Should_return_tokens_for_a_valid_request.yaml new file mode 100644 index 00000000..ac626c34 --- /dev/null +++ b/test/data/recordings/authentication/TestMFAVerifyWithOTP/Should_return_tokens_for_a_valid_request.yaml @@ -0,0 +1,46 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 2208 + transfer_encoding: [] + trailer: {} + host: go-auth0-dev.eu.auth0.com + remote_addr: "" + request_uri: "" + body: client_id=test-client_id&client_secret=test-client_secret&grant_type=http%3A%2F%2Fauth0.com%2Foauth%2Fgrant-type%2Fmfa-otp&mfa_token=mfa-token&otp=853860 + form: + client_id: + - test-client_id + client_secret: + - test-client_secret + grant_type: + - http://auth0.com/oauth/grant-type/mfa-otp + mfa_token: + - mfa-token + otp: + - "853860" + headers: + Content-Type: + - application/x-www-form-urlencoded + url: https://go-auth0-dev.eu.auth0.com/oauth/token + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: true + body: '{"access_token":"test-access-token","expires_in":86400,"scope":"openid profile","token_type":"Bearer"}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 388.255208ms diff --git a/test/data/recordings/authentication/TestMFAVerifyWithRecoveryCode/Should_return_tokens_for_a_valid_request.yaml b/test/data/recordings/authentication/TestMFAVerifyWithRecoveryCode/Should_return_tokens_for_a_valid_request.yaml new file mode 100644 index 00000000..f3455edc --- /dev/null +++ b/test/data/recordings/authentication/TestMFAVerifyWithRecoveryCode/Should_return_tokens_for_a_valid_request.yaml @@ -0,0 +1,46 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 2246 + transfer_encoding: [] + trailer: {} + host: go-auth0-dev.eu.auth0.com + remote_addr: "" + request_uri: "" + body: client_id=test-client_id&client_secret=test-client_secret&grant_type=http%3A%2F%2Fauth0.com%2Foauth%2Fgrant-type%2Fmfa-recovery-code&mfa_token=mfa-token + form: + client_id: + - test-client_id + client_secret: + - test-client_secret + grant_type: + - http://auth0.com/oauth/grant-type/mfa-recovery-code + mfa_token: + - mfa-token + recovery_code: + - M67LWV6ZJGNDUGH43BADW46N + headers: + Content-Type: + - application/x-www-form-urlencoded + url: https://go-auth0-dev.eu.auth0.com/oauth/token + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + transfer_encoding: [] + trailer: {} + content_length: -1 + uncompressed: true + body: '{"access_token":"test-access-token","expires_in":86400,"scope":"openid profile","token_type":"Bearer","recovery_code":"17EQHNZC2SZDYG7X4QXVX9SW"}' + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 542.712042ms