Skip to content

Commit

Permalink
Support for MFA Authentication Endpoints: Add, List, and Delete an Au…
Browse files Browse the repository at this point in the history
…thenticator
  • Loading branch information
developerkunal committed Sep 20, 2024
1 parent 86c7e2f commit 72cd3e3
Show file tree
Hide file tree
Showing 9 changed files with 338 additions and 3 deletions.
5 changes: 5 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,10 @@ func (a *Error) Error() string {
return fmt.Sprintf("%d %s: %s", a.StatusCode, a.Err, a.Message)
}

func (a *Error) GetMFAToken() string {

Check failure on line 46 in authentication/authentication_error.go

View workflow job for this annotation

GitHub Actions / Checks

exported: exported method Error.GetMFAToken should have comment or be unexported (revive)
return a.MFAToken
}

// Status returns the status code of the error.
func (a *Error) Status() int {
return a.StatusCode
Expand Down
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
}

// AddAnAuthenticator Associates or adds a new authenticator for multi-factor authentication (MFA).
//
// See: https://auth0.com/docs/api/authentication#add-an-authenticator
func (m *MFA) AddAnAuthenticator(ctx context.Context, accessOrMfaToken string, body mfa.AddAnAuthenticatorRequest, opts ...RequestOption) (a *mfa.AddAnAuthenticatorResponse, 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, accessToken string, opts ...RequestOption) (a []mfa.ListAuthenticatorsResponse, err error) {
opts = append(opts, Header("Authorization", "Bearer "+accessToken))
err = m.authentication.Request(ctx, "GET", m.authentication.URI("mfa", "authenticators"), nil, &a, opts...)
return
}

// DeleteAnAuthenticator Deletes an associated authenticator using its ID.
//
// See: https://auth0.com/docs/api/authentication#delete-an-authenticator
func (m *MFA) DeleteAnAuthenticator(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"`
}

// AddAnAuthenticatorRequest defines the request body for adding an authenticator.
type AddAnAuthenticatorRequest 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"`
}

// AddAnAuthenticatorResponse defines the response when adding an authenticator.
type AddAnAuthenticatorResponse 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"`
}
102 changes: 102 additions & 0 deletions authentication/mfa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,105 @@ func TestMFAVerifyWithRecoveryCode(t *testing.T) {
assert.NotEmpty(t, tokenset.RecoveryCode)
})
}

func TestMFA_AddAnAuthenticator(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.AddAnAuthenticator(context.Background(),
"mfa-token",
mfa.AddAnAuthenticatorRequest{})
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.AddAnAuthenticator(context.Background(),
"mfa-token",
mfa.AddAnAuthenticatorRequest{
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.AddAnAuthenticator(context.Background(),
"mfa-token",
mfa.AddAnAuthenticatorRequest{
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.AddAnAuthenticator(context.Background(),
"mfa-token",
mfa.AddAnAuthenticatorRequest{
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 TestMFA_ListAuthenticators(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 TestMFA_DeleteAnAuthenticator(t *testing.T) {
t.Run("Should return no error for a valid request", func(t *testing.T) {
skipE2E(t)
configureHTTPTestRecordings(t, authAPI)
err := authAPI.MFA.DeleteAnAuthenticator(context.Background(),
"access-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
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: 216
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":["sms"],"phone_number":"+91123456789"}'
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","binding_method":"prompt","recovery_codes":["MZCRK3YX5V3AKNNYY6JEBQCL"],"oob_channel":"sms","oob_code":"Fe26.2*SERVER_1726721698*68ae34ffe0f5892e37a04a28d22affdfd162db318a54e7782baa861d3c4d82fd*m5UkQdNFz_T0FgNARMjsDg*s2IYxd72tpwCVmol-n4Uv3qH7F69sdZOXEZNIo1ytuNRtyTOX5bmzUFbr85QP4qjUjSDhSOwHiT3GmhFqAqi3Qp0QBMlucMwjytIYy2cQZGY3wGrnu6clRomH2pOeg7WUaiaCiji6pgrQbaVTVjw2RTQ1elvMwtYIPpI-s9mRVHY1dXYCchlXsC2iMnpONyYlw1_JKczeJA-Zwctam6lGNOz9au1x2Ng1fWmT4iM5GYMTfVDKzqEDcePdoewzPIup8y0p7_rrT6SUkvSOiP0825MiIsIGVP5VwHQxXXwt0MPcU0Zg4ZH2G48zXftna-YiujmXtAsHyPuQ-tvn1RIOw*1726830032592*6881d78b0217ebd7c659b60dd2e7e730d45a48735d17d3681431af9d0a9b8ad2*BM7ztZhWUIbgVK5jX1NUFcPrL5FShD4udVkHjjvqCA0"}'
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 755.116375ms
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: 162
transfer_encoding: []
trailer: {}
host: go-auth0-dev.eu.auth0.com
remote_addr: ""
request_uri: ""
body: '{"authenticator_types":["otp"],"client_id":"test-client_id","client_secret":"test-client_secret"}'
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: 241
uncompressed: false
body: '{"authenticator_type":"otp","secret":"HJ6XM2DNERXDAVCUONGWKRTCMNTU6VDF","barcode_uri":"otpauth://totp/My%20Test%20Tenant:user%40example.com?secret=HJ6XM2DNERXDAVCUONGWKRTCMNTU6VDF&issuer=My%20Test%20Tenant&algorithm=SHA1&digits=6&period=30"}'
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 502.489417ms
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: 0
transfer_encoding: []
trailer: {}
host: go-auth0-dev.eu.auth0.com
remote_addr: ""
request_uri: ""
body: ""
form: {}
headers:
Content-Type:
- application/json
url: https://go-auth0-dev.eu.auth0.com/mfa/authenticators
method: GET
response:
proto: HTTP/2.0
proto_major: 2
proto_minor: 0
transfer_encoding: []
trailer: {}
content_length: -1
uncompressed: false
body: '[{"id":"sms|dev_klnv5yshzzoosSUm","authenticator_type":"oob","active":false,"oob_channel":"sms","name":"XXXXXXXXX9167"},{"id":"recovery-code|dev_i1kgKoFSZLbPt6G1","authenticator_type":"recovery-code","active":false},{"id":"push|dev_BBTKYpxKHOXVBnql","authenticator_type":"oob","active":false,"oob_channel":"auth0"},{"id":"totp|dev_WzMZ2sZbUw1wbC2F","authenticator_type":"otp","active":false},{"id":"totp|dev_wLdxko6pjg6ziKL9","authenticator_type":"otp","active":false},{"id":"email|dev_hdkmORJuqOmjRRQU","authenticator_type":"oob","active":true,"oob_channel":"email","name":"****@exam*******"}]'
headers:
Content-Type:
- application/json; charset=utf-8
status: 200 OK
code: 200
duration: 324.845792ms

0 comments on commit 72cd3e3

Please sign in to comment.