Skip to content

Commit

Permalink
Feature: Implemented MFA APIs (#442)
Browse files Browse the repository at this point in the history
  • Loading branch information
poovamraj authored Jan 7, 2022
1 parent 5418b4a commit 761ac93
Show file tree
Hide file tree
Showing 4 changed files with 514 additions and 0 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,22 @@ auth0.auth
.catch(console.error);
```

#### Login using MFA with One Time Password code

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.

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.

```js
auth0.auth
.loginWithOTP({
mfaToken: error.json.mfa_token,
otp: '{user entered OTP}',
})
.then(console.log)
.catch(console.error);
```

#### Login with Passwordless

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.
Expand Down
103 changes: 103 additions & 0 deletions src/auth/__tests__/__snapshots__/index.spec.js.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,108 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`auth Multifactor Challenge Flow should handle success 1`] = `
Object {
"bindingMethod": "prompt",
"challengeType": "oob",
"oobCode": "oob-code",
}
`;

exports[`auth Multifactor Challenge Flow should handle success with optional parameters Challenge Type and Authenticator Id 1`] = `
Object {
"bindingMethod": "prompt",
"challengeType": "oob",
"oobCode": "oob-code",
}
`;

exports[`auth Multifactor Challenge Flow should handle Challenge Type and Authenticator ID as optional 1`] = `Object {}`;

exports[`auth Multifactor Challenge Flow should handle unexpected error 1`] = `[a0.response.invalid: Internal Server Error]`;

exports[`auth Multifactor Challenge Flow should require MFA Token 1`] = `
"Missing required parameters: [
\\"mfa_token\\"
]"
`;

exports[`auth OOB flow binding code should be optional 1`] = `Object {}`;

exports[`auth OOB flow should handle malformed OOB code 1`] = `[invalid_grant: Malformed oob_code]`;

exports[`auth OOB flow should handle success with binding code 1`] = `
Object {
"accessToken": "1234",
"expiresIn": 86400,
"idToken": "id-123",
"scope": "openid profile email address phone",
"tokenType": "Bearer",
}
`;

exports[`auth OOB flow should handle success without binding code 1`] = `
Object {
"accessToken": "1234",
"expiresIn": 86400,
"idToken": "id-123",
"scope": "openid profile email address phone",
"tokenType": "Bearer",
}
`;

exports[`auth OOB flow should handle unexpected error 1`] = `[a0.response.invalid: Internal Server Error]`;

exports[`auth OOB flow should require MFA Token and OOB Code 1`] = `
"Missing required parameters: [
\\"mfa_token\\",
\\"oob_code\\"
]"
`;

exports[`auth OTP flow should handle unexpected error 1`] = `[a0.response.invalid: Internal Server Error]`;

exports[`auth OTP flow should require MFA Token and OTP 1`] = `
"Missing required parameters: [
\\"mfa_token\\",
\\"otp\\"
]"
`;

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.]`;

exports[`auth OTP flow when MFA succeeds 1`] = `
Object {
"accessToken": "1234",
"expiresIn": 86400,
"idToken": "id-123",
"scope": "openid profile email address phone",
"tokenType": "Bearer",
}
`;

exports[`auth OTP flow when OTP Code is invalid 1`] = `[invalid_grant: Invalid otp_code.]`;

exports[`auth Recovery Code flow should handle unexpected error 1`] = `[a0.response.invalid: Internal Server Error]`;

exports[`auth Recovery Code flow should require MFA Token and Recovery Code 1`] = `
"Missing required parameters: [
\\"mfa_token\\",
\\"recovery_code\\"
]"
`;

exports[`auth Recovery Code flow when Recovery code succeeds 1`] = `
Object {
"accessToken": "1234",
"expiresIn": 86400,
"idToken": "id-123",
"scope": "openid profile email address phone",
"tokenType": "Bearer",
}
`;

exports[`auth Recovery Code flow when user does not have Recovery Code 1`] = `[unsupported_challenge_type: User does not have a recovery-code.]`;

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"`;

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"`;
Expand Down
259 changes: 259 additions & 0 deletions src/auth/__tests__/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -693,4 +693,263 @@ describe('auth', () => {
await expect(auth.createUser(parameters)).rejects.toMatchSnapshot();
});
});

describe('OTP flow', () => {
const parameters = {
mfaToken: '1234',
otp: '1234',
};

const notAssociatedError = {
status: 401,
body: {
name: 'unsupported_challenge_type',
error: 'unsupported_challenge_type',
error_description:
'User is not enrolled. You can use /mfa/associate endpoint to enroll the first authenticator.',
},
headers: {'Content-Type': 'application/json'},
};

const invalidOtpError = {
status: 403,
body: {
name: 'invalid_grant',
error: 'invalid_grant',
error_description: 'Invalid otp_code.',
},
headers: {'Content-Type': 'application/json'},
};

const success = {
accessToken: '1234',
expiresIn: 86400,
idToken: 'id-123',
scope: 'openid profile email address phone',
tokenType: 'Bearer',
};

it('should require MFA Token and OTP', async () => {
expect.assertions(1);
expect(() => auth.loginWithOTP({})).toThrowErrorMatchingSnapshot();
});

it('should handle unexpected error', async () => {
fetchMock.postOnce(
'https://samples.auth0.com/oauth/token',
unexpectedError,
);
expect.assertions(1);
await expect(auth.loginWithOTP(parameters)).rejects.toMatchSnapshot();
});

it('when MFA is not associated', async () => {
fetchMock.postOnce(
'https://samples.auth0.com/oauth/token',
notAssociatedError,
);
expect.assertions(1);
await expect(auth.loginWithOTP(parameters)).rejects.toMatchSnapshot();
});

it('when OTP Code is invalid', async () => {
fetchMock.postOnce(
'https://samples.auth0.com/oauth/token',
invalidOtpError,
);
expect.assertions(1);
await expect(auth.loginWithOTP(parameters)).rejects.toMatchSnapshot();
});

it('when MFA succeeds', async () => {
fetchMock.postOnce('https://samples.auth0.com/oauth/token', success);
expect.assertions(1);
await expect(auth.loginWithOTP(parameters)).resolves.toMatchSnapshot();
});
});

