Skip to content

Commit

Permalink
Add Support for MFA Authentication Endpoints: Add, List, and Delete a…
Browse files Browse the repository at this point in the history
…n Authenticator (#447)
  • Loading branch information
developerkunal authored Sep 23, 2024
1 parent 720c3a0 commit cf30fcd
Show file tree
Hide file tree
Showing 11 changed files with 403 additions and 8 deletions.
9 changes: 9 additions & 0 deletions authentication/authentication_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type Error struct {
StatusCode int `json:"statusCode"`
Err string `json:"error"`
Message string `json:"error_description"`
MFAToken string `json:"mfa_token,omitempty"`
}

func newError(response *http.Response) error {
Expand Down Expand Up @@ -42,6 +43,14 @@ func (a *Error) Error() string {
return fmt.Sprintf("%d %s: %s", a.StatusCode, a.Err, a.Message)
}

// GetMFAToken returns the MFA token associated with the error, if any.
func (a *Error) GetMFAToken() string {
if a == nil || a.MFAToken == "" {
return ""
}
return a.MFAToken
}

// Status returns the status code of the error.
func (a *Error) Status() int {
return a.StatusCode
Expand Down
35 changes: 30 additions & 5 deletions authentication/authentication_error_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package authentication

import (
"errors"
"io"
"net/http"
"strings"
Expand All @@ -11,9 +12,10 @@ import (

func Test_newError(t *testing.T) {
var testCases = []struct {
name string
givenResponse http.Response
expectedError Error
name string
givenResponse http.Response
expectedError Error
expectedMFAToken string
}{
{
name: "it fails to decode if body is not json",
Expand All @@ -26,6 +28,7 @@ func Test_newError(t *testing.T) {
Err: "Forbidden",
Message: "failed to decode json error response payload: invalid character 'H' looking for beginning of value",
},
expectedMFAToken: "",
},
{
name: "it correctly decodes the error response payload",
Expand All @@ -38,6 +41,7 @@ func Test_newError(t *testing.T) {
Err: "invalid_scope",
Message: "Scope must be an array or a string",
},
expectedMFAToken: "",
},
{
name: "it will still post the correct status code if the body doesn't have the correct structure",
Expand All @@ -50,6 +54,7 @@ func Test_newError(t *testing.T) {
Err: "Internal Server Error",
Message: "",
},
expectedMFAToken: "",
},
{
name: "it will handle an invalid sign up response",
Expand All @@ -62,6 +67,7 @@ func Test_newError(t *testing.T) {
Err: "invalid_signup",
Message: "Invalid sign up",
},
expectedMFAToken: "",
},
{
name: "it will handle invalid password response",
Expand All @@ -74,13 +80,32 @@ func Test_newError(t *testing.T) {
Err: "invalid_password",
Message: `{"rules":[{"message":"At least %d characters in length","format":[8],"code":"lengthAtLeast","verified":true},{"message":"Contain at least %d of the following %d types of characters:","code":"containsAtLeast","format":[3,4],"items":[{"message":"lower case letters (a-z)","code":"lowerCase","verified":true},{"message":"upper case letters (A-Z)","code":"upperCase","verified":false},{"message":"numbers (i.e. 0-9)","code":"numbers","verified":false},{"message":"special characters (e.g. !@#$%^&*)","code":"specialCharacters","verified":true}],"verified":false}],"verified":false}`,
},
expectedMFAToken: "",
},
{
name: "it will handle invalid password response with MFA token",
givenResponse: http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser((strings.NewReader(`{"name":"PasswordStrengthError","message":"Password is too weak","code":"invalid_password","description":{"rules":[{"message":"At least %d characters in length","format":[8],"code":"lengthAtLeast","verified":true},{"message":"Contain at least %d of the following %d types of characters:","code":"containsAtLeast","format":[3,4],"items":[{"message":"lower case letters (a-z)","code":"lowerCase","verified":true},{"message":"upper case letters (A-Z)","code":"upperCase","verified":false},{"message":"numbers (i.e. 0-9)","code":"numbers","verified":false},{"message":"special characters (e.g. !@#$%^&*)","code":"specialCharacters","verified":true}],"verified":false}],"verified":false},"policy":"* At least 8 characters in length\n* Contain at least 3 of the following 4 types of characters:\n * lower case letters (a-z)\n * upper case letters (A-Z)\n * numbers (i.e. 0-9)\n * special characters (e.g. !@#$%^&*)","mfa_token":"123456","statusCode":400}`))),
},
expectedError: Error{
StatusCode: 400,
Err: "invalid_password",
Message: `{"rules":[{"message":"At least %d characters in length","format":[8],"code":"lengthAtLeast","verified":true},{"message":"Contain at least %d of the following %d types of characters:","code":"containsAtLeast","format":[3,4],"items":[{"message":"lower case letters (a-z)","code":"lowerCase","verified":true},{"message":"upper case letters (A-Z)","code":"upperCase","verified":false},{"message":"numbers (i.e. 0-9)","code":"numbers","verified":false},{"message":"special characters (e.g. !@#$%^&*)","code":"specialCharacters","verified":true}],"verified":false}],"verified":false}`,
MFAToken: "123456",
},
expectedMFAToken: "123456",
},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
actualError := newError(&testCase.givenResponse)
assert.Equal(t, &testCase.expectedError, actualError)
err := newError(&testCase.givenResponse)
var actualError *Error
ok := errors.As(err, &actualError)
assert.True(t, ok, "newError should return an *Error")
assert.Equal(t, testCase.expectedError, *actualError)
assert.Equal(t, testCase.expectedMFAToken, actualError.GetMFAToken())
})
}
}
10 changes: 7 additions & 3 deletions authentication/http_recordings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,12 @@ func configureHTTPTestRecordings(t *testing.T, auth *Authentication) {
bodyMatches := false
switch r.Header.Get("Content-Type") {
case "application/json":
v := map[string]string{}
v := map[string]interface{}{}
err := json.Unmarshal([]byte(rb), &v)
require.NoError(t, err)

if v["client_assertion"] != "" {
verifyClientAssertion(t, v["client_assertion"])
if assertion, ok := v["client_assertion"].(string); ok && assertion != "" {
verifyClientAssertion(t, assertion)
v["client_assertion"] = "test-client_assertion"
}

Expand Down Expand Up @@ -168,6 +168,10 @@ func redactClientAuth(t *testing.T, i *cassette.Interaction) {
} else if contentType == "application/json" {
jsonBody := map[string]interface{}{}

if len(i.Request.Body) == 0 {
return
}

err := json.Unmarshal([]byte(i.Request.Body), &jsonBody)
require.NoError(t, err)

Expand Down
38 changes: 38 additions & 0 deletions authentication/mfa.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,41 @@ func (m *MFA) VerifyWithRecoveryCode(ctx context.Context, body mfa.VerifyWithRec

return
}

// AddAuthenticator Associates or adds a new authenticator for multi-factor authentication (MFA).
//
// See: https://auth0.com/docs/api/authentication#add-an-authenticator
func (m *MFA) AddAuthenticator(ctx context.Context, accessOrMfaToken string, body mfa.AddAuthenticatorRequest, opts ...RequestOption) (a *mfa.AddAuthenticatorResponse, err error) {
opts = append(opts, Header("Authorization", "Bearer "+accessOrMfaToken))
missing := []string{}
check(&missing, "ClientID", (body.ClientID != "" || m.authentication.clientID != ""))
check(&missing, "AuthenticatorTypes", body.AuthenticatorTypes != nil && len(body.AuthenticatorTypes) > 0)
if len(missing) > 0 {
return nil, fmt.Errorf("Missing required fields: %s", strings.Join(missing, ", "))
}

if err = m.authentication.addClientAuthenticationToClientAuthStruct(&body.ClientAuthentication, true); err != nil {
return
}

err = m.authentication.Request(ctx, "POST", m.authentication.URI("mfa", "associate"), body, &a, opts...)
return
}

// ListAuthenticators Returns a list of authenticators associated with your application.
//
// See: https://auth0.com/docs/api/authentication#list-authenticators
func (m *MFA) ListAuthenticators(ctx context.Context, accessOrMfaToken string, opts ...RequestOption) (a []mfa.ListAuthenticatorsResponse, err error) {
opts = append(opts, Header("Authorization", "Bearer "+accessOrMfaToken))
err = m.authentication.Request(ctx, "GET", m.authentication.URI("mfa", "authenticators"), nil, &a, opts...)
return
}

// DeleteAuthenticator Deletes an associated authenticator using its ID.
//
// See: https://auth0.com/docs/api/authentication#delete-an-authenticator
func (m *MFA) DeleteAuthenticator(ctx context.Context, accessToken string, authenticatorID string, opts ...RequestOption) (err error) {
opts = append(opts, Header("Authorization", "Bearer "+accessToken))
err = m.authentication.Request(ctx, "DELETE", m.authentication.URI("mfa", "authenticators", authenticatorID), nil, nil, opts...)
return
}
42 changes: 42 additions & 0 deletions authentication/mfa/mfa.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,45 @@ type VerifyWithRecoveryCodeResponse struct {
// If present, a new recovery code that should be presented to the user to store.
RecoveryCode string `json:"recovery_code,omitempty"`
}

// AddAuthenticatorRequest defines the request body for adding an authenticator.
type AddAuthenticatorRequest struct {
oauth.ClientAuthentication
// The type of authenticators supported by the client.
// An array with values "otp" or "oob".
AuthenticatorTypes []string `json:"authenticator_types"`
// The type of OOB channels supported by the client.
// An array with values "auth0", "sms", "voice".
// Required if authenticator_types include oob.
OOBChannels []string `json:"oob_channels,omitempty"`
// The phone number to use for SMS or Voice.
// Required if oob_channels includes sms or voice.
PhoneNumber string `json:"phone_number,omitempty"`
}

// AddAuthenticatorResponse defines the response when adding an authenticator.
type AddAuthenticatorResponse struct {
// If present, the OOB code that should be presented to the user to verify the authenticator.
OOBCode string `json:"oob_code,omitempty"`
// If present, a new recovery code that should be presented to the user to store.
RecoveryCodes []string `json:"recovery_codes,omitempty"`
// The URI to generate a QR code for the authenticator.
BarcodeURI string `json:"barcode_uri,omitempty"`
// The secret to use for the OTP.
Secret string `json:"secret,omitempty"`
// The type of authenticator added.
AuthenticatorType string `json:"authenticator_type,omitempty"`
// The OOB channels supported by the authenticator.
OOBChannels string `json:"oob_channels,omitempty"`
// The binding method to use when verifying the authenticator.
BindingMethod string `json:"binding_method,omitempty"`
}

// ListAuthenticatorsResponse defines the response when listing authenticators.
type ListAuthenticatorsResponse struct {
ID string `json:"id,omitempty"`
AuthenticatorType string `json:"authenticator_type,omitempty"`
OOBChannels string `json:"oob_channels,omitempty"`
Name string `json:"name,omitempty"`
Active bool `json:"active,omitempty"`
}
97 changes: 97 additions & 0 deletions authentication/mfa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,100 @@ func TestMFAVerifyWithRecoveryCode(t *testing.T) {
assert.NotEmpty(t, tokenset.RecoveryCode)
})
}

func TestMFAAddAuthenticator(t *testing.T) {
t.Run("Should require ClientID, AuthenticatorTypes", func(t *testing.T) {
auth, err := New(
context.Background(),
domain,
)
require.NoError(t, err)

_, err = auth.MFA.AddAuthenticator(context.Background(),
"mfa-token",
mfa.AddAuthenticatorRequest{})
assert.ErrorContains(t, err, "Missing required fields: ClientID, AuthenticatorTypes")
})

t.Run("Should return response for OOB (SMS channel)", func(t *testing.T) {
skipE2E(t)
configureHTTPTestRecordings(t, authAPI)

response, err := authAPI.MFA.AddAuthenticator(context.Background(),
"mfa-token",
mfa.AddAuthenticatorRequest{
ClientAuthentication: oauth.ClientAuthentication{
ClientSecret: clientSecret,
},
AuthenticatorTypes: []string{"oob"},
OOBChannels: []string{"sms"},
PhoneNumber: "+91123456789",
})

require.NoError(t, err)
assert.NotEmpty(t, response.OOBCode)
assert.NotEmpty(t, response.RecoveryCodes)
assert.NotEmpty(t, response.BindingMethod)
assert.Equal(t, "oob", response.AuthenticatorType)
})

t.Run("Should return response for OOB (Auth0 channel)", func(t *testing.T) {
skipE2E(t)
configureHTTPTestRecordings(t, authAPI)

response, err := authAPI.MFA.AddAuthenticator(context.Background(),
"mfa-token",
mfa.AddAuthenticatorRequest{
ClientAuthentication: oauth.ClientAuthentication{
ClientSecret: clientSecret,
},
AuthenticatorTypes: []string{"oob"},
OOBChannels: []string{"auth0"},
})

require.NoError(t, err)
assert.NotEmpty(t, response.OOBCode)
assert.NotEmpty(t, response.BarcodeURI)
assert.Equal(t, "oob", response.AuthenticatorType)
})

t.Run("Should return response for OTP", func(t *testing.T) {
skipE2E(t)
configureHTTPTestRecordings(t, authAPI)

response, err := authAPI.MFA.AddAuthenticator(context.Background(),
"mfa-token",
mfa.AddAuthenticatorRequest{
ClientAuthentication: oauth.ClientAuthentication{
ClientSecret: clientSecret,
},
AuthenticatorTypes: []string{"otp"},
})

require.NoError(t, err)
assert.NotEmpty(t, response.Secret)
assert.NotEmpty(t, response.BarcodeURI)
assert.Equal(t, "otp", response.AuthenticatorType)
})
}

func TestMFAListAuthenticators(t *testing.T) {
skipE2E(t)
configureHTTPTestRecordings(t, authAPI)

authenticators, err := authAPI.MFA.ListAuthenticators(context.Background(),
"mfa-token",
)
require.NoError(t, err)
assert.NotEmpty(t, authenticators)
}

func TestMFADeleteAuthenticator(t *testing.T) {
skipE2E(t)
configureHTTPTestRecordings(t, authAPI)

err := authAPI.MFA.DeleteAuthenticator(context.Background(),
"mfa-token",
"push|dev_BBTKYpxKHOXVBnql")
require.NoError(t, err)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
version: 2
interactions:
- id: 0
request:
proto: HTTP/1.1
proto_major: 1
proto_minor: 1
content_length: 187
transfer_encoding: []
trailer: {}
host: go-auth0-dev.eu.auth0.com
remote_addr: ""
request_uri: ""
body: '{"authenticator_types":["oob"],"client_id":"test-client_id","client_secret":"test-client_secret","oob_channels":["auth0"]}'
form: {}
headers:
Content-Type:
- application/json
url: https://go-auth0-dev.eu.auth0.com/mfa/associate
method: POST
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
transfer_encoding: []
trailer: {}
content_length: -1
uncompressed: false
body: '{"authenticator_type":"oob","barcode_uri":"otpauth://totp/My%20Test%20Tenant:user%40example.com?enrollment_tx_id=t5FmaYNt8APzKlh3WCRNLi2dG3uZYnAC&base_url=https%3A%2F%2Fgo-auth0-dev.eu.auth0.com.guardian.us.auth0.com","oob_channel":"auth0","oob_code":"Fe26.2*SERVER_1726721698*94c26c1812d382d29f0210c276b7d795c956e2cbe7c7898364a51657ea8ba96e*lbN3rPAurOL7DSpnNiApdA*IetTiASmxalPfLyoq9n8wWchR-NObAMzL0s6JsszU3Ji3CYWAmtxkLzAyE3vdXvfnsdQxdCjOokgZ2hb2r-DKLVQNagDW5SvzwYtTlL5BOswZk3Q28SWkzvfP1m-Y3VPmhQrcrplw202Ol4UUrUeDch9td4Tb9R8KvYcPFbM0Icmzy9pECDuAdnqjleOGWpFBRiY7mcHMVXumNA1QU3sjYikS2_N1Drkp97FE7VLufnOeE-FFEEwE0L7Ig31Q0GxVwX8OYUKf3PXlVh38aTsiIlrYIlEQiE0rDaxWI6TeXjizLVvpiiELOUrRCWrPqhqBa5YDWqzx2ADy-uycm4LCg5GSj0epBkXWs0XZnAHbPmDlu90HKRiU8wLGjjDAEKQ*1726830033006*2f04c74d171617c64475a58f49575b3221ce5f00c8d55e9045e86af3d7184f5e*tRDv38UmQkIVli-G7lDkN4sKIN3FRE07UlEO6qnQwZo"}'
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 410.832333ms
Loading

0 comments on commit cf30fcd

Please sign in to comment.