diff --git a/packages/core/package.json b/packages/core/package.json index a88a39525..accb5765b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,7 +17,7 @@ "add-connector": "node build/cli/add-connector.js", "add-official-connectors": "node build/cli/add-official-connectors.js", "alteration": "node build/cli/alteration.js", - "test": "jest --testPathIgnorePatterns=/core/connectors/", + "test": "jest", "test:coverage": "jest --coverage --silent", "test:report": "codecov -F core" }, diff --git a/packages/core/src/routes/session/passwordless.test.ts b/packages/core/src/routes/session/passwordless.test.ts index 7ebe2ff5d..7fe22ea5d 100644 --- a/packages/core/src/routes/session/passwordless.test.ts +++ b/packages/core/src/routes/session/passwordless.test.ts @@ -8,7 +8,7 @@ import RequestError from '@/errors/RequestError'; import { createRequester } from '@/utils/test-utils'; import { verificationTimeout } from './consts'; -import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless'; +import passwordlessRoutes, { signInRoute, registerRoute } from './passwordless'; const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); const findUserById = jest.fn(async (): Promise => mockUser); @@ -51,10 +51,6 @@ jest.mock('oidc-provider', () => ({ })), })); -afterEach(() => { - interactionResult.mockClear(); -}); - describe('session -> passwordlessRoutes', () => { const sessionRequest = createRequester({ anonymousRoutes: passwordlessRoutes, @@ -105,12 +101,6 @@ describe('session -> passwordlessRoutes', () => { .send({ flow: 'register' }); expect(response.statusCode).toEqual(400); }); - it('throw when email given in input params', async () => { - const response = await sessionRequest - .post('/session/passwordless/sms/send') - .send({ email: 'a@a.com', phone: '13000000000', flow: 'register' }); - expect(response.statusCode).toEqual(400); - }); }); describe('POST /session/passwordless/email/send', () => { @@ -149,12 +139,6 @@ describe('session -> passwordlessRoutes', () => { .send({ flow: 'register' }); expect(response.statusCode).toEqual(400); }); - it('throw when phone given in input params', async () => { - const response = await sessionRequest - .post('/session/passwordless/email/send') - .send({ email: 'a@a.com', phone: '13000000000', flow: 'register' }); - expect(response.statusCode).toEqual(400); - }); }); describe('POST /session/passwordless/sms/verify', () => { @@ -212,18 +196,6 @@ describe('session -> passwordlessRoutes', () => { .send({ phone: '13000000000', code: '1231', flow: 'sign-in' }); expect(response.statusCode).toEqual(400); }); - it('throw when phone not given in input params', async () => { - const response = await sessionRequest - .post('/session/passwordless/sms/verify') - .send({ code: '1234', flow: 'register' }); - expect(response.statusCode).toEqual(400); - }); - it('throw when email given in input params', async () => { - const response = await sessionRequest - .post('/session/passwordless/sms/verify') - .send({ email: 'a@a.com', phone: '13000000000', code: '1234', flow: 'register' }); - expect(response.statusCode).toEqual(400); - }); }); describe('POST /session/passwordless/email/verify', () => { @@ -281,23 +253,11 @@ describe('session -> passwordlessRoutes', () => { .send({ email: 'a@a.com', code: '1231', flow: 'sign-in' }); expect(response.statusCode).toEqual(400); }); - it('throw when phone not given in input params', async () => { - const response = await sessionRequest - .post('/session/passwordless/email/verify') - .send({ code: '1234', flow: 'register' }); - expect(response.statusCode).toEqual(400); - }); - it('throw when email given in input params', async () => { - const response = await sessionRequest - .post('/session/passwordless/email/verify') - .send({ email: 'a@a.com', phone: '13000000000', code: '1234', flow: 'register' }); - expect(response.statusCode).toEqual(400); - }); }); describe('POST /session/sign-in/passwordless/sms', () => { - afterEach(() => { - jest.clearAllMocks(); + beforeEach(() => { + jest.resetAllMocks(); }); it('should call interactionResult', async () => { interactionDetails.mockResolvedValueOnce({ @@ -399,8 +359,8 @@ describe('session -> passwordlessRoutes', () => { }); describe('POST /session/sign-in/passwordless/email', () => { - afterEach(() => { - jest.clearAllMocks(); + beforeEach(() => { + jest.resetAllMocks(); }); it('should call interactionResult', async () => { interactionDetails.mockResolvedValueOnce({ diff --git a/packages/core/src/routes/session/passwordless.ts b/packages/core/src/routes/session/passwordless.ts index c49e33529..c56a3ba28 100644 --- a/packages/core/src/routes/session/passwordless.ts +++ b/packages/core/src/routes/session/passwordless.ts @@ -15,17 +15,17 @@ import { hasUserWithEmail, hasUserWithPhone, } from '@/queries/user'; -import { - verificationGuard, - flowTypeGuard, - viaGuard, - PasscodePayload, -} from '@/routes/session/types'; +import { verificationGuard, flowTypeGuard } from '@/routes/session/types'; import assertThat from '@/utils/assert-that'; import { AnonymousRouter } from '../types'; import { verificationTimeout } from './consts'; -import { getRoutePrefix, getPasscodeType, getPasswordlessRelatedLogType } from './utils'; +import { + getRoutePrefix, + getPasscodeType, + getPasswordlessRelatedLogType, + verificationSessionCheckByFlow, +} from './utils'; export const registerRoute = getRoutePrefix('register', 'passwordless'); export const signInRoute = getRoutePrefix('sign-in', 'passwordless'); @@ -35,40 +35,24 @@ export default function passwordlessRoutes( provider: Provider ) { router.post( - '/session/passwordless/:via/send', + '/session/passwordless/sms/send', koaGuard({ body: object({ - phone: string().regex(phoneRegEx).optional(), - email: string().regex(emailRegEx).optional(), + phone: string().regex(phoneRegEx), flow: flowTypeGuard, }), - params: object({ via: viaGuard }), }), async (ctx, next) => { const { jti } = await provider.interactionDetails(ctx.req, ctx.res); const { - body: { email, phone, flow }, - params: { via }, + body: { phone, flow }, } = ctx.guard; - // eslint-disable-next-line @silverhand/fp/no-let - let payload: PasscodePayload; - - if (via === 'email') { - assertThat(email && !phone, new RequestError({ code: 'guard.invalid_input' })); - // eslint-disable-next-line @silverhand/fp/no-mutation - payload = { email }; - } else { - assertThat(!email && phone, new RequestError({ code: 'guard.invalid_input' })); - // eslint-disable-next-line @silverhand/fp/no-mutation - payload = { phone }; - } - - const type = getPasswordlessRelatedLogType(flow, via, 'send'); - ctx.log(type, payload); + const type = getPasswordlessRelatedLogType(flow, 'sms', 'send'); + ctx.log(type, { phone }); const passcodeType = getPasscodeType(flow); - const passcode = await createPasscode(jti, passcodeType, payload); + const passcode = await createPasscode(jti, passcodeType, { phone }); const { dbEntry } = await sendPasscode(passcode); ctx.log(type, { connectorId: dbEntry.id }); ctx.status = 204; @@ -78,47 +62,92 @@ export default function passwordlessRoutes( ); router.post( - '/session/passwordless/:via/verify', + '/session/passwordless/email/send', koaGuard({ body: object({ - phone: string().regex(phoneRegEx).optional(), - email: string().regex(emailRegEx).optional(), - code: string(), + email: string().regex(emailRegEx), flow: flowTypeGuard, }), - params: object({ via: viaGuard }), }), async (ctx, next) => { const { jti } = await provider.interactionDetails(ctx.req, ctx.res); const { - body: { email, phone, code, flow }, - params: { via }, + body: { email, flow }, } = ctx.guard; - // eslint-disable-next-line @silverhand/fp/no-let - let payload: PasscodePayload; - - if (via === 'email') { - assertThat(email && !phone, new RequestError({ code: 'guard.invalid_input' })); - // eslint-disable-next-line @silverhand/fp/no-mutation - payload = { email }; - } else { - assertThat(!email && phone, new RequestError({ code: 'guard.invalid_input' })); - // eslint-disable-next-line @silverhand/fp/no-mutation - payload = { phone }; - } - - const type = getPasswordlessRelatedLogType(flow, via, 'verify'); - ctx.log(type, payload); + const type = getPasswordlessRelatedLogType(flow, 'email', 'send'); + ctx.log(type, { email }); const passcodeType = getPasscodeType(flow); - await verifyPasscode(jti, passcodeType, code, payload); + const passcode = await createPasscode(jti, passcodeType, { email }); + const { dbEntry } = await sendPasscode(passcode); + ctx.log(type, { connectorId: dbEntry.id }); + ctx.status = 204; + + return next(); + } + ); + + router.post( + '/session/passwordless/sms/verify', + koaGuard({ + body: object({ + phone: string().regex(phoneRegEx), + code: string(), + flow: flowTypeGuard, + }), + }), + async (ctx, next) => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + const { + body: { phone, code, flow }, + } = ctx.guard; + + const type = getPasswordlessRelatedLogType(flow, 'sms', 'verify'); + ctx.log(type, { phone }); + + const passcodeType = getPasscodeType(flow); + await verifyPasscode(jti, passcodeType, code, { phone }); await provider.interactionResult(ctx.req, ctx.res, { verification: { flow, expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(), - ...payload, + phone, + }, + }); + ctx.status = 204; + + return next(); + } + ); + + router.post( + '/session/passwordless/email/verify', + koaGuard({ + body: object({ + email: string().regex(emailRegEx), + code: string(), + flow: flowTypeGuard, + }), + }), + async (ctx, next) => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + const { + body: { email, code, flow }, + } = ctx.guard; + + const type = getPasswordlessRelatedLogType(flow, 'email', 'verify'); + ctx.log(type, { email }); + + const passcodeType = getPasscodeType(flow); + await verifyPasscode(jti, passcodeType, code, { email }); + + await provider.interactionResult(ctx.req, ctx.res, { + verification: { + flow, + expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(), + email, }, }); ctx.status = 204; @@ -130,7 +159,6 @@ export default function passwordlessRoutes( router.post(`${signInRoute}/sms`, async (ctx, next) => { const { result } = await provider.interactionDetails(ctx.req, ctx.res); - console.log(result); const verificationResult = verificationGuard.safeParse(result); assertThat( verificationResult.success, @@ -147,20 +175,13 @@ export default function passwordlessRoutes( const type = getPasswordlessRelatedLogType('sign-in', 'sms'); ctx.log(type, { phone, flow, expiresAt }); - assertThat( - flow === 'sign-in', - new RequestError({ code: 'session.passwordless_not_verified', status: 401 }) - ); - - assertThat( - dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()), - new RequestError({ code: 'session.verification_expired', status: 401 }) - ); + verificationSessionCheckByFlow('sign-in', { flow, expiresAt }); assertThat( phone && (await hasUserWithPhone(phone)), new RequestError({ code: 'user.phone_not_exists', status: 422 }) ); + const { id } = await findUserByPhone(phone); ctx.log(type, { userId: id }); @@ -189,15 +210,7 @@ export default function passwordlessRoutes( const type = getPasswordlessRelatedLogType('sign-in', 'email'); ctx.log(type, { email, flow, expiresAt }); - assertThat( - flow === 'sign-in', - new RequestError({ code: 'session.passwordless_not_verified', status: 401 }) - ); - - assertThat( - dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()), - new RequestError({ code: 'session.verification_expired', status: 401 }) - ); + verificationSessionCheckByFlow('sign-in', { flow, expiresAt }); assertThat( email && (await hasUserWithEmail(email)), @@ -232,15 +245,7 @@ export default function passwordlessRoutes( const type = getPasswordlessRelatedLogType('register', 'sms'); ctx.log(type, { phone, flow, expiresAt }); - assertThat( - flow === 'register', - new RequestError({ code: 'session.passwordless_not_verified', status: 401 }) - ); - - assertThat( - dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()), - new RequestError({ code: 'session.verification_expired', status: 401 }) - ); + verificationSessionCheckByFlow('register', { flow, expiresAt }); assertThat( phone && !(await hasUserWithPhone(phone)), @@ -275,15 +280,7 @@ export default function passwordlessRoutes( const type = getPasswordlessRelatedLogType('register', 'email'); ctx.log(type, { email, flow, expiresAt }); - assertThat( - flow === 'register', - new RequestError({ code: 'session.passwordless_not_verified', status: 401 }) - ); - - assertThat( - dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()), - new RequestError({ code: 'session.verification_expired', status: 401 }) - ); + verificationSessionCheckByFlow('register', { flow, expiresAt }); assertThat( email && !(await hasUserWithEmail(email)), diff --git a/packages/core/src/routes/session/types.ts b/packages/core/src/routes/session/types.ts index fe2b98f5d..3e5067a3a 100644 --- a/packages/core/src/routes/session/types.ts +++ b/packages/core/src/routes/session/types.ts @@ -14,13 +14,17 @@ export type Operation = z.infer; export type PasscodePayload = { email: string } | { phone: string }; +export const verificationStorageGuard = z.object({ + email: z.string().optional(), + phone: z.string().optional(), + flow: flowTypeGuard, + expiresAt: z.string(), +}); + +export type VerificationStorage = z.infer; + export const verificationGuard = z.object({ - verification: z.object({ - email: z.string().optional(), - phone: z.string().optional(), - flow: flowTypeGuard, - expiresAt: z.string(), - }), + verification: verificationStorageGuard, }); export type Verification = z.infer; diff --git a/packages/core/src/routes/session/utils.ts b/packages/core/src/routes/session/utils.ts index fe0d743e7..6e1ccb8a1 100644 --- a/packages/core/src/routes/session/utils.ts +++ b/packages/core/src/routes/session/utils.ts @@ -1,10 +1,12 @@ import { logTypeGuard, LogType, PasscodeType } from '@logto/schemas'; import { Truthy } from '@silverhand/essentials'; +import camelcase from 'camelcase'; +import dayjs from 'dayjs'; import RequestError from '@/errors/RequestError'; import assertThat from '@/utils/assert-that'; -import { FlowType, Operation, Via } from './types'; +import { FlowType, Operation, VerificationStorage, Via } from './types'; export const getRoutePrefix = ( type: FlowType, @@ -17,11 +19,15 @@ export const getRoutePrefix = ( }; export const getPasscodeType = (type: FlowType) => { - return type === 'sign-in' - ? PasscodeType.SignIn - : type === 'register' - ? PasscodeType.Register - : PasscodeType.ForgotPassword; + if (type === 'sign-in') { + return PasscodeType.SignIn; + } + + if (type === 'register') { + return PasscodeType.Register; + } + + return PasscodeType.ForgotPassword; }; export const getPasswordlessRelatedLogType = ( @@ -29,8 +35,7 @@ export const getPasswordlessRelatedLogType = ( via: Via, operation?: Operation ): LogType => { - const prefix = - flow === 'register' ? 'Register' : flow === 'sign-in' ? 'SignIn' : 'ForgotPassword'; + const prefix = camelcase(flow, { pascalCase: true }); const body = via === 'email' ? 'Email' : 'Sms'; const suffix = operation === 'send' ? 'SendPasscode' : ''; @@ -39,3 +44,20 @@ export const getPasswordlessRelatedLogType = ( return result.data; }; + +export const verificationSessionCheckByFlow = ( + currentFlow: FlowType, + payload: Pick +) => { + const { flow, expiresAt } = payload; + + assertThat( + flow === currentFlow, + new RequestError({ code: 'session.passwordless_not_verified', status: 401 }) + ); + + assertThat( + dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()), + new RequestError({ code: 'session.verification_expired', status: 401 }) + ); +};