From d3d189aa7751c8094794bf2dd84ba972b2ad9637 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Mon, 10 Oct 2022 14:21:39 +0800 Subject: [PATCH] refactor(core,ui,test): sign-in and register flows mount on passwordless APIs (#2054) --- .../src/routes/session/passwordless.test.ts | 518 ++++++++++-------- .../core/src/routes/session/passwordless.ts | 284 ++++------ packages/core/src/routes/session/types.ts | 54 +- packages/core/src/routes/session/utils.ts | 76 ++- packages/integration-tests/src/api/session.ts | 118 ++-- .../tests/api/session.test.ts | 38 +- packages/phrases/src/locales/en/errors.ts | 2 - packages/phrases/src/locales/fr/errors.ts | 2 - packages/phrases/src/locales/ko-kr/errors.ts | 2 - packages/phrases/src/locales/pt-pt/errors.ts | 2 - packages/phrases/src/locales/tr-tr/errors.ts | 2 - packages/phrases/src/locales/zh-cn/errors.ts | 1 - packages/ui/src/apis/index.test.ts | 33 +- packages/ui/src/apis/register.ts | 46 +- packages/ui/src/apis/sign-in.ts | 42 +- 15 files changed, 673 insertions(+), 547 deletions(-) diff --git a/packages/core/src/routes/session/passwordless.test.ts b/packages/core/src/routes/session/passwordless.test.ts index e4870d195..1af81ea46 100644 --- a/packages/core/src/routes/session/passwordless.test.ts +++ b/packages/core/src/routes/session/passwordless.test.ts @@ -254,260 +254,342 @@ describe('session -> passwordlessRoutes', () => { it('throw when code is wrong', async () => { const response = await sessionRequest .post('/session/passwordless/email/verify') - .send({ email: 'a@a.com', code: '1231', flow: 'sign-in' }); + .send({ email: 'a@a.com', code: '1231', flow: PasscodeType.SignIn }); expect(response.statusCode).toEqual(400); }); }); - describe('POST /session/sign-in/passwordless/sms/send-passcode', () => { - beforeAll(() => { + describe('POST /session/sign-in/passwordless/sms', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('should call interactionResult', async () => { interactionDetails.mockResolvedValueOnce({ - jti: 'jti', + result: { + verification: { + phone: '13000000000', + flow: PasscodeType.SignIn, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, }); - }); - it('should call sendPasscode', async () => { - const response = await sessionRequest - .post(`${signInRoute}/sms/send-passcode`) - .send({ phone: '13000000000' }); - expect(response.statusCode).toEqual(204); - expect(sendPasscode).toHaveBeenCalled(); - }); - it('throw error if phone does not exist', async () => { - const response = await sessionRequest - .post(`${signInRoute}/sms/send-passcode`) - .send({ phone: '13000000001' }); - expect(response.statusCode).toEqual(422); - }); - }); - - describe('POST /session/sign-in/passwordless/sms/verify-passcode', () => { - it('assign result and redirect', async () => { - const response = await sessionRequest - .post(`${signInRoute}/sms/verify-passcode`) - .send({ phone: '13000000000', code: '1234' }); + const response = await sessionRequest.post(`${signInRoute}/sms`); expect(response.statusCode).toEqual(200); - expect(response.body).toHaveProperty('redirectTo'); expect(interactionResult).toHaveBeenCalledWith( expect.anything(), expect.anything(), - expect.objectContaining({ login: { accountId: 'id' } }), + expect.objectContaining({ + login: { accountId: 'id' }, + }), expect.anything() ); }); - it('throw error if phone does not exist', async () => { - const response = await sessionRequest - .post(`${signInRoute}/sms/verify-passcode`) - .send({ phone: '13000000001', code: '1234' }); - expect(response.statusCode).toEqual(422); - }); - it('throw error if verifyPasscode failed', async () => { - const response = await sessionRequest - .post(`${signInRoute}/sms/verify-passcode`) - .send({ phone: '13000000000', code: '1231' }); - expect(response.statusCode).toEqual(400); - }); - }); - - describe('POST /session/sign-in/passwordless/email/send-passcode', () => { - beforeAll(() => { - interactionDetails.mockResolvedValue({ - jti: 'jti', - }); - }); - it('should call sendPasscode', async () => { - const response = await sessionRequest - .post(`${signInRoute}/email/send-passcode`) - .send({ email: 'a@a.com' }); - expect(response.statusCode).toEqual(204); - expect(sendPasscode).toHaveBeenCalled(); - }); - it('throw error if email does not exist', async () => { - const response = await sessionRequest - .post(`${signInRoute}/email/send-passcode`) - .send({ email: 'b@a.com' }); - expect(response.statusCode).toEqual(422); - }); - }); - - describe('POST /session/sign-in/passwordless/email/verify-passcode', () => { - it('assign result and redirect', async () => { - const response = await sessionRequest - .post(`${signInRoute}/email/verify-passcode`) - .send({ email: 'a@a.com', code: '1234' }); - expect(response.statusCode).toEqual(200); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: 'id' } }), - expect.anything() - ); - }); - it('throw error if email does not exist', async () => { - const response = await sessionRequest - .post(`${signInRoute}/email/send-passcode`) - .send({ email: 'b@a.com' }); - expect(response.statusCode).toEqual(422); - }); - it('throw error if verifyPasscode failed', async () => { - const response = await sessionRequest - .post(`${signInRoute}/email/verify-passcode`) - .send({ email: 'a@a.com', code: '1231' }); - expect(response.statusCode).toEqual(400); - }); - }); - - describe('POST /session/register/passwordless/sms/send-passcode', () => { - beforeAll(() => { + it('throw when verification session invalid', async () => { interactionDetails.mockResolvedValueOnce({ - jti: 'jti', + result: { + verification: { + phone: '13000000000', + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, }); + const response = await sessionRequest.post(`${signInRoute}/sms`); + expect(response.statusCode).toEqual(404); }); - - it('should call sendPasscode', async () => { - const response = await sessionRequest - .post(`${registerRoute}/sms/send-passcode`) - .send({ phone: '13000000001' }); - expect(response.statusCode).toEqual(204); - expect(sendPasscode).toHaveBeenCalled(); - }); - - it('throw error if phone not valid (charactors other than digits)', async () => { - const response = await sessionRequest - .post(`${registerRoute}/sms/send-passcode`) - .send({ phone: '1300000000a' }); - expect(response.statusCode).toEqual(400); - }); - - it('throw error if phone not valid (without digits)', async () => { - const response = await sessionRequest - .post(`${registerRoute}/sms/send-passcode`) - .send({ phone: 'abcdefg' }); - expect(response.statusCode).toEqual(400); - }); - - it('throw error if phone exists', async () => { - const response = await sessionRequest - .post(`${registerRoute}/sms/send-passcode`) - .send({ phone: '13000000000' }); - expect(response.statusCode).toEqual(422); - }); - }); - - describe('POST /session/register/passwordless/sms/verify-passcode', () => { - it('assign result and redirect', async () => { - const response = await sessionRequest - .post(`${registerRoute}/sms/verify-passcode`) - .send({ phone: '13000000001', code: '1234' }); - expect(response.statusCode).toEqual(200); - expect(response.body).toHaveProperty('redirectTo'); - expect(insertUser).toHaveBeenCalledWith( - expect.objectContaining({ id: 'user1', primaryPhone: '13000000001' }) - ); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: 'user1' } }), - expect.anything() - ); - }); - - it('throw error if phone is invalid (characters other than digits)', async () => { - const response = await sessionRequest - .post(`${registerRoute}/sms/verify-passcode`) - .send({ phone: '1300000000a', code: '1234' }); - expect(response.statusCode).toEqual(400); - }); - - it('throw error if phone not valid (without digits)', async () => { - const response = await sessionRequest - .post(`${registerRoute}/sms/verify-passcode`) - .send({ phone: 'abcdefg', code: '1234' }); - expect(response.statusCode).toEqual(400); - }); - - it('throw error if phone exists', async () => { - const response = await sessionRequest - .post(`${registerRoute}/sms/verify-passcode`) - .send({ phone: '13000000000', code: '1234' }); - expect(response.statusCode).toEqual(422); - }); - - it('throw error if verifyPasscode failed', async () => { - const response = await sessionRequest - .post(`${registerRoute}/sms/verify-passcode`) - .send({ phone: '13000000001', code: '1231' }); - expect(response.statusCode).toEqual(400); - }); - }); - - describe('POST /session/register/passwordless/email/send-passcode', () => { - beforeAll(() => { + it('throw when flow is not `sign-in`', async () => { interactionDetails.mockResolvedValueOnce({ - jti: 'jti', + result: { + verification: { + phone: '13000000000', + flow: PasscodeType.Register, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, }); + const response = await sessionRequest.post(`${signInRoute}/sms`); + expect(response.statusCode).toEqual(404); }); - - it('should call sendPasscode', async () => { - const response = await sessionRequest - .post(`${registerRoute}/email/send-passcode`) - .send({ email: 'b@a.com' }); - expect(response.statusCode).toEqual(204); - expect(sendPasscode).toHaveBeenCalled(); + it('throw when expiresAt is not valid ISO date string', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + phone: '13000000000', + flow: PasscodeType.SignIn, + expiresAt: 'invalid date string', + }, + }, + }); + const response = await sessionRequest.post(`${signInRoute}/sms`); + expect(response.statusCode).toEqual(401); }); - - it('throw error if email not valid', async () => { - const response = await sessionRequest - .post(`${registerRoute}/email/send-passcode`) - .send({ email: 'aaa.com' }); - expect(response.statusCode).toEqual(400); + it('throw when validation expired', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + phone: '13000000000', + flow: PasscodeType.SignIn, + expiresAt: dayjs().subtract(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${signInRoute}/sms`); + expect(response.statusCode).toEqual(401); }); - - it('throw error if email exists', async () => { - const response = await sessionRequest - .post(`${registerRoute}/email/send-passcode`) - .send({ email: 'a@a.com' }); + it('throw when phone not exist', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + flow: PasscodeType.SignIn, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${signInRoute}/sms`); + expect(response.statusCode).toEqual(404); + }); + it("throw when phone not exist as user's primaryPhone", async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + phone: '13000000001', + flow: PasscodeType.SignIn, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${signInRoute}/sms`); expect(response.statusCode).toEqual(422); }); }); - describe('POST /session/register/passwordless/email/verify-passcode', () => { - it('assign result and redirect', async () => { - const response = await sessionRequest - .post(`${registerRoute}/email/verify-passcode`) - .send({ email: 'b@a.com', code: '1234' }); + describe('POST /session/sign-in/passwordless/email', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('should call interactionResult', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + email: 'a@a.com', + flow: PasscodeType.SignIn, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${signInRoute}/email`); expect(response.statusCode).toEqual(200); - expect(response.body).toHaveProperty('redirectTo'); - expect(insertUser).toHaveBeenCalledWith( - expect.objectContaining({ id: 'user1', primaryEmail: 'b@a.com' }) - ); expect(interactionResult).toHaveBeenCalledWith( expect.anything(), expect.anything(), - expect.objectContaining({ login: { accountId: 'user1' } }), + expect.objectContaining({ + login: { accountId: 'id' }, + }), expect.anything() ); }); - - it('throw error if email not valid', async () => { - const response = await sessionRequest - .post(`${signInRoute}/email/send-passcode`) - .send({ email: 'aaa.com' }); - expect(response.statusCode).toEqual(400); + it('throw when verification session invalid', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + email: 'a@a.com', + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${signInRoute}/email`); + expect(response.statusCode).toEqual(404); }); - - it('throw error if email exist', async () => { - const response = await sessionRequest - .post(`${signInRoute}/email/send-passcode`) - .send({ email: 'b@a.com' }); + it('throw when flow is not `sign-in`', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + email: 'a@a.com', + flow: PasscodeType.Register, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${signInRoute}/email`); + expect(response.statusCode).toEqual(404); + }); + it('throw when email not exist', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + flow: PasscodeType.SignIn, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${signInRoute}/email`); + expect(response.statusCode).toEqual(404); + }); + it("throw when email not exist as user's primaryEmail", async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + email: 'b@a.com', + flow: PasscodeType.SignIn, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${signInRoute}/email`); expect(response.statusCode).toEqual(422); }); + }); - it('throw error if verifyPasscode failed', async () => { - const response = await sessionRequest - .post(`${signInRoute}/email/verify-passcode`) - .send({ email: 'a@a.com', code: '1231' }); - expect(response.statusCode).toEqual(400); + describe('POST /session/register/passwordless/sms', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('should call interactionResult', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + phone: '13000000001', + flow: PasscodeType.Register, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${registerRoute}/sms`); + expect(response.statusCode).toEqual(200); + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + login: { accountId: 'user1' }, + }), + expect.anything() + ); + }); + it('throw when verification session invalid', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + phone: '13000000001', + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${registerRoute}/sms`); + expect(response.statusCode).toEqual(404); + }); + it('throw when flow is not `register`', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + phone: '13000000001', + flow: PasscodeType.SignIn, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${registerRoute}/sms`); + expect(response.statusCode).toEqual(404); + }); + it('throw when phone not exist', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + flow: PasscodeType.Register, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${registerRoute}/sms`); + expect(response.statusCode).toEqual(404); + }); + it("throw when phone already exist as user's primaryPhone", async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + phone: '13000000000', + flow: PasscodeType.Register, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${registerRoute}/sms`); + expect(response.statusCode).toEqual(422); + }); + }); + + describe('POST /session/register/passwordless/email', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('should call interactionResult', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + email: 'b@a.com', + flow: PasscodeType.Register, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${registerRoute}/email`); + expect(response.statusCode).toEqual(200); + expect(interactionResult).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + login: { accountId: 'user1' }, + }), + expect.anything() + ); + }); + it('throw when verification session invalid', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + email: 'b@a.com', + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${registerRoute}/email`); + expect(response.statusCode).toEqual(404); + }); + it('throw when flow is not `register`', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + email: 'b@a.com', + flow: PasscodeType.SignIn, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${registerRoute}/email`); + expect(response.statusCode).toEqual(404); + }); + it('throw when email not exist', async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + flow: PasscodeType.Register, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${registerRoute}/email`); + expect(response.statusCode).toEqual(404); + }); + it("throw when email already exist as user's primaryEmail", async () => { + interactionDetails.mockResolvedValueOnce({ + result: { + verification: { + email: 'a@a.com', + flow: PasscodeType.Register, + expiresAt: dayjs().add(1, 'day').toISOString(), + }, + }, + }); + const response = await sessionRequest.post(`${registerRoute}/email`); + expect(response.statusCode).toEqual(422); }); }); }); diff --git a/packages/core/src/routes/session/passwordless.ts b/packages/core/src/routes/session/passwordless.ts index aa7ab1b65..76a855f1c 100644 --- a/packages/core/src/routes/session/passwordless.ts +++ b/packages/core/src/routes/session/passwordless.ts @@ -1,6 +1,5 @@ import { emailRegEx, phoneRegEx } from '@logto/core-kit'; import { PasscodeType } from '@logto/schemas'; -import dayjs from 'dayjs'; import { Provider } from 'oidc-provider'; import { object, string } from 'zod'; @@ -10,18 +9,29 @@ import { assignInteractionResults } from '@/lib/session'; import { generateUserId, insertUser } from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; import { - updateUserById, - hasUserWithEmail, - hasUserWithPhone, findUserByEmail, findUserByPhone, + hasUserWithEmail, + hasUserWithPhone, + updateUserById, } from '@/queries/user'; -import { passcodeTypeGuard } from '@/routes/session/types'; +import { + emailRegisterSessionResultGuard, + emailSignInSessionResultGuard, + passcodeTypeGuard, + smsRegisterSessionResultGuard, + smsSignInSessionResultGuard, +} from '@/routes/session/types'; import assertThat from '@/utils/assert-that'; import { AnonymousRouter } from '../types'; -import { verificationTimeout } from './consts'; -import { getPasswordlessRelatedLogType, getRoutePrefix } from './utils'; +import { + assignVerificationResult, + getPasswordlessRelatedLogType, + getRoutePrefix, + getVerificationStorageFromInteraction, + validateAndCheckWhetherVerificationExpires, +} from './utils'; export const registerRoute = getRoutePrefix('register', 'passwordless'); export const signInRoute = getRoutePrefix('sign-in', 'passwordless'); @@ -102,13 +112,7 @@ export default function passwordlessRoutes( await verifyPasscode(jti, flow, code, { phone }); - await provider.interactionResult(ctx.req, ctx.res, { - verification: { - flow, - expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(), - phone, - }, - }); + await assignVerificationResult(ctx, provider, flow, { phone }); ctx.status = 204; return next(); @@ -135,208 +139,118 @@ export default function passwordlessRoutes( await verifyPasscode(jti, flow, code, { email }); - await provider.interactionResult(ctx.req, ctx.res, { - verification: { - flow, - expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(), - email, - }, - }); + await assignVerificationResult(ctx, provider, flow, { email }); ctx.status = 204; return next(); } ); - router.post( - `${signInRoute}/sms/send-passcode`, - koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }), - async (ctx, next) => { - const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - const { phone } = ctx.guard.body; - const type = 'SignInSmsSendPasscode'; - ctx.log(type, { phone }); + router.post(`${signInRoute}/sms`, async (ctx, next) => { + const verificationStorage = await getVerificationStorageFromInteraction( + ctx, + provider, + smsSignInSessionResultGuard + ); - assertThat( - await hasUserWithPhone(phone), - new RequestError({ code: 'user.phone_not_exists', status: 422 }) - ); + const type = getPasswordlessRelatedLogType(PasscodeType.SignIn, 'sms'); + ctx.log(type, verificationStorage); - const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone }); - const { dbEntry } = await sendPasscode(passcode); - ctx.log(type, { connectorId: dbEntry.id }); - ctx.status = 204; + const { phone, expiresAt } = verificationStorage; - return next(); - } - ); + validateAndCheckWhetherVerificationExpires(expiresAt); - router.post( - `${signInRoute}/sms/verify-passcode`, - koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }), - async (ctx, next) => { - const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - const { phone, code } = ctx.guard.body; - const type = 'SignInSms'; - ctx.log(type, { phone, code }); + assertThat( + await hasUserWithPhone(phone), + new RequestError({ code: 'user.phone_not_exists', status: 422 }) + ); + const { id } = await findUserByPhone(phone); + ctx.log(type, { userId: id }); - assertThat( - await hasUserWithPhone(phone), - new RequestError({ code: 'user.phone_not_exists', status: 422 }) - ); + await updateUserById(id, { lastSignInAt: Date.now() }); + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - await verifyPasscode(jti, PasscodeType.SignIn, code, { phone }); - const { id } = await findUserByPhone(phone); - ctx.log(type, { userId: id }); + return next(); + }); - await updateUserById(id, { lastSignInAt: Date.now() }); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true); + router.post(`${signInRoute}/email`, async (ctx, next) => { + const verificationStorage = await getVerificationStorageFromInteraction( + ctx, + provider, + emailSignInSessionResultGuard + ); - return next(); - } - ); + const type = getPasswordlessRelatedLogType(PasscodeType.SignIn, 'email'); + ctx.log(type, verificationStorage); - router.post( - `${signInRoute}/email/send-passcode`, - koaGuard({ body: object({ email: string().regex(emailRegEx) }) }), - async (ctx, next) => { - const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - const { email } = ctx.guard.body; - const type = 'SignInEmailSendPasscode'; - ctx.log(type, { email }); + const { email, expiresAt } = verificationStorage; - assertThat( - await hasUserWithEmail(email), - new RequestError({ code: 'user.email_not_exists', status: 422 }) - ); + validateAndCheckWhetherVerificationExpires(expiresAt); - const passcode = await createPasscode(jti, PasscodeType.SignIn, { email }); - const { dbEntry } = await sendPasscode(passcode); - ctx.log(type, { connectorId: dbEntry.id }); - ctx.status = 204; + assertThat( + await hasUserWithEmail(email), + new RequestError({ code: 'user.email_not_exists', status: 422 }) + ); + const { id } = await findUserByEmail(email); + ctx.log(type, { userId: id }); - return next(); - } - ); + await updateUserById(id, { lastSignInAt: Date.now() }); + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - router.post( - `${signInRoute}/email/verify-passcode`, - koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }), - async (ctx, next) => { - const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - const { email, code } = ctx.guard.body; - const type = 'SignInEmail'; - ctx.log(type, { email, code }); + return next(); + }); - assertThat( - await hasUserWithEmail(email), - new RequestError({ code: 'user.email_not_exists', status: 422 }) - ); + router.post(`${registerRoute}/sms`, async (ctx, next) => { + const verificationStorage = await getVerificationStorageFromInteraction( + ctx, + provider, + smsRegisterSessionResultGuard + ); - await verifyPasscode(jti, PasscodeType.SignIn, code, { email }); - const { id } = await findUserByEmail(email); - ctx.log(type, { userId: id }); + const type = getPasswordlessRelatedLogType(PasscodeType.Register, 'sms'); + ctx.log(type, verificationStorage); - await updateUserById(id, { lastSignInAt: Date.now() }); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true); + const { phone, expiresAt } = verificationStorage; - return next(); - } - ); + validateAndCheckWhetherVerificationExpires(expiresAt); - router.post( - `${registerRoute}/sms/send-passcode`, - koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }), - async (ctx, next) => { - const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - const { phone } = ctx.guard.body; - const type = 'RegisterSmsSendPasscode'; - ctx.log(type, { phone }); + assertThat( + !(await hasUserWithPhone(phone)), + new RequestError({ code: 'user.phone_exists_register', status: 422 }) + ); + const id = await generateUserId(); + ctx.log(type, { userId: id }); - assertThat( - !(await hasUserWithPhone(phone)), - new RequestError({ code: 'user.phone_exists_register', status: 422 }) - ); + await insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() }); + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - const passcode = await createPasscode(jti, PasscodeType.Register, { phone }); - const { dbEntry } = await sendPasscode(passcode); - ctx.log(type, { connectorId: dbEntry.id }); - ctx.status = 204; + return next(); + }); - return next(); - } - ); + router.post(`${registerRoute}/email`, async (ctx, next) => { + const verificationStorage = await getVerificationStorageFromInteraction( + ctx, + provider, + emailRegisterSessionResultGuard + ); - router.post( - `${registerRoute}/sms/verify-passcode`, - koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }), - async (ctx, next) => { - const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - const { phone, code } = ctx.guard.body; - const type = 'RegisterSms'; - ctx.log(type, { phone, code }); + const type = getPasswordlessRelatedLogType(PasscodeType.Register, 'email'); + ctx.log(type, verificationStorage); - assertThat( - !(await hasUserWithPhone(phone)), - new RequestError({ code: 'user.phone_exists_register', status: 422 }) - ); + const { email, expiresAt } = verificationStorage; - await verifyPasscode(jti, PasscodeType.Register, code, { phone }); - const id = await generateUserId(); - ctx.log(type, { userId: id }); + validateAndCheckWhetherVerificationExpires(expiresAt); - await insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() }); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }); + assertThat( + !(await hasUserWithEmail(email)), + new RequestError({ code: 'user.email_exists_register', status: 422 }) + ); + const id = await generateUserId(); + ctx.log(type, { userId: id }); - return next(); - } - ); + await insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() }); + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - router.post( - `${registerRoute}/email/send-passcode`, - koaGuard({ body: object({ email: string().regex(emailRegEx) }) }), - async (ctx, next) => { - const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - const { email } = ctx.guard.body; - const type = 'RegisterEmailSendPasscode'; - ctx.log(type, { email }); - - assertThat( - !(await hasUserWithEmail(email)), - new RequestError({ code: 'user.email_exists_register', status: 422 }) - ); - - const passcode = await createPasscode(jti, PasscodeType.Register, { email }); - const { dbEntry } = await sendPasscode(passcode); - ctx.log(type, { connectorId: dbEntry.id }); - ctx.status = 204; - - return next(); - } - ); - - router.post( - `${registerRoute}/email/verify-passcode`, - koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }), - async (ctx, next) => { - const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - const { email, code } = ctx.guard.body; - const type = 'RegisterEmail'; - ctx.log(type, { email, code }); - - assertThat( - !(await hasUserWithEmail(email)), - new RequestError({ code: 'user.email_exists_register', status: 422 }) - ); - - await verifyPasscode(jti, PasscodeType.Register, code, { email }); - const id = await generateUserId(); - ctx.log(type, { userId: id }); - - await insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() }); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - - return next(); - } - ); + return next(); + }); } diff --git a/packages/core/src/routes/session/types.ts b/packages/core/src/routes/session/types.ts index 7edd7e451..cbb53cc9c 100644 --- a/packages/core/src/routes/session/types.ts +++ b/packages/core/src/routes/session/types.ts @@ -3,15 +3,15 @@ import { z } from 'zod'; export const passcodeTypeGuard = z.nativeEnum(PasscodeType); -export const viaGuard = z.enum(['email', 'sms']); +export const methodGuard = z.enum(['email', 'sms']); -export type Via = z.infer; +export type Method = z.infer; export const operationGuard = z.enum(['send', 'verify']); export type Operation = z.infer; -export type PasscodePayload = { email: string } | { phone: string }; +export type VerifiedIdentity = { email: string } | { phone: string }; export const verificationStorageGuard = z.object({ email: z.string().optional(), @@ -21,3 +21,51 @@ export const verificationStorageGuard = z.object({ }); export type VerificationStorage = z.infer; + +export type VerificationResult = { verification: T }; + +const smsSignInSessionStorageGuard = z.object({ + flow: z.literal(PasscodeType.SignIn), + expiresAt: z.string(), + phone: z.string(), +}); + +export type SmsSignInSessionStorage = z.infer; + +export const smsSignInSessionResultGuard = z.object({ verification: smsSignInSessionStorageGuard }); + +const emailSignInSessionStorageGuard = z.object({ + flow: z.literal(PasscodeType.SignIn), + expiresAt: z.string(), + email: z.string(), +}); + +export type EmailSignInSessionStorage = z.infer; + +export const emailSignInSessionResultGuard = z.object({ + verification: emailSignInSessionStorageGuard, +}); + +const smsRegisterSessionStorageGuard = z.object({ + flow: z.literal(PasscodeType.Register), + expiresAt: z.string(), + phone: z.string(), +}); + +export type SmsRegisterSessionStorage = z.infer; + +export const smsRegisterSessionResultGuard = z.object({ + verification: smsRegisterSessionStorageGuard, +}); + +const emailRegisterSessionStorageGuard = z.object({ + flow: z.literal(PasscodeType.Register), + expiresAt: z.string(), + email: z.string(), +}); + +export type EmailRegisterSessionStorage = z.infer; + +export const emailRegisterSessionResultGuard = z.object({ + verification: emailRegisterSessionStorageGuard, +}); diff --git a/packages/core/src/routes/session/utils.ts b/packages/core/src/routes/session/utils.ts index 2bf8755d7..15a6cf7e7 100644 --- a/packages/core/src/routes/session/utils.ts +++ b/packages/core/src/routes/session/utils.ts @@ -1,12 +1,15 @@ import { logTypeGuard, LogType, PasscodeType } from '@logto/schemas'; import { Truthy } from '@silverhand/essentials'; import dayjs from 'dayjs'; -import { z } from 'zod'; +import { Context } from 'koa'; +import { Provider } from 'oidc-provider'; +import { ZodType, ZodTypeDef } from 'zod'; import RequestError from '@/errors/RequestError'; import assertThat from '@/utils/assert-that'; -import { verificationStorageGuard, Operation, VerificationStorage, Via } from './types'; +import { verificationTimeout } from './consts'; +import { Method, Operation, VerificationResult, VerifiedIdentity } from './types'; export const getRoutePrefix = ( type: 'sign-in' | 'register' | 'forgot-password', @@ -20,10 +23,10 @@ export const getRoutePrefix = ( export const getPasswordlessRelatedLogType = ( flow: PasscodeType, - via: Via, + method: Method, operation?: Operation ): LogType => { - const body = via === 'email' ? 'Email' : 'Sms'; + const body = method === 'email' ? 'Email' : 'Sms'; const suffix = operation === 'send' ? 'SendPasscode' : ''; const result = logTypeGuard.safeParse(flow + body + suffix); @@ -32,37 +35,54 @@ export const getPasswordlessRelatedLogType = ( return result.data; }; -export const parseVerificationStorage = (data: unknown): VerificationStorage => { - const verificationResult = z - .object({ - verification: verificationStorageGuard, - }) - .safeParse(data); +const parseVerificationStorage = ( + data: unknown, + resultGuard: ZodType, ZodTypeDef, unknown> +): T => { + const verificationResult = resultGuard.safeParse(data); - assertThat( - verificationResult.success, - new RequestError({ - code: 'session.verification_session_not_found', - status: 404, - }) - ); + if (!verificationResult.success) { + throw new RequestError( + { + code: 'session.verification_session_not_found', + status: 404, + }, + verificationResult.error + ); + } return verificationResult.data.verification; }; -export const verificationSessionCheckByFlow = ( - currentFlow: PasscodeType, - payload: Pick -) => { - const { flow, expiresAt } = payload; - - assertThat( - flow === currentFlow, - new RequestError({ code: 'session.passwordless_not_verified', status: 401 }) - ); - +export const validateAndCheckWhetherVerificationExpires = (expiresAt: string) => { assertThat( dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()), new RequestError({ code: 'session.verification_expired', status: 401 }) ); }; + +export const getVerificationStorageFromInteraction = async ( + ctx: Context, + provider: Provider, + resultGuard: ZodType, ZodTypeDef, unknown> +): Promise => { + const { result } = await provider.interactionDetails(ctx.req, ctx.res); + + return parseVerificationStorage(result, resultGuard); +}; + +export const assignVerificationResult = async ( + ctx: Context, + provider: Provider, + flow: PasscodeType, + identity: VerifiedIdentity +) => { + const verificationStorage: VerificationResult = { + verification: { + flow, + expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(), + ...identity, + }, + }; + await provider.interactionResult(ctx.req, ctx.res, verificationStorage); +}; diff --git a/packages/integration-tests/src/api/session.ts b/packages/integration-tests/src/api/session.ts index b0b637f51..2a00e9ef7 100644 --- a/packages/integration-tests/src/api/session.ts +++ b/packages/integration-tests/src/api/session.ts @@ -1,3 +1,5 @@ +import { PasscodeType } from '@logto/schemas'; + import api from './api'; type RedirectResponse = { @@ -51,12 +53,13 @@ export const consent = async (interactionCookie: string) => .json(); export const sendRegisterUserWithEmailPasscode = (email: string, interactionCookie: string) => - api.post('session/register/passwordless/email/send-passcode', { + api.post('session/passwordless/email/send', { headers: { cookie: interactionCookie, }, json: { email, + flow: PasscodeType.Register, }, }); @@ -65,25 +68,34 @@ export const verifyRegisterUserWithEmailPasscode = ( code: string, interactionCookie: string ) => - api - .post('session/register/passwordless/email/verify-passcode', { - headers: { - cookie: interactionCookie, - }, - json: { - email, - code, - }, - }) - .json(); - -export const sendSignInUserWithEmailPasscode = (email: string, interactionCookie: string) => - api.post('session/sign-in/passwordless/email/send-passcode', { + api.post('session/passwordless/email/verify', { headers: { cookie: interactionCookie, }, json: { email, + code, + flow: PasscodeType.Register, + }, + }); + +export const checkVerificationSessionAndRegisterWithEmail = (interactionCookie: string) => + api + .post('session/register/passwordless/email', { + headers: { + cookie: interactionCookie, + }, + }) + .json(); + +export const sendSignInUserWithEmailPasscode = (email: string, interactionCookie: string) => + api.post('session/passwordless/email/send', { + headers: { + cookie: interactionCookie, + }, + json: { + email, + flow: PasscodeType.SignIn, }, }); @@ -92,25 +104,34 @@ export const verifySignInUserWithEmailPasscode = ( code: string, interactionCookie: string ) => + api.post('session/passwordless/email/verify', { + headers: { + cookie: interactionCookie, + }, + json: { + email, + code, + flow: PasscodeType.SignIn, + }, + }); + +export const checkVerificationSessionAndSignInWithEmail = (interactionCookie: string) => api - .post('session/sign-in/passwordless/email/verify-passcode', { + .post('session/sign-in/passwordless/email', { headers: { cookie: interactionCookie, }, - json: { - email, - code, - }, }) .json(); export const sendRegisterUserWithSmsPasscode = (phone: string, interactionCookie: string) => - api.post('session/register/passwordless/sms/send-passcode', { + api.post('session/passwordless/sms/send', { headers: { cookie: interactionCookie, }, json: { phone, + flow: PasscodeType.Register, }, }); @@ -119,25 +140,34 @@ export const verifyRegisterUserWithSmsPasscode = ( code: string, interactionCookie: string ) => - api - .post('session/register/passwordless/sms/verify-passcode', { - headers: { - cookie: interactionCookie, - }, - json: { - phone, - code, - }, - }) - .json(); - -export const sendSignInUserWithSmsPasscode = (phone: string, interactionCookie: string) => - api.post('session/sign-in/passwordless/sms/send-passcode', { + api.post('session/passwordless/sms/verify', { headers: { cookie: interactionCookie, }, json: { phone, + code, + flow: PasscodeType.Register, + }, + }); + +export const checkVerificationSessionAndRegisterWithSms = (interactionCookie: string) => + api + .post('session/register/passwordless/sms', { + headers: { + cookie: interactionCookie, + }, + }) + .json(); + +export const sendSignInUserWithSmsPasscode = (phone: string, interactionCookie: string) => + api.post('session/passwordless/sms/send', { + headers: { + cookie: interactionCookie, + }, + json: { + phone, + flow: PasscodeType.SignIn, }, }); @@ -146,15 +176,23 @@ export const verifySignInUserWithSmsPasscode = ( code: string, interactionCookie: string ) => + api.post('session/passwordless/sms/verify', { + headers: { + cookie: interactionCookie, + }, + json: { + phone, + code, + flow: PasscodeType.SignIn, + }, + }); + +export const checkVerificationSessionAndSignInWithSms = (interactionCookie: string) => api - .post('session/sign-in/passwordless/sms/verify-passcode', { + .post('session/sign-in/passwordless/sms', { headers: { cookie: interactionCookie, }, - json: { - phone, - code, - }, }) .json(); diff --git a/packages/integration-tests/tests/api/session.test.ts b/packages/integration-tests/tests/api/session.test.ts index 43060f2f0..cdb9c78df 100644 --- a/packages/integration-tests/tests/api/session.test.ts +++ b/packages/integration-tests/tests/api/session.test.ts @@ -10,12 +10,16 @@ import { import { sendRegisterUserWithEmailPasscode, verifyRegisterUserWithEmailPasscode, + checkVerificationSessionAndRegisterWithEmail, sendSignInUserWithEmailPasscode, verifySignInUserWithEmailPasscode, + checkVerificationSessionAndSignInWithEmail, sendRegisterUserWithSmsPasscode, verifyRegisterUserWithSmsPasscode, + checkVerificationSessionAndRegisterWithSms, sendSignInUserWithSmsPasscode, verifySignInUserWithSmsPasscode, + checkVerificationSessionAndSignInWithSms, disableConnector, signInWithUsernameAndPassword, } from '@/api'; @@ -69,9 +73,11 @@ describe('email passwordless flow', () => { const { code } = passcodeRecord; - const { redirectTo } = await verifyRegisterUserWithEmailPasscode( - email, - code, + await expect( + verifyRegisterUserWithEmailPasscode(email, code, client.interactionCookie) + ).resolves.not.toThrow(); + + const { redirectTo } = await checkVerificationSessionAndRegisterWithEmail( client.interactionCookie ); @@ -99,9 +105,11 @@ describe('email passwordless flow', () => { const { code } = passcodeRecord; - const { redirectTo } = await verifySignInUserWithEmailPasscode( - email, - code, + await expect( + verifySignInUserWithEmailPasscode(email, code, client.interactionCookie) + ).resolves.not.toThrow(); + + const { redirectTo } = await checkVerificationSessionAndSignInWithEmail( client.interactionCookie ); @@ -142,9 +150,11 @@ describe('sms passwordless flow', () => { const { code } = passcodeRecord; - const { redirectTo } = await verifyRegisterUserWithSmsPasscode( - phone, - code, + await expect( + verifyRegisterUserWithSmsPasscode(phone, code, client.interactionCookie) + ).resolves.not.toThrow(); + + const { redirectTo } = await checkVerificationSessionAndRegisterWithSms( client.interactionCookie ); @@ -172,11 +182,11 @@ describe('sms passwordless flow', () => { const { code } = passcodeRecord; - const { redirectTo } = await verifySignInUserWithSmsPasscode( - phone, - code, - client.interactionCookie - ); + await expect( + verifySignInUserWithSmsPasscode(phone, code, client.interactionCookie) + ).resolves.not.toThrow(); + + const { redirectTo } = await checkVerificationSessionAndSignInWithSms(client.interactionCookie); await client.processSession(redirectTo); diff --git a/packages/phrases/src/locales/en/errors.ts b/packages/phrases/src/locales/en/errors.ts index 9351c5bfb..6c07fe41c 100644 --- a/packages/phrases/src/locales/en/errors.ts +++ b/packages/phrases/src/locales/en/errors.ts @@ -62,8 +62,6 @@ const errors = { 'Forgot password verification has expired. Please go back and verify again.', verification_session_not_found: 'Passwordless verification session not found. Please go back and retry.', - passwordless_not_verified: - 'Passwordless of {{flow}} flow is not verified. Please go back and verify.', verification_expired: 'Passwordless verification has expired. Please go back and verify again.', unauthorized: 'Please sign in first.', unsupported_prompt_name: 'Unsupported prompt name.', diff --git a/packages/phrases/src/locales/fr/errors.ts b/packages/phrases/src/locales/fr/errors.ts index c6dad0007..42dc411f4 100644 --- a/packages/phrases/src/locales/fr/errors.ts +++ b/packages/phrases/src/locales/fr/errors.ts @@ -67,8 +67,6 @@ const errors = { 'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED verification_session_not_found: 'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED - passwordless_not_verified: - 'Passwordless of {{flow}} flow is not verified. Please go back and verify.', // UNTRANSLATED verification_expired: 'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED unauthorized: "Veuillez vous enregistrer d'abord.", unsupported_prompt_name: "Nom d'invite non supporté.", diff --git a/packages/phrases/src/locales/ko-kr/errors.ts b/packages/phrases/src/locales/ko-kr/errors.ts index 2c47b67c3..094b4b4d4 100644 --- a/packages/phrases/src/locales/ko-kr/errors.ts +++ b/packages/phrases/src/locales/ko-kr/errors.ts @@ -61,8 +61,6 @@ const errors = { 'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED verification_session_not_found: 'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED - passwordless_not_verified: - 'Passwordless of {{flow}} flow is not verified. Please go back and verify.', // UNTRANSLATED verification_expired: 'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED unauthorized: '로그인을 먼저 해주세요.', unsupported_prompt_name: '지원하지 않는 Prompt 이름이예요.', diff --git a/packages/phrases/src/locales/pt-pt/errors.ts b/packages/phrases/src/locales/pt-pt/errors.ts index 5349e6aa7..092339aed 100644 --- a/packages/phrases/src/locales/pt-pt/errors.ts +++ b/packages/phrases/src/locales/pt-pt/errors.ts @@ -63,8 +63,6 @@ const errors = { 'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED verification_session_not_found: 'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED - passwordless_not_verified: - 'Passwordless of {{flow}} flow is not verified. Please go back and verify.', // UNTRANSLATED verification_expired: 'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED unauthorized: 'Faça login primeiro.', unsupported_prompt_name: 'Nome de prompt não suportado.', diff --git a/packages/phrases/src/locales/tr-tr/errors.ts b/packages/phrases/src/locales/tr-tr/errors.ts index 08eb37522..8544fcd74 100644 --- a/packages/phrases/src/locales/tr-tr/errors.ts +++ b/packages/phrases/src/locales/tr-tr/errors.ts @@ -63,8 +63,6 @@ const errors = { 'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED verification_session_not_found: 'Passwordless verification session not found. Please go back and retry.', // UNTRANSLATED - passwordless_not_verified: - 'Passwordless of {{flow}} flow is not verified. Please go back and verify.', // UNTRANSLATED verification_expired: 'Passwordless verification has expired. Please go back and verify again.', // UNTRANSLATED unauthorized: 'Lütfen önce oturum açın.', unsupported_prompt_name: 'Desteklenmeyen prompt adı.', diff --git a/packages/phrases/src/locales/zh-cn/errors.ts b/packages/phrases/src/locales/zh-cn/errors.ts index b23bfdf88..ef3168bb7 100644 --- a/packages/phrases/src/locales/zh-cn/errors.ts +++ b/packages/phrases/src/locales/zh-cn/errors.ts @@ -58,7 +58,6 @@ const errors = { forgot_password_session_not_found: '无法找到忘记密码验证信息,请尝试重新验证。', forgot_password_verification_expired: '忘记密码验证已过期,请尝试重新验证。', verification_session_not_found: '无法找到无密码流程验证信息,请尝试重新验证。', - passwordless_not_verified: '无密码验证 {{flow}} 流程没找到。请返回并验证。', verification_expired: '无密码验证已过期。请返回重新验证。', unauthorized: '请先登录', unsupported_prompt_name: '不支持的 prompt name', diff --git a/packages/ui/src/apis/index.test.ts b/packages/ui/src/apis/index.test.ts index 405992ef5..1392088e6 100644 --- a/packages/ui/src/apis/index.test.ts +++ b/packages/ui/src/apis/index.test.ts @@ -1,3 +1,4 @@ +import { PasscodeType } from '@logto/schemas'; import ky from 'ky'; import { consent } from './consent'; @@ -87,50 +88,56 @@ describe('api', () => { it('sendSignInSmsPasscode', async () => { await sendSignInSmsPasscode(phone); - expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms/send-passcode', { + expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', { json: { phone, + flow: PasscodeType.SignIn, }, }); }); it('verifySignInSmsPasscode', async () => { - mockKyPost.mockReturnValueOnce({ + mockKyPost.mockReturnValueOnce({}).mockReturnValueOnce({ json: () => ({ redirectTo: '/', }), }); await verifySignInSmsPasscode(phone, code); - expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms/verify-passcode', { + expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', { json: { phone, code, + flow: PasscodeType.SignIn, }, }); + expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms'); }); it('sendSignInEmailPasscode', async () => { await sendSignInEmailPasscode(email); - expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email/send-passcode', { + expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', { json: { email, + flow: PasscodeType.SignIn, }, }); }); it('verifySignInEmailPasscode', async () => { - mockKyPost.mockReturnValueOnce({ + mockKyPost.mockReturnValueOnce({}).mockReturnValueOnce({ json: () => ({ redirectTo: '/', }), }); await verifySignInEmailPasscode(email, code); - expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email/verify-passcode', { + expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', { json: { email, code, + flow: PasscodeType.SignIn, }, }); + expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email'); }); it('consent', async () => { @@ -150,40 +157,46 @@ describe('api', () => { it('sendRegisterSmsPasscode', async () => { await sendRegisterSmsPasscode(phone); - expect(ky.post).toBeCalledWith('/api/session/register/passwordless/sms/send-passcode', { + expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', { json: { phone, + flow: PasscodeType.Register, }, }); }); it('verifyRegisterSmsPasscode', async () => { await verifyRegisterSmsPasscode(phone, code); - expect(ky.post).toBeCalledWith('/api/session/register/passwordless/sms/verify-passcode', { + expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', { json: { phone, code, + flow: PasscodeType.Register, }, }); + expect(ky.post).toBeCalledWith('/api/session/register/passwordless/sms'); }); it('sendRegisterEmailPasscode', async () => { await sendRegisterEmailPasscode(email); - expect(ky.post).toBeCalledWith('/api/session/register/passwordless/email/send-passcode', { + expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', { json: { email, + flow: PasscodeType.Register, }, }); }); it('verifyRegisterEmailPasscode', async () => { await verifyRegisterEmailPasscode(email, code); - expect(ky.post).toBeCalledWith('/api/session/register/passwordless/email/verify-passcode', { + expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', { json: { email, code, + flow: PasscodeType.Register, }, }); + expect(ky.post).toBeCalledWith('/api/session/register/passwordless/email'); }); it('sendForgotPasswordSmsPasscode', async () => { diff --git a/packages/ui/src/apis/register.ts b/packages/ui/src/apis/register.ts index 029230373..85d262bcc 100644 --- a/packages/ui/src/apis/register.ts +++ b/packages/ui/src/apis/register.ts @@ -1,6 +1,8 @@ +import { PasscodeType } from '@logto/schemas'; + import api from './api'; -const registerApiPrefix = '/api/session/register'; +const apiPrefix = '/api/session'; export const register = async (username: string, password: string) => { type Response = { @@ -8,7 +10,7 @@ export const register = async (username: string, password: string) => { }; return api - .post(`${registerApiPrefix}/username-password`, { + .post(`${apiPrefix}/register/username-password`, { json: { username, password, @@ -19,9 +21,10 @@ export const register = async (username: string, password: string) => { export const sendRegisterSmsPasscode = async (phone: string) => { await api - .post(`${registerApiPrefix}/passwordless/sms/send-passcode`, { + .post(`${apiPrefix}/passwordless/sms/send`, { json: { phone, + flow: PasscodeType.Register, }, }) .json(); @@ -34,21 +37,23 @@ export const verifyRegisterSmsPasscode = async (phone: string, code: string) => redirectTo: string; }; - return api - .post(`${registerApiPrefix}/passwordless/sms/verify-passcode`, { - json: { - phone, - code, - }, - }) - .json(); + await api.post(`${apiPrefix}/passwordless/sms/verify`, { + json: { + phone, + code, + flow: PasscodeType.Register, + }, + }); + + return api.post(`${apiPrefix}/register/passwordless/sms`).json(); }; export const sendRegisterEmailPasscode = async (email: string) => { await api - .post(`${registerApiPrefix}/passwordless/email/send-passcode`, { + .post(`${apiPrefix}/passwordless/email/send`, { json: { email, + flow: PasscodeType.Register, }, }) .json(); @@ -61,12 +66,13 @@ export const verifyRegisterEmailPasscode = async (email: string, code: string) = redirectTo: string; }; - return api - .post(`${registerApiPrefix}/passwordless/email/verify-passcode`, { - json: { - email, - code, - }, - }) - .json(); + await api.post(`${apiPrefix}/passwordless/email/verify`, { + json: { + email, + code, + flow: PasscodeType.Register, + }, + }); + + return api.post(`${apiPrefix}/register/passwordless/email`).json(); }; diff --git a/packages/ui/src/apis/sign-in.ts b/packages/ui/src/apis/sign-in.ts index 537598c59..c4a0dffc0 100644 --- a/packages/ui/src/apis/sign-in.ts +++ b/packages/ui/src/apis/sign-in.ts @@ -1,3 +1,5 @@ +import { PasscodeType } from '@logto/schemas'; + import api from './api'; import { bindSocialAccount } from './social'; @@ -24,9 +26,10 @@ export const signInBasic = async (username: string, password: string, socialToBi export const sendSignInSmsPasscode = async (phone: string) => { await api - .post('/api/session/sign-in/passwordless/sms/send-passcode', { + .post('/api/session/passwordless/sms/send', { json: { phone, + flow: PasscodeType.SignIn, }, }) .json(); @@ -43,14 +46,15 @@ export const verifySignInSmsPasscode = async ( redirectTo: string; }; - const result = await api - .post('/api/session/sign-in/passwordless/sms/verify-passcode', { - json: { - phone, - code, - }, - }) - .json(); + await api.post('/api/session/passwordless/sms/verify', { + json: { + phone, + code, + flow: PasscodeType.SignIn, + }, + }); + + const result = await api.post('/api/session/sign-in/passwordless/sms').json(); if (result.redirectTo && socialToBind) { await bindSocialAccount(socialToBind); @@ -61,9 +65,10 @@ export const verifySignInSmsPasscode = async ( export const sendSignInEmailPasscode = async (email: string) => { await api - .post('/api/session/sign-in/passwordless/email/send-passcode', { + .post('/api/session/passwordless/email/send', { json: { email, + flow: PasscodeType.SignIn, }, }) .json(); @@ -80,14 +85,15 @@ export const verifySignInEmailPasscode = async ( redirectTo: string; }; - const result = await api - .post('/api/session/sign-in/passwordless/email/verify-passcode', { - json: { - email, - code, - }, - }) - .json(); + await api.post('/api/session/passwordless/email/verify', { + json: { + email, + code, + flow: PasscodeType.SignIn, + }, + }); + + const result = await api.post('/api/session/sign-in/passwordless/email').json(); if (result.redirectTo && socialToBind) { await bindSocialAccount(socialToBind);