Skip to content

Commit

Permalink
test(core): implement the mfa binding integration tests
Browse files Browse the repository at this point in the history
implement the mfa binding integration tests
  • Loading branch information
simeng-li committed Jul 25, 2024
1 parent 4433932 commit b8d05e7
Show file tree
Hide file tree
Showing 6 changed files with 495 additions and 4 deletions.
8 changes: 4 additions & 4 deletions packages/core/src/routes/experience/classes/mfa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export class Mfa {
* @throws {RequestError} with status 400 if the verification record is not verified
* @throws {RequestError} with status 400 if the verification record has no secret
* @throws {RequestError} with status 404 if the verification record is not found
* @throws {RequestError} with status 422 if TOTP is not enabled in the sign-in experience
* @throws {RequestError} with status 400 if TOTP is not enabled in the sign-in experience
* @throws {RequestError} with status 422 if the user already has a TOTP factor
*
* - Any existing TOTP factor will be replaced with the new one.
Expand Down Expand Up @@ -210,7 +210,7 @@ export class Mfa {
* @throws {RequestError} with status 400 if the verification record is not verified
* @throws {RequestError} with status 400 if the verification record has no registration data
* @throws {RequestError} with status 404 if the verification record is not found
* @throws {RequestError} with status 422 if WebAuthn is not enabled in the sign-in experience
* @throws {RequestError} with status 400 if WebAuthn is not enabled in the sign-in experience
*/
async addWebAuthnByVerificationId(verificationId: string) {
const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId(
Expand All @@ -226,7 +226,7 @@ export class Mfa {
/**
* Generates new backup codes for the user.
*
* @throws {RequestError} with status 422 if Backup Code is not enabled in the sign-in experience
* @throws {RequestError} with status 400 if Backup Code is not enabled in the sign-in experience
* @throws {RequestError} with status 422 if the backup code is the only MFA factor
**/
async generateBackupCodes() {
Expand Down Expand Up @@ -277,7 +277,7 @@ export class Mfa {
}

/**
* @throws {RequestError} with status 422 if the mfa factors are not enabled in the sign-in experience
* @throws {RequestError} with status 400 if the mfa factors are not enabled in the sign-in experience
*/
async checkAvailability() {
const newBindMfaFactors = deduplicate(this.bindMfaFactorsArray.map(({ type }) => type));
Expand Down
1 change: 1 addition & 0 deletions packages/integration-tests/src/client/experience/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export const experienceRoutes = {
verification: `${prefix}/verification`,
identification: `${prefix}/identification`,
profile: `${prefix}/profile`,
mfa: `${prefix}/profile/mfa`,
prefix,
};
28 changes: 28 additions & 0 deletions packages/integration-tests/src/client/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type CreateExperienceApiPayload,
type IdentificationApiPayload,
type InteractionEvent,
type MfaFactor,
type NewPasswordIdentityVerificationPayload,
type PasswordVerificationPayload,
type UpdateProfileApiPayload,
Expand Down Expand Up @@ -211,4 +212,31 @@ export class ExperienceClient extends MockClient {
json: payload,
});
}

public async skipMfaBinding() {
return api.post(`${experienceRoutes.mfa}/mfa-skipped`, {
headers: { cookie: this.interactionCookie },
});
}

public async bindMfa(type: MfaFactor.TOTP | MfaFactor.WebAuthn, verificationId: string) {
return api.post(`${experienceRoutes.mfa}`, {
headers: { cookie: this.interactionCookie },
json: { type, verificationId },
});
}

public async generateMfaBackupCodes() {
return api
.post(`${experienceRoutes.mfa}/backup-codes/generate`, {
headers: { cookie: this.interactionCookie },
})
.json<{ codes: string[] }>();
}

public async bindBackupCodes() {
return api.post(`${experienceRoutes.mfa}/backup-codes`, {
headers: { cookie: this.interactionCookie },
});
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { authenticator } from 'otplib';

import { type ExperienceClient } from '#src/client/experience/index.js';

export const successFullyCreateNewTotpSecret = async (client: ExperienceClient) => {
Expand All @@ -23,3 +25,15 @@ export const successfullyVerifyTotp = async (

return verificationId;
};

export const successfullyCreateAndVerifyTotp = async (client: ExperienceClient) => {
const { secret, verificationId } = await successFullyCreateNewTotpSecret(client);
const code = authenticator.generate(secret);

await successfullyVerifyTotp(client, {
code,
verificationId,
});

return verificationId;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import { InteractionEvent, MfaFactor, SignInIdentifier } from '@logto/schemas';
import { authenticator } from 'otplib';

import { createUserMfaVerification, deleteUser } from '#src/api/admin-user.js';
import { initExperienceClient, logoutClient, processSession } from '#src/helpers/client.js';
import {
identifyUserWithUsernamePassword,
signInWithPassword,
} from '#src/helpers/experience/index.js';
import {
successfullyCreateAndVerifyTotp,
successfullyVerifyTotp,
} from '#src/helpers/experience/totp-verification.js';
import { expectRejects } from '#src/helpers/index.js';
import {
enableAllPasswordSignInMethods,
enableMandatoryMfaWithTotp,
enableMandatoryMfaWithTotpAndBackupCode,
enableUserControlledMfaWithTotp,
} from '#src/helpers/sign-in-experience.js';
import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
import { devFeatureTest } from '#src/utils.js';

devFeatureTest.describe('Bind MFA APIs happy path', () => {
const userApi = new UserApiTest();

beforeAll(async () => {
await enableAllPasswordSignInMethods({
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
});
});

afterEach(async () => {
await userApi.cleanUp();
});

describe('mandatory TOTP', () => {
beforeAll(async () => {
await enableMandatoryMfaWithTotp();
});

it('should bind TOTP on register', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.Register });

const { verificationId } = await client.createNewPasswordIdentityVerification({
identifier: {
type: SignInIdentifier.Username,
value: username,
},
password,
});

await client.identifyUser({ verificationId });

await expectRejects(client.submitInteraction(), {
code: 'user.missing_mfa',
status: 422,
});

const totpVerificationId = await successfullyCreateAndVerifyTotp(client);

await client.bindMfa(MfaFactor.TOTP, totpVerificationId);

const { redirectTo } = await client.submitInteraction();
const userId = await processSession(client, redirectTo);
await logoutClient(client);

const signInClient = await initExperienceClient();
await identifyUserWithUsernamePassword(signInClient, username, password);

await expectRejects(signInClient.submitInteraction(), {
code: 'session.mfa.require_mfa_verification',
status: 403,
});

await deleteUser(userId);
});

it('should bind TOTP on sign-in', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
await userApi.create({ username, password });

const client = await initExperienceClient();
await identifyUserWithUsernamePassword(client, username, password);

await expectRejects(client.submitInteraction(), {
code: 'user.missing_mfa',
status: 422,
});

const totpVerificationId = await successfullyCreateAndVerifyTotp(client);

await client.bindMfa(MfaFactor.TOTP, totpVerificationId);

const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
});

it('should not throw if user already has TOTP', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const user = await userApi.create({ username, password });
const response = await createUserMfaVerification(user.id, MfaFactor.TOTP);

if (response.type !== MfaFactor.TOTP) {
throw new Error('unexpected mfa type');
}

const { secret } = response;

const client = await initExperienceClient();
await identifyUserWithUsernamePassword(client, username, password);
const code = authenticator.generate(secret);

await successfullyVerifyTotp(client, { code });

const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
});
});

describe('user controlled TOTP', () => {
beforeAll(async () => {
await enableUserControlledMfaWithTotp();
});

it('should able to skip MFA binding on register', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.Register });

const { verificationId } = await client.createNewPasswordIdentityVerification({
identifier: {
type: SignInIdentifier.Username,
value: username,
},
password,
});

await client.identifyUser({ verificationId });

await expectRejects(client.submitInteraction(), {
code: 'user.missing_mfa',
status: 422,
});

await client.skipMfaBinding();

const { redirectTo } = await client.submitInteraction();
const userId = await processSession(client, redirectTo);
await logoutClient(client);

await signInWithPassword({
identifier: {
type: SignInIdentifier.Username,
value: username,
},
password,
});

await deleteUser(userId);
});

it('should able to skip MFA binding on sign-in', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
await userApi.create({ username, password });

const client = await initExperienceClient();
await identifyUserWithUsernamePassword(client, username, password);

await expectRejects(client.submitInteraction(), {
code: 'user.missing_mfa',
status: 422,
});

await client.skipMfaBinding();

const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
});
});

describe('mandatory TOTP with backup codes', () => {
beforeAll(async () => {
await enableMandatoryMfaWithTotpAndBackupCode();
});

it('should bind TOTP and backup codes on register', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.Register });

const { verificationId } = await client.createNewPasswordIdentityVerification({
identifier: {
type: SignInIdentifier.Username,
value: username,
},
password,
});

await client.identifyUser({ verificationId });

await expectRejects(client.submitInteraction(), {
code: 'user.missing_mfa',
status: 422,
});

const totpVerificationId = await successfullyCreateAndVerifyTotp(client);

await client.bindMfa(MfaFactor.TOTP, totpVerificationId);

await expectRejects(client.submitInteraction(), {
code: 'session.mfa.backup_code_required',
status: 422,
});

const { codes } = await client.generateMfaBackupCodes();

expect(codes.length).toBeGreaterThan(0);

await client.bindBackupCodes();

const { redirectTo } = await client.submitInteraction();
const userId = await processSession(client, redirectTo);
await logoutClient(client);

await deleteUser(userId);
});

it('should bind backup codes on sign-in', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const user = await userApi.create({ username, password });

const result = await createUserMfaVerification(user.id, MfaFactor.TOTP);

if (result.type !== MfaFactor.TOTP) {
throw new Error('unexpected mfa type');
}

const { secret } = result;
const code = authenticator.generate(secret);

const client = await initExperienceClient();
await identifyUserWithUsernamePassword(client, username, password);

await expectRejects(client.submitInteraction(), {
code: 'session.mfa.require_mfa_verification',
status: 403,
});

await successfullyVerifyTotp(client, { code });

await expectRejects(client.submitInteraction(), {
code: 'session.mfa.backup_code_required',
status: 422,
});

const { codes } = await client.generateMfaBackupCodes();
expect(codes.length).toBeGreaterThan(0);

await client.bindBackupCodes();

const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
});
});
});
Loading

0 comments on commit b8d05e7

Please sign in to comment.