Fix TOTP validation ignoring the configured tolerance window (#23632)
## Problem Entering a 2FA code right after it rotates fails with "Invalid OTP". A tolerance window was configured (`TOTP_DEFAULT_CONFIGURATION.window`) but never applied: `TotpStrategy` validated its options with Zod and then discarded them, and `validate()` called the global otplib `authenticator`, which defaults to `window: 0`. Only the current 30-second code was ever accepted, and clock drift between the device and the server made it feel even stricter. ## Changes - `TotpStrategy` now clones the otplib authenticator with the validated options (window, step, digits, algorithm, encoding, epoch) and uses that instance for both `initiate` and `validate`, so the configured tolerance actually applies. - Default window set to 1: the previous and next codes are accepted alongside the current one, so a code stays valid for up to 30 extra seconds after rotation and forward clock skew is tolerated. This follows the RFC 6238 recommendation of one time step, kept deliberately tight since `getAuthTokensFromOTP` has no rate limiting beyond the optional captcha guard. - Replaced the placeholder strategy tests with deterministic assertions using fixed epochs: previous and next tokens accepted within the window, a token two steps old rejected, and no-window strategies still reject the previous token. ## Testing - All 2FA module unit tests pass (105), including 19 for the strategy. - `lint:diff-with-main` and `typecheck` pass for twenty-server. --- _Generated by [Claude Code](https://claude.ai/code/session_01Va3ESUmkp14Wu65sAXkL7k)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23632?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+1
-1
@@ -21,7 +21,7 @@ export const TOTP_DEFAULT_CONFIGURATION = {
|
||||
algorithm: TOTPHashAlgorithms.SHA1,
|
||||
digits: 6,
|
||||
encodings: TOTPKeyEncodings.HEX, // Keep as hex - this is correct for @otplib/core
|
||||
window: 3,
|
||||
window: 1,
|
||||
step: 30,
|
||||
};
|
||||
|
||||
|
||||
+70
-55
@@ -2,6 +2,7 @@ import { authenticator } from 'otplib';
|
||||
|
||||
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
|
||||
import { OTPStatus } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/otp.constants';
|
||||
import { TwoFactorAuthenticationException } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.exception';
|
||||
|
||||
import { TotpStrategy } from './totp.strategy';
|
||||
|
||||
@@ -10,23 +11,21 @@ import {
|
||||
type TotpContext,
|
||||
} from './constants/totp.strategy.constants';
|
||||
|
||||
const RESYNCH_WINDOW = 3;
|
||||
const FIXED_EPOCH_MS = 1_700_000_000_000;
|
||||
const STEP_DURATION_MS = 30 * 1000;
|
||||
|
||||
const generateTokenAtEpoch = (secret: string, epochMs: number): string =>
|
||||
authenticator.clone({ epoch: epochMs }).generate(secret);
|
||||
|
||||
describe('TOTPStrategy Configuration', () => {
|
||||
let strategy: TotpStrategy;
|
||||
let secret: string;
|
||||
let context: TotpContext;
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
secret = authenticator.generateSecret();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('Valid Configurations', () => {
|
||||
it('should create a strategy with default options', () => {
|
||||
expect(() => new TotpStrategy()).not.toThrow();
|
||||
@@ -43,27 +42,22 @@ describe('TOTPStrategy Configuration', () => {
|
||||
expect(() => new TotpStrategy(validOptions)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should warn when all custom options are valid but not recommended', () => {
|
||||
// Since we simplified the implementation, this test no longer applies
|
||||
// as we don't have custom configuration warnings
|
||||
it('should accept a large window', () => {
|
||||
expect(() => new TotpStrategy({ window: 10 })).not.toThrow();
|
||||
// Remove the warning expectation since our simplified implementation doesn't warn
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid Configurations', () => {
|
||||
it('should throw for a negative window', () => {
|
||||
expect(() => new TotpStrategy({ window: -1 })).toThrow(
|
||||
TwoFactorAuthenticationException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly set the window property', () => {
|
||||
// Since we simplified the implementation to use otplib defaults,
|
||||
// we can't directly access internal configuration
|
||||
const strategy = new TotpStrategy({ window: 10 });
|
||||
|
||||
expect(strategy).toBeDefined();
|
||||
});
|
||||
|
||||
it('should default window to 0 if not provided', () => {
|
||||
// Since we simplified the implementation to use otplib defaults,
|
||||
// we can't directly access internal configuration
|
||||
const strategy = new TotpStrategy();
|
||||
|
||||
expect(strategy).toBeDefined();
|
||||
it('should throw for digits below the minimum', () => {
|
||||
expect(() => new TotpStrategy({ digits: 4 })).toThrow(
|
||||
TwoFactorAuthenticationException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,9 +86,7 @@ describe('TOTPStrategy Configuration', () => {
|
||||
|
||||
describe('validate', () => {
|
||||
beforeEach(() => {
|
||||
strategy = new TotpStrategy({
|
||||
window: RESYNCH_WINDOW,
|
||||
});
|
||||
strategy = new TotpStrategy({ window: 1, epoch: FIXED_EPOCH_MS });
|
||||
|
||||
context = {
|
||||
status: OTPStatus.VERIFIED,
|
||||
@@ -103,12 +95,9 @@ describe('TOTPStrategy Configuration', () => {
|
||||
});
|
||||
|
||||
it('should return true for a valid token at the current counter', () => {
|
||||
// Use the initiate method to generate a proper secret
|
||||
const initResult = strategy.initiate('test@example.com', 'TestApp');
|
||||
// Use authenticator.generate to match what authenticator.check expects
|
||||
const token = authenticator.generate(initResult.context.secret);
|
||||
const token = generateTokenAtEpoch(secret, FIXED_EPOCH_MS);
|
||||
|
||||
const result = strategy.validate(token, initResult.context);
|
||||
const result = strategy.validate(token, context);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
@@ -120,23 +109,49 @@ describe('TOTPStrategy Configuration', () => {
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should succeed if the token is valid within the window', () => {
|
||||
// Use the initiate method to generate a proper secret
|
||||
const initResult = strategy.initiate('test@example.com', 'TestApp');
|
||||
// Use authenticator.generate to match what authenticator.check expects
|
||||
const futureToken = authenticator.generate(initResult.context.secret);
|
||||
it('should accept the previous token within the window', () => {
|
||||
const previousToken = generateTokenAtEpoch(
|
||||
secret,
|
||||
FIXED_EPOCH_MS - STEP_DURATION_MS,
|
||||
);
|
||||
|
||||
const result = strategy.validate(futureToken, initResult.context);
|
||||
const result = strategy.validate(previousToken, context);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail if the token is valid but outside the window', () => {
|
||||
// For this test, we'll use a completely invalid token since we can't easily
|
||||
// generate tokens outside the window with the simplified implementation
|
||||
const invalidToken = '000000';
|
||||
it('should accept the next token within the window', () => {
|
||||
const nextToken = generateTokenAtEpoch(
|
||||
secret,
|
||||
FIXED_EPOCH_MS + STEP_DURATION_MS,
|
||||
);
|
||||
|
||||
const result = strategy.validate(invalidToken, context);
|
||||
const result = strategy.validate(nextToken, context);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a token generated outside the window', () => {
|
||||
const staleToken = generateTokenAtEpoch(
|
||||
secret,
|
||||
FIXED_EPOCH_MS - 2 * STEP_DURATION_MS,
|
||||
);
|
||||
|
||||
const result = strategy.validate(staleToken, context);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject the previous token when no window is configured', () => {
|
||||
const strategyWithoutWindow = new TotpStrategy({
|
||||
epoch: FIXED_EPOCH_MS,
|
||||
});
|
||||
const previousToken = generateTokenAtEpoch(
|
||||
secret,
|
||||
FIXED_EPOCH_MS - STEP_DURATION_MS,
|
||||
);
|
||||
|
||||
const result = strategyWithoutWindow.validate(previousToken, context);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
@@ -147,8 +162,6 @@ describe('TOTPStrategy Configuration', () => {
|
||||
secret: 'invalid-secret' as PlaintextString,
|
||||
};
|
||||
|
||||
// The authenticator.check method doesn't throw for invalid secrets,
|
||||
// it just returns false
|
||||
const result = strategy.validate('123456', invalidContext);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
@@ -160,18 +173,17 @@ describe('TOTPStrategy Configuration', () => {
|
||||
secret: '' as PlaintextString,
|
||||
};
|
||||
|
||||
// The authenticator.check method doesn't throw for empty secrets,
|
||||
// it just returns false
|
||||
const result = strategy.validate('123456', invalidContext);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should return the original context on validation success', () => {
|
||||
// Use the initiate method to generate a proper secret
|
||||
const initResult = strategy.initiate('test@example.com', 'TestApp');
|
||||
// Use authenticator.generate to match what authenticator.check expects
|
||||
const token = authenticator.generate(initResult.context.secret);
|
||||
const token = generateTokenAtEpoch(
|
||||
initResult.context.secret,
|
||||
FIXED_EPOCH_MS,
|
||||
);
|
||||
|
||||
const result = strategy.validate(token, initResult.context);
|
||||
|
||||
@@ -194,24 +206,27 @@ describe('TOTPStrategy Configuration', () => {
|
||||
});
|
||||
|
||||
it('should handle empty token gracefully', () => {
|
||||
const context = {
|
||||
const errorHandlingContext = {
|
||||
status: OTPStatus.VERIFIED,
|
||||
secret: secret as PlaintextString,
|
||||
};
|
||||
|
||||
const result = strategy.validate('', context);
|
||||
const result = strategy.validate('', errorHandlingContext);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.context.status).toBe(OTPStatus.VERIFIED);
|
||||
});
|
||||
|
||||
it('should handle null token gracefully', () => {
|
||||
const context = {
|
||||
const errorHandlingContext = {
|
||||
status: OTPStatus.VERIFIED,
|
||||
secret: secret as PlaintextString,
|
||||
};
|
||||
|
||||
const result = strategy.validate(null as any, context);
|
||||
const result = strategy.validate(
|
||||
null as unknown as string,
|
||||
errorHandlingContext,
|
||||
);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.context.status).toBe(OTPStatus.VERIFIED);
|
||||
|
||||
+55
-7
@@ -3,7 +3,6 @@ import { Injectable } from '@nestjs/common';
|
||||
import { authenticator } from 'otplib';
|
||||
import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type ZodSafeParseResult } from 'zod';
|
||||
|
||||
import { type OTPAuthenticationStrategyInterface } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/interfaces/otp.strategy.interface';
|
||||
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
|
||||
@@ -20,15 +19,22 @@ import {
|
||||
TOTPStrategyConfig,
|
||||
} from './constants/totp.strategy.constants';
|
||||
|
||||
type TotpAuthenticator = typeof authenticator;
|
||||
type TotpAuthenticatorOptions = NonNullable<
|
||||
Parameters<TotpAuthenticator['clone']>[0]
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class TotpStrategy implements OTPAuthenticationStrategyInterface {
|
||||
public readonly name = TwoFactorAuthenticationStrategy.TOTP;
|
||||
|
||||
private readonly totpAuthenticator: TotpAuthenticator;
|
||||
|
||||
constructor(options?: TOTPStrategyConfig) {
|
||||
let result: ZodSafeParseResult<TOTPStrategyConfig> | undefined;
|
||||
let validatedOptions: TOTPStrategyConfig | undefined;
|
||||
|
||||
if (isDefined(options)) {
|
||||
result = TOTP_STRATEGY_CONFIG_SCHEMA.safeParse(options);
|
||||
const result = TOTP_STRATEGY_CONFIG_SCHEMA.safeParse(options);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessages = Object.entries(result.error.flatten().fieldErrors)
|
||||
@@ -43,9 +49,51 @@ export class TotpStrategy implements OTPAuthenticationStrategyInterface {
|
||||
TwoFactorAuthenticationExceptionCode.INVALID_CONFIGURATION,
|
||||
);
|
||||
}
|
||||
|
||||
validatedOptions = result.data;
|
||||
}
|
||||
|
||||
// otplib will use its defaults: sha1, 6 digits, 30 second step, etc.
|
||||
this.totpAuthenticator = authenticator.clone(
|
||||
this.buildAuthenticatorOptions(validatedOptions),
|
||||
);
|
||||
}
|
||||
|
||||
private buildAuthenticatorOptions(
|
||||
config?: TOTPStrategyConfig,
|
||||
): TotpAuthenticatorOptions {
|
||||
const authenticatorOptions: TotpAuthenticatorOptions = {};
|
||||
|
||||
if (!isDefined(config)) {
|
||||
return authenticatorOptions;
|
||||
}
|
||||
|
||||
if (isDefined(config.algorithm)) {
|
||||
authenticatorOptions.algorithm =
|
||||
config.algorithm as unknown as TotpAuthenticatorOptions['algorithm'];
|
||||
}
|
||||
|
||||
if (isDefined(config.encodings)) {
|
||||
authenticatorOptions.encoding =
|
||||
config.encodings as unknown as TotpAuthenticatorOptions['encoding'];
|
||||
}
|
||||
|
||||
if (isDefined(config.digits)) {
|
||||
authenticatorOptions.digits = config.digits;
|
||||
}
|
||||
|
||||
if (isDefined(config.window)) {
|
||||
authenticatorOptions.window = config.window;
|
||||
}
|
||||
|
||||
if (isDefined(config.step)) {
|
||||
authenticatorOptions.step = config.step;
|
||||
}
|
||||
|
||||
if (isDefined(config.epoch)) {
|
||||
authenticatorOptions.epoch = config.epoch;
|
||||
}
|
||||
|
||||
return authenticatorOptions;
|
||||
}
|
||||
|
||||
public initiate(
|
||||
@@ -55,8 +103,8 @@ export class TotpStrategy implements OTPAuthenticationStrategyInterface {
|
||||
uri: string;
|
||||
context: TotpContext;
|
||||
} {
|
||||
const secret = authenticator.generateSecret() as PlaintextString;
|
||||
const uri = authenticator.keyuri(accountName, issuer, secret);
|
||||
const secret = this.totpAuthenticator.generateSecret() as PlaintextString;
|
||||
const uri = this.totpAuthenticator.keyuri(accountName, issuer, secret);
|
||||
|
||||
return {
|
||||
uri,
|
||||
@@ -74,7 +122,7 @@ export class TotpStrategy implements OTPAuthenticationStrategyInterface {
|
||||
isValid: boolean;
|
||||
context: TotpContext;
|
||||
} {
|
||||
const isValid = authenticator.check(token, context.secret);
|
||||
const isValid = this.totpAuthenticator.check(token, context.secret);
|
||||
|
||||
return {
|
||||
isValid,
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { authenticator } from 'otplib';
|
||||
import { initiateOtpProvisioningForAuthenticatedUser } from 'test/integration/graphql/utils/initiate-otp-provisioning-for-authenticated-user.util';
|
||||
import { verifyTwoFactorAuthenticationMethod } from 'test/integration/graphql/utils/verify-two-factor-authentication-method.util';
|
||||
|
||||
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
|
||||
|
||||
const TOTP_STEP_DURATION_MS = 30_000;
|
||||
const MINIMUM_STEP_REMAINING_MS = 5_000;
|
||||
|
||||
const generateTokenAtEpoch = (secret: string, epochMs: number): string =>
|
||||
authenticator.clone({ epoch: epochMs }).generate(secret);
|
||||
|
||||
// A previous-step token generated right before a step boundary would already be
|
||||
// two steps old once the server validates it, so wait out the boundary first
|
||||
const waitUntilSafelyInsideTotpStep = async (): Promise<void> => {
|
||||
const millisecondsRemainingInStep =
|
||||
TOTP_STEP_DURATION_MS - (Date.now() % TOTP_STEP_DURATION_MS);
|
||||
|
||||
if (millisecondsRemainingInStep < MINIMUM_STEP_REMAINING_MS) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, millisecondsRemainingInStep + 100),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteJonyTwoFactorAuthenticationMethods = async (): Promise<void> => {
|
||||
await global.testDataSource.query(
|
||||
`DELETE FROM core."twoFactorAuthenticationMethod" WHERE "userWorkspaceId" = $1`,
|
||||
[USER_WORKSPACE_DATA_SEED_IDS.JONY],
|
||||
);
|
||||
};
|
||||
|
||||
describe('Two-factor authentication TOTP verification (integration)', () => {
|
||||
let secret: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await deleteJonyTwoFactorAuthenticationMethods();
|
||||
|
||||
const { data, errors } = await initiateOtpProvisioningForAuthenticatedUser({
|
||||
accessToken: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
expect(errors).toBeUndefined();
|
||||
|
||||
const uri = data.initiateOTPProvisioningForAuthenticatedUser.uri;
|
||||
|
||||
expect(uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
|
||||
const secretFromUri = uri.match(/[?&]secret=([^&]+)/)?.[1];
|
||||
|
||||
if (secretFromUri === undefined || secretFromUri === '') {
|
||||
throw new Error('Expected the otpauth URI to contain a secret');
|
||||
}
|
||||
|
||||
secret = secretFromUri;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteJonyTwoFactorAuthenticationMethods();
|
||||
});
|
||||
|
||||
it('should reject a code that is more than one step old', async () => {
|
||||
const staleToken = generateTokenAtEpoch(
|
||||
secret,
|
||||
Date.now() - 2 * TOTP_STEP_DURATION_MS,
|
||||
);
|
||||
|
||||
const { data, errors } = await verifyTwoFactorAuthenticationMethod({
|
||||
otp: staleToken,
|
||||
accessToken: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
||||
expectToFail: true,
|
||||
});
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(
|
||||
data?.verifyTwoFactorAuthenticationMethodForAuthenticatedUser,
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should accept the current code', async () => {
|
||||
await waitUntilSafelyInsideTotpStep();
|
||||
|
||||
const currentToken = generateTokenAtEpoch(secret, Date.now());
|
||||
|
||||
const { data, errors } = await verifyTwoFactorAuthenticationMethod({
|
||||
otp: currentToken,
|
||||
accessToken: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
expect(errors).toBeUndefined();
|
||||
expect(
|
||||
data.verifyTwoFactorAuthenticationMethodForAuthenticatedUser.success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept the code from the previous step', async () => {
|
||||
await waitUntilSafelyInsideTotpStep();
|
||||
|
||||
const previousStepToken = generateTokenAtEpoch(
|
||||
secret,
|
||||
Date.now() - TOTP_STEP_DURATION_MS,
|
||||
);
|
||||
|
||||
const { data, errors } = await verifyTwoFactorAuthenticationMethod({
|
||||
otp: previousStepToken,
|
||||
accessToken: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
expect(errors).toBeUndefined();
|
||||
expect(
|
||||
data.verifyTwoFactorAuthenticationMethodForAuthenticatedUser.success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
|
||||
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
|
||||
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
|
||||
|
||||
import { type InitiateTwoFactorAuthenticationProvisioningDTO } from 'src/engine/core-modules/two-factor-authentication/dto/initiate-two-factor-authentication-provisioning.dto';
|
||||
|
||||
type InitiateOtpProvisioningForAuthenticatedUserUtilArgs = {
|
||||
accessToken: string;
|
||||
expectToFail?: boolean;
|
||||
};
|
||||
|
||||
export const initiateOtpProvisioningForAuthenticatedUser = async ({
|
||||
accessToken,
|
||||
expectToFail,
|
||||
}: InitiateOtpProvisioningForAuthenticatedUserUtilArgs): CommonResponseBody<{
|
||||
initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioningDTO;
|
||||
}> => {
|
||||
const mutation = gql`
|
||||
mutation InitiateOTPProvisioningForAuthenticatedUser {
|
||||
initiateOTPProvisioningForAuthenticatedUser {
|
||||
uri
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await makeMetadataAPIRequest(
|
||||
{
|
||||
query: mutation,
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
|
||||
if (expectToFail === true) {
|
||||
warnIfNoErrorButExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'OTP provisioning should have failed but did not',
|
||||
});
|
||||
}
|
||||
|
||||
if (expectToFail === false) {
|
||||
warnIfErrorButNotExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'OTP provisioning has failed but should not',
|
||||
});
|
||||
}
|
||||
|
||||
return { data: response.body.data, errors: response.body.errors };
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
|
||||
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
|
||||
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
|
||||
|
||||
import { type VerifyTwoFactorAuthenticationMethodDTO } from 'src/engine/core-modules/two-factor-authentication/dto/verify-two-factor-authentication-method.dto';
|
||||
|
||||
type VerifyTwoFactorAuthenticationMethodUtilArgs = {
|
||||
otp: string;
|
||||
accessToken: string;
|
||||
expectToFail?: boolean;
|
||||
};
|
||||
|
||||
export const verifyTwoFactorAuthenticationMethod = async ({
|
||||
otp,
|
||||
accessToken,
|
||||
expectToFail,
|
||||
}: VerifyTwoFactorAuthenticationMethodUtilArgs): CommonResponseBody<{
|
||||
verifyTwoFactorAuthenticationMethodForAuthenticatedUser: VerifyTwoFactorAuthenticationMethodDTO;
|
||||
}> => {
|
||||
const mutation = gql`
|
||||
mutation VerifyTwoFactorAuthenticationMethodForAuthenticatedUser(
|
||||
$otp: String!
|
||||
) {
|
||||
verifyTwoFactorAuthenticationMethodForAuthenticatedUser(otp: $otp) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await makeMetadataAPIRequest(
|
||||
{
|
||||
query: mutation,
|
||||
variables: { otp },
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
|
||||
if (expectToFail === true) {
|
||||
warnIfNoErrorButExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'OTP verification should have failed but did not',
|
||||
});
|
||||
}
|
||||
|
||||
if (expectToFail === false) {
|
||||
warnIfErrorButNotExpectedToFail({
|
||||
response,
|
||||
errorMessage: 'OTP verification has failed but should not',
|
||||
});
|
||||
}
|
||||
|
||||
return { data: response.body.data, errors: response.body.errors };
|
||||
};
|
||||
Reference in New Issue
Block a user