mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
refactor(core): rebase and update interaction hooks middleware
rebase and update interaction hooks middleware
This commit is contained in:
parent
8745886aa0
commit
f8000fe18d
10 changed files with 155 additions and 167 deletions
|
@ -34,9 +34,7 @@ mockEsm('#src/queries/user.js', () => ({
|
|||
mockEsm('#src/queries/application.js', () => ({
|
||||
findApplicationById: () => ({ id: 'app_id', extraField: 'not_ok' }),
|
||||
}));
|
||||
mockEsm('#src/connectors/index.js', () => ({
|
||||
getLogtoConnectorById: () => ({ metadata: { id: 'connector_id', extraField: 'not_ok' } }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
mockEsmDefault('#src/env-set/create-query-client-by-env.js', () => () => queryClient);
|
||||
jest.spyOn(queryClient, 'query').mockImplementation(queryFunction);
|
||||
|
@ -49,7 +47,7 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
|||
});
|
||||
|
||||
it('should return if no user ID found', async () => {
|
||||
await triggerInteractionHooksIfNeeded({ event: Event.SignIn });
|
||||
await triggerInteractionHooksIfNeeded();
|
||||
|
||||
expect(queryFunction).not.toBeCalled();
|
||||
});
|
||||
|
@ -58,11 +56,14 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
|||
jest.useFakeTimers().setSystemTime(100_000);
|
||||
|
||||
await triggerInteractionHooksIfNeeded(
|
||||
{ event: Event.SignIn, identifier: { connectorId: 'bar' } },
|
||||
// @ts-expect-error for testing
|
||||
{
|
||||
jti: 'some_jti',
|
||||
result: { login: { accountId: '123' } },
|
||||
result: {
|
||||
login: { accountId: '123' },
|
||||
event: Event.SignIn,
|
||||
identifier: { connectorId: 'bar' },
|
||||
},
|
||||
params: { client_id: 'some_client' },
|
||||
} as Interaction
|
||||
);
|
||||
|
@ -78,7 +79,6 @@ describe('triggerInteractionHooksIfNeeded()', () => {
|
|||
userId: '123',
|
||||
user: { id: 'user_id', username: 'user' },
|
||||
application: { id: 'app_id' },
|
||||
connectors: [{ id: 'connector_id' }],
|
||||
createdAt: new Date(100_000).toISOString(),
|
||||
},
|
||||
retry: { limit: 3 },
|
||||
|
|
|
@ -5,11 +5,10 @@ import { conditional } from '@silverhand/essentials';
|
|||
import { got } from 'got';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
|
||||
import { getLogtoConnectorById } from '#src/connectors/index.js';
|
||||
import modelRouters from '#src/model-routers/index.js';
|
||||
import { findApplicationById } from '#src/queries/application.js';
|
||||
import { findUserById } from '#src/queries/user.js';
|
||||
import type { InteractionPayload } from '#src/routes/interaction/types/index.js';
|
||||
import { getInteractionStorage } from '#src/routes/interaction/utils/interaction.js';
|
||||
|
||||
const eventToHook: Record<Event, HookEvent> = {
|
||||
[Event.Register]: HookEvent.PostRegister,
|
||||
|
@ -31,7 +30,6 @@ const pick = <T, Keys extends Array<keyof T>>(
|
|||
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
|
||||
|
||||
export const triggerInteractionHooksIfNeeded = async (
|
||||
interactionPayload: InteractionPayload,
|
||||
details?: Interaction,
|
||||
userAgent?: string
|
||||
) => {
|
||||
|
@ -43,22 +41,19 @@ export const triggerInteractionHooksIfNeeded = async (
|
|||
return;
|
||||
}
|
||||
|
||||
const { event, identifier } = interactionPayload;
|
||||
const interactionPayload = getInteractionStorage(details.result);
|
||||
const { event } = interactionPayload;
|
||||
|
||||
const hookEvent = eventToHook[event];
|
||||
const { rows } = await modelRouters.hook.client.readAll();
|
||||
const [user, application, connector] = await Promise.all([
|
||||
|
||||
const [user, application] = await Promise.all([
|
||||
trySafe(findUserById(userId)),
|
||||
trySafe(async () =>
|
||||
conditional(typeof applicationId === 'string' && (await findApplicationById(applicationId)))
|
||||
),
|
||||
trySafe(async () =>
|
||||
conditional(
|
||||
identifier &&
|
||||
'connectorId' in identifier &&
|
||||
(await getLogtoConnectorById(identifier.connectorId))
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
const payload = {
|
||||
event: hookEvent,
|
||||
interactionEvent: event,
|
||||
|
@ -68,9 +63,6 @@ export const triggerInteractionHooksIfNeeded = async (
|
|||
userId,
|
||||
user: user && pick(user, ...userInfoSelectFields),
|
||||
application: application && pick(application, 'id', 'type', 'name', 'description'),
|
||||
connectors: connector && [
|
||||
pick(connector.metadata, 'id', 'name', 'description', 'platform', 'target', 'isStandard'),
|
||||
],
|
||||
} satisfies Omit<HookEventPayload, 'hookId'>;
|
||||
|
||||
await Promise.all(
|
||||
|
|
|
@ -45,21 +45,22 @@ await mockEsmWithActual('#src/connectors/index.js', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
await mockEsmWithActual('#src/libraries/sign-in-experience/index.js', () => ({
|
||||
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
|
||||
}));
|
||||
|
||||
const { assignInteractionResults } = await mockEsmWithActual('#src/libraries/session.js', () => ({
|
||||
assignInteractionResults: jest.fn(),
|
||||
}));
|
||||
|
||||
const {
|
||||
getSignInExperience,
|
||||
verifySignInModeSettings,
|
||||
verifyIdentifierSettings,
|
||||
verifyProfileSettings,
|
||||
} = mockEsm('./utils/sign-in-experience-validation.js', () => ({
|
||||
getSignInExperience: jest.fn(async () => mockSignInExperience),
|
||||
verifySignInModeSettings: jest.fn(),
|
||||
verifyIdentifierSettings: jest.fn(),
|
||||
verifyProfileSettings: jest.fn(),
|
||||
}));
|
||||
const { verifySignInModeSettings, verifyIdentifierSettings, verifyProfileSettings } = mockEsm(
|
||||
'./utils/sign-in-experience-validation.js',
|
||||
() => ({
|
||||
verifySignInModeSettings: jest.fn(),
|
||||
verifyIdentifierSettings: jest.fn(),
|
||||
verifyProfileSettings: jest.fn(),
|
||||
})
|
||||
);
|
||||
|
||||
const submitInteraction = mockEsmDefault('./actions/submit-interaction.js', () => jest.fn());
|
||||
|
||||
|
@ -133,7 +134,6 @@ describe('session -> interactionRoutes', () => {
|
|||
profile: { phone: '1234567890' },
|
||||
};
|
||||
const response = await sessionRequest.put(path).send(body);
|
||||
expect(getSignInExperience).toBeCalled();
|
||||
expect(verifySignInModeSettings).toBeCalled();
|
||||
expect(verifyIdentifierSettings).toBeCalled();
|
||||
expect(verifyProfileSettings).toBeCalled();
|
||||
|
@ -154,7 +154,7 @@ describe('session -> interactionRoutes', () => {
|
|||
const path = `${interactionPrefix}/event`;
|
||||
|
||||
it('should call verifySignInModeSettings properly', async () => {
|
||||
getInteractionStorage.mockResolvedValueOnce({
|
||||
getInteractionStorage.mockReturnValueOnce({
|
||||
event: Event.SignIn,
|
||||
});
|
||||
const body = {
|
||||
|
@ -169,7 +169,7 @@ describe('session -> interactionRoutes', () => {
|
|||
});
|
||||
|
||||
it('should reject if switch sign-in event to forgot-password directly', async () => {
|
||||
getInteractionStorage.mockResolvedValueOnce({
|
||||
getInteractionStorage.mockReturnValueOnce({
|
||||
event: Event.SignIn,
|
||||
});
|
||||
|
||||
|
@ -184,7 +184,7 @@ describe('session -> interactionRoutes', () => {
|
|||
});
|
||||
|
||||
it('should reject if switch forgot-password to sign-in directly', async () => {
|
||||
getInteractionStorage.mockResolvedValueOnce({
|
||||
getInteractionStorage.mockReturnValueOnce({
|
||||
event: Event.ForgotPassword,
|
||||
});
|
||||
|
||||
|
@ -272,7 +272,7 @@ describe('session -> interactionRoutes', () => {
|
|||
});
|
||||
|
||||
it('should not call validateMandatoryUserProfile for forgot password request', async () => {
|
||||
getInteractionStorage.mockResolvedValueOnce({
|
||||
getInteractionStorage.mockReturnValueOnce({
|
||||
event: Event.ForgotPassword,
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { LogtoErrorCode } from '@logto/phrases';
|
||||
import { Event, eventGuard, identifierPayloadGuard, profileGuard } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import type Router from 'koa-router';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
@ -12,6 +12,10 @@ import assertThat from '#src/utils/assert-that.js';
|
|||
|
||||
import type { AnonymousRouter } from '../types.js';
|
||||
import submitInteraction from './actions/submit-interaction.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 { sendPasscodePayloadGuard, socialAuthorizationUrlPayloadGuard } from './types/guard.js';
|
||||
import {
|
||||
getInteractionStorage,
|
||||
|
@ -20,7 +24,6 @@ import {
|
|||
} from './utils/interaction.js';
|
||||
import { sendPasscodeToIdentifier } from './utils/passcode-validation.js';
|
||||
import {
|
||||
getSignInExperience,
|
||||
verifySignInModeSettings,
|
||||
verifyIdentifierSettings,
|
||||
verifyProfileSettings,
|
||||
|
@ -36,27 +39,19 @@ import {
|
|||
export const interactionPrefix = '/interaction';
|
||||
export const verificationPath = 'verification';
|
||||
|
||||
type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
||||
|
||||
export default function interactionRoutes<T extends AnonymousRouter>(
|
||||
router: T,
|
||||
anonymousRouter: T,
|
||||
provider: Provider
|
||||
) {
|
||||
router.use(koaAuditLog(), async (ctx, next) => {
|
||||
await next();
|
||||
|
||||
// Prepend interaction context to log entries
|
||||
try {
|
||||
const {
|
||||
jti,
|
||||
params: { client_id },
|
||||
} = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
ctx.prependAllLogEntries({
|
||||
sessionId: jti,
|
||||
applicationId: conditional(typeof client_id === 'string' && client_id),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(`Failed to get oidc provider interaction details`, error);
|
||||
}
|
||||
});
|
||||
const router =
|
||||
// @ts-expect-error for good koa types
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(anonymousRouter as Router<unknown, WithInteractionDetailsContext<RouterContext<T>>>).use(
|
||||
koaAuditLog(),
|
||||
koaInteractionDetails(provider)
|
||||
);
|
||||
|
||||
// Create a new interaction
|
||||
router.put(
|
||||
|
@ -68,18 +63,19 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
profile: profileGuard.optional(),
|
||||
}),
|
||||
}),
|
||||
koaInteractionSIE(),
|
||||
async (ctx, next) => {
|
||||
const { event, identifier, profile } = ctx.guard.body;
|
||||
const experience = await getSignInExperience(ctx, provider);
|
||||
const { signInExperience } = ctx;
|
||||
|
||||
verifySignInModeSettings(event, experience);
|
||||
verifySignInModeSettings(event, signInExperience);
|
||||
|
||||
if (identifier) {
|
||||
verifyIdentifierSettings(identifier, experience);
|
||||
verifyIdentifierSettings(identifier, signInExperience);
|
||||
}
|
||||
|
||||
if (profile) {
|
||||
verifyProfileSettings(profile, experience);
|
||||
verifyProfileSettings(profile, signInExperience);
|
||||
}
|
||||
|
||||
const verifiedIdentifier = identifier && [
|
||||
|
@ -102,7 +98,6 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
|
||||
// Delete Interaction
|
||||
router.delete(interactionPrefix, async (ctx, next) => {
|
||||
await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const error: LogtoErrorCode = 'oidc.aborted';
|
||||
await assignInteractionResults(ctx, provider, { error });
|
||||
|
||||
|
@ -113,11 +108,14 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
router.put(
|
||||
`${interactionPrefix}/event`,
|
||||
koaGuard({ body: z.object({ event: eventGuard }) }),
|
||||
koaInteractionSIE(),
|
||||
async (ctx, next) => {
|
||||
const { event } = ctx.guard.body;
|
||||
verifySignInModeSettings(event, await getSignInExperience(ctx, provider));
|
||||
const { signInExperience, interactionDetails } = ctx;
|
||||
|
||||
const interactionStorage = await getInteractionStorage(ctx, provider);
|
||||
verifySignInModeSettings(event, signInExperience);
|
||||
|
||||
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||
|
||||
// Forgot Password specific event interaction storage can't be shared with other types of interactions
|
||||
assertThat(
|
||||
|
@ -143,11 +141,13 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
koaGuard({
|
||||
body: identifierPayloadGuard,
|
||||
}),
|
||||
koaInteractionSIE(),
|
||||
async (ctx, next) => {
|
||||
const identifierPayload = ctx.guard.body;
|
||||
verifyIdentifierSettings(identifierPayload, await getSignInExperience(ctx, provider));
|
||||
const { signInExperience, interactionDetails } = ctx;
|
||||
verifyIdentifierSettings(identifierPayload, signInExperience);
|
||||
|
||||
const interactionStorage = await getInteractionStorage(ctx, provider);
|
||||
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||
|
||||
const verifiedIdentifier = await verifyIdentifierPayload(
|
||||
ctx,
|
||||
|
@ -172,11 +172,13 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
koaGuard({
|
||||
body: profileGuard,
|
||||
}),
|
||||
koaInteractionSIE(),
|
||||
async (ctx, next) => {
|
||||
const profilePayload = ctx.guard.body;
|
||||
verifyProfileSettings(profilePayload, await getSignInExperience(ctx, provider));
|
||||
const { signInExperience, interactionDetails } = ctx;
|
||||
verifyProfileSettings(profilePayload, signInExperience);
|
||||
|
||||
const interactionStorage = await getInteractionStorage(ctx, provider);
|
||||
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||
|
||||
await storeInteractionResult(
|
||||
{
|
||||
|
@ -198,7 +200,8 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
|
||||
// Delete Interaction Profile
|
||||
router.delete(`${interactionPrefix}/profile`, async (ctx, next) => {
|
||||
const interactionStorage = await getInteractionStorage(ctx, provider);
|
||||
const { interactionDetails } = ctx;
|
||||
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||
const { profile, ...rest } = interactionStorage;
|
||||
await storeInteractionResult(rest, ctx, provider);
|
||||
|
||||
|
@ -208,23 +211,29 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
});
|
||||
|
||||
// Submit Interaction
|
||||
router.post(`${interactionPrefix}/submit`, async (ctx, next) => {
|
||||
const interactionStorage = await getInteractionStorage(ctx, provider);
|
||||
router.post(
|
||||
`${interactionPrefix}/submit`,
|
||||
koaInteractionSIE(),
|
||||
koaInteractionHooks(),
|
||||
async (ctx, next) => {
|
||||
const { interactionDetails } = ctx;
|
||||
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||
|
||||
const { event } = interactionStorage;
|
||||
const { event } = interactionStorage;
|
||||
|
||||
const accountVerifiedInteraction = await verifyIdentifier(ctx, provider, interactionStorage);
|
||||
const accountVerifiedInteraction = await verifyIdentifier(ctx, provider, interactionStorage);
|
||||
|
||||
const verifiedInteraction = await verifyProfile(accountVerifiedInteraction);
|
||||
const verifiedInteraction = await verifyProfile(accountVerifiedInteraction);
|
||||
|
||||
if (event !== Event.ForgotPassword) {
|
||||
await validateMandatoryUserProfile(ctx, provider, verifiedInteraction);
|
||||
if (event !== Event.ForgotPassword) {
|
||||
await validateMandatoryUserProfile(ctx, verifiedInteraction);
|
||||
}
|
||||
|
||||
await submitInteraction(verifiedInteraction, ctx, provider);
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
await submitInteraction(verifiedInteraction, ctx, provider);
|
||||
|
||||
return next();
|
||||
});
|
||||
);
|
||||
|
||||
// Create social authorization url interaction verification
|
||||
router.post(
|
||||
|
@ -232,7 +241,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
koaGuard({ body: socialAuthorizationUrlPayloadGuard }),
|
||||
async (ctx, next) => {
|
||||
// Check interaction exists
|
||||
await getInteractionStorage(ctx, provider);
|
||||
getInteractionStorage(ctx.interactionDetails.result);
|
||||
|
||||
const { body: payload } = ctx.guard;
|
||||
|
||||
|
@ -252,7 +261,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
}),
|
||||
async (ctx, next) => {
|
||||
// Check interaction exists
|
||||
await getInteractionStorage(ctx, provider);
|
||||
getInteractionStorage(ctx.interactionDetails.result);
|
||||
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
await sendPasscodeToIdentifier(ctx.guard.body, jti, ctx.createLog);
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import type { MiddlewareType } from 'koa';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
|
||||
export type WithInteractionDetailsContext<ContextT> = ContextT & {
|
||||
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
|
||||
export type WithInteractionDetailsContext<ContextT = WithLogContext> = ContextT & {
|
||||
interactionDetails: Awaited<ReturnType<Provider['interactionDetails']>>;
|
||||
};
|
||||
|
||||
export default function koaInteractionDetails<StateT, ContextT, ResponseBodyT>(
|
||||
export default function koaInteractionDetails<StateT, ContextT>(
|
||||
provider: Provider
|
||||
): MiddlewareType<StateT, WithInteractionDetailsContext<ContextT>, ResponseBodyT> {
|
||||
): MiddlewareType<StateT, WithInteractionDetailsContext<ContextT>> {
|
||||
return async (ctx, next) => {
|
||||
ctx.interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
|
||||
|
|
|
@ -3,23 +3,16 @@ import type { IRouterParamContext } from 'koa-router';
|
|||
|
||||
import { triggerInteractionHooksIfNeeded } from '#src/libraries/hook.js';
|
||||
|
||||
import type { WithInteractionPayloadContext } from './koa-interaction-body-guard.js';
|
||||
import type { WithInteractionDetailsContext } from './koa-interaction-details.js';
|
||||
|
||||
export default function koaInteractionHooks<
|
||||
StateT,
|
||||
ContextT extends WithInteractionPayloadContext<
|
||||
WithInteractionDetailsContext<IRouterParamContext>
|
||||
>,
|
||||
ContextT extends WithInteractionDetailsContext<IRouterParamContext>,
|
||||
ResponseT
|
||||
>(): MiddlewareType<StateT, ContextT, ResponseT> {
|
||||
return async (ctx, next) => {
|
||||
await next();
|
||||
|
||||
void triggerInteractionHooksIfNeeded(
|
||||
ctx.interactionPayload,
|
||||
ctx.interactionDetails,
|
||||
ctx.header['user-agent']
|
||||
);
|
||||
void triggerInteractionHooksIfNeeded(ctx.interactionDetails, ctx.header['user-agent']);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import type { ConnectorSession } from '@logto/connector-kit';
|
|||
import { connectorSessionGuard } from '@logto/connector-kit';
|
||||
import type { Event, Profile } from '@logto/schemas';
|
||||
import type { Context } from 'koa';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
import type { Provider, InteractionResults } from 'oidc-provider';
|
||||
import { z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
|
@ -83,10 +83,6 @@ export const isAccountVerifiedInteractionResult = (
|
|||
interaction: AnonymousInteractionResult
|
||||
): interaction is AccountVerifiedInteractionResult => Boolean(interaction.accountId);
|
||||
|
||||
type Options = {
|
||||
merge?: boolean;
|
||||
};
|
||||
|
||||
export const storeInteractionResult = async (
|
||||
interaction: Omit<AnonymousInteractionResult, 'event'> & { event?: Event },
|
||||
ctx: Context,
|
||||
|
@ -107,12 +103,10 @@ export const storeInteractionResult = async (
|
|||
);
|
||||
};
|
||||
|
||||
export const getInteractionStorage = async (
|
||||
ctx: Context,
|
||||
provider: Provider
|
||||
): Promise<AnonymousInteractionResult> => {
|
||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const parseResult = anonymousInteractionResultGuard.safeParse(result);
|
||||
export const getInteractionStorage = (
|
||||
interaction?: InteractionResults
|
||||
): AnonymousInteractionResult => {
|
||||
const parseResult = anonymousInteractionResultGuard.safeParse(interaction);
|
||||
|
||||
assertThat(
|
||||
parseResult.success,
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
import type { SignInExperience, Profile, IdentifierPayload } from '@logto/schemas';
|
||||
import { SignInMode, SignInIdentifier, Event } from '@logto/schemas';
|
||||
import type { Context } from 'koa';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
const forbiddenEventError = new RequestError({ code: 'auth.forbidden', status: 403 });
|
||||
|
@ -122,11 +119,3 @@ export const verifyProfileSettings = (profile: Profile, { signUp }: SignInExperi
|
|||
assertThat(signUp.password, forbiddenIdentifierError);
|
||||
}
|
||||
};
|
||||
|
||||
export const getSignInExperience = async (ctx: Context, provider: Provider) => {
|
||||
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
|
||||
return getSignInExperienceForApplication(
|
||||
typeof interaction.params.client_id === 'string' ? interaction.params.client_id : undefined
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Event, MissingProfile, SignInIdentifier } from '@logto/schemas';
|
||||
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
|
||||
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
|
@ -18,24 +19,25 @@ const { isUserPasswordSet } = mockEsm('../utils/index.js', () => ({
|
|||
isUserPasswordSet: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getSignInExperience } = mockEsm('../utils/sign-in-experience-validation.js', () => ({
|
||||
getSignInExperience: jest.fn().mockReturnValue(mockSignInExperience),
|
||||
}));
|
||||
|
||||
const validateMandatoryUserProfile = await pickDefault(
|
||||
import('./mandatory-user-profile-validation.js')
|
||||
);
|
||||
|
||||
describe('validateMandatoryUserProfile', () => {
|
||||
const provider = createMockProvider();
|
||||
const baseCtx = createContextWithRouteParameters();
|
||||
const baseCtx = {
|
||||
...createContextWithRouteParameters(),
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
interactionDetails: {} as Awaited<ReturnType<Provider['interactionDetails']>>,
|
||||
signInExperience: mockSignInExperience,
|
||||
};
|
||||
const interaction: IdentifierVerifiedInteractionResult = {
|
||||
event: Event.SignIn,
|
||||
accountId: 'foo',
|
||||
};
|
||||
|
||||
it('username and password missing but required', async () => {
|
||||
await expect(validateMandatoryUserProfile(baseCtx, provider, interaction)).rejects.toMatchError(
|
||||
await expect(validateMandatoryUserProfile(baseCtx, interaction)).rejects.toMatchError(
|
||||
new RequestError(
|
||||
{ code: 'user.missing_profile', status: 422 },
|
||||
{ missingProfile: [MissingProfile.password, MissingProfile.username] }
|
||||
|
@ -43,7 +45,7 @@ describe('validateMandatoryUserProfile', () => {
|
|||
);
|
||||
|
||||
await expect(
|
||||
validateMandatoryUserProfile(baseCtx, provider, {
|
||||
validateMandatoryUserProfile(baseCtx, {
|
||||
...interaction,
|
||||
profile: {
|
||||
username: 'username',
|
||||
|
@ -59,18 +61,19 @@ describe('validateMandatoryUserProfile', () => {
|
|||
});
|
||||
isUserPasswordSet.mockResolvedValueOnce(true);
|
||||
|
||||
await expect(
|
||||
validateMandatoryUserProfile(baseCtx, provider, interaction)
|
||||
).resolves.not.toThrow();
|
||||
await expect(validateMandatoryUserProfile(baseCtx, interaction)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('email missing but required', async () => {
|
||||
getSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
|
||||
});
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(validateMandatoryUserProfile(baseCtx, provider, interaction)).rejects.toMatchError(
|
||||
await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError(
|
||||
new RequestError(
|
||||
{ code: 'user.missing_profile', status: 422 },
|
||||
{ missingProfile: [MissingProfile.email] }
|
||||
|
@ -83,23 +86,27 @@ describe('validateMandatoryUserProfile', () => {
|
|||
primaryEmail: 'email',
|
||||
});
|
||||
|
||||
getSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
|
||||
});
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateMandatoryUserProfile(baseCtx, provider, interaction)
|
||||
).resolves.not.toThrow();
|
||||
await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('phone missing but required', async () => {
|
||||
getSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
|
||||
});
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(validateMandatoryUserProfile(baseCtx, provider, interaction)).rejects.toMatchError(
|
||||
await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError(
|
||||
new RequestError(
|
||||
{ code: 'user.missing_profile', status: 422 },
|
||||
{ missingProfile: [MissingProfile.phone] }
|
||||
|
@ -112,27 +119,31 @@ describe('validateMandatoryUserProfile', () => {
|
|||
primaryPhone: 'phone',
|
||||
});
|
||||
|
||||
getSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
|
||||
});
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateMandatoryUserProfile(baseCtx, provider, interaction)
|
||||
).resolves.not.toThrow();
|
||||
await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('email or Phone required', async () => {
|
||||
getSignInExperience.mockResolvedValue({
|
||||
...mockSignInExperience,
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms],
|
||||
password: false,
|
||||
verify: true,
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms],
|
||||
password: false,
|
||||
verify: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await expect(validateMandatoryUserProfile(baseCtx, provider, interaction)).rejects.toMatchError(
|
||||
await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError(
|
||||
new RequestError(
|
||||
{ code: 'user.missing_profile', status: 422 },
|
||||
{ missingProfile: [MissingProfile.emailOrPhone] }
|
||||
|
@ -140,14 +151,14 @@ describe('validateMandatoryUserProfile', () => {
|
|||
);
|
||||
|
||||
await expect(
|
||||
validateMandatoryUserProfile(baseCtx, provider, {
|
||||
validateMandatoryUserProfile(ctx, {
|
||||
...interaction,
|
||||
profile: { email: 'email' },
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
|
||||
await expect(
|
||||
validateMandatoryUserProfile(baseCtx, provider, {
|
||||
validateMandatoryUserProfile(ctx, {
|
||||
...interaction,
|
||||
profile: { phone: '123456' },
|
||||
})
|
||||
|
|
|
@ -2,15 +2,14 @@ import type { Profile, SignInExperience, User } from '@logto/schemas';
|
|||
import { Event, MissingProfile, SignInIdentifier } from '@logto/schemas';
|
||||
import type { Nullable } from '@silverhand/essentials';
|
||||
import type { Context } from 'koa';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { findUserById } from '#src/queries/user.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type { WithInteractionSIEContext } from '../middleware/koa-interaction-sie.js';
|
||||
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
|
||||
import { isUserPasswordSet } from '../utils/index.js';
|
||||
import { getSignInExperience } from '../utils/sign-in-experience-validation.js';
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
const getMissingProfileBySignUpIdentifiers = ({
|
||||
|
@ -71,11 +70,10 @@ const getMissingProfileBySignUpIdentifiers = ({
|
|||
};
|
||||
|
||||
export default async function validateMandatoryUserProfile(
|
||||
ctx: Context,
|
||||
provider: Provider,
|
||||
ctx: WithInteractionSIEContext<Context>,
|
||||
interaction: IdentifierVerifiedInteractionResult
|
||||
) {
|
||||
const { signUp } = await getSignInExperience(ctx, provider);
|
||||
const { signUp } = ctx.signInExperience;
|
||||
const { event, accountId, profile } = interaction;
|
||||
|
||||
const user = event === Event.Register ? null : await findUserById(accountId);
|
||||
|
|
Loading…
Add table
Reference in a new issue