0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core,phrases,schemas): implement bind mfa totp (#4551)

This commit is contained in:
wangsijie 2023-09-26 14:18:26 +08:00 committed by GitHub
parent 19939811c1
commit 08a35a1695
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1205 additions and 175 deletions

View file

@ -69,6 +69,7 @@
"lru-cache": "^10.0.0",
"nanoid": "^4.0.0",
"oidc-provider": "^8.2.2",
"otplib": "^12.0.1",
"p-retry": "^6.0.0",
"pg-protocol": "^1.6.0",
"redis": "^4.6.5",

View file

@ -0,0 +1,6 @@
import { MfaFactor, type BindMfa } from '@logto/schemas';
export const mockTotpBind: BindMfa = {
type: MfaFactor.TOTP,
secret: 'secret',
};

View file

@ -1,4 +1,9 @@
import { InteractionEvent, adminConsoleApplicationId, adminTenantId } from '@logto/schemas';
import {
InteractionEvent,
MfaFactor,
adminConsoleApplicationId,
adminTenantId,
} from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import type Provider from 'oidc-provider';
@ -31,14 +36,19 @@ const { encryptUserPassword } = mockEsm('#src/libraries/user.js', () => ({
}),
}));
mockEsm('@logto/shared', () => ({
generateStandardId: jest.fn().mockReturnValue('uid'),
}));
mockEsm('#src/utils/tenant.js', () => ({
getTenantId: () => adminTenantId,
}));
const userQueries = {
findUserById: jest
.fn()
.mockResolvedValue({ identities: { google: { userId: 'googleId', details: {} } } }),
findUserById: jest.fn().mockResolvedValue({
identities: { google: { userId: 'googleId', details: {} } },
mfaVerifications: [],
}),
updateUserById: jest.fn(),
hasActiveUsers: jest.fn().mockResolvedValue(true),
};
@ -165,6 +175,35 @@ describe('submit action', () => {
);
});
it('register with bindMfa', async () => {
const interaction: VerifiedRegisterInteractionResult = {
event: InteractionEvent.Register,
profile,
identifiers,
bindMfa: { type: MfaFactor.TOTP, secret: 'secret' },
};
await submitInteraction(interaction, ctx, tenant);
expect(generateUserId).toBeCalled();
expect(hasActiveUsers).not.toBeCalled();
expect(insertUser).toBeCalledWith(
{
id: 'uid',
mfaVerifications: [
{
type: MfaFactor.TOTP,
key: 'secret',
id: 'uid',
createdAt: new Date(now).toISOString(),
},
],
...upsertProfile,
},
['user']
);
});
it('admin user register', async () => {
hasActiveUsers.mockResolvedValueOnce(false);
const adminConsoleCtx = {
@ -234,6 +273,41 @@ describe('submit action', () => {
});
});
it('sign-in with bindMfa', async () => {
getLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'logto' },
dbEntry: { syncProfile: false },
});
const interaction: VerifiedSignInInteractionResult = {
event: InteractionEvent.SignIn,
accountId: 'foo',
identifiers,
bindMfa: {
type: MfaFactor.TOTP,
secret: 'secret',
},
};
await submitInteraction(interaction, ctx, tenant);
expect(getLogtoConnectorById).toBeCalledWith('logto');
expect(updateUserById).toBeCalledWith('foo', {
mfaVerifications: [
{
type: MfaFactor.TOTP,
key: 'secret',
id: 'uid',
createdAt: new Date(now).toISOString(),
},
],
lastSignInAt: now,
});
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'foo' },
});
});
it('sign-in and sync new Social', async () => {
getLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'logto' },

View file

@ -11,7 +11,7 @@ import {
InteractionEvent,
adminConsoleApplicationId,
} from '@logto/schemas';
import { type OmitAutoSetFields } from '@logto/shared';
import { generateStandardId, type OmitAutoSetFields } from '@logto/shared';
import { conditional, conditionalArray, trySafe } from '@silverhand/essentials';
import { type IRouterContext } from 'koa-router';
@ -168,6 +168,23 @@ const parseUserProfile = async (
};
};
const parseBindMfa = ({
bindMfa,
}: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult):
| User['mfaVerifications'][number]
| undefined => {
if (!bindMfa) {
return;
}
return {
type: bindMfa.type,
key: bindMfa.secret,
id: generateStandardId(),
createdAt: new Date().toISOString(),
};
};
const getInitialUserRoles = (
isInAdminTenant: boolean,
isCreatingFirstAdminUser: boolean,
@ -220,6 +237,7 @@ export default async function submitInteraction(
if (event === InteractionEvent.Register) {
const id = await generateUserId();
const userProfile = await parseUserProfile(connectors, interaction);
const mfaVerification = parseBindMfa(interaction);
const { client_id } = ctx.interactionDetails.params;
@ -234,6 +252,7 @@ export default async function submitInteraction(
{
id,
...userProfile,
...conditional(mfaVerification && { mfaVerifications: [mfaVerification] }),
},
getInitialUserRoles(isInAdminTenant, isCreatingFirstAdminUser, isCloud)
);
@ -265,8 +284,14 @@ export default async function submitInteraction(
if (event === InteractionEvent.SignIn) {
const user = await findUserById(accountId);
const updateUserProfile = await parseUserProfile(connectors, interaction, user);
const mfaVerification = parseBindMfa(interaction);
await updateUserById(accountId, updateUserProfile);
await updateUserById(accountId, {
...updateUserProfile,
...conditional(
mfaVerification && { mfaVerifications: [...user.mfaVerifications, mfaVerification] }
),
});
await assignInteractionResults(ctx, provider, { login: { accountId } });
ctx.assignInteractionHookResult({ userId: accountId });

View file

@ -0,0 +1,199 @@
import { ConnectorType } from '@logto/connector-kit';
import { demoAppApplicationId, InteractionEvent } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import RequestError from '#src/errors/RequestError/index.js';
import type koaAuditLog from '#src/middleware/koa-audit-log.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import type { LogtoConnector } from '#src/utils/connectors/types.js';
import { createRequester } from '#src/utils/test-utils.js';
import { verificationPath, interactionPrefix } from './const.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
// FIXME @Darcy: no more `enabled` for `connectors` table
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
const metadata = {
id:
connectorId === 'social_enabled'
? 'social_enabled'
: connectorId === 'social_disabled'
? 'social_disabled'
: 'others',
};
return {
dbEntry: {},
metadata,
type: connectorId.startsWith('social') ? ConnectorType.Social : ConnectorType.Sms,
getAuthorizationUri: jest.fn(async () => ''),
};
});
const { getInteractionStorage, storeInteractionResult } = await mockEsmWithActual(
'./utils/interaction.js',
() => ({
getInteractionStorage: jest.fn().mockReturnValue({
event: InteractionEvent.SignIn,
}),
storeInteractionResult: jest.fn(),
})
);
const { sendVerificationCodeToIdentifier } = await mockEsmWithActual(
'./utils/verification-code-validation.js',
() => ({
sendVerificationCodeToIdentifier: jest.fn(),
})
);
const { createLog, prependAllLogEntries } = createMockLogContext();
await mockEsmWithActual(
'#src/middleware/koa-audit-log.js',
(): { default: typeof koaAuditLog } => ({
// eslint-disable-next-line unicorn/consistent-function-scoping
default: () => async (ctx, next) => {
ctx.createLog = createLog;
ctx.prependAllLogEntries = prependAllLogEntries;
return next();
},
})
);
const baseProviderMock = {
params: {},
jti: 'jti',
client_id: demoAppApplicationId,
};
const tenantContext = new MockTenant(
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
{
signInExperiences: {
findDefaultSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
},
},
{
getLogtoConnectorById: async (connectorId: string) => {
const connector = await getLogtoConnectorByIdHelper(connectorId);
if (connector.type !== ConnectorType.Social) {
throw new RequestError({
code: 'entity.not_found',
status: 404,
});
}
// @ts-expect-error
return connector as LogtoConnector;
},
}
);
const { default: interactionRoutes } = await import('./index.js');
describe('interaction routes', () => {
const sessionRequest = createRequester({
anonymousRoutes: interactionRoutes,
tenantContext,
});
afterEach(() => {
jest.clearAllMocks();
getInteractionStorage.mockReturnValue({
event: InteractionEvent.SignIn,
});
});
describe('POST /interaction/verification/verification-code', () => {
const path = `${interactionPrefix}/${verificationPath}/verification-code`;
it('should call send verificationCode properly', async () => {
const body = {
email: 'email@logto.io',
};
const response = await sessionRequest.post(path).send(body);
expect(getInteractionStorage).toBeCalled();
expect(sendVerificationCodeToIdentifier).toBeCalledWith(
{
event: InteractionEvent.SignIn,
...body,
},
'jti',
createLog,
tenantContext.libraries.passcodes
);
expect(response.status).toEqual(204);
});
});
describe('POST /verification/social/authorization-uri', () => {
const path = `${interactionPrefix}/${verificationPath}/social-authorization-uri`;
it('should throw when redirectURI is invalid', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_enabled',
state: 'state',
redirectUri: 'logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('should return the authorization-uri properly', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_enabled',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo', '');
});
it('throw error when sign-in with social but miss state', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_enabled',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when sign-in with social but miss redirectUri', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_enabled',
state: 'state',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when no social connector is found', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'others',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(404);
});
});
describe('POST /verification/totp', () => {
const path = `${interactionPrefix}/${verificationPath}/totp`;
it('should return the generated secret', async () => {
const response = await sessionRequest.post(path).send();
expect(getInteractionStorage).toBeCalled();
expect(storeInteractionResult).toBeCalled();
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('secret');
});
});
});

View file

@ -0,0 +1,102 @@
import { MfaFactor, requestVerificationCodePayloadGuard } from '@logto/schemas';
import type Router from 'koa-router';
import { type IRouterParamContext } from 'koa-router';
import { z } from 'zod';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import { interactionPrefix, verificationPath } from './const.js';
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
import { socialAuthorizationUrlPayloadGuard } from './types/guard.js';
import { getInteractionStorage, storeInteractionResult } from './utils/interaction.js';
import { createSocialAuthorizationUrl } from './utils/social-verification.js';
import { generateTotpSecret } from './utils/totp-validation.js';
import { sendVerificationCodeToIdentifier } from './utils/verification-code-validation.js';
export default function additionalRoutes<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
tenant: TenantContext
) {
// Create social authorization url interaction verification
router.post(
`${interactionPrefix}/${verificationPath}/social-authorization-uri`,
koaGuard({
body: socialAuthorizationUrlPayloadGuard,
status: [200, 400, 404],
response: z.object({
redirectTo: z.string(),
}),
}),
async (ctx, next) => {
// Check interaction exists
const { event } = getInteractionStorage(ctx.interactionDetails.result);
const log = ctx.createLog(`Interaction.${event}.Identifier.Social.Create`);
const { body: payload } = ctx.guard;
log.append(payload);
const redirectTo = await createSocialAuthorizationUrl(ctx, tenant, payload);
ctx.body = { redirectTo };
return next();
}
);
// Create passwordless interaction verification-code
router.post(
`${interactionPrefix}/${verificationPath}/verification-code`,
koaGuard({
body: requestVerificationCodePayloadGuard,
status: [204, 400, 404],
}),
async (ctx, next) => {
const { interactionDetails, guard, createLog } = ctx;
// Check interaction exists
const { event } = getInteractionStorage(interactionDetails.result);
await sendVerificationCodeToIdentifier(
{ event, ...guard.body },
interactionDetails.jti,
createLog,
tenant.libraries.passcodes
);
ctx.status = 204;
return next();
}
);
// Prepare new totp secret
router.post(
`${interactionPrefix}/${verificationPath}/totp`,
koaGuard({
status: [200],
response: z.object({
secret: z.string(),
}),
}),
async (ctx, next) => {
const { interactionDetails, createLog } = ctx;
// Check interaction exists
const { event } = getInteractionStorage(interactionDetails.result);
createLog(`Interaction.${event}.BindMfa.Totp.Create`);
const secret = generateTotpSecret();
await storeInteractionResult(
{ pendingMfa: { type: MfaFactor.TOTP, secret } },
ctx,
tenant.provider,
true
);
ctx.body = { secret };
return next();
}
);
}

