mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor(core): add interactionGuard middleware (#2497)
This commit is contained in:
parent
115f75b5de
commit
14b717801f
10 changed files with 468 additions and 0 deletions
26
packages/core/src/__mocks__/interactions.ts
Normal file
26
packages/core/src/__mocks__/interactions.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
export const interactionMocks = [
|
||||||
|
{
|
||||||
|
username: 'username',
|
||||||
|
password: 'password',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'email',
|
||||||
|
password: 'password',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phone: 'phone',
|
||||||
|
password: 'password',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'email@logto.io',
|
||||||
|
passcode: 'passcode',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phone: '123456',
|
||||||
|
passcode: 'passcode',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connectorId: 'connectorId',
|
||||||
|
connectorData: { code: 'code' },
|
||||||
|
},
|
||||||
|
];
|
36
packages/core/src/routes/interaction/index.ts
Normal file
36
packages/core/src/routes/interaction/index.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import type { Provider } from 'oidc-provider';
|
||||||
|
|
||||||
|
import type { AnonymousRouter } from '../types.js';
|
||||||
|
import koaInteractionBodyGuard from './middleware/koa-interaction-body-guard.js';
|
||||||
|
import koaSessionSignInExperienceGuard from './middleware/koa-session-sign-in-experience-guard.js';
|
||||||
|
|
||||||
|
export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
|
router: T,
|
||||||
|
provider: Provider
|
||||||
|
) {
|
||||||
|
router.put(
|
||||||
|
'/interaction',
|
||||||
|
koaInteractionBodyGuard(),
|
||||||
|
koaSessionSignInExperienceGuard(provider),
|
||||||
|
async (ctx, next) => {
|
||||||
|
// Check interaction session
|
||||||
|
await provider.interactionDetails(ctx.req, ctx.res);
|
||||||
|
|
||||||
|
const { event } = ctx.interactionPayload;
|
||||||
|
|
||||||
|
if (event === 'sign-in') {
|
||||||
|
// Sign-in flow
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'register') {
|
||||||
|
// Register flow
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forgot password flow
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,138 @@
|
||||||
|
import type { Context } from 'koa';
|
||||||
|
|
||||||
|
import { interactionMocks } from '#src/__mocks__/interactions.js';
|
||||||
|
import { emptyMiddleware, createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
import type { WithGuardedIdentifierPayloadContext } from './koa-interaction-body-guard.js';
|
||||||
|
import koaInteractionBodyGuard from './koa-interaction-body-guard.js';
|
||||||
|
|
||||||
|
jest.mock('koa-body', () => emptyMiddleware);
|
||||||
|
|
||||||
|
// User this to bypass the context type assertion
|
||||||
|
const mockIdentifierPayload = Object.freeze({
|
||||||
|
type: 'username_password',
|
||||||
|
username: 'username',
|
||||||
|
password: 'password',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('koaInteractionBodyGuard', () => {
|
||||||
|
const baseCtx = createContextWithRouteParameters();
|
||||||
|
const next = jest.fn();
|
||||||
|
|
||||||
|
describe('event', () => {
|
||||||
|
it('invalid event should throw', async () => {
|
||||||
|
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
|
||||||
|
...baseCtx,
|
||||||
|
request: {
|
||||||
|
...baseCtx.request,
|
||||||
|
body: {
|
||||||
|
event: 'test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interactionPayload: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(koaInteractionBodyGuard()(ctx, next)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['sign-in', 'register', 'forgot-password'])(
|
||||||
|
'%p should parse successfully',
|
||||||
|
async (event) => {
|
||||||
|
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
|
||||||
|
...baseCtx,
|
||||||
|
request: {
|
||||||
|
...baseCtx.request,
|
||||||
|
body: {
|
||||||
|
event,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interactionPayload: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow();
|
||||||
|
expect(ctx.interactionPayload.event).toEqual(event);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('identifier', () => {
|
||||||
|
it('invalid identifier should not parsed', async () => {
|
||||||
|
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
|
||||||
|
...baseCtx,
|
||||||
|
request: {
|
||||||
|
...baseCtx.request,
|
||||||
|
body: {
|
||||||
|
event: 'sign-in',
|
||||||
|
identifier: {
|
||||||
|
google: 'username',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interactionPayload: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow();
|
||||||
|
expect(ctx.interactionPayload.identifier).not.toContain({ google: 'username' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(interactionMocks)('interaction methods should parse successfully', async (input) => {
|
||||||
|
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
|
||||||
|
...baseCtx,
|
||||||
|
request: {
|
||||||
|
...baseCtx.request,
|
||||||
|
body: {
|
||||||
|
event: 'sign-in',
|
||||||
|
identifier: input,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interactionPayload: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow();
|
||||||
|
expect(ctx.interactionPayload.identifier).toEqual(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('profile', () => {
|
||||||
|
it('invalid profile format should throw', async () => {
|
||||||
|
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
|
||||||
|
...baseCtx,
|
||||||
|
request: {
|
||||||
|
...baseCtx.request,
|
||||||
|
body: {
|
||||||
|
event: 'sign-in',
|
||||||
|
profile: {
|
||||||
|
email: 'username',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interactionPayload: {},
|
||||||
|
};
|
||||||
|
await expect(koaInteractionBodyGuard()(ctx, next)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('profile should resolve properly', async () => {
|
||||||
|
const profile = {
|
||||||
|
email: 'foo@logto.io',
|
||||||
|
phone: '123123',
|
||||||
|
username: 'username',
|
||||||
|
password: '123456',
|
||||||
|
connectorId: 'connectorId',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
|
||||||
|
...baseCtx,
|
||||||
|
request: {
|
||||||
|
...baseCtx.request,
|
||||||
|
body: {
|
||||||
|
event: 'sign-in',
|
||||||
|
profile,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interactionPayload: {},
|
||||||
|
};
|
||||||
|
await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow();
|
||||||
|
expect(ctx.interactionPayload.profile).toEqual(profile);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { MiddlewareType } from 'koa';
|
||||||
|
import koaBody from 'koa-body';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
|
||||||
|
import type { InteractionPayload } from '../types/guard.js';
|
||||||
|
import { interactionPayloadGuard } from '../types/guard.js';
|
||||||
|
|
||||||
|
export type WithGuardedIdentifierPayloadContext<ContextT> = ContextT & {
|
||||||
|
interactionPayload: InteractionPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parse = (data: unknown) => {
|
||||||
|
try {
|
||||||
|
return interactionPayloadGuard.parse(data);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new RequestError({ code: 'guard.invalid_input' }, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Need this as our koaGuard does not infer the body type properly
|
||||||
|
* from the ZodEffects Output after data transform
|
||||||
|
*/
|
||||||
|
export default function koaInteractionBodyGuard<StateT, ContextT, ResponseT>(): MiddlewareType<
|
||||||
|
StateT,
|
||||||
|
WithGuardedIdentifierPayloadContext<ContextT>,
|
||||||
|
ResponseT
|
||||||
|
> {
|
||||||
|
return async (ctx, next) => {
|
||||||
|
return koaBody<StateT, ContextT>()(ctx, async () => {
|
||||||
|
ctx.interactionPayload = parse(ctx.request.body);
|
||||||
|
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { SignInExperience } from '@logto/schemas';
|
||||||
|
import { SignInMode } from '@logto/schemas';
|
||||||
|
import type { MiddlewareType } from 'koa';
|
||||||
|
import type { IRouterParamContext } from 'koa-router';
|
||||||
|
import type { Provider } from 'oidc-provider';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import { getSignInExperienceForApplication } from '#src/lib/sign-in-experience/index.js';
|
||||||
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
|
import type { WithGuardedIdentifierPayloadContext } from './koa-interaction-body-guard.js';
|
||||||
|
|
||||||
|
const forbiddenEventError = new RequestError({ code: 'auth.forbidden', status: 403 });
|
||||||
|
|
||||||
|
export type WithSignInExperienceContext<ContextT> = ContextT & {
|
||||||
|
signInExperience: SignInExperience;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function koaSessionSignInExperienceGuard<
|
||||||
|
StateT,
|
||||||
|
ContextT extends WithGuardedIdentifierPayloadContext<IRouterParamContext>,
|
||||||
|
ResponseBodyT
|
||||||
|
>(
|
||||||
|
provider: Provider
|
||||||
|
): MiddlewareType<StateT, WithSignInExperienceContext<ContextT>, ResponseBodyT> {
|
||||||
|
return async (ctx, next) => {
|
||||||
|
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
|
||||||
|
const { event } = ctx.interactionPayload;
|
||||||
|
|
||||||
|
const signInExperience = await getSignInExperienceForApplication(
|
||||||
|
typeof interaction.params.client_id === 'string' ? interaction.params.client_id : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// SignInMode validation
|
||||||
|
if (event === 'sign-in') {
|
||||||
|
assertThat(signInExperience.signInMode !== SignInMode.Register, forbiddenEventError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'register') {
|
||||||
|
assertThat(signInExperience.signInMode !== SignInMode.SignIn, forbiddenEventError);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.signInExperience = signInExperience;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { SignInMode } from '@logto/schemas';
|
||||||
|
import { Provider } from 'oidc-provider';
|
||||||
|
|
||||||
|
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||||
|
import { getSignInExperienceForApplication } from '#src/lib/sign-in-experience/index.js';
|
||||||
|
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
|
import koaSessionSignInExperienceGuard from './koa-session-sign-in-experience-guard.js';
|
||||||
|
|
||||||
|
jest.mock('#src/lib/sign-in-experience/index.js', () => ({
|
||||||
|
getSignInExperienceForApplication: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('oidc-provider', () => ({
|
||||||
|
Provider: jest.fn(() => ({
|
||||||
|
interactionDetails: jest.fn(async () => ({ params: {} })),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('koaSessionSignInExperienceGuard', () => {
|
||||||
|
const getSignInExperienceForApplicationMock = getSignInExperienceForApplication as jest.Mock;
|
||||||
|
const baseCtx = createContextWithRouteParameters();
|
||||||
|
const next = jest.fn();
|
||||||
|
|
||||||
|
describe('sign-in mode guard', () => {
|
||||||
|
it('should reject register', async () => {
|
||||||
|
getSignInExperienceForApplicationMock.mockImplementationOnce(() => ({
|
||||||
|
signInMode: SignInMode.SignIn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: Object.freeze({
|
||||||
|
event: 'register',
|
||||||
|
}),
|
||||||
|
signInExperience: mockSignInExperience,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(koaSessionSignInExperienceGuard(new Provider(''))(ctx, next)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject sign-in', async () => {
|
||||||
|
getSignInExperienceForApplicationMock.mockImplementationOnce(() => ({
|
||||||
|
signInMode: SignInMode.Register,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: Object.freeze({
|
||||||
|
event: 'sign-in',
|
||||||
|
}),
|
||||||
|
signInExperience: mockSignInExperience,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(koaSessionSignInExperienceGuard(new Provider(''))(ctx, next)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow register', async () => {
|
||||||
|
getSignInExperienceForApplicationMock.mockImplementationOnce(() => ({
|
||||||
|
signInMode: SignInMode.SignInAndRegister,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
...baseCtx,
|
||||||
|
interactionPayload: Object.freeze({
|
||||||
|
event: 'register',
|
||||||
|
}),
|
||||||
|
signInExperience: mockSignInExperience,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
koaSessionSignInExperienceGuard(new Provider(''))(ctx, next)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
expect(ctx.signInExperience).toEqual({
|
||||||
|
signInMode: SignInMode.SignInAndRegister,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
10
packages/core/src/routes/interaction/types/guard.ts
Normal file
10
packages/core/src/routes/interaction/types/guard.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { eventGuard, profileGuard, identifierGuard } from '@logto/schemas';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const interactionPayloadGuard = z.object({
|
||||||
|
event: eventGuard.optional(),
|
||||||
|
identifier: identifierGuard.optional(),
|
||||||
|
profile: profileGuard.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InteractionPayload = z.infer<typeof interactionPayloadGuard>;
|
21
packages/core/src/routes/interaction/types/index.ts
Normal file
21
packages/core/src/routes/interaction/types/index.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
export type Identifier =
|
||||||
|
| AccountIdIdentifier
|
||||||
|
| VerifiedEmailIdentifier
|
||||||
|
| VerifiedPhoneIdentifier
|
||||||
|
| SocialIdentifier;
|
||||||
|
|
||||||
|
export type AccountIdIdentifier = { key: 'accountId'; value: string };
|
||||||
|
|
||||||
|
export type VerifiedEmailIdentifier = { key: 'verifiedEmail'; value: string };
|
||||||
|
|
||||||
|
export type VerifiedPhoneIdentifier = { key: 'verifiedPhone'; value: string };
|
||||||
|
|
||||||
|
export type SocialIdentifier = { key: 'social'; connectorId: string; value: UseInfo };
|
||||||
|
|
||||||
|
type UseInfo = {
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
name?: string;
|
||||||
|
avatar?: string;
|
||||||
|
id: string;
|
||||||
|
};
|
|
@ -3,3 +3,4 @@ export * from './log.js';
|
||||||
export * from './oidc-config.js';
|
export * from './oidc-config.js';
|
||||||
export * from './user.js';
|
export * from './user.js';
|
||||||
export * from './logto-config.js';
|
export * from './logto-config.js';
|
||||||
|
export * from './interactions.js';
|
||||||
|
|
73
packages/schemas/src/types/interactions.ts
Normal file
73
packages/schemas/src/types/interactions.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import { emailRegEx, phoneRegEx, usernameRegEx, passwordRegEx } from '@logto/core-kit';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailed Identifier Methods guard
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const usernamePasswordPayloadGuard = z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
export type UsernamePasswordPayload = z.infer<typeof usernamePasswordPayloadGuard>;
|
||||||
|
|
||||||
|
export const emailPasswordPayloadGuard = z.object({
|
||||||
|
email: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
export type EmailPasswordPayload = z.infer<typeof emailPasswordPayloadGuard>;
|
||||||
|
|
||||||
|
export const phonePasswordPayloadGuard = z.object({
|
||||||
|
phone: z.string().min(1),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
export type PhonePasswordPayload = z.infer<typeof phonePasswordPayloadGuard>;
|
||||||
|
|
||||||
|
export const emailPasscodePayloadGuard = z.object({
|
||||||
|
email: z.string().min(1),
|
||||||
|
passcode: z.string().min(1),
|
||||||
|
});
|
||||||
|
export type EmailPasscodePayload = z.infer<typeof emailPasscodePayloadGuard>;
|
||||||
|
|
||||||
|
export const phonePasscodePayloadGuard = z.object({
|
||||||
|
phone: z.string().min(1),
|
||||||
|
passcode: z.string().min(1),
|
||||||
|
});
|
||||||
|
export type PhonePasscodePayload = z.infer<typeof phonePasscodePayloadGuard>;
|
||||||
|
|
||||||
|
export const socialConnectorPayloadGuard = z.object({
|
||||||
|
connectorId: z.string(),
|
||||||
|
connectorData: z.unknown(),
|
||||||
|
});
|
||||||
|
export type SocialConnectorPayload = z.infer<typeof socialConnectorPayloadGuard>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interaction Payload Guard
|
||||||
|
*/
|
||||||
|
export const eventGuard = z.union([
|
||||||
|
z.literal('sign-in'),
|
||||||
|
z.literal('register'),
|
||||||
|
z.literal('forgot-password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type Event = z.infer<typeof eventGuard>;
|
||||||
|
|
||||||
|
export const identifierGuard = z.object({
|
||||||
|
username: z.string().min(1).optional(),
|
||||||
|
email: z.string().min(1).optional(),
|
||||||
|
phone: z.string().min(1).optional(),
|
||||||
|
connectorId: z.string().min(1).optional(),
|
||||||
|
password: z.string().min(1).optional(),
|
||||||
|
passcode: z.string().min(1).optional(),
|
||||||
|
connectorData: z.unknown().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileGuard = z.object({
|
||||||
|
username: z.string().regex(usernameRegEx).optional(),
|
||||||
|
email: z.string().regex(emailRegEx).optional(),
|
||||||
|
phone: z.string().regex(phoneRegEx).optional(),
|
||||||
|
connectorId: z.string().optional(),
|
||||||
|
password: z.string().regex(passwordRegEx).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Profile = z.infer<typeof profileGuard>;
|
Loading…
Reference in a new issue