Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Implemented MFA APIs #442

Merged
merged 2 commits into from
Jan 7, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 () => {
poovamraj marked this conversation as resolved.
Show resolved Hide resolved
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