View file

@ -11,10 +11,10 @@ import { MockTenant } from '#src/test-utils/tenant.js';
import type { LogtoConnector } from '#src/utils/connectors/types.js';
import { createRequester } from '#src/utils/test-utils.js';
import { verificationPath, interactionPrefix } from './const.js';
import { interactionPrefix } from './const.js';
const { jest } = import.meta;
const { mockEsm, mockEsmDefault, mockEsmWithActual } = createMockUtils(jest);
const { mockEsmDefault, mockEsmWithActual } = createMockUtils(jest);
// FIXME @Darcy: no more `enabled` for `connectors` table
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
@ -39,24 +39,30 @@ const { assignInteractionResults } = await mockEsmWithActual('#src/libraries/ses
assignInteractionResults: jest.fn(),
}));
const { verifySignInModeSettings, verifyIdentifierSettings, verifyProfileSettings } = mockEsm(
'./utils/sign-in-experience-validation.js',
() => ({
const { verifySignInModeSettings, verifyIdentifierSettings, verifyProfileSettings } =
await mockEsmWithActual('./utils/sign-in-experience-validation.js', () => ({
verifySignInModeSettings: jest.fn(),
verifyIdentifierSettings: jest.fn(),
verifyProfileSettings: jest.fn(),
})
);
}));
const submitInteraction = mockEsmDefault('./actions/submit-interaction.js', () => jest.fn());
const { verifyIdentifierPayload, verifyIdentifier, verifyProfile, validateMandatoryUserProfile } =
await mockEsmWithActual('./verifications/index.js', () => ({
verifyIdentifierPayload: jest.fn(),
verifyIdentifier: jest.fn().mockResolvedValue({}),
verifyProfile: jest.fn(),
validateMandatoryUserProfile: jest.fn(),
}));
const {
verifyIdentifierPayload,
verifyIdentifier,
verifyProfile,
validateMandatoryUserProfile,
validateMandatoryBindMfa,
verifyBindMfa,
} = await mockEsmWithActual('./verifications/index.js', () => ({
verifyIdentifierPayload: jest.fn(),
verifyIdentifier: jest.fn().mockResolvedValue({}),
verifyProfile: jest.fn(),
validateMandatoryUserProfile: jest.fn(),
validateMandatoryBindMfa: jest.fn(),
verifyBindMfa: jest.fn(),
}));
const { storeInteractionResult, mergeIdentifiers, getInteractionStorage } = await mockEsmWithActual(
'./utils/interaction.js',
@ -69,13 +75,6 @@ const { storeInteractionResult, mergeIdentifiers, getInteractionStorage } = awai
})
);
const { sendVerificationCodeToIdentifier } = await mockEsmWithActual(
'./utils/verification-code-validation.js',
() => ({
sendVerificationCodeToIdentifier: jest.fn(),
})
);
const { validatePassword } = await mockEsmWithActual('./utils/validate-password.js', () => ({
validatePassword: jest.fn(),
}));
@ -175,20 +174,28 @@ describe('interaction routes', () => {
jest.clearAllMocks();
});
it('should call identifier and profile verification properly', async () => {
it('should call identifier, profile and bindMfa verification properly', async () => {
verifyProfile.mockReturnValueOnce({
event: InteractionEvent.SignIn,
});
validateMandatoryUserProfile.mockReturnValueOnce({
event: InteractionEvent.SignIn,
});
verifyBindMfa.mockReturnValueOnce({
event: InteractionEvent.SignIn,
});
await sessionRequest.post(path).send();
expect(getInteractionStorage).toBeCalled();
expect(verifyIdentifier).toBeCalled();
expect(verifyProfile).toBeCalled();
expect(validateMandatoryUserProfile).toBeCalled();
expect(verifyBindMfa).toBeCalled();
expect(validateMandatoryBindMfa).toBeCalled();
expect(submitInteraction).toBeCalled();
});
it('should not call validateMandatoryUserProfile for forgot password request', async () => {
it('should not call validateMandatoryUserProfile and validateMandatoryBindMfa for forgot password request', async () => {
getInteractionStorage.mockReturnValue({
event: InteractionEvent.ForgotPassword,
});
@ -196,12 +203,19 @@ describe('interaction routes', () => {
verifyProfile.mockReturnValueOnce({
event: InteractionEvent.ForgotPassword,
});
validateMandatoryUserProfile.mockReturnValueOnce({
event: InteractionEvent.ForgotPassword,
});
verifyBindMfa.mockReturnValueOnce({
event: InteractionEvent.ForgotPassword,
});
await sessionRequest.post(path).send();
expect(getInteractionStorage).toBeCalled();
expect(verifyIdentifier).toBeCalled();
expect(verifyProfile).toBeCalled();
expect(validateMandatoryUserProfile).not.toBeCalled();
expect(validateMandatoryBindMfa).not.toBeCalled();
expect(submitInteraction).toBeCalled();
});
});
@ -309,76 +323,4 @@ describe('interaction routes', () => {
expect(response.status).toEqual(204);
});
});
describe('POST /interaction/verification/verification-code', () => {
const path = `${interactionPrefix}/${verificationPath}/verification-code`;
it('should call send verificationCode properly', async () => {
const body = {
email: 'email@logto.io',
};
const response = await sessionRequest.post(path).send(body);
expect(getInteractionStorage).toBeCalled();
expect(sendVerificationCodeToIdentifier).toBeCalledWith(
{
event: InteractionEvent.SignIn,
...body,
},
'jti',
createLog,
tenantContext.libraries.passcodes
);
expect(response.status).toEqual(204);
});
});
describe('POST /verification/social/authorization-uri', () => {
const path = `${interactionPrefix}/${verificationPath}/social-authorization-uri`;
it('should throw when redirectURI is invalid', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_enabled',
state: 'state',
redirectUri: 'logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('should return the authorization-uri properly', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_enabled',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(200);
expect(response.body).toHaveProperty('redirectTo', '');
});
it('throw error when sign-in with social but miss state', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_enabled',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when sign-in with social but miss redirectUri', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'social_enabled',
state: 'state',
});
expect(response.statusCode).toEqual(400);
});
it('throw error when no social connector is found', async () => {
const response = await sessionRequest.post(path).send({
connectorId: 'others',
state: 'state',
redirectUri: 'https://logto.dev',
});
expect(response.statusCode).toEqual(404);
});
});
});

