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 './user.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