Skip to content

Commit 761ac93

Browse files
authored
Feature: Implemented MFA APIs (#442)
1 parent 5418b4a commit 761ac93

File tree

4 files changed

+514
-0
lines changed

4 files changed

+514
-0
lines changed

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,22 @@ auth0.auth
280280
.catch(console.error);
281281
```
282282

283+
#### Login using MFA with One Time Password code
284+
285+
This call requires the client to have the _MFA_ Client Grant Type enabled. Check [this article](https://auth0.com/docs/clients/client-grant-types) to learn how to enable it.
286+
287+
When you sign in to a multifactor authentication enabled connection using the `passwordRealm` method, you receive an error stating that MFA is required for that user along with an `mfa_token` value. Use this value to call `loginWithOTP` and complete the MFA flow passing the One Time Password from the enrolled MFA code generator app.
288+
289+
```js
290+
auth0.auth
291+
.loginWithOTP({
292+
mfaToken: error.json.mfa_token,
293+
otp: '{user entered OTP}',
294+
})
295+
.then(console.log)
296+
.catch(console.error);
297+
```
298+
283299
#### Login with Passwordless
284300

285301
Passwordless is a two-step authentication flow that makes use of this type of connection. The **Passwordless OTP** grant is required to be enabled in your Auth0 application beforehand. Check [our guide](https://auth0.com/docs/dashboard/guides/applications/update-grant-types) to learn how to enable it.

src/auth/__tests__/__snapshots__/index.spec.js.snap

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,108 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`auth Multifactor Challenge Flow should handle success 1`] = `
4+
Object {
5+
"bindingMethod": "prompt",
6+
"challengeType": "oob",
7+
"oobCode": "oob-code",
8+
}
9+
`;
10+
11+
exports[`auth Multifactor Challenge Flow should handle success with optional parameters Challenge Type and Authenticator Id 1`] = `
12+
Object {
13+
"bindingMethod": "prompt",
14+
"challengeType": "oob",
15+
"oobCode": "oob-code",
16+
}
17+
`;
18+
19+
exports[`auth Multifactor Challenge Flow should handle Challenge Type and Authenticator ID as optional 1`] = `Object {}`;
20+
21+
exports[`auth Multifactor Challenge Flow should handle unexpected error 1`] = `[a0.response.invalid: Internal Server Error]`;
22+
23+
exports[`auth Multifactor Challenge Flow should require MFA Token 1`] = `
24+
"Missing required parameters: [
25+
\\"mfa_token\\"
26+
]"
27+
`;
28+
29+
exports[`auth OOB flow binding code should be optional 1`] = `Object {}`;
30+
31+
exports[`auth OOB flow should handle malformed OOB code 1`] = `[invalid_grant: Malformed oob_code]`;
32+
33+
exports[`auth OOB flow should handle success with binding code 1`] = `
34+
Object {
35+
"accessToken": "1234",
36+
"expiresIn": 86400,
37+
"idToken": "id-123",
38+
"scope": "openid profile email address phone",
39+
"tokenType": "Bearer",
40+
}
41+
`;
42+
43+
exports[`auth OOB flow should handle success without binding code 1`] = `
44+
Object {
45+
"accessToken": "1234",
46+
"expiresIn": 86400,
47+
"idToken": "id-123",
48+
"scope": "openid profile email address phone",
49+
"tokenType": "Bearer",
50+
}
51+
`;
52+
53+
exports[`auth OOB flow should handle unexpected error 1`] = `[a0.response.invalid: Internal Server Error]`;
54+
55+
exports[`auth OOB flow should require MFA Token and OOB Code 1`] = `
56+
"Missing required parameters: [
57+
\\"mfa_token\\",
58+
\\"oob_code\\"
59+
]"
60+
`;
61+
62+
exports[`auth OTP flow should handle unexpected error 1`] = `[a0.response.invalid: Internal Server Error]`;
63+
64+
exports[`auth OTP flow should require MFA Token and OTP 1`] = `
65+
"Missing required parameters: [
66+
\\"mfa_token\\",
67+
\\"otp\\"
68+
]"
69+
`;
70+
71+
exports[`auth OTP flow when MFA is not associated 1`] = `[unsupported_challenge_type: User is not enrolled. You can use /mfa/associate endpoint to enroll the first authenticator.]`;
72+
73+
exports[`auth OTP flow when MFA succeeds 1`] = `
74+
Object {
75+
"accessToken": "1234",
76+
"expiresIn": 86400,
77+
"idToken": "id-123",
78+
"scope": "openid profile email address phone",
79+
"tokenType": "Bearer",
80+
}
81+
`;
82+
83+
exports[`auth OTP flow when OTP Code is invalid 1`] = `[invalid_grant: Invalid otp_code.]`;
84+
85+
exports[`auth Recovery Code flow should handle unexpected error 1`] = `[a0.response.invalid: Internal Server Error]`;
86+
87+
exports[`auth Recovery Code flow should require MFA Token and Recovery Code 1`] = `
88+
"Missing required parameters: [
89+
\\"mfa_token\\",
90+
\\"recovery_code\\"
91+
]"
92+
`;
93+
94+
exports[`auth Recovery Code flow when Recovery code succeeds 1`] = `
95+
Object {
96+
"accessToken": "1234",
97+
"expiresIn": 86400,
98+
"idToken": "id-123",
99+
"scope": "openid profile email address phone",
100+
"tokenType": "Bearer",
101+
}
102+
`;
103+
104+
exports[`auth Recovery Code flow when user does not have Recovery Code 1`] = `[unsupported_challenge_type: User does not have a recovery-code.]`;
105+
3106
exports[`auth authorizeUrl should return default authorize url 1`] = `"https://samples.auth0.com/authorize?response_type=code&redirect_uri=https%3A%2F%2Fmysite.com%2Fcallback&state=a_random_state&client_id=A_CLIENT_ID_OF_YOUR_ACCOUNT&auth0Client=eyJuYW1lIjoicmVhY3QtbmF0aXZlLWF1dGgwIiwidmVyc2lvbiI6IjEuMC4wIn0%3D"`;
4107

5108
exports[`auth authorizeUrl should return default authorize url with extra parameters 1`] = `"https://samples.auth0.com/authorize?response_type=code&redirect_uri=https%3A%2F%2Fmysite.com%2Fcallback&state=a_random_state&connection=facebook&client_id=A_CLIENT_ID_OF_YOUR_ACCOUNT&auth0Client=eyJuYW1lIjoicmVhY3QtbmF0aXZlLWF1dGgwIiwidmVyc2lvbiI6IjEuMC4wIn0%3D"`;

src/auth/__tests__/index.spec.js

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,4 +693,263 @@ describe('auth', () => {
693693
await expect(auth.createUser(parameters)).rejects.toMatchSnapshot();
694694
});
695695
});
696+
697+
describe('OTP flow', () => {
698+
const parameters = {
699+
mfaToken: '1234',
700+
otp: '1234',
701+
};
702+
703+
const notAssociatedError = {
704+
status: 401,
705+
body: {
706+
name: 'unsupported_challenge_type',
707+
error: 'unsupported_challenge_type',
708+
error_description:
709+
'User is not enrolled. You can use /mfa/associate endpoint to enroll the first authenticator.',
710+
},
711+
headers: {'Content-Type': 'application/json'},
712+
};
713+
714+
const invalidOtpError = {
715+
status: 403,
716+
body: {
717+
name: 'invalid_grant',
718+
error: 'invalid_grant',
719+
error_description: 'Invalid otp_code.',
720+
},
721+
headers: {'Content-Type': 'application/json'},
722+
};
723+
724+
const success = {
725+
accessToken: '1234',
726+
expiresIn: 86400,
727+
idToken: 'id-123',
728+
scope: 'openid profile email address phone',
729+
tokenType: 'Bearer',
730+
};
731+
732+
it('should require MFA Token and OTP', async () => {
733+
expect.assertions(1);
734+
expect(() => auth.loginWithOTP({})).toThrowErrorMatchingSnapshot();
735+
});
736+
737+
it('should handle unexpected error', async () => {
738+
fetchMock.postOnce(
739+
'https://samples.auth0.com/oauth/token',
740+
unexpectedError,
741+
);
742+
expect.assertions(1);
743+
await expect(auth.loginWithOTP(parameters)).rejects.toMatchSnapshot();
744+
});
745+
746+
it('when MFA is not associated', async () => {
747+
fetchMock.postOnce(
748+
'https://samples.auth0.com/oauth/token',
749+
notAssociatedError,
750+
);
751+
expect.assertions(1);
752+
await expect(auth.loginWithOTP(parameters)).rejects.toMatchSnapshot();
753+
});
754+
755+
it('when OTP Code is invalid', async () => {
756+
fetchMock.postOnce(
757+
'https://samples.auth0.com/oauth/token',
758+
invalidOtpError,
759+
);
760+
expect.assertions(1);
761+
await expect(auth.loginWithOTP(parameters)).rejects.toMatchSnapshot();
762+
});
763+
764+
it('when MFA succeeds', async () => {
765+
fetchMock.postOnce('https://samples.auth0.com/oauth/token', success);
766+
expect.assertions(1);
767+
await expect(auth.loginWithOTP(parameters)).resolves.toMatchSnapshot();
768+
});
769+
});
770+
771+
describe('OOB flow', () => {
772+
const parameters = {
773+
mfaToken: '1234',
774+
oobCode: '123',
775+
};
776+
777+
const malformedOOBError = {
778+
status: 403,
779+
body: {
780+
name: 'invalid_grant',
781+
error: 'invalid_grant',
782+
error_description: 'Malformed oob_code',
783+
},
784+
headers: {'Content-Type': 'application/json'},
785+
};
786+
787+
const success = {
788+
accessToken: '1234',
789+
expiresIn: 86400,
790+
idToken: 'id-123',
791+
scope: 'openid profile email address phone',
792+
tokenType: 'Bearer',
793+
};
794+
795+
it('should require MFA Token and OOB Code', async () => {
796+
expect.assertions(1);
797+
expect(() => auth.loginWithOOB({})).toThrowErrorMatchingSnapshot();
798+
});
799+
800+
it('binding code should be optional', async () => {
801+
fetchMock.postOnce('https://samples.auth0.com/oauth/token', {});
802+
expect.assertions(1);
803+
await expect(auth.loginWithOOB(parameters)).resolves.toMatchSnapshot();
804+
});
805+
806+
it('should handle unexpected error', async () => {
807+
fetchMock.postOnce(
808+
'https://samples.auth0.com/oauth/token',
809+
unexpectedError,
810+
);
811+
expect.assertions(1);
812+
await expect(auth.loginWithOOB(parameters)).rejects.toMatchSnapshot();
813+
});
814+
815+
it('should handle malformed OOB code', async () => {
816+
fetchMock.postOnce(
817+
'https://samples.auth0.com/oauth/token',
818+
malformedOOBError,
819+
);
820+
expect.assertions(1);
821+
await expect(auth.loginWithOOB(parameters)).rejects.toMatchSnapshot();
822+
});
823+
824+
it('should handle success without binding code', async () => {
825+
fetchMock.postOnce('https://samples.auth0.com/oauth/token', success);
826+
expect.assertions(1);
827+
await expect(auth.loginWithOOB(parameters)).resolves.toMatchSnapshot();
828+
});
829+
830+
it('should handle success with binding code', async () => {
831+
parameters.bindingCode = '1234';
832+
fetchMock.postOnce('https://samples.auth0.com/oauth/token', success);
833+
expect.assertions(1);
834+
await expect(auth.loginWithOOB(parameters)).resolves.toMatchSnapshot();
835+
});
836+
});
837+
838+
describe('Recovery Code flow', () => {
839+
const parameters = {
840+
mfaToken: '123',
841+
recoveryCode: '123',
842+
};
843+
844+
const success = {
845+
accessToken: '1234',
846+
expiresIn: 86400,
847+
idToken: 'id-123',
848+
scope: 'openid profile email address phone',
849+
tokenType: 'Bearer',
850+
};
851+
852+
const unAuthorizedClientError = {
853+
status: 403,
854+
body: {
855+
name: 'unsupported_challenge_type',
856+
error: 'unsupported_challenge_type',
857+
error_description: 'User does not have a recovery-code.',
858+
},
859+
headers: {'Content-Type': 'application/json'},
860+
};
861+
862+
it('should require MFA Token and Recovery Code', async () => {
863+
expect.assertions(1);
864+
expect(() =>
865+
auth.loginWithRecoveryCode({}),
866+
).toThrowErrorMatchingSnapshot();
867+
});
868+
869+
it('when user does not have Recovery Code', async () => {
870+
fetchMock.postOnce(
871+
'https://samples.auth0.com/oauth/token',
872+
unAuthorizedClientError,
873+
);
874+
expect.assertions(1);
875+
await expect(
876+
auth.loginWithRecoveryCode(parameters),
877+
).rejects.toMatchSnapshot();
878+
});
879+
880+
it('should handle unexpected error', async () => {
881+
fetchMock.postOnce(
882+
'https://samples.auth0.com/oauth/token',
883+
unexpectedError,
884+
);
885+
expect.assertions(1);
886+
await expect(
887+
auth.loginWithRecoveryCode(parameters),
888+
).rejects.toMatchSnapshot();
889+
});
890+
891+
it('when Recovery code succeeds', async () => {
892+
fetchMock.postOnce('https://samples.auth0.com/oauth/token', success);
893+
expect.assertions(1);
894+
await expect(
895+
auth.loginWithRecoveryCode(parameters),
896+
).resolves.toMatchSnapshot();
897+
});
898+
});
899+
900+
describe('Multifactor Challenge Flow', () => {
901+
const parameters = {
902+
mfaToken: '123',
903+
};
904+
905+
const success = {
906+
bindingMethod: 'prompt',
907+
challengeType: 'oob',
908+
oobCode: 'oob-code',
909+
};
910+
911+
it('should require MFA Token', async () => {
912+
expect.assertions(1);
913+
expect(() =>
914+
auth.multifactorChallenge({}),
915+
).toThrowErrorMatchingSnapshot();
916+
});
917+
918+
it('should handle Challenge Type and Authenticator ID as optional', async () => {
919+
fetchMock.postOnce('https://samples.auth0.com/mfa/challenge', {});
920+
expect.assertions(1);
921+
await expect(
922+
auth.multifactorChallenge(parameters),
923+
).resolves.toMatchSnapshot();
924+
});
925+
926+
it('should handle unexpected error', async () => {
927+
fetchMock.postOnce(
928+
'https://samples.auth0.com/mfa/challenge',
929+
unexpectedError,
930+
);
931+
expect.assertions(1);
932+
await expect(
933+
auth.multifactorChallenge(parameters),
934+
).rejects.toMatchSnapshot();
935+
});
936+
937+
it('should handle success', async () => {
938+
fetchMock.postOnce('https://samples.auth0.com/mfa/challenge', success);
939+
expect.assertions(1);
940+
await expect(
941+
auth.multifactorChallenge(parameters),
942+
).resolves.toMatchSnapshot();
943+
});
944+
945+
it('should handle success with optional parameters Challenge Type and Authenticator Id', async () => {
946+
parameters.mfaToken = '123';
947+
parameters.challengeType = '123';
948+
fetchMock.postOnce('https://samples.auth0.com/mfa/challenge', success);
949+
expect.assertions(1);
950+
await expect(
951+
auth.multifactorChallenge(parameters),
952+
).resolves.toMatchSnapshot();
953+
});
954+
});
696955
});

0 commit comments

Comments
 (0)