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:
parent
19939811c1
commit
08a35a1695
24 changed files with 1205 additions and 175 deletions
|
@ -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",
|
||||
|
|
6
packages/core/src/__mocks__/mfa-verification.ts
Normal file
6
packages/core/src/__mocks__/mfa-verification.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { MfaFactor, type BindMfa } from '@logto/schemas';
|
||||
|
||||
export const mockTotpBind: BindMfa = {
|
||||
type: MfaFactor.TOTP,
|
||||
secret: 'secret',
|
||||
};
|
|
@ -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' },
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
199
packages/core/src/routes/interaction/additional.test.ts
Normal file
199
packages/core/src/routes/interaction/additional.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
102
packages/core/src/routes/interaction/additional.ts
Normal file
102
packages/core/src/routes/interaction/additional.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
111
packages/core/src/routes/interaction/mfa.test.ts
Normal file
111
packages/core/src/routes/interaction/mfa.test.ts
Normal 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()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
50
packages/core/src/routes/interaction/mfa.ts
Normal file
50
packages/core/src/routes/interaction/mfa.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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());
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
import { authenticator } from 'otplib';
|
||||
|
||||
export const generateTotpSecret = () => authenticator.generateSecret();
|
||||
|
||||
export const validateTotpToken = (secret: string, token: string) =>
|
||||
authenticator.check(token, secret);
|
|
@ -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';
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
|
@ -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 }));
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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>;
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue