0
Fork 0
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:
simeng-li 2022-11-23 16:49:35 +08:00 committed by GitHub
parent 115f75b5de
commit 14b717801f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 468 additions and 0 deletions

View 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' },
},
];

View 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();
}
);
}

View file

@ -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);
});
});
});

View file

@ -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();
});
};
}

View file

@ -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();
};
}

View file

@ -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,
});
});
});
});

View 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>;

View 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;
};

View file

@ -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';

View 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>;