describe('OOB flow', () => {
const parameters = {
mfaToken: '1234',
oobCode: '123',
};

const malformedOOBError = {
status: 403,
body: {
name: 'invalid_grant',
error: 'invalid_grant',
error_description: 'Malformed oob_code',
},
headers: {'Content-Type': 'application/json'},
};

const success = {
accessToken: '1234',
expiresIn: 86400,
idToken: 'id-123',
scope: 'openid profile email address phone',
tokenType: 'Bearer',
};

it('should require MFA Token and OOB Code', async () => {
expect.assertions(1);
expect(() => auth.loginWithOOB({})).toThrowErrorMatchingSnapshot();
});

it('binding code should be optional', async () => {
fetchMock.postOnce('https://samples.auth0.com/oauth/token', {});
expect.assertions(1);
await expect(auth.loginWithOOB(parameters)).resolves.toMatchSnapshot();
});

it('should handle unexpected error', async () => {
fetchMock.postOnce(
'https://samples.auth0.com/oauth/token',
unexpectedError,
);
expect.assertions(1);
await expect(auth.loginWithOOB(parameters)).rejects.toMatchSnapshot();
});

it('should handle malformed OOB code', async () => {
fetchMock.postOnce(
'https://samples.auth0.com/oauth/token',
malformedOOBError,
);
expect.assertions(1);
await expect(auth.loginWithOOB(parameters)).rejects.toMatchSnapshot();
});

it('should handle success without binding code', async () => {
fetchMock.postOnce('https://samples.auth0.com/oauth/token', success);
expect.assertions(1);
await expect(auth.loginWithOOB(parameters)).resolves.toMatchSnapshot();
});

it('should handle success with binding code', async () => {
parameters.bindingCode = '1234';
fetchMock.postOnce('https://samples.auth0.com/oauth/token', success);
expect.assertions(1);
await expect(auth.loginWithOOB(parameters)).resolves.toMatchSnapshot();
});
});

describe('Recovery Code flow', () => {
const parameters = {
mfaToken: '123',
recoveryCode: '123',
};

const success = {
accessToken: '1234',
expiresIn: 86400,
idToken: 'id-123',
scope: 'openid profile email address phone',
tokenType: 'Bearer',
};

const unAuthorizedClientError = {
status: 403,
body: {
name: 'unsupported_challenge_type',
error: 'unsupported_challenge_type',
error_description: 'User does not have a recovery-code.',
},
headers: {'Content-Type': 'application/json'},
};

it('should require MFA Token and Recovery Code', async () => {
expect.assertions(1);
expect(() =>
auth.loginWithRecoveryCode({}),
).toThrowErrorMatchingSnapshot();
});

it('when user does not have Recovery Code', async () => {
fetchMock.postOnce(
'https://samples.auth0.com/oauth/token',
unAuthorizedClientError,
);
expect.assertions(1);
await expect(
auth.loginWithRecoveryCode(parameters),
).rejects.toMatchSnapshot();
});

it('should handle unexpected error', async () => {
fetchMock.postOnce(
'https://samples.auth0.com/oauth/token',
unexpectedError,
);
expect.assertions(1);
await expect(
auth.loginWithRecoveryCode(parameters),
).rejects.toMatchSnapshot();
});

it('when Recovery code succeeds', async () => {
fetchMock.postOnce('https://samples.auth0.com/oauth/token', success);
expect.assertions(1);
await expect(
auth.loginWithRecoveryCode(parameters),
).resolves.toMatchSnapshot();
});
});

describe('Multifactor Challenge Flow', () => {
const parameters = {
mfaToken: '123',
};

const success = {
bindingMethod: 'prompt',
challengeType: 'oob',
oobCode: 'oob-code',
};

it('should require MFA Token', async () => {
expect.assertions(1);
expect(() =>
auth.multifactorChallenge({}),
).toThrowErrorMatchingSnapshot();
});

it('should handle Challenge Type and Authenticator ID as optional', async () => {
fetchMock.postOnce('https://samples.auth0.com/mfa/challenge', {});
expect.assertions(1);
await expect(
auth.multifactorChallenge(parameters),
).resolves.toMatchSnapshot();
});

it('should handle unexpected error', async () => {
fetchMock.postOnce(
'https://samples.auth0.com/mfa/challenge',
unexpectedError,
);
expect.assertions(1);
await expect(
auth.multifactorChallenge(parameters),
).rejects.toMatchSnapshot();
});

it('should handle success', async () => {
fetchMock.postOnce('https://samples.auth0.com/mfa/challenge', success);
expect.assertions(1);
await expect(
auth.multifactorChallenge(parameters),
).resolves.toMatchSnapshot();
});

it('should handle success with optional parameters Challenge Type and Authenticator Id', async () => {
parameters.mfaToken = '123';
parameters.challengeType = '123';
fetchMock.postOnce('https://samples.auth0.com/mfa/challenge', success);
expect.assertions(1);
await expect(
auth.multifactorChallenge(parameters),
).resolves.toMatchSnapshot();
});
});
});
Loading

0 comments on commit 761ac93

Please sign in to comment.