diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index efd8245fc..8fb2a3083 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -53,5 +53,8 @@ "eslintConfig": { "extends": "@silverhand" }, - "prettier": "@silverhand/eslint-config/.prettierrc" + "prettier": "@silverhand/eslint-config/.prettierrc", + "dependencies": { + "otplib": "^12.0.1" + } } diff --git a/packages/integration-tests/src/api/interaction.ts b/packages/integration-tests/src/api/interaction.ts index ecbf26865..991f215e0 100644 --- a/packages/integration-tests/src/api/interaction.ts +++ b/packages/integration-tests/src/api/interaction.ts @@ -3,6 +3,7 @@ import type { IdentifierPayload, Profile, RequestVerificationCodePayload, + BindMfaPayload, } from '@logto/schemas'; import type { Got } from 'got'; @@ -67,6 +68,15 @@ export const putInteractionProfile = async (cookie: string, payload: Profile) => }) .json(); +export const putInteractionBindMfa = async (cookie: string, payload: BindMfaPayload) => + api + .put('interaction/bind-mfa', { + headers: { cookie }, + json: payload, + followRedirect: false, + }) + .json(); + export const deleteInteractionProfile = async (cookie: string) => api .delete('interaction/profile', { @@ -106,6 +116,15 @@ export const createSocialAuthorizationUri = async ( followRedirect: false, }); +export const initTotp = async (cookie: string) => + api + .post('interaction/verification/totp', { + headers: { cookie }, + json: {}, + followRedirect: false, + }) + .json<{ secret: string }>(); + export const consent = async (api: Got, cookie: string) => api .post('interaction/consent', { diff --git a/packages/integration-tests/src/helpers/sign-in-experience.ts b/packages/integration-tests/src/helpers/sign-in-experience.ts index da7594bf8..0b744a72d 100644 --- a/packages/integration-tests/src/helpers/sign-in-experience.ts +++ b/packages/integration-tests/src/helpers/sign-in-experience.ts @@ -1,5 +1,5 @@ import type { SignInExperience } from '@logto/schemas'; -import { SignInMode, SignInIdentifier } from '@logto/schemas'; +import { SignInMode, SignInIdentifier, MfaFactor, MfaPolicy } from '@logto/schemas'; import { updateSignInExperience } from '#src/api/index.js'; @@ -60,6 +60,7 @@ export const enableAllPasswordSignInMethods = async ( signIn: { methods: defaultPasswordSignInMethods, }, + mfa: { factors: [], policy: MfaPolicy.UserControlled }, }); export const enableAllVerificationCodeSignInMethods = async ( @@ -71,4 +72,13 @@ export const enableAllVerificationCodeSignInMethods = async ( signIn: { methods: defaultVerificationCodeSignInMethods, }, + mfa: { factors: [], policy: MfaPolicy.UserControlled }, + }); + +export const enableMandatoryMfaWithTotp = async () => + updateSignInExperience({ + mfa: { + factors: [MfaFactor.TOTP], + policy: MfaPolicy.Mandatory, + }, }); diff --git a/packages/integration-tests/src/tests/api/interaction/mfa/totp.test.ts b/packages/integration-tests/src/tests/api/interaction/mfa/totp.test.ts new file mode 100644 index 000000000..0e1ebbecb --- /dev/null +++ b/packages/integration-tests/src/tests/api/interaction/mfa/totp.test.ts @@ -0,0 +1,150 @@ +import { InteractionEvent, MfaFactor, SignInIdentifier } from '@logto/schemas'; +import { authenticator } from 'otplib'; + +import { putInteraction, deleteUser, initTotp, putInteractionBindMfa } from '#src/api/index.js'; +import { initClient, processSession, logoutClient } from '#src/helpers/client.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { + enableAllPasswordSignInMethods, + enableMandatoryMfaWithTotp, +} from '#src/helpers/sign-in-experience.js'; +import { generateNewUser, generateNewUserProfile } from '#src/helpers/user.js'; + +describe('register with mfa (mandatory TOTP)', () => { + beforeAll(async () => { + await enableAllPasswordSignInMethods({ + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }); + await enableMandatoryMfaWithTotp(); + }); + + it('should fail with missing_mfa error for normal register', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const client = await initClient(); + + await client.send(putInteraction, { + event: InteractionEvent.Register, + profile: { + username, + password, + }, + }); + + await expectRejects(client.submitInteraction(), { + code: 'user.missing_mfa', + statusCode: 422, + }); + }); + + it('should fail with wrong totp code', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const client = await initClient(); + + await client.send(putInteraction, { + event: InteractionEvent.Register, + profile: { + username, + password, + }, + }); + + await client.send(initTotp); + await expectRejects( + client.send(putInteractionBindMfa, { + type: MfaFactor.TOTP, + code: '123456', + }), + { + code: 'session.mfa.invalid_totp_code', + statusCode: 400, + } + ); + }); + + it('should register and setup TOTP successfully', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const client = await initClient(); + + await client.send(putInteraction, { + event: InteractionEvent.Register, + profile: { + username, + password, + }, + }); + + const { secret } = await client.send(initTotp); + const code = authenticator.generate(secret); + + await client.send(putInteractionBindMfa, { + type: MfaFactor.TOTP, + code, + }); + + const { redirectTo } = await client.submitInteraction(); + + const id = await processSession(client, redirectTo); + await logoutClient(client); + await deleteUser(id); + }); +}); + +describe('sign in and fulfill mfa (mandatory TOTP)', () => { + beforeAll(async () => { + await enableAllPasswordSignInMethods({ + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }); + await enableMandatoryMfaWithTotp(); + }); + + it('should fail with missing_mfa error for normal sign in', async () => { + const { userProfile, user } = await generateNewUser({ username: true, password: true }); + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { + username: userProfile.username, + password: userProfile.password, + }, + }); + + await expectRejects(client.submitInteraction(), { + code: 'user.missing_mfa', + statusCode: 422, + }); + + await deleteUser(user.id); + }); + + it('should sign in and fulfill totp', async () => { + const { userProfile, user } = await generateNewUser({ username: true, password: true }); + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { + username: userProfile.username, + password: userProfile.password, + }, + }); + + const { secret } = await client.send(initTotp); + const code = authenticator.generate(secret); + + await client.send(putInteractionBindMfa, { + type: MfaFactor.TOTP, + code, + }); + + const { redirectTo } = await client.submitInteraction(); + + await processSession(client, redirectTo); + await logoutClient(client); + await deleteUser(user.id); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89b3db83c..1cd3a2e97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3679,6 +3679,10 @@ importers: version: 3.20.2 packages/integration-tests: + dependencies: + otplib: + specifier: ^12.0.1 + version: 12.0.1 devDependencies: '@jest/test-sequencer': specifier: ^29.5.0