View file

@ -1,11 +1,5 @@
import type { LogtoErrorCode } from '@logto/phrases';
import {
InteractionEvent,
eventGuard,
identifierPayloadGuard,
profileGuard,
requestVerificationCodePayloadGuard,
} from '@logto/schemas';
import { InteractionEvent, eventGuard, identifierPayloadGuard, profileGuard } from '@logto/schemas';
import type Router from 'koa-router';
import { z } from 'zod';
@ -18,13 +12,14 @@ import assertThat from '#src/utils/assert-that.js';
import type { AnonymousRouter, RouterInitArgs } from '../types.js';
import submitInteraction from './actions/submit-interaction.js';
import additionalRoutes from './additional.js';
import consentRoutes from './consent.js';
import { interactionPrefix, verificationPath } from './const.js';
import { interactionPrefix } from './const.js';
import mfaRoutes from './mfa.js';
import koaInteractionDetails from './middleware/koa-interaction-details.js';
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
import koaInteractionHooks from './middleware/koa-interaction-hooks.js';
import koaInteractionSie from './middleware/koa-interaction-sie.js';
import { socialAuthorizationUrlPayloadGuard } from './types/guard.js';
import {
getInteractionStorage,
storeInteractionResult,
@ -36,14 +31,14 @@ import {
verifyIdentifierSettings,
verifyProfileSettings,
} from './utils/sign-in-experience-validation.js';
import { createSocialAuthorizationUrl } from './utils/social-verification.js';
import { validatePassword } from './utils/validate-password.js';
import { sendVerificationCodeToIdentifier } from './utils/verification-code-validation.js';
import {
verifyIdentifierPayload,
verifyIdentifier,
verifyProfile,
validateMandatoryUserProfile,
validateMandatoryBindMfa,
verifyBindMfa,
} from './verifications/index.js';
export type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
@ -340,68 +335,30 @@ export default function interactionRoutes<T extends AnonymousRouter>(
const profileVerifiedInteraction = await verifyProfile(tenant, accountVerifiedInteraction);
const interaction = isForgotPasswordInteractionResult(profileVerifiedInteraction)
// TODO @simeng-li: make all these verification steps in a middleware.
const mandatoryProfileVerifiedInteraction = isForgotPasswordInteractionResult(
profileVerifiedInteraction
)
? profileVerifiedInteraction
: await validateMandatoryUserProfile(queries.users, ctx, profileVerifiedInteraction);
const bindMfaVerifiedInteraction = isForgotPasswordInteractionResult(
mandatoryProfileVerifiedInteraction
)
? mandatoryProfileVerifiedInteraction
: await verifyBindMfa(tenant, mandatoryProfileVerifiedInteraction);
const interaction = isForgotPasswordInteractionResult(bindMfaVerifiedInteraction)
? bindMfaVerifiedInteraction
: await validateMandatoryBindMfa(tenant, ctx, bindMfaVerifiedInteraction);
await submitInteraction(interaction, ctx, tenant, log);
return next();
}
);
// Create social authorization url interaction verification
router.post(
`${interactionPrefix}/${verificationPath}/social-authorization-uri`,
koaGuard({
body: socialAuthorizationUrlPayloadGuard,
status: [200, 400, 404],
response: z.object({
redirectTo: z.string(),
}),
}),
async (ctx, next) => {
// Check interaction exists
const { event } = getInteractionStorage(ctx.interactionDetails.result);
const log = ctx.createLog(`Interaction.${event}.Identifier.Social.Create`);
const { body: payload } = ctx.guard;
log.append(payload);
const redirectTo = await createSocialAuthorizationUrl(ctx, tenant, payload);
ctx.body = { redirectTo };
return next();
}
);
// Create passwordless interaction verification-code
router.post(
`${interactionPrefix}/${verificationPath}/verification-code`,
koaGuard({
body: requestVerificationCodePayloadGuard,
status: [204, 400, 404],
}),
async (ctx, next) => {
const { interactionDetails, guard, createLog } = ctx;
// Check interaction exists
const { event } = getInteractionStorage(interactionDetails.result);
await sendVerificationCodeToIdentifier(
// eslint-disable-next-line max-lines -- TODO: refactor @simeng
{ event, ...guard.body },
interactionDetails.jti,
createLog,
libraries.passcodes
);
ctx.status = 204;
return next();
}
);
consentRoutes(router, tenant);
additionalRoutes(router, tenant);
mfaRoutes(router, tenant);
}

View file

@ -0,0 +1,111 @@
import { demoAppApplicationId, InteractionEvent, MfaFactor } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import { mockTotpBind } from '#src/__mocks__/mfa-verification.js';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import type koaAuditLog from '#src/middleware/koa-audit-log.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
import { interactionPrefix } from './const.js';
const { jest } = import.meta;
const { mockEsmWithActual } = createMockUtils(jest);
const { getInteractionStorage, storeInteractionResult } = await mockEsmWithActual(
'./utils/interaction.js',
() => ({
getInteractionStorage: jest.fn().mockReturnValue({
event: InteractionEvent.SignIn,
}),
storeInteractionResult: jest.fn(),
})
);
const { createLog, prependAllLogEntries } = createMockLogContext();
await mockEsmWithActual(
'#src/middleware/koa-audit-log.js',
(): { default: typeof koaAuditLog } => ({
// eslint-disable-next-line unicorn/consistent-function-scoping
default: () => async (ctx, next) => {
ctx.createLog = createLog;
ctx.prependAllLogEntries = prependAllLogEntries;
return next();
},
})
);
const { verifyMfaSettings } = await mockEsmWithActual(
'./utils/sign-in-experience-validation.js',
() => ({
verifyMfaSettings: jest.fn(),
})
);
const { bindMfaPayloadVerification } = await mockEsmWithActual(
'./verifications/mfa-payload-verification.js',
() => ({
bindMfaPayloadVerification: jest.fn(),
})
);
const baseProviderMock = {
params: {},
jti: 'jti',
client_id: demoAppApplicationId,
};
const tenantContext = new MockTenant(
createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)),
{
signInExperiences: {
findDefaultSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
},
}
);
const { default: interactionRoutes } = await import('./index.js');
describe('interaction routes (MFA verification)', () => {
const sessionRequest = createRequester({
anonymousRoutes: interactionRoutes,
tenantContext,
});
afterEach(() => {
jest.clearAllMocks();
getInteractionStorage.mockReturnValue({
event: InteractionEvent.SignIn,
});
});
describe('PUT /interaction/bind-mfa', () => {
const path = `${interactionPrefix}/bind-mfa`;
it('should return 204 and store results in session', async () => {
bindMfaPayloadVerification.mockResolvedValue(mockTotpBind);
const body = {
type: MfaFactor.TOTP,
code: '123456',
};
const response = await sessionRequest.put(path).send(body);
expect(response.status).toEqual(204);
expect(getInteractionStorage).toBeCalled();
expect(verifyMfaSettings).toBeCalled();
expect(bindMfaPayloadVerification).toBeCalled();
expect(storeInteractionResult).toBeCalledWith(
{ bindMfa: mockTotpBind },
expect.anything(),
expect.anything(),
expect.anything()
);
});
});
});

View file

@ -0,0 +1,50 @@
import { InteractionEvent, bindMfaPayloadGuard } from '@logto/schemas';
import type Router from 'koa-router';
import { type IRouterParamContext } from 'koa-router';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import { interactionPrefix } from './const.js';
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
import koaInteractionSie from './middleware/koa-interaction-sie.js';
import { getInteractionStorage, storeInteractionResult } from './utils/interaction.js';
import { verifyMfaSettings } from './utils/sign-in-experience-validation.js';
import { bindMfaPayloadVerification } from './verifications/mfa-payload-verification.js';
export default function mfaRoutes<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
{ provider, queries }: TenantContext
) {
// Update New MFA
router.put(
`${interactionPrefix}/bind-mfa`,
koaGuard({
body: bindMfaPayloadGuard,
status: [204, 400, 401, 404, 422],
}),
koaInteractionSie(queries),
async (ctx, next) => {
const bindMfaPayload = ctx.guard.body;
const { signInExperience, interactionDetails, createLog } = ctx;
const interactionStorage = getInteractionStorage(interactionDetails.result);
const log = createLog(`Interaction.${interactionStorage.event}.BindMfa.Totp.Submit`);
if (interactionStorage.event !== InteractionEvent.ForgotPassword) {
verifyMfaSettings(bindMfaPayload.type, signInExperience);
}
const bindMfa = await bindMfaPayloadVerification(ctx, bindMfaPayload, interactionStorage);
log.append({ bindMfa, interactionStorage });
await storeInteractionResult({ bindMfa }, ctx, provider, true);
ctx.status = 204;
return next();
}
);
}

View file

@ -1,6 +1,6 @@
import { socialUserInfoGuard } from '@logto/connector-kit';
import { validateRedirectUrl } from '@logto/core-kit';
import { eventGuard, profileGuard } from '@logto/schemas';
import { bindMfaGuard, eventGuard, pendingMfaGuard, profileGuard } from '@logto/schemas';
import { z } from 'zod';
// Social Authorization Uri Route Payload Guard
@ -44,6 +44,10 @@ export const anonymousInteractionResultGuard = z.object({
profile: profileGuard.optional(),
accountId: z.string().optional(),
identifiers: z.array(identifierGuard).optional(),
// The new mfa to be bound to the account
bindMfa: bindMfaGuard.optional(),
// The pending mfa info, such as secret of TOTP
pendingMfa: pendingMfaGuard.optional(),
});
export const forgotPasswordProfileGuard = z.object({

View file

@ -7,6 +7,7 @@ import type {
SocialEmailPayload,
SocialPhonePayload,
Profile,
BindMfa,
} from '@logto/schemas';
import type { z } from 'zod';
@ -76,6 +77,7 @@ export type VerifiedRegisterInteractionResult = {
event: InteractionEvent.Register;
profile?: Profile;
identifiers?: Identifier[];
bindMfa?: BindMfa;
};
export type VerifiedSignInInteractionResult = {
@ -83,6 +85,7 @@ export type VerifiedSignInInteractionResult = {
accountId: string;
identifiers: Identifier[];
profile?: Profile;
bindMfa?: BindMfa;
};
export type VerifiedForgotPasswordInteractionResult = {

View file

@ -1,8 +1,8 @@
import type {
SocialConnectorPayload,
User,
IdentifierPayload,
VerifyVerificationCodePayload,
import {
type SocialConnectorPayload,
type User,
type IdentifierPayload,
type VerifyVerificationCodePayload,
} from '@logto/schemas';
import type { PasswordIdentifierPayload } from '../types/index.js';

View file

@ -1,5 +1,5 @@
import type { SignInExperience } from '@logto/schemas';
import { SignInIdentifier, SignInMode, InteractionEvent } from '@logto/schemas';
import { SignInIdentifier, SignInMode, InteractionEvent, MfaFactor } from '@logto/schemas';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
@ -7,6 +7,7 @@ import {
verifySignInModeSettings,
verifyIdentifierSettings,
verifyProfileSettings,
verifyMfaSettings,
} from './sign-in-experience-validation.js';
describe('verifySignInModeSettings', () => {
@ -375,3 +376,27 @@ describe('profile validation', () => {
}).toThrow();
});
});
describe('MFA', () => {
it('MFA sign-in-experience settings verification', () => {
expect(() => {
verifyMfaSettings(MfaFactor.TOTP, {
...mockSignInExperience,
mfa: {
...mockSignInExperience.mfa,
factors: [MfaFactor.TOTP],
},
});
}).not.toThrow();
expect(() => {
verifyMfaSettings(MfaFactor.TOTP, {
...mockSignInExperience,
mfa: {
...mockSignInExperience.mfa,
factors: [MfaFactor.WebAuthn],
},
});
}).toThrow();
});
});

View file

@ -1,4 +1,4 @@
import type { SignInExperience, Profile, IdentifierPayload } from '@logto/schemas';
import type { SignInExperience, Profile, IdentifierPayload, MfaFactor } from '@logto/schemas';
import { SignInMode, SignInIdentifier, InteractionEvent } from '@logto/schemas';
import RequestError from '#src/errors/RequestError/index.js';
@ -127,3 +127,11 @@ export const verifyProfileSettings = (profile: Profile, { signUp }: SignInExperi
assertThat(signUp.password, forbiddenIdentifierError());
}
};
export const verifyMfaSettings = (type: MfaFactor, signInExperience: SignInExperience) => {
const {
mfa: { factors },
} = signInExperience;
assertThat(factors.includes(type), forbiddenIdentifierError());
};

View file

@ -0,0 +1,34 @@
const { jest } = import.meta;
const { generateTotpSecret, validateTotpToken } = await import('./totp-validation.js');
describe('generateTotpSecret', () => {
it('should generate a secret', () => {
expect(typeof generateTotpSecret()).toBe('string');
});
});
describe('validateTotpToken', () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date(1_695_010_563_617));
});
afterAll(() => {
jest.useRealTimers();
});
it('should return true on valid token', () => {
const secret = 'JBSWY3DPEHPK3PXP';
const token = '971144';
expect(validateTotpToken(secret, token)).toBe(true);
});
it('should return false on invalid token', () => {
const secret = 'JBSWY3DPEHPK3PXP';
const token = '123456';
expect(validateTotpToken(secret, token)).toBe(false);
});
});

View file

@ -0,0 +1,6 @@
import { authenticator } from 'otplib';
export const generateTotpSecret = () => authenticator.generateSecret();
export const validateTotpToken = (secret: string, token: string) =>
authenticator.check(token, secret);

View file

@ -2,3 +2,4 @@ export { default as verifyProfile } from './profile-verification.js';
export { default as validateMandatoryUserProfile } from './mandatory-user-profile-validation.js';
export { default as verifyIdentifierPayload } from './identifier-payload-verification.js';
export { default as verifyIdentifier } from './identifier-verification.js';
export * from './mfa-verification.js';

View file

@ -0,0 +1,80 @@
import { InteractionEvent, MfaFactor } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import type Provider from 'oidc-provider';
import RequestError from '#src/errors/RequestError/index.js';
import { createMockLogContext } from '#src/test-utils/koa-audit-log.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
const { validateTotpToken } = mockEsm('../utils/totp-validation.js', () => ({
validateTotpToken: jest.fn().mockReturnValue(true),
}));
const { bindMfaPayloadVerification } = await import('./mfa-payload-verification.js');
describe('bindMfaPayloadVerification', () => {
const baseCtx = {
...createContextWithRouteParameters(),
...createMockLogContext(),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
interactionDetails: {} as Awaited<ReturnType<Provider['interactionDetails']>>,
};
const interaction: IdentifierVerifiedInteractionResult = {
event: InteractionEvent.SignIn,
identifiers: [{ key: 'accountId', value: 'foo' }],
accountId: 'foo',
};
describe('totp', () => {
it('should return result of BindMfa', async () => {
await expect(
bindMfaPayloadVerification(
baseCtx,
{ type: MfaFactor.TOTP, code: '123456' },
{
...interaction,
pendingMfa: {
type: MfaFactor.TOTP,
secret: 'secret',
},
}
)
).resolves.toMatchObject({
type: MfaFactor.TOTP,
secret: 'secret',
});
expect(validateTotpToken).toHaveBeenCalled();
});
it('should reject when pendingMfa is missing', async () => {
await expect(
bindMfaPayloadVerification(baseCtx, { type: MfaFactor.TOTP, code: '123456' }, interaction)
).rejects.toEqual(new RequestError('session.mfa.pending_info_not_found'));
});
it('should reject when code is invalid', async () => {
validateTotpToken.mockReturnValueOnce(false);
await expect(
bindMfaPayloadVerification(
baseCtx,
{ type: MfaFactor.TOTP, code: '123456' },
{
...interaction,
pendingMfa: {
type: MfaFactor.TOTP,
secret: 'secret',
},
}
)
).rejects.toEqual(new RequestError('session.mfa.invalid_totp_code'));
});
});
});

View file

@ -0,0 +1,42 @@
import {
MfaFactor,
type BindTotp,
type BindTotpPayload,
type BindMfaPayload,
type BindMfa,
} from '@logto/schemas';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import assertThat from '#src/utils/assert-that.js';
import type { AnonymousInteractionResult } from '../types/index.js';
import { validateTotpToken } from '../utils/totp-validation.js';
const verifyBindTotp = async (
interactionStorage: AnonymousInteractionResult,
payload: BindTotpPayload,
ctx: WithLogContext
): Promise<BindTotp> => {
const { event, pendingMfa } = interactionStorage;
ctx.createLog(`Interaction.${event}.BindMfa.Totp.Submit`);
assertThat(pendingMfa, 'session.mfa.pending_info_not_found');
// Will add more type, disable the rule for now, this can be a reminder when adding new type
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
assertThat(pendingMfa.type === MfaFactor.TOTP, 'session.mfa.pending_info_not_found');
const { code, type } = payload;
const { secret } = pendingMfa;
assertThat(validateTotpToken(secret, code), 'session.mfa.invalid_totp_code');
return { type, secret };
};
export async function bindMfaPayloadVerification(
ctx: WithLogContext,
bindMfaPayload: BindMfaPayload,
interactionStorage: AnonymousInteractionResult
): Promise<BindMfa> {
return verifyBindTotp(interactionStorage, bindMfaPayload, ctx);
}

View file

@ -0,0 +1,190 @@
import crypto from 'node:crypto';
import { PasswordPolicyChecker } from '@logto/core-kit';
import { InteractionEvent, MfaFactor, MfaPolicy } from '@logto/schemas';
import type Provider from 'oidc-provider';
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
import { mockUser } from '#src/__mocks__/user.js';
import RequestError from '#src/errors/RequestError/index.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
import { verifyBindMfa } from './mfa-verification.js';
const { jest } = import.meta;
const findUserById = jest.fn();
const tenantContext = new MockTenant(undefined, {
users: {
findUserById,
},
});
const { validateMandatoryBindMfa } = await import('./mfa-verification.js');
const baseCtx = {
...createContextWithRouteParameters(),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
interactionDetails: {} as Awaited<ReturnType<Provider['interactionDetails']>>,
signInExperience: {
...mockSignInExperience,
mfa: {
factors: [],
policy: MfaPolicy.UserControlled,
},
},
passwordPolicyChecker: new PasswordPolicyChecker(
mockSignInExperience.passwordPolicy,
crypto.subtle
),
};
const mfaRequiredCtx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
mfa: {
factors: [MfaFactor.TOTP],
policy: MfaPolicy.Mandatory,
},
},
};
const interaction: IdentifierVerifiedInteractionResult = {
event: InteractionEvent.Register,
identifiers: [{ key: 'accountId', value: 'foo' }],
};
const signInInteraction: IdentifierVerifiedInteractionResult = {
event: InteractionEvent.SignIn,
identifiers: [{ key: 'accountId', value: 'foo' }],
accountId: 'foo',
};
describe('validateMandatoryBindMfa', () => {
describe('register', () => {
it('bindMfa missing but required should throw', async () => {
await expect(
validateMandatoryBindMfa(tenantContext, mfaRequiredCtx, interaction)
).rejects.toMatchError(new RequestError({ code: 'user.missing_mfa', status: 422 }));
});
it('bindMfa exists should pass', async () => {
await expect(
validateMandatoryBindMfa(tenantContext, mfaRequiredCtx, {
...interaction,
bindMfa: {
type: MfaFactor.TOTP,
secret: 'foo',
},
})
).resolves.not.toThrow();
});
it('bindMfa missing and not required should pass', async () => {
await expect(
validateMandatoryBindMfa(tenantContext, baseCtx, interaction)
).resolves.not.toThrow();
});
});
describe('signIn', () => {
it('user mfaVerifications and bindMfa missing but required should throw', async () => {
findUserById.mockResolvedValueOnce(mockUser);
await expect(
validateMandatoryBindMfa(tenantContext, mfaRequiredCtx, signInInteraction)
).rejects.toMatchError(new RequestError({ code: 'user.missing_mfa', status: 422 }));
});
it('user mfaVerifications and bindMfa missing and not required should pass', async () => {
findUserById.mockResolvedValueOnce(mockUser);
await expect(
validateMandatoryBindMfa(tenantContext, baseCtx, signInInteraction)
).resolves.not.toThrow();
});
it('user mfaVerifications missing, bindMfa existing and required should pass', async () => {
findUserById.mockResolvedValueOnce(mockUser);
await expect(
validateMandatoryBindMfa(tenantContext, mfaRequiredCtx, {
...signInInteraction,
bindMfa: {
type: MfaFactor.TOTP,
secret: 'foo',
},
})
).resolves.not.toThrow();
});
it('user mfaVerifications existing, bindMfa missing and required should pass', async () => {
findUserById.mockResolvedValueOnce({
...mockUser,
mfaVerifications: [
{
type: MfaFactor.TOTP,
secret: 'secret',
},
],
});
await expect(
validateMandatoryBindMfa(tenantContext, baseCtx, signInInteraction)
).resolves.not.toThrow();
});
});
});
describe('verifyBindMfa', () => {
it('should pass if bindMfa is missing', async () => {
await expect(verifyBindMfa(tenantContext, signInInteraction)).resolves.not.toThrow();
});
it('should pass if event is not sign in', async () => {
await expect(
verifyBindMfa(tenantContext, {
...interaction,
bindMfa: {
type: MfaFactor.TOTP,
secret: 'foo',
},
})
).resolves.not.toThrow();
});
it('pass if the user has no TOTP factor', async () => {
findUserById.mockResolvedValueOnce(mockUser);
await expect(
verifyBindMfa(tenantContext, {
...signInInteraction,
bindMfa: {
type: MfaFactor.TOTP,
secret: 'foo',
},
})
).resolves.not.toThrow();
});
it('should reject if the user already has a TOTP factor', async () => {
findUserById.mockResolvedValueOnce({
...mockUser,
mfaVerifications: [
{
type: MfaFactor.TOTP,
secret: 'secret',
},
],
});
await expect(
verifyBindMfa(tenantContext, {
...signInInteraction,
bindMfa: {
type: MfaFactor.TOTP,
secret: 'foo',
},
})
).rejects.toMatchError(new RequestError({ code: 'user.totp_already_in_use', status: 422 }));
});
});

View file

@ -0,0 +1,86 @@
import { InteractionEvent, MfaFactor, MfaPolicy } from '@logto/schemas';
import RequestError from '#src/errors/RequestError/index.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import { type WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js';
import { type WithInteractionSieContext } from '../middleware/koa-interaction-sie.js';
import {
type VerifiedSignInInteractionResult,
type VerifiedInteractionResult,
type VerifiedRegisterInteractionResult,
} from '../types/index.js';
export const verifyBindMfa = async (
tenant: TenantContext,
interaction: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult
): Promise<VerifiedInteractionResult> => {
const { bindMfa, event } = interaction;
if (!bindMfa || event !== InteractionEvent.SignIn) {
return interaction;
}
const { type } = bindMfa;
// There will be more types later
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (type === MfaFactor.TOTP) {
const { accountId } = interaction;
const { mfaVerifications } = await tenant.queries.users.findUserById(accountId);
// A user can only bind one TOTP factor
assertThat(
mfaVerifications.every(({ type }) => type !== MfaFactor.TOTP),
new RequestError({
code: 'user.totp_already_in_use',
status: 422,
})
);
}
return interaction;
};
export const validateMandatoryBindMfa = async (
tenant: TenantContext,
ctx: WithInteractionSieContext & WithInteractionDetailsContext,
interaction: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult
): Promise<VerifiedInteractionResult> => {
const {
mfa: { policy, factors },
} = ctx.signInExperience;
const { event, bindMfa } = interaction;
if (policy !== MfaPolicy.Mandatory) {
return interaction;
}
const hasEnoughBindFactor = Boolean(bindMfa && factors.includes(bindMfa.type));
if (event === InteractionEvent.Register) {
assertThat(
hasEnoughBindFactor,
new RequestError({
code: 'user.missing_mfa',
status: 422,
})
);
}
if (event === InteractionEvent.SignIn) {
const { accountId } = interaction;
const { mfaVerifications } = await tenant.queries.users.findUserById(accountId);
assertThat(
hasEnoughBindFactor ||
factors.some((factor) => mfaVerifications.some(({ type }) => type === factor)),
new RequestError({
code: 'user.missing_mfa',
status: 422,
})
);
}
return interaction;
};

View file

@ -1,7 +1,7 @@
import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
import { z } from 'zod';
import { jsonObjectGuard } from '../foundations/index.js';
import { MfaFactor, jsonObjectGuard } from '../foundations/index.js';
import type {
EmailVerificationCodePayload,
@ -100,3 +100,38 @@ export enum MissingProfile {
password = 'password',
emailOrPhone = 'emailOrPhone',
}
export const bindTotpPayloadGuard = z.object({
// Unlike identifier payload which has indicator like "email",
// mfa payload must have an additional type field to indicate type
type: z.literal(MfaFactor.TOTP),
code: z.string(),
});
export type BindTotpPayload = z.infer<typeof bindTotpPayloadGuard>;
export const bindMfaPayloadGuard = bindTotpPayloadGuard;
export type BindMfaPayload = z.infer<typeof bindMfaPayloadGuard>;
export const pendingTotpGuard = z.object({
type: z.literal(MfaFactor.TOTP),
secret: z.string(),
});
export type PendingTotp = z.infer<typeof pendingTotpGuard>;
// Some information like TOTP secret should be generated in the backend
// and stored in the interaction temporarily.
export const pendingMfaGuard = pendingTotpGuard;
export type PendingMfa = z.infer<typeof pendingMfaGuard>;
export const bindTotpGuard = pendingTotpGuard;
export type BindTotp = z.infer<typeof bindTotpGuard>;
// The type for binding new mfa verification to a user, not always equals to the pending type.
export const bindMfaGuard = bindTotpGuard;
export type BindMfa = z.infer<typeof bindMfaGuard>;

View file

@ -3247,6 +3247,9 @@ importers:
oidc-provider:
specifier: ^8.2.2
version: 8.2.2
otplib:
specifier: ^12.0.1
version: 12.0.1
p-retry:
specifier: ^6.0.0
version: 6.0.0
@ -7756,6 +7759,39 @@ packages:
tslib: 2.5.0
dev: false
/@otplib/core@12.0.1:
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
dev: false
/@otplib/plugin-crypto@12.0.1:
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
dependencies:
'@otplib/core': 12.0.1
dev: false
/@otplib/plugin-thirty-two@12.0.1:
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
dependencies:
'@otplib/core': 12.0.1
thirty-two: 1.0.2
dev: false
/@otplib/preset-default@12.0.1:
resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==}
dependencies:
'@otplib/core': 12.0.1
'@otplib/plugin-crypto': 12.0.1
'@otplib/plugin-thirty-two': 12.0.1
dev: false
/@otplib/preset-v11@12.0.1:
resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
dependencies:
'@otplib/core': 12.0.1
'@otplib/plugin-crypto': 12.0.1
'@otplib/plugin-thirty-two': 12.0.1
dev: false
/@parcel/bundler-default@2.9.3(@parcel/core@2.9.3):
resolution: {integrity: sha512-JjJK8dq39/UO/MWI/4SCbB1t/qgpQRFnFDetAAAezQ8oN++b24u1fkMDa/xqQGjbuPmGeTds5zxGgYs7id7PYg==}
engines: {node: '>= 12.0.0', parcel: ^2.9.3}
@ -16637,6 +16673,14 @@ packages:
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
engines: {node: '>=0.10.0'}
/otplib@12.0.1:
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
dependencies:
'@otplib/core': 12.0.1
'@otplib/preset-default': 12.0.1
'@otplib/preset-v11': 12.0.1
dev: false
/outdent@0.5.0:
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
dev: true
@ -19655,6 +19699,11 @@ packages:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
/thirty-two@1.0.2:
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
engines: {node: '>=0.2.6'}
dev: false
/through2@2.0.5:
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
dependencies: