diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d9132021c..7fceb6eef 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,2 @@ /packages/schemas/tables @simeng-li @wangsijie -/packages/core/src/routes/session @simeng-li @wangsijie /.changeset @gao-sun diff --git a/packages/core/src/middleware/koa-audit-log-legacy.test.ts b/packages/core/src/middleware/koa-audit-log-legacy.test.ts deleted file mode 100644 index eb91ef73f..000000000 --- a/packages/core/src/middleware/koa-audit-log-legacy.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { LogResult } from '@logto/schemas'; -import type { LogPayload } from '@logto/schemas/lib/types/log-legacy.js'; -import { pickDefault, createMockUtils } from '@logto/shared/esm'; -import i18next from 'i18next'; - -import RequestError from '#src/errors/RequestError/index.js'; -import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; - -import type { WithLogContextLegacy } from './koa-audit-log-legacy.js'; - -const { jest } = import.meta; - -const { mockEsm } = createMockUtils(jest); - -const nanoIdMock = 'mockId'; - -const addLogContext = jest.fn(); -const log = jest.fn(); - -const { insertLog } = mockEsm('#src/queries/log.js', () => ({ - insertLog: jest.fn(), -})); - -mockEsm('nanoid', () => ({ - nanoid: () => nanoIdMock, -})); - -const koaLog = await pickDefault(import('./koa-audit-log-legacy.js')); - -describe('koaLog middleware', () => { - const type = 'SignInUsernamePassword'; - const mockPayload: LogPayload = { - userId: 'foo', - username: 'Bar', - }; - - const ip = '192.168.0.1'; - const userAgent = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36'; - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should insert a success log when next() does not throw an error', async () => { - const ctx: WithLogContextLegacy> = { - ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), - // Bypass middleware context type assert - addLogContext, - log, - }; - ctx.request.ip = ip; - const additionalMockPayload: LogPayload = { foo: 'bar' }; - - const next = async () => { - ctx.log(type, mockPayload); - ctx.log(type, additionalMockPayload); - }; - await koaLog()(ctx, next); - - expect(insertLog).toBeCalledWith({ - id: nanoIdMock, - key: type, - payload: { - ...mockPayload, - ...additionalMockPayload, - result: LogResult.Success, - ip, - userAgent, - }, - }); - }); - - it('should not insert a log when there is no log type', async () => { - const ctx: WithLogContextLegacy> = { - ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), - // Bypass middleware context type assert - addLogContext, - log, - }; - ctx.request.ip = ip; - - // eslint-disable-next-line unicorn/consistent-function-scoping, @typescript-eslint/no-empty-function - const next = async () => {}; - await koaLog()(ctx, next); - expect(insertLog).not.toBeCalled(); - }); - - describe('should insert an error log with the error message when next() throws an error', () => { - it('should log with error message when next throws a normal Error', async () => { - const ctx: WithLogContextLegacy> = { - ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), - // Bypass middleware context type assert - addLogContext, - log, - }; - ctx.request.ip = ip; - - const message = 'Normal error'; - const error = new Error(message); - - const next = async () => { - ctx.log(type, mockPayload); - throw error; - }; - await expect(koaLog()(ctx, next)).rejects.toMatchError(error); - - expect(insertLog).toBeCalledWith({ - id: nanoIdMock, - key: type, - payload: { - ...mockPayload, - result: LogResult.Error, - error: { message: `Error: ${message}` }, - ip, - userAgent, - }, - }); - }); - - it('should insert an error log with the error body when next() throws a RequestError', async () => { - const ctx: WithLogContextLegacy> = { - ...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }), - // Bypass middleware context type assert - addLogContext, - log, - }; - ctx.request.ip = ip; - - const message = 'Error message'; - jest.spyOn(i18next, 't').mockReturnValueOnce(message); // Used in - const code = 'connector.general'; - const data = { foo: 'bar', num: 123 }; - const error = new RequestError(code, data); - - const next = async () => { - ctx.log(type, mockPayload); - throw error; - }; - await expect(koaLog()(ctx, next)).rejects.toMatchError(error); - - expect(insertLog).toBeCalledWith({ - id: nanoIdMock, - key: type, - payload: { - ...mockPayload, - result: LogResult.Error, - error: { message, code, data }, - ip, - userAgent, - }, - }); - }); - }); -}); diff --git a/packages/core/src/middleware/koa-audit-log-legacy.ts b/packages/core/src/middleware/koa-audit-log-legacy.ts deleted file mode 100644 index 71eb20053..000000000 --- a/packages/core/src/middleware/koa-audit-log-legacy.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { LogResult } from '@logto/schemas'; -import type { - BaseLogPayload, - LogPayload, - LogPayloads, - LogType, -} from '@logto/schemas/lib/types/log-legacy.js'; -import { pick } from '@silverhand/essentials'; -import deepmerge from 'deepmerge'; -import type { MiddlewareType } from 'koa'; -import type { IRouterParamContext } from 'koa-router'; -import { nanoid } from 'nanoid'; - -import RequestError from '#src/errors/RequestError/index.js'; -import { insertLog } from '#src/queries/log.js'; - -type MergeLog = (type: T, payload: LogPayloads[T]) => void; - -type SessionPayload = { - sessionId?: string; - applicationId?: string; -}; - -type AddLogContext = (sessionPayload: SessionPayload) => void; - -/** @deprecated This will be removed soon. Use `kua-audit-log.js` instead. */ -export type LogContextLegacy = { - addLogContext: AddLogContext; - log: MergeLog; -}; - -/** @deprecated This will be removed soon. Use `kua-audit-log.js` instead. */ -export type WithLogContextLegacy = - ContextT & LogContextLegacy; - -type Logger = { - type?: LogType; - basePayload?: BaseLogPayload; - payload: LogPayload; - set: (basePayload: BaseLogPayload) => void; - log: MergeLog; - save: () => Promise; -}; - -/* eslint-disable @silverhand/fp/no-mutation */ -const initLogger = (basePayload?: Readonly) => { - const logger: Logger = { - type: undefined, - basePayload, - payload: {}, - set: (basePayload) => { - logger.basePayload = { - ...logger.basePayload, - ...basePayload, - }; - }, - log: (type, payload) => { - if (type !== logger.type) { - logger.type = type; - logger.payload = payload; - - return; - } - - logger.payload = deepmerge(logger.payload, payload); - }, - save: async () => { - if (!logger.type) { - return; - } - - await insertLog({ - id: nanoid(), - key: logger.type, - payload: { - ...logger.basePayload, - ...logger.payload, - }, - }); - }, - }; - - return logger; -}; -/* eslint-enable @silverhand/fp/no-mutation */ - -/** @deprecated This will be removed soon. Use `kua-audit-log.js` instead. */ -export default function koaAuditLogLegacy< - StateT, - ContextT extends IRouterParamContext, - ResponseBodyT ->(): MiddlewareType, ResponseBodyT> { - return async (ctx, next) => { - const { - ip, - headers: { 'user-agent': userAgent }, - } = ctx.request; - - const logger = initLogger({ result: LogResult.Success, ip, userAgent }); - ctx.addLogContext = logger.set; - ctx.log = logger.log; - - try { - await next(); - } catch (error: unknown) { - logger.set({ - result: LogResult.Error, - error: - error instanceof RequestError - ? pick(error, 'message', 'code', 'data') - : { message: String(error) }, - }); - throw error; - } finally { - await logger.save(); - } - }; -} diff --git a/packages/core/src/middleware/koa-log-session-legacy.test.ts b/packages/core/src/middleware/koa-log-session-legacy.test.ts deleted file mode 100644 index c147cf597..000000000 --- a/packages/core/src/middleware/koa-log-session-legacy.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import Provider from 'oidc-provider'; - -import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js'; -import koaLogSessionLegacy from '#src/middleware/koa-log-session-legacy.js'; -import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; - -const { jest } = import.meta; - -const provider = new Provider('https://logto.test'); -const interactionDetails = jest.spyOn(provider, 'interactionDetails'); - -describe('koaLogSessionLegacy', () => { - const sessionId = 'sessionId'; - const applicationId = 'applicationId'; - const addLogContext = jest.fn(); - const log = jest.fn(); - const next = jest.fn(); - - // @ts-expect-error for testing - interactionDetails.mockResolvedValue({ - jti: sessionId, - params: { - client_id: applicationId, - }, - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should get session info from the provider', async () => { - const ctx: WithLogContextLegacy> = { - ...createContextWithRouteParameters(), - addLogContext, - log, - }; - - await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow(); - expect(interactionDetails).toHaveBeenCalled(); - }); - - it('should log session id and application id', async () => { - const ctx: WithLogContextLegacy> = { - ...createContextWithRouteParameters(), - addLogContext, - log, - }; - - await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow(); - expect(addLogContext).toHaveBeenCalledWith({ sessionId, applicationId }); - }); - - it('should call next', async () => { - const ctx: WithLogContextLegacy> = { - ...createContextWithRouteParameters(), - addLogContext, - log, - }; - - await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow(); - expect(next).toHaveBeenCalled(); - }); - - it('should not throw when interactionDetails throw error', async () => { - const ctx: WithLogContextLegacy> = { - ...createContextWithRouteParameters(), - addLogContext, - log, - }; - - interactionDetails.mockImplementationOnce(() => { - throw new Error('message'); - }); - - await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow(); - }); -}); diff --git a/packages/core/src/middleware/koa-log-session-legacy.ts b/packages/core/src/middleware/koa-log-session-legacy.ts deleted file mode 100644 index 60ce095e7..000000000 --- a/packages/core/src/middleware/koa-log-session-legacy.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { MiddlewareType } from 'koa'; -import type Provider from 'oidc-provider'; - -import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js'; - -/** @deprecated This will be removed soon. Use `kua-log-session.js` instead. */ -export default function koaLogSessionLegacy< - StateT, - ContextT extends WithLogContextLegacy, - ResponseBodyT ->(provider: Provider): MiddlewareType { - return async (ctx, next) => { - await next(); - - try { - const { - jti, - params: { client_id }, - } = await provider.interactionDetails(ctx.req, ctx.res); - ctx.addLogContext({ sessionId: jti, applicationId: String(client_id) }); - } catch (error: unknown) { - console.error(`Failed to get oidc provider interaction`, error); - } - }; -} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index ef85809bd..7be8ed076 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -2,11 +2,9 @@ import { UserRole } from '@logto/schemas'; import Koa from 'koa'; import Router from 'koa-router'; -import koaAuditLogLegacy from '#src/middleware/koa-audit-log-legacy.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import koaAuth from '../middleware/koa-auth.js'; -import koaLogSessionLegacy from '../middleware/koa-log-session-legacy.js'; import adminUserRoleRoutes from './admin-user-role.js'; import adminUserRoutes from './admin-user.js'; import applicationRoutes from './application.js'; @@ -21,19 +19,14 @@ import phraseRoutes from './phrase.js'; import profileRoutes from './profile.js'; import resourceRoutes from './resource.js'; import roleRoutes from './role.js'; -import sessionRoutes from './session/index.js'; import settingRoutes from './setting.js'; import signInExperiencesRoutes from './sign-in-experience/index.js'; import statusRoutes from './status.js'; import swaggerRoutes from './swagger.js'; -import type { AnonymousRouter, AnonymousRouterLegacy, AuthedRouter } from './types.js'; +import type { AnonymousRouter, AuthedRouter } from './types.js'; import wellKnownRoutes from './well-known.js'; const createRouters = (tenant: TenantContext) => { - const sessionRouter: AnonymousRouterLegacy = new Router(); - sessionRouter.use(koaAuditLogLegacy(), koaLogSessionLegacy(tenant.provider)); - sessionRoutes(sessionRouter, tenant); - const interactionRouter: AnonymousRouter = new Router(); interactionRoutes(interactionRouter, tenant); @@ -62,21 +55,19 @@ const createRouters = (tenant: TenantContext) => { authnRoutes(anonymousRouter, tenant); // The swagger.json should contain all API routers. swaggerRoutes(anonymousRouter, [ - sessionRouter, interactionRouter, profileRouter, managementRouter, anonymousRouter, ]); - return [sessionRouter, interactionRouter, profileRouter, managementRouter, anonymousRouter]; + return [interactionRouter, profileRouter, managementRouter, anonymousRouter]; }; export default function initRouter(tenant: TenantContext): Koa { const apisApp = new Koa(); for (const router of createRouters(tenant)) { - // @ts-expect-error will remove once interaction refactor finished apisApp.use(router.routes()).use(router.allowedMethods()); } diff --git a/packages/core/src/routes/session/continue.test.ts b/packages/core/src/routes/session/continue.test.ts deleted file mode 100644 index 8358afd7f..000000000 --- a/packages/core/src/routes/session/continue.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { VerificationCodeType } from '@logto/connector-kit'; -import { addDays, subSeconds } from 'date-fns'; -import Provider from 'oidc-provider'; - -import { mockUser } from '#src/__mocks__/index.js'; -import { createRequester } from '#src/utils/test-utils.js'; - -import continueRoutes, { continueRoute } from './continue.js'; - -const getTomorrowIsoString = () => addDays(Date.now(), 1).toISOString(); -const getVerificationStorageFromInteraction = jest.fn(); - -const checkRequiredProfile = jest.fn(); -jest.mock('./utils', () => ({ - ...jest.requireActual('./utils'), - checkRequiredProfile: () => checkRequiredProfile(), - getVerificationStorageFromInteraction: () => getVerificationStorageFromInteraction(), -})); - -jest.mock('#src/queries/sign-in-experience.js', () => ({ - findDefaultSignInExperience: jest.fn(), -})); - -const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser); -const findUserById = jest.fn(async (..._args: unknown[]) => mockUser); -const hasUser = jest.fn(); -const hasUserWithPhone = jest.fn(); -const hasUserWithEmail = jest.fn(); - -jest.mock('#src/queries/user.js', () => ({ - updateUserById: async (...args: unknown[]) => updateUserById(...args), - findUserById: async () => findUserById(), - hasUser: async () => hasUser(), - hasUserWithPhone: async () => hasUserWithPhone(), - hasUserWithEmail: async () => hasUserWithEmail(), -})); - -const interactionResult = jest.fn(async () => 'redirectTo'); -const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({})); - -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - interactionDetails, - interactionResult, - })), -})); - -afterEach(() => { - interactionResult.mockClear(); -}); - -describe('session -> continueRoutes', () => { - const sessionRequest = createRequester({ - // @ts-expect-error will remove once interaction refactor finished - anonymousRoutes: continueRoutes, - provider: new Provider(''), - middlewares: [ - async (ctx, next) => { - ctx.addLogContext = jest.fn(); - ctx.log = jest.fn(); - - return next(); - }, - ], - }); - - describe('POST /session/sign-in/continue/password', () => { - it('updates user password, checks required profile, and sign in', async () => { - interactionDetails.mockResolvedValueOnce({ - jti: 'jti', - result: { - continueSignIn: { - userId: mockUser.id, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - findUserById.mockResolvedValueOnce({ - ...mockUser, - passwordEncrypted: null, - identities: {}, - }); - const response = await sessionRequest.post(`${continueRoute}/password`).send({ - password: 'password', - }); - expect(response.statusCode).toEqual(200); - expect(checkRequiredProfile).toHaveBeenCalled(); - expect(updateUserById).toHaveBeenCalledWith(mockUser.id, expect.anything()); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: mockUser.id } }), - expect.anything() - ); - }); - }); - - describe('POST /session/sign-in/continue/username', () => { - it('updates user username, checks required profile, and sign in', async () => { - interactionDetails.mockResolvedValueOnce({ - jti: 'jti', - result: { - continueSignIn: { - userId: mockUser.id, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - findUserById.mockResolvedValueOnce({ - ...mockUser, - username: null, - }); - const response = await sessionRequest.post(`${continueRoute}/username`).send({ - username: 'username', - }); - expect(response.statusCode).toEqual(200); - expect(checkRequiredProfile).toHaveBeenCalled(); - expect(hasUser).toHaveBeenCalled(); - expect(updateUserById).toHaveBeenCalledWith(mockUser.id, expect.anything()); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: mockUser.id } }), - expect.anything() - ); - }); - }); - - describe('POST /session/sign-in/continue/email', () => { - beforeEach(() => { - getVerificationStorageFromInteraction.mockResolvedValueOnce({ email: 'email' }); - }); - - it('updates user email, checks required profile, and sign in', async () => { - interactionDetails.mockResolvedValueOnce({ - jti: 'jti', - result: { - continueSignIn: { - userId: mockUser.id, - expiresAt: getTomorrowIsoString(), - type: VerificationCodeType.Continue, - }, - }, - }); - findUserById.mockResolvedValueOnce({ - ...mockUser, - primaryEmail: null, - }); - const response = await sessionRequest.post(`${continueRoute}/email`).send(); - expect(response.statusCode).toEqual(200); - expect(checkRequiredProfile).toHaveBeenCalled(); - expect(hasUser).toHaveBeenCalled(); - expect(updateUserById).toHaveBeenCalledWith(mockUser.id, expect.anything()); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: mockUser.id } }), - expect.anything() - ); - }); - }); - - describe('POST /session/sign-in/continue/sms', () => { - it('updates user phone, checks required profile, and sign in', async () => { - getVerificationStorageFromInteraction.mockResolvedValueOnce({ phone: 'phone' }); - interactionDetails.mockResolvedValueOnce({ - jti: 'jti', - result: { - continueSignIn: { - userId: mockUser.id, - expiresAt: getTomorrowIsoString(), - type: VerificationCodeType.Continue, - }, - }, - }); - findUserById.mockResolvedValueOnce({ - ...mockUser, - primaryPhone: null, - }); - const response = await sessionRequest.post(`${continueRoute}/sms`).send(); - expect(response.statusCode).toEqual(200); - expect(checkRequiredProfile).toHaveBeenCalled(); - expect(hasUserWithPhone).toHaveBeenCalled(); - expect(updateUserById).toHaveBeenCalledWith(mockUser.id, expect.anything()); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: mockUser.id } }), - expect.anything() - ); - }); - }); - - describe('general invalid cases', () => { - test.each(['password', 'username', 'email', 'sms'])( - 'throws on empty continue sign in storage', - async (route) => { - interactionDetails.mockResolvedValueOnce({ - jti: 'jti', - result: {}, - }); - const response = await sessionRequest.post(`${continueRoute}/${route}`).send({ - password: 'password', - username: 'username', - }); - expect(response.statusCode).toEqual(401); - } - ); - - test.each(['password', 'username', 'email', 'sms'])( - 'throws on expired continue sign in storage', - async () => { - interactionDetails.mockResolvedValueOnce({ - jti: 'jti', - result: { - continueSignIn: { - userId: mockUser.id, - expiresAt: subSeconds(Date.now(), 1).toISOString(), - }, - }, - }); - const response = await sessionRequest.post(`${continueRoute}/password`).send({ - password: 'password', - }); - expect(response.statusCode).toEqual(401); - } - ); - }); -}); diff --git a/packages/core/src/routes/session/continue.ts b/packages/core/src/routes/session/continue.ts deleted file mode 100644 index b7df83d64..000000000 --- a/packages/core/src/routes/session/continue.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { passwordRegEx, usernameRegEx } from '@logto/core-kit'; -import type Provider from 'oidc-provider'; -import { object, string } from 'zod'; - -import RequestError from '#src/errors/RequestError/index.js'; -import { - assignInteractionResults, - getApplicationIdFromInteraction, -} from '#src/libraries/session.js'; -import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js'; -import { encryptUserPassword } from '#src/libraries/user.js'; -import koaGuard from '#src/middleware/koa-guard.js'; -import { - findUserById, - hasUser, - hasUserWithEmail, - hasUserWithPhone, - updateUserById, -} from '#src/queries/user.js'; -import assertThat from '#src/utils/assert-that.js'; - -import type { AnonymousRouterLegacy } from '../types.js'; -import { continueEmailSessionResultGuard, continueSmsSessionResultGuard } from './types.js'; -import { - checkRequiredProfile, - getContinueSignInResult, - getRoutePrefix, - getVerificationStorageFromInteraction, - isUserPasswordSet, -} from './utils.js'; - -export const continueRoute = getRoutePrefix('sign-in', 'continue'); - -export default function continueRoutes( - router: T, - provider: Provider -) { - router.post( - `${continueRoute}/password`, - koaGuard({ - body: object({ - password: string().regex(passwordRegEx), - }), - }), - async (ctx, next) => { - const { password } = ctx.guard.body; - const { userId } = await getContinueSignInResult(ctx, provider); - const user = await findUserById(userId); - - // Social identities can take place the role of password - assertThat( - !isUserPasswordSet(user), - new RequestError({ - code: 'user.password_exists_in_profile', - }) - ); - - const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password); - const updatedUser = await updateUserById(userId, { - passwordEncrypted, - passwordEncryptionMethod, - }); - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - await checkRequiredProfile(ctx, provider, updatedUser, signInExperience); - await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } }); - - return next(); - } - ); - - router.post( - `${continueRoute}/username`, - koaGuard({ - body: object({ - username: string().regex(usernameRegEx), - }), - }), - async (ctx, next) => { - const { username } = ctx.guard.body; - const { userId } = await getContinueSignInResult(ctx, provider); - const user = await findUserById(userId); - - assertThat( - !user.username, - new RequestError({ - code: 'user.username_exists_in_profile', - }) - ); - - assertThat( - !(await hasUser(username)), - new RequestError({ - code: 'user.username_already_in_use', - status: 422, - }) - ); - - const updatedUser = await updateUserById(userId, { - username, - }); - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - await checkRequiredProfile(ctx, provider, updatedUser, signInExperience); - await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } }); - - return next(); - } - ); - - router.post(`${continueRoute}/email`, async (ctx, next) => { - const { userId } = await getContinueSignInResult(ctx, provider); - const { email } = await getVerificationStorageFromInteraction( - ctx, - provider, - continueEmailSessionResultGuard - ); - const user = await findUserById(userId); - - assertThat( - !user.primaryEmail, - new RequestError({ - code: 'user.email_exists_in_profile', - }) - ); - - assertThat( - !(await hasUserWithEmail(email)), - new RequestError({ - code: 'user.email_already_in_use', - status: 422, - }) - ); - - const updatedUser = await updateUserById(userId, { - primaryEmail: email, - }); - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - await checkRequiredProfile(ctx, provider, updatedUser, signInExperience); - await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } }); - - return next(); - }); - - router.post(`${continueRoute}/sms`, async (ctx, next) => { - const { userId } = await getContinueSignInResult(ctx, provider); - const { phone } = await getVerificationStorageFromInteraction( - ctx, - provider, - continueSmsSessionResultGuard - ); - const user = await findUserById(userId); - - assertThat( - !user.primaryPhone, - new RequestError({ - code: 'user.phone_exists_in_profile', - }) - ); - - assertThat( - !(await hasUserWithPhone(phone)), - new RequestError({ - code: 'user.phone_already_in_use', - status: 422, - }) - ); - - const updatedUser = await updateUserById(userId, { - primaryPhone: phone, - }); - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - await checkRequiredProfile(ctx, provider, updatedUser, signInExperience); - await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } }); - - return next(); - }); -} diff --git a/packages/core/src/routes/session/forgot-password.test.ts b/packages/core/src/routes/session/forgot-password.test.ts deleted file mode 100644 index 6685aacaa..000000000 --- a/packages/core/src/routes/session/forgot-password.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { VerificationCodeType } from '@logto/connector-kit'; -import type { User } from '@logto/schemas'; -import { addDays, subDays } from 'date-fns'; -import Provider from 'oidc-provider'; - -import { - mockPasswordEncrypted, - mockSignInExperience, - mockUserWithPassword, -} from '#src/__mocks__/index.js'; -import RequestError from '#src/errors/RequestError/index.js'; -import { createRequester } from '#src/utils/test-utils.js'; - -import forgotPasswordRoutes, { forgotPasswordRoute } from './forgot-password.js'; - -const encryptUserPassword = jest.fn(async (password: string) => ({ - passwordEncrypted: password + '_user1', - passwordEncryptionMethod: 'Argon2i', -})); -const findUserById = jest.fn(async (): Promise => mockUserWithPassword); -const updateUserById = jest.fn(async (..._args: unknown[]) => ({ userId: 'id' })); -const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience); -const getYesterdayDate = () => subDays(Date.now(), 1); -const getTomorrowDate = () => addDays(Date.now(), 1); - -jest.mock('#src/libraries/user.js', () => ({ - ...jest.requireActual('#src/libraries/user.js'), - encryptUserPassword: async (password: string) => encryptUserPassword(password), -})); - -jest.mock('#src/queries/user.js', () => ({ - ...jest.requireActual('#src/queries/user.js'), - hasUserWithPhone: async (phone: string) => phone === '13000000000', - findUserByPhone: async () => ({ userId: 'id' }), - hasUserWithEmail: async (email: string) => email === 'a@a.com', - findUserByEmail: async () => ({ userId: 'id' }), - findUserById: async () => findUserById(), - updateUserById: async (...args: unknown[]) => updateUserById(...args), -})); - -jest.mock('#src/queries/sign-in-experience.js', () => ({ - findDefaultSignInExperience: async () => findDefaultSignInExperience(), -})); - -const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } })); -jest.mock('#src/libraries/passcode.js', () => ({ - createPasscode: async () => ({ userId: 'id' }), - sendPasscode: async () => sendPasscode(), - verifyPasscode: async (_a: unknown, _b: unknown, code: string) => { - if (code !== '1234') { - throw new RequestError('verification_code.code_mismatch'); - } - }, -})); - -const mockArgon2Verify = jest.fn(async (password: string) => password === mockPasswordEncrypted); -jest.mock('hash-wasm', () => ({ - argon2Verify: async (password: string) => mockArgon2Verify(password), -})); - -const interactionResult = jest.fn(async () => 'redirectTo'); -const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({})); - -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - interactionDetails, - interactionResult, - })), -})); - -afterEach(() => { - interactionResult.mockClear(); -}); - -describe('session -> forgotPasswordRoutes', () => { - const sessionRequest = createRequester({ - // @ts-expect-error will remove once interaction refactor finished - anonymousRoutes: forgotPasswordRoutes, - provider: new Provider(''), - middlewares: [ - async (ctx, next) => { - ctx.addLogContext = jest.fn(); - ctx.log = jest.fn(); - - return next(); - }, - ], - }); - - describe('POST /session/forgot-password/reset', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - it('assign result and redirect', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - userId: 'id', - expiresAt: getTomorrowDate().toISOString(), - flow: VerificationCodeType.ForgotPassword, - }, - }, - }); - const response = await sessionRequest - .post(`${forgotPasswordRoute}/reset`) - .send({ password: mockPasswordEncrypted }); - expect(updateUserById).toBeCalledWith( - 'id', - expect.objectContaining({ - passwordEncrypted: 'a1b2c3_user1', - passwordEncryptionMethod: 'Argon2i', - }) - ); - expect(response.statusCode).toEqual(204); - }); - it('should throw when `id` is missing', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - expiresAt: getTomorrowDate().toISOString(), - flow: VerificationCodeType.ForgotPassword, - }, - }, - }); - const response = await sessionRequest - .post(`${forgotPasswordRoute}/reset`) - .send({ password: mockPasswordEncrypted }); - expect(response).toHaveProperty('status', 404); - expect(updateUserById).toBeCalledTimes(0); - }); - it('should throw when flow is not `forgot-password`', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - userId: 'id', - expiresAt: getTomorrowDate().toISOString(), - flow: VerificationCodeType.SignIn, - }, - }, - }); - const response = await sessionRequest - .post(`${forgotPasswordRoute}/reset`) - .send({ password: mockPasswordEncrypted }); - expect(response).toHaveProperty('status', 404); - expect(updateUserById).toBeCalledTimes(0); - }); - it('should throw when `verification.expiresAt` is not string', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { userId: 'id', expiresAt: 0, flow: VerificationCodeType.ForgotPassword }, - }, - }); - const response = await sessionRequest - .post(`${forgotPasswordRoute}/reset`) - .send({ password: mockPasswordEncrypted }); - expect(response).toHaveProperty('status', 404); - expect(updateUserById).toBeCalledTimes(0); - }); - it('should throw when `expiresAt` is not a valid date string', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - userId: 'id', - expiresAt: 'invalid date string', - flow: VerificationCodeType.ForgotPassword, - }, - }, - }); - const response = await sessionRequest - .post(`${forgotPasswordRoute}/reset`) - .send({ password: mockPasswordEncrypted }); - expect(response).toHaveProperty('status', 401); - expect(updateUserById).toBeCalledTimes(0); - }); - it('should throw when verification expires', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - userId: 'id', - expiresAt: getYesterdayDate().toISOString(), - flow: VerificationCodeType.ForgotPassword, - }, - }, - }); - const response = await sessionRequest - .post(`${forgotPasswordRoute}/reset`) - .send({ password: mockPasswordEncrypted }); - expect(response).toHaveProperty('status', 401); - expect(updateUserById).toBeCalledTimes(0); - }); - it('should throw when new password is the same as old one', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - userId: 'id', - expiresAt: getTomorrowDate().toISOString(), - flow: VerificationCodeType.ForgotPassword, - }, - }, - }); - mockArgon2Verify.mockResolvedValueOnce(true); - const response = await sessionRequest - .post(`${forgotPasswordRoute}/reset`) - .send({ password: mockPasswordEncrypted }); - expect(response).toHaveProperty('status', 422); - expect(updateUserById).toBeCalledTimes(0); - }); - it('should redirect when there was no old password', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - userId: 'id', - expiresAt: getTomorrowDate().toISOString(), - flow: VerificationCodeType.ForgotPassword, - }, - }, - }); - findUserById.mockResolvedValueOnce({ - ...mockUserWithPassword, - passwordEncrypted: null, - passwordEncryptionMethod: null, - }); - const response = await sessionRequest - .post(`${forgotPasswordRoute}/reset`) - .send({ password: mockPasswordEncrypted }); - expect(updateUserById).toBeCalledWith( - 'id', - expect.objectContaining({ - passwordEncrypted: 'a1b2c3_user1', - passwordEncryptionMethod: 'Argon2i', - }) - ); - expect(response.statusCode).toEqual(204); - }); - }); -}); diff --git a/packages/core/src/routes/session/forgot-password.ts b/packages/core/src/routes/session/forgot-password.ts deleted file mode 100644 index 3b4803c17..000000000 --- a/packages/core/src/routes/session/forgot-password.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { passwordRegEx } from '@logto/core-kit'; -import { argon2Verify } from 'hash-wasm'; -import type Provider from 'oidc-provider'; -import { z } from 'zod'; - -import RequestError from '#src/errors/RequestError/index.js'; -import { encryptUserPassword } from '#src/libraries/user.js'; -import koaGuard from '#src/middleware/koa-guard.js'; -import { findUserById, updateUserById } from '#src/queries/user.js'; -import assertThat from '#src/utils/assert-that.js'; - -import type { AnonymousRouterLegacy } from '../types.js'; -import { forgotPasswordSessionResultGuard } from './types.js'; -import { - clearVerificationResult, - getRoutePrefix, - getVerificationStorageFromInteraction, - checkValidateExpiration, -} from './utils.js'; - -export const forgotPasswordRoute = getRoutePrefix('forgot-password'); - -export default function forgotPasswordRoutes( - router: T, - provider: Provider -) { - router.post( - `${forgotPasswordRoute}/reset`, - koaGuard({ body: z.object({ password: z.string().regex(passwordRegEx) }) }), - async (ctx, next) => { - const { password } = ctx.guard.body; - - const verificationStorage = await getVerificationStorageFromInteraction( - ctx, - provider, - forgotPasswordSessionResultGuard - ); - - const type = 'ForgotPasswordReset'; - ctx.log(type, verificationStorage); - - const { userId, expiresAt } = verificationStorage; - - checkValidateExpiration(expiresAt); - - const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(userId); - - assertThat( - !oldPasswordEncrypted || !(await argon2Verify({ password, hash: oldPasswordEncrypted })), - new RequestError({ code: 'user.same_password', status: 422 }) - ); - - const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password); - - ctx.log(type, { userId }); - - await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod }); - await clearVerificationResult(ctx, provider); - ctx.status = 204; - - return next(); - } - ); -} diff --git a/packages/core/src/routes/session/index.test.ts b/packages/core/src/routes/session/index.test.ts deleted file mode 100644 index 222351e92..000000000 --- a/packages/core/src/routes/session/index.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { User } from '@logto/schemas'; -import { adminConsoleApplicationId } from '@logto/schemas'; -import Provider from 'oidc-provider'; - -import { mockUser } from '#src/__mocks__/index.js'; -import { createRequester } from '#src/utils/test-utils.js'; - -import sessionRoutes from './index.js'; - -const findUserById = jest.fn(async (): Promise => mockUser); -const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); - -const grantSave = jest.fn(async () => 'finalGrantId'); -const grantAddOIDCScope = jest.fn(); -const grantAddResourceScope = jest.fn(); -const interactionResult = jest.fn(async () => 'redirectTo'); -const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({})); - -jest.mock('#src/queries/user.js', () => ({ - findUserById: async () => findUserById(), - updateUserById: async (...args: unknown[]) => updateUserById(...args), -})); - -class Grant { - static async find(id: string) { - return id === 'exists' ? new Grant() : undefined; - } - - save: typeof grantSave; - addOIDCScope: typeof grantAddOIDCScope; - addResourceScope: typeof grantAddResourceScope; - - constructor() { - this.save = grantSave; - this.addOIDCScope = grantAddOIDCScope; - this.addResourceScope = grantAddResourceScope; - } -} - -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - Grant, - interactionDetails, - interactionResult, - })), -})); - -afterEach(() => { - grantSave.mockClear(); - interactionResult.mockClear(); -}); - -describe('sessionRoutes', () => { - const sessionRequest = createRequester({ - // @ts-expect-error will remove once interaction refactor finished - anonymousRoutes: sessionRoutes, - provider: new Provider(''), - middlewares: [ - async (ctx, next) => { - ctx.addLogContext = jest.fn(); - ctx.log = jest.fn(); - - return next(); - }, - ], - }); - - describe('POST /session', () => { - it('should redirect to /session/consent with consent prompt name', async () => { - interactionDetails.mockResolvedValueOnce({ - prompt: { name: 'consent' }, - }); - const response = await sessionRequest.post('/session'); - - expect(response.statusCode).toEqual(200); - expect(response.body).toHaveProperty( - 'redirectTo', - expect.stringContaining('/session/consent') - ); - }); - - it('throw error with other prompt name', async () => { - interactionDetails.mockResolvedValueOnce({ - prompt: { name: 'invalid' }, - }); - await expect(sessionRequest.post('/session').send({})).resolves.toHaveProperty('status', 400); - }); - }); - - describe('POST /session/consent', () => { - describe('should call grant.save() and assign interaction results', () => { - afterEach(() => { - updateUserById.mockClear(); - }); - - it('with empty details and reusing old grant', async () => { - interactionDetails.mockResolvedValueOnce({ - session: { accountId: 'accountId' }, - params: { client_id: 'clientId' }, - prompt: { details: {} }, - }); - const response = await sessionRequest.post('/session/consent'); - expect(response.statusCode).toEqual(200); - expect(grantSave).toHaveBeenCalled(); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - consent: { grantId: 'finalGrantId' }, - }), - expect.anything() - ); - }); - it('with empty details and creating new grant', async () => { - interactionDetails.mockResolvedValueOnce({ - session: { accountId: 'accountId' }, - params: { client_id: 'clientId' }, - prompt: { details: {} }, - grantId: 'exists', - }); - const response = await sessionRequest.post('/session/consent'); - expect(response.statusCode).toEqual(200); - expect(grantSave).toHaveBeenCalled(); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - consent: { grantId: 'finalGrantId' }, - }), - expect.anything() - ); - }); - - it('should save application id when the user first consented', async () => { - interactionDetails.mockResolvedValueOnce({ - session: { accountId: mockUser.id }, - params: { client_id: 'clientId' }, - prompt: { - name: 'consent', - details: {}, - reasons: ['consent_prompt', 'native_client_prompt'], - }, - grantId: 'grantId', - }); - - findUserById.mockImplementationOnce(async () => ({ ...mockUser, applicationId: null })); - - const response = await sessionRequest.post('/session/consent'); - - expect(updateUserById).toHaveBeenCalledWith(mockUser.id, { applicationId: 'clientId' }); - expect(response.statusCode).toEqual(200); - }); - - it('missingOIDCScope and missingResourceScopes', async () => { - interactionDetails.mockResolvedValueOnce({ - session: { accountId: 'accountId' }, - params: { client_id: 'clientId' }, - prompt: { - details: { - missingOIDCScope: ['scope1', 'scope2'], - missingResourceScopes: { - resource1: ['scope1', 'scope2'], - resource2: ['scope3'], - }, - }, - }, - }); - const response = await sessionRequest.post('/session/consent'); - expect(response.statusCode).toEqual(200); - expect(grantAddOIDCScope).toHaveBeenCalledWith('scope1 scope2'); - expect(grantAddResourceScope).toHaveBeenCalledWith('resource1', 'scope1 scope2'); - expect(grantAddResourceScope).toHaveBeenCalledWith('resource2', 'scope3'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - consent: { grantId: 'finalGrantId' }, - }), - expect.anything() - ); - }); - }); - - it('should throw is non-admin user request for AC consent', async () => { - interactionDetails.mockResolvedValueOnce({ - session: { accountId: mockUser.id }, - params: { client_id: adminConsoleApplicationId }, - prompt: { - name: 'consent', - details: {}, - reasons: ['consent_prompt', 'native_client_prompt'], - }, - grantId: 'grantId', - }); - - findUserById.mockImplementationOnce(async () => ({ - ...mockUser, - roleNames: [], - applicationId: null, - })); - - const response = await sessionRequest.post('/session/consent'); - - expect(response.statusCode).toEqual(401); - }); - - it('throws if session is missing', async () => { - interactionDetails.mockResolvedValueOnce({ params: { client_id: 'clientId' } }); - await expect(sessionRequest.post('/session/consent')).resolves.toHaveProperty( - 'statusCode', - 400 - ); - }); - }); - - it('DELETE /session', async () => { - const response = await sessionRequest.delete('/session'); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ error: 'oidc.aborted' }), - expect.anything() - ); - }); -}); diff --git a/packages/core/src/routes/session/index.ts b/packages/core/src/routes/session/index.ts deleted file mode 100644 index 920c2470b..000000000 --- a/packages/core/src/routes/session/index.ts +++ /dev/null @@ -1,108 +0,0 @@ -import path from 'path'; - -import type { LogtoErrorCode } from '@logto/phrases'; -import { UserRole, adminConsoleApplicationId } from '@logto/schemas'; -import { conditional } from '@silverhand/essentials'; -import { object, string } from 'zod'; - -import RequestError from '#src/errors/RequestError/index.js'; -import { assignInteractionResults, saveUserFirstConsentedAppId } from '#src/libraries/session.js'; -import { findUserById } from '#src/queries/user.js'; -import assertThat from '#src/utils/assert-that.js'; - -import type { AnonymousRouterLegacy, RouterInitArgs } from '../types.js'; -import continueRoutes from './continue.js'; -import forgotPasswordRoutes from './forgot-password.js'; -import koaGuardSessionAction from './middleware/koa-guard-session-action.js'; -import passwordRoutes from './password.js'; -import passwordlessRoutes from './passwordless.js'; -import socialRoutes from './social.js'; -import { getRoutePrefix } from './utils.js'; - -export default function sessionRoutes( - ...[router, { provider }]: RouterInitArgs -) { - router.use(getRoutePrefix('sign-in'), koaGuardSessionAction(provider, 'sign-in')); - router.use(getRoutePrefix('register'), koaGuardSessionAction(provider, 'register')); - - router.post('/session', async (ctx, next) => { - const { - prompt: { name }, - } = await provider.interactionDetails(ctx.req, ctx.res); - - if (name === 'consent') { - ctx.body = { redirectTo: path.join(ctx.request.origin, '/session/consent') }; - - return next(); - } - - throw new RequestError('session.unsupported_prompt_name'); - }); - - router.post('/session/consent', async (ctx, next) => { - const interaction = await provider.interactionDetails(ctx.req, ctx.res); - const { - session, - grantId, - params: { client_id }, - prompt, - } = interaction; - assertThat(session, 'session.not_found'); - - const { accountId } = session; - - // Temp solution before migrating to RBAC. Block non-admin user from consenting to admin console - if (String(client_id) === adminConsoleApplicationId) { - const { roleNames } = await findUserById(accountId); - - assertThat( - roleNames.includes(UserRole.Admin), - new RequestError({ code: 'auth.forbidden', status: 401 }) - ); - } - - const grant = - conditional(grantId && (await provider.Grant.find(grantId))) ?? - new provider.Grant({ accountId, clientId: String(client_id) }); - - await saveUserFirstConsentedAppId(accountId, String(client_id)); - - // V2: fulfill missing claims / resources - const PromptDetailsBody = object({ - missingOIDCScope: string().array().optional(), - missingResourceScopes: object({}).catchall(string().array()).optional(), - }); - const { missingOIDCScope, missingResourceScopes } = PromptDetailsBody.parse(prompt.details); - - if (missingOIDCScope) { - grant.addOIDCScope(missingOIDCScope.join(' ')); - } - - if (missingResourceScopes) { - for (const [indicator, scope] of Object.entries(missingResourceScopes)) { - grant.addResourceScope(indicator, scope.join(' ')); - } - } - - const finalGrantId = await grant.save(); - - // V2: configure consent - await assignInteractionResults(ctx, provider, { consent: { grantId: finalGrantId } }, true); - - return next(); - }); - - router.delete('/session', async (ctx, next) => { - await provider.interactionDetails(ctx.req, ctx.res); - const error: LogtoErrorCode = 'oidc.aborted'; - await assignInteractionResults(ctx, provider, { error }); - - return next(); - }); - - passwordRoutes(router, provider); - passwordlessRoutes(router, provider); - socialRoutes(router, provider); - continueRoutes(router, provider); - forgotPasswordRoutes(router, provider); -} diff --git a/packages/core/src/routes/session/middleware/koa-guard-session-action.ts b/packages/core/src/routes/session/middleware/koa-guard-session-action.ts deleted file mode 100644 index cd0bdee6e..000000000 --- a/packages/core/src/routes/session/middleware/koa-guard-session-action.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { SignInMode, adminConsoleApplicationId } from '@logto/schemas'; -import type { MiddlewareType } from 'koa'; -import type Provider from 'oidc-provider'; -import { errors } from 'oidc-provider'; - -import RequestError from '#src/errors/RequestError/index.js'; -import { getApplicationIdFromInteraction } from '#src/libraries/session.js'; -import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js'; -import assertThat from '#src/utils/assert-that.js'; - -export default function koaGuardSessionAction( - provider: Provider, - forType: 'sign-in' | 'register' -): MiddlewareType { - const forbiddenError = new RequestError({ code: 'auth.forbidden', status: 403 }); - - return async (ctx, next) => { - const interaction = await provider - .interactionDetails(ctx.req, ctx.res) - .catch((error: unknown) => { - // Should not block if interaction is not found - if (error instanceof errors.SessionNotFound) { - return null; - } - - throw error; - }); - - /** - * We don't guard admin console in API for now since logically there's no need. - * Update to honor the config if we're implementing per-app SIE. - */ - if (interaction?.params.client_id === adminConsoleApplicationId) { - return next(); - } - - const { signInMode } = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - - if (forType === 'sign-in') { - assertThat(signInMode !== SignInMode.Register, forbiddenError); - } - - if (forType === 'register') { - assertThat(signInMode !== SignInMode.SignIn, forbiddenError); - } - - return next(); - }; -} diff --git a/packages/core/src/routes/session/middleware/passwordless-action.ts b/packages/core/src/routes/session/middleware/passwordless-action.ts deleted file mode 100644 index 6337ead01..000000000 --- a/packages/core/src/routes/session/middleware/passwordless-action.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { VerificationCodeType } from '@logto/connector-kit'; -import { SignInIdentifier } from '@logto/schemas'; -import type { MiddlewareType } from 'koa'; -import type Provider from 'oidc-provider'; - -import RequestError from '#src/errors/RequestError/index.js'; -import { - assignInteractionResults, - getApplicationIdFromInteraction, -} from '#src/libraries/session.js'; -import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js'; -import { generateUserId, insertUser } from '#src/libraries/user.js'; -import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js'; -import { - hasUserWithPhone, - hasUserWithEmail, - findUserByPhone, - findUserByEmail, - updateUserById, -} from '#src/queries/user.js'; -import assertThat from '#src/utils/assert-that.js'; - -import { smsSessionResultGuard, emailSessionResultGuard } from '../types.js'; -import { - getVerificationStorageFromInteraction, - getPasswordlessRelatedLogType, - checkValidateExpiration, - checkRequiredProfile, -} from '../utils.js'; - -export const smsSignInAction = ( - provider: Provider -): MiddlewareType => { - return async (ctx, next) => { - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - assertThat( - signInExperience.signIn.methods.some( - ({ identifier, verificationCode }) => - identifier === SignInIdentifier.Phone && verificationCode - ), - new RequestError({ - code: 'user.sign_in_method_not_enabled', - status: 422, - }) - ); - - const verificationStorage = await getVerificationStorageFromInteraction( - ctx, - provider, - smsSessionResultGuard - ); - - const type = getPasswordlessRelatedLogType(VerificationCodeType.SignIn, 'sms'); - ctx.log(type, verificationStorage); - - const { phone, expiresAt } = verificationStorage; - - checkValidateExpiration(expiresAt); - - const user = await findUserByPhone(phone); - assertThat(user, new RequestError({ code: 'user.phone_not_exist', status: 404 })); - const { id, isSuspended } = user; - assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); - ctx.log(type, { userId: id }); - - await checkRequiredProfile(ctx, provider, user, signInExperience); - await updateUserById(id, { lastSignInAt: Date.now() }); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - - return next(); - }; -}; - -export const emailSignInAction = ( - provider: Provider -): MiddlewareType => { - return async (ctx, next) => { - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - assertThat( - signInExperience.signIn.methods.some( - ({ identifier, verificationCode }) => - identifier === SignInIdentifier.Email && verificationCode - ), - new RequestError({ - code: 'user.sign_in_method_not_enabled', - status: 422, - }) - ); - - const verificationStorage = await getVerificationStorageFromInteraction( - ctx, - provider, - emailSessionResultGuard - ); - - const type = getPasswordlessRelatedLogType(VerificationCodeType.SignIn, 'email'); - ctx.log(type, verificationStorage); - - const { email, expiresAt } = verificationStorage; - - checkValidateExpiration(expiresAt); - - const user = await findUserByEmail(email); - assertThat(user, new RequestError({ code: 'user.email_not_exist', status: 404 })); - const { id, isSuspended } = user; - assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); - ctx.log(type, { userId: id }); - - await checkRequiredProfile(ctx, provider, user, signInExperience); - await updateUserById(id, { lastSignInAt: Date.now() }); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - - return next(); - }; -}; - -export const smsRegisterAction = ( - provider: Provider -): MiddlewareType => { - return async (ctx, next) => { - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - - assertThat( - signInExperience.signUp.identifiers.includes(SignInIdentifier.Phone), - new RequestError({ - code: 'user.sign_up_method_not_enabled', - status: 422, - }) - ); - - const verificationStorage = await getVerificationStorageFromInteraction( - ctx, - provider, - smsSessionResultGuard - ); - - const type = getPasswordlessRelatedLogType(VerificationCodeType.Register, 'sms'); - ctx.log(type, verificationStorage); - - const { phone, expiresAt } = verificationStorage; - - checkValidateExpiration(expiresAt); - - assertThat( - !(await hasUserWithPhone(phone)), - new RequestError({ code: 'user.phone_already_in_use', status: 422 }) - ); - const id = await generateUserId(); - ctx.log(type, { userId: id }); - - const user = await insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() }); - await checkRequiredProfile(ctx, provider, user, signInExperience); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - - return next(); - }; -}; - -export const emailRegisterAction = ( - provider: Provider -): MiddlewareType => { - return async (ctx, next) => { - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - - assertThat( - signInExperience.signUp.identifiers.includes(SignInIdentifier.Email), - new RequestError({ - code: 'user.sign_up_method_not_enabled', - status: 422, - }) - ); - - const verificationStorage = await getVerificationStorageFromInteraction( - ctx, - provider, - emailSessionResultGuard - ); - - const type = getPasswordlessRelatedLogType(VerificationCodeType.Register, 'email'); - ctx.log(type, verificationStorage); - - const { email, expiresAt } = verificationStorage; - - checkValidateExpiration(expiresAt); - - assertThat( - !(await hasUserWithEmail(email)), - new RequestError({ code: 'user.email_already_in_use', status: 422 }) - ); - const id = await generateUserId(); - ctx.log(type, { userId: id }); - - const user = await insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() }); - await checkRequiredProfile(ctx, provider, user, signInExperience); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - - return next(); - }; -}; diff --git a/packages/core/src/routes/session/password.test.ts b/packages/core/src/routes/session/password.test.ts deleted file mode 100644 index 4a0b33008..000000000 --- a/packages/core/src/routes/session/password.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import type { User } from '@logto/schemas'; -import { UserRole, SignInIdentifier, adminConsoleApplicationId } from '@logto/schemas'; -import Provider from 'oidc-provider'; - -import { mockSignInExperience, mockUser } from '#src/__mocks__/index.js'; -import { createRequester } from '#src/utils/test-utils.js'; - -import passwordRoutes, { registerRoute, signInRoute } from './password.js'; - -const insertUser = jest.fn(async (..._args: unknown[]) => mockUser); -const hasUser = jest.fn(async (username: string) => username === 'username1'); -const findUserById = jest.fn(async (): Promise => mockUser); -const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser); -const hasActiveUsers = jest.fn(async () => true); -const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience); - -jest.mock('#src/queries/user.js', () => ({ - findUserById: async () => findUserById(), - findUserByIdentity: async () => ({ id: mockUser.id, identities: {} }), - findUserByPhone: async () => mockUser, - findUserByEmail: async () => mockUser, - updateUserById: async (...args: unknown[]) => updateUserById(...args), - hasUser: async (username: string) => hasUser(username), - hasUserWithIdentity: async (connectorId: string, userId: string) => - connectorId === 'connectorId' && userId === mockUser.id, - hasUserWithPhone: async (phone: string) => phone === '13000000000', - hasUserWithEmail: async (email: string) => email === 'a@a.com', - hasActiveUsers: async () => hasActiveUsers(), - async findUserByUsername(username: string) { - const roleNames = username === 'admin' ? [UserRole.Admin] : []; - - return { ...mockUser, username, roleNames }; - }, -})); - -jest.mock('#src/queries/sign-in-experience.js', () => ({ - findDefaultSignInExperience: async () => findDefaultSignInExperience(), -})); - -jest.mock('#src/libraries/user.js', () => ({ - async verifyUserPassword(user: User) { - return user; - }, - generateUserId: () => 'user1', - encryptUserPassword: (password: string) => ({ - passwordEncrypted: password + '_user1', - passwordEncryptionMethod: 'Argon2i', - }), - updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args), - insertUser: async (...args: unknown[]) => insertUser(...args), -})); - -jest.mock('#src/libraries/session.js', () => ({ - ...jest.requireActual('#src/libraries/session.js'), - getApplicationIdFromInteraction: jest.fn(), -})); - -const grantSave = jest.fn(async () => 'finalGrantId'); -const grantAddOIDCScope = jest.fn(); -const grantAddResourceScope = jest.fn(); -const interactionResult = jest.fn(async () => 'redirectTo'); -const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({})); - -class Grant { - static async find(id: string) { - return id === 'exists' ? new Grant() : undefined; - } - - save: typeof grantSave; - addOIDCScope: typeof grantAddOIDCScope; - addResourceScope: typeof grantAddResourceScope; - - constructor() { - this.save = grantSave; - this.addOIDCScope = grantAddOIDCScope; - this.addResourceScope = grantAddResourceScope; - } -} - -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - Grant, - interactionDetails, - interactionResult, - })), -})); - -afterEach(() => { - grantSave.mockClear(); - interactionResult.mockClear(); -}); - -describe('session -> password routes', () => { - const sessionRequest = createRequester({ - // @ts-expect-error will remove once interaction refactor finished - anonymousRoutes: passwordRoutes, - provider: new Provider(''), - middlewares: [ - async (ctx, next) => { - ctx.addLogContext = jest.fn(); - ctx.log = jest.fn(); - - return next(); - }, - ], - }); - - it('POST /session/sign-in/password/username', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - const response = await sessionRequest.post(`${signInRoute}/username`).send({ - username: 'username', - password: 'password', - }); - expect(response.statusCode).toEqual(200); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: mockUser.id } }), - expect.anything() - ); - }); - - it('POST /session/sign-in/password/email', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - const response = await sessionRequest.post(`${signInRoute}/email`).send({ - email: 'email', - password: 'password', - }); - expect(response.statusCode).toEqual(200); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: mockUser.id } }), - expect.anything() - ); - }); - - it('POST /session/sign-in/password/sms', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - const response = await sessionRequest.post(`${signInRoute}/sms`).send({ - phone: 'phone', - password: 'password', - }); - expect(response.statusCode).toEqual(200); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: mockUser.id } }), - expect.anything() - ); - }); - - describe('POST /session/register/password/username', () => { - it('assign result and redirect', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - - const fakeTime = Date.now(); - jest.useFakeTimers().setSystemTime(fakeTime); - - const response = await sessionRequest - .post(`${registerRoute}/username`) - .send({ username: 'username', password: 'password' }); - expect(insertUser).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'user1', - username: 'username', - passwordEncrypted: 'password_user1', - passwordEncryptionMethod: 'Argon2i', - roleNames: [], - lastSignInAt: fakeTime, - }) - ); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: 'user1' } }), - expect.anything() - ); - jest.useRealTimers(); - }); - - it('register user with admin role for admin console if no active user found', async () => { - interactionDetails.mockResolvedValueOnce({ - params: { client_id: adminConsoleApplicationId }, - }); - - hasActiveUsers.mockResolvedValueOnce(false); - - await sessionRequest - .post(`${registerRoute}/username`) - .send({ username: 'username', password: 'password' }); - - expect(insertUser).toHaveBeenCalledWith( - expect.objectContaining({ - roleNames: ['admin'], - }) - ); - }); - - it('should not register user with admin role for admin console if any active user found', async () => { - interactionDetails.mockResolvedValueOnce({ - params: { client_id: adminConsoleApplicationId }, - }); - - await sessionRequest - .post(`${registerRoute}/username`) - .send({ username: 'username', password: 'password' }); - - expect(insertUser).toHaveBeenCalledWith( - expect.objectContaining({ - roleNames: [], - }) - ); - }); - - it('throw error if username not valid', async () => { - const usernameStartedWithNumber = '1username'; - const response = await sessionRequest - .post(`${registerRoute}/username`) - .send({ username: usernameStartedWithNumber, password: 'password' }); - expect(response.statusCode).toEqual(400); - }); - - it('throw error if username exists', async () => { - const response = await sessionRequest - .post(`${registerRoute}/username`) - .send({ username: 'username1', password: 'password' }); - expect(response.statusCode).toEqual(422); - }); - - it('throws if sign up identifier is not username', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - - findDefaultSignInExperience.mockResolvedValueOnce({ - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Email], - }, - }); - - const response = await sessionRequest - .post(`${registerRoute}/username`) - .send({ username: 'username', password: 'password' }); - expect(response.statusCode).toEqual(422); - }); - }); - - describe('POST /session/register/password/check-username', () => { - it('check and return empty', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - - const response = await sessionRequest - .post(`${registerRoute}/check-username`) - .send({ username: 'username' }); - expect(response.status).toEqual(204); - expect(hasUser).toHaveBeenCalled(); - }); - - it('throw error if username not valid', async () => { - const usernameStartedWithNumber = '1username'; - const response = await sessionRequest - .post(`${registerRoute}/check-username`) - .send({ username: usernameStartedWithNumber, password: 'password' }); - expect(response.statusCode).toEqual(400); - }); - - it('throw error if username exists', async () => { - const response = await sessionRequest - .post(`${registerRoute}/check-username`) - .send({ username: 'username1' }); - expect(response.statusCode).toEqual(422); - }); - - it('throws if sign up identifier is not username', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - - findDefaultSignInExperience.mockResolvedValueOnce({ - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Email], - }, - }); - - const response = await sessionRequest - .post(`${registerRoute}/check-username`) - .send({ username: 'username' }); - expect(response.statusCode).toEqual(422); - }); - }); -}); diff --git a/packages/core/src/routes/session/password.ts b/packages/core/src/routes/session/password.ts deleted file mode 100644 index 48a3b0e4f..000000000 --- a/packages/core/src/routes/session/password.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { passwordRegEx, usernameRegEx } from '@logto/core-kit'; -import { SignInIdentifier, UserRole, adminConsoleApplicationId } from '@logto/schemas'; -import type Provider from 'oidc-provider'; -import { object, string } from 'zod'; - -import RequestError from '#src/errors/RequestError/index.js'; -import { - assignInteractionResults, - getApplicationIdFromInteraction, -} from '#src/libraries/session.js'; -import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js'; -import { encryptUserPassword, generateUserId, insertUser } from '#src/libraries/user.js'; -import koaGuard from '#src/middleware/koa-guard.js'; -import { - findUserByEmail, - findUserByPhone, - findUserByUsername, - hasActiveUsers, - hasUser, -} from '#src/queries/user.js'; -import assertThat from '#src/utils/assert-that.js'; - -import type { AnonymousRouterLegacy } from '../types.js'; -import { checkRequiredProfile, getRoutePrefix, signInWithPassword } from './utils.js'; - -export const registerRoute = getRoutePrefix('register', 'password'); -export const signInRoute = getRoutePrefix('sign-in', 'password'); - -export default function passwordRoutes( - router: T, - provider: Provider -) { - router.post( - `${signInRoute}/username`, - koaGuard({ - body: object({ - username: string().min(1), - password: string().min(1), - }), - }), - async (ctx, next) => { - const { username, password } = ctx.guard.body; - const type = 'SignInUsernamePassword'; - await signInWithPassword(ctx, provider, { - identifier: SignInIdentifier.Username, - password, - logType: type, - logPayload: { username }, - findUser: async () => findUserByUsername(username), - }); - - return next(); - } - ); - - router.post( - `${signInRoute}/email`, - koaGuard({ - body: object({ - email: string().min(1), - password: string().min(1), - }), - }), - async (ctx, next) => { - const { email, password } = ctx.guard.body; - const type = 'SignInEmailPassword'; - await signInWithPassword(ctx, provider, { - identifier: SignInIdentifier.Email, - password, - logType: type, - logPayload: { email }, - findUser: async () => findUserByEmail(email), - }); - - return next(); - } - ); - - router.post( - `${signInRoute}/sms`, - koaGuard({ - body: object({ - phone: string().min(1), - password: string().min(1), - }), - }), - async (ctx, next) => { - const { phone, password } = ctx.guard.body; - const type = 'SignInSmsPassword'; - await signInWithPassword(ctx, provider, { - identifier: SignInIdentifier.Phone, - password, - logType: type, - logPayload: { phone }, - findUser: async () => findUserByPhone(phone), - }); - - return next(); - } - ); - - router.post( - `${registerRoute}/check-username`, - koaGuard({ - body: object({ - username: string().regex(usernameRegEx), - }), - }), - async (ctx, next) => { - const { username } = ctx.guard.body; - - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - assertThat( - signInExperience.signUp.identifiers.includes(SignInIdentifier.Username), - new RequestError({ - code: 'user.sign_up_method_not_enabled', - status: 422, - }) - ); - - assertThat( - !(await hasUser(username)), - new RequestError({ - code: 'user.username_already_in_use', - status: 422, - }) - ); - - ctx.status = 204; - - return next(); - } - ); - - router.post( - `${registerRoute}/username`, - koaGuard({ - body: object({ - username: string().regex(usernameRegEx), - password: string().regex(passwordRegEx), - }), - }), - async (ctx, next) => { - const { username, password } = ctx.guard.body; - const type = 'RegisterUsernamePassword'; - ctx.log(type, { username }); - - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - assertThat( - signInExperience.signUp.identifiers.includes(SignInIdentifier.Username), - new RequestError({ - code: 'user.sign_up_method_not_enabled', - status: 422, - }) - ); - - assertThat( - !(await hasUser(username)), - new RequestError({ - code: 'user.username_already_in_use', - status: 422, - }) - ); - - const { - params: { client_id }, - } = await provider.interactionDetails(ctx.req, ctx.res); - - const createAdminUser = - String(client_id) === adminConsoleApplicationId && !(await hasActiveUsers()); - const roleNames = createAdminUser ? [UserRole.Admin] : []; - - const id = await generateUserId(); - - ctx.log(type, { userId: id, roleNames }); - - const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password); - - const user = await insertUser({ - id, - username, - passwordEncrypted, - passwordEncryptionMethod, - roleNames, - lastSignInAt: Date.now(), - }); - await checkRequiredProfile(ctx, provider, user, signInExperience); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - - return next(); - } - ); -} diff --git a/packages/core/src/routes/session/passwordless.test.ts b/packages/core/src/routes/session/passwordless.test.ts deleted file mode 100644 index e8ec5e4c0..000000000 --- a/packages/core/src/routes/session/passwordless.test.ts +++ /dev/null @@ -1,964 +0,0 @@ -/* eslint-disable max-lines */ -import { VerificationCodeType } from '@logto/connector-kit'; -import type { User } from '@logto/schemas'; -import { SignInIdentifier } from '@logto/schemas'; -import type { Nullable } from '@silverhand/essentials'; -import { addDays, addSeconds, subDays } from 'date-fns'; -import Provider from 'oidc-provider'; - -import { mockSignInExperience, mockSignInMethod, mockUser } from '#src/__mocks__/index.js'; -import RequestError from '#src/errors/RequestError/index.js'; -import { createRequester } from '#src/utils/test-utils.js'; - -import { verificationTimeout } from '../consts.js'; -import * as passwordlessActions from './middleware/passwordless-action.js'; -import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless.js'; - -const insertUser = jest.fn(async (..._args: unknown[]) => mockUser); -const findUserById = jest.fn(async (): Promise => mockUser); -const findUserByEmail = jest.fn(async (): Promise> => mockUser); -const findUserByPhone = jest.fn(async (): Promise> => mockUser); -const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser); -const findDefaultSignInExperience = jest.fn(async () => ({ - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Username], - password: false, - verify: true, - }, -})); -const getTomorrowIsoString = () => addDays(Date.now(), 1).toISOString(); - -jest.mock('#src/libraries/user.js', () => ({ - generateUserId: () => 'user1', - insertUser: async (...args: unknown[]) => insertUser(...args), -})); - -jest.mock('#src/libraries/session.js', () => ({ - ...jest.requireActual('#src/libraries/session.js'), - getApplicationIdFromInteraction: jest.fn(), -})); - -jest.mock('#src/queries/user.js', () => ({ - findUserById: async () => findUserById(), - findUserByPhone: async () => findUserByPhone(), - findUserByEmail: async () => findUserByEmail(), - updateUserById: async (...args: unknown[]) => updateUserById(...args), - hasUser: async (username: string) => username === 'username1', - hasUserWithPhone: async (phone: string) => phone === '13000000000', - hasUserWithEmail: async (email: string) => email === 'a@a.com', -})); - -jest.mock('#src/queries/sign-in-experience.js', () => ({ - findDefaultSignInExperience: async () => findDefaultSignInExperience(), -})); -const smsSignInActionSpy = jest.spyOn(passwordlessActions, 'smsSignInAction'); -const emailSignInActionSpy = jest.spyOn(passwordlessActions, 'emailSignInAction'); -const smsRegisterActionSpy = jest.spyOn(passwordlessActions, 'smsRegisterAction'); -const emailRegisterActionSpy = jest.spyOn(passwordlessActions, 'emailRegisterAction'); - -const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } })); -const createPasscode = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); -jest.mock('#src/libraries/passcode.js', () => ({ - createPasscode: async (..._args: unknown[]) => createPasscode(..._args), - sendPasscode: async () => sendPasscode(), - verifyPasscode: async (_a: unknown, _b: unknown, code: string) => { - if (code !== '1234') { - throw new RequestError('verification_code.code_mismatch'); - } - }, -})); - -const interactionResult = jest.fn(async () => 'redirectTo'); -const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({})); - -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - interactionDetails, - interactionResult, - })), -})); - -afterEach(() => { - interactionResult.mockClear(); -}); - -describe('session -> passwordlessRoutes', () => { - const sessionRequest = createRequester({ - // @ts-expect-error will remove once interaction refactor finished - anonymousRoutes: passwordlessRoutes, - provider: new Provider(''), - middlewares: [ - async (ctx, next) => { - ctx.addLogContext = jest.fn(); - ctx.log = jest.fn(); - - return next(); - }, - ], - }); - - describe('POST /session/passwordless/sms/send', () => { - beforeEach(() => { - interactionDetails.mockResolvedValueOnce({ - jti: 'jti', - }); - }); - it('should call sendPasscode (with flow `sign-in`)', async () => { - const response = await sessionRequest - .post('/session/passwordless/sms/send') - .send({ phone: '13000000000', flow: VerificationCodeType.SignIn }); - expect(response.statusCode).toEqual(204); - expect(createPasscode).toHaveBeenCalledWith('jti', VerificationCodeType.SignIn, { - phone: '13000000000', - }); - expect(sendPasscode).toHaveBeenCalled(); - }); - it('should call sendPasscode (with flow `register`)', async () => { - const response = await sessionRequest - .post('/session/passwordless/sms/send') - .send({ phone: '13000000000', flow: VerificationCodeType.Register }); - expect(response.statusCode).toEqual(204); - expect(createPasscode).toHaveBeenCalledWith('jti', VerificationCodeType.Register, { - phone: '13000000000', - }); - expect(sendPasscode).toHaveBeenCalled(); - }); - it('should call sendPasscode (with flow `forgot-password`)', async () => { - const response = await sessionRequest - .post('/session/passwordless/sms/send') - .send({ phone: '13000000000', flow: VerificationCodeType.ForgotPassword }); - expect(response.statusCode).toEqual(204); - expect(createPasscode).toHaveBeenCalledWith('jti', VerificationCodeType.ForgotPassword, { - phone: '13000000000', - }); - expect(sendPasscode).toHaveBeenCalled(); - }); - it('throw when phone not given in input params', async () => { - const response = await sessionRequest - .post('/session/passwordless/sms/send') - .send({ flow: VerificationCodeType.Register }); - expect(response.statusCode).toEqual(400); - }); - }); - - describe('POST /session/passwordless/email/send', () => { - beforeEach(() => { - interactionDetails.mockResolvedValueOnce({ - jti: 'jti', - }); - }); - it('should call sendPasscode (with flow `sign-in`)', async () => { - const response = await sessionRequest - .post('/session/passwordless/email/send') - .send({ email: 'a@a.com', flow: VerificationCodeType.SignIn }); - expect(response.statusCode).toEqual(204); - expect(createPasscode).toHaveBeenCalledWith('jti', VerificationCodeType.SignIn, { - email: 'a@a.com', - }); - expect(sendPasscode).toHaveBeenCalled(); - }); - it('should call sendPasscode (with flow `register`)', async () => { - const response = await sessionRequest - .post('/session/passwordless/email/send') - .send({ email: 'a@a.com', flow: VerificationCodeType.Register }); - expect(response.statusCode).toEqual(204); - expect(createPasscode).toHaveBeenCalledWith('jti', VerificationCodeType.Register, { - email: 'a@a.com', - }); - expect(sendPasscode).toHaveBeenCalled(); - }); - it('should call sendPasscode (with flow `forgot-password`)', async () => { - const response = await sessionRequest - .post('/session/passwordless/email/send') - .send({ email: 'a@a.com', flow: VerificationCodeType.ForgotPassword }); - expect(response.statusCode).toEqual(204); - expect(createPasscode).toHaveBeenCalledWith('jti', VerificationCodeType.ForgotPassword, { - email: 'a@a.com', - }); - expect(sendPasscode).toHaveBeenCalled(); - }); - it('throw when email not given in input params', async () => { - const response = await sessionRequest - .post('/session/passwordless/email/send') - .send({ flow: VerificationCodeType.Register }); - expect(response.statusCode).toEqual(400); - }); - }); - - describe('POST /session/passwordless/sms/verify', () => { - beforeEach(() => { - interactionDetails.mockResolvedValueOnce({ - jti: 'jti', - }); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should call interactionResult (with flow `sign-in`)', async () => { - const fakeTime = new Date(); - jest.useFakeTimers().setSystemTime(fakeTime); - - await sessionRequest - .post('/session/passwordless/sms/verify') - .send({ phone: '13000000000', code: '1234', flow: VerificationCodeType.SignIn }); - - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - verification: { - flow: VerificationCodeType.SignIn, - phone: '13000000000', - expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(), - }, - }) - ); - - // Should call sign-in with sms properly - expect(smsSignInActionSpy).toBeCalled(); - }); - - it('should call interactionResult (with flow `register`)', async () => { - const fakeTime = new Date(); - jest.useFakeTimers().setSystemTime(fakeTime); - - await sessionRequest - .post('/session/passwordless/sms/verify') - .send({ phone: '13000000000', code: '1234', flow: VerificationCodeType.Register }); - - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - verification: { - flow: VerificationCodeType.Register, - phone: '13000000000', - expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(), - }, - }) - ); - - expect(smsRegisterActionSpy).toBeCalled(); - }); - - it('should call interactionResult (with flow `forgot-password`)', async () => { - const fakeTime = new Date(); - jest.useFakeTimers().setSystemTime(fakeTime); - - const response = await sessionRequest - .post('/session/passwordless/sms/verify') - .send({ phone: '13000000000', code: '1234', flow: VerificationCodeType.ForgotPassword }); - - expect(response.statusCode).toEqual(204); - - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - verification: { - userId: mockUser.id, - expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(), - flow: VerificationCodeType.ForgotPassword, - }, - }) - ); - }); - - it('throw 404 (with flow `forgot-password`)', async () => { - findUserByPhone.mockResolvedValueOnce(null); - const response = await sessionRequest - .post('/session/passwordless/sms/verify') - .send({ phone: '13000000001', code: '1234', flow: VerificationCodeType.ForgotPassword }); - expect(response.statusCode).toEqual(404); - expect(interactionResult).toHaveBeenCalledTimes(0); - }); - - it('throw when code is wrong', async () => { - const response = await sessionRequest - .post('/session/passwordless/sms/verify') - .send({ phone: '13000000000', code: '1231', flow: VerificationCodeType.SignIn }); - expect(response.statusCode).toEqual(400); - }); - }); - - describe('POST /session/passwordless/email/verify', () => { - beforeEach(() => { - interactionDetails.mockResolvedValueOnce({ - jti: 'jti', - }); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should call interactionResult (with flow `sign-in`)', async () => { - const fakeTime = new Date(); - jest.useFakeTimers().setSystemTime(fakeTime); - - await sessionRequest - .post('/session/passwordless/email/verify') - .send({ email: 'a@a.com', code: '1234', flow: VerificationCodeType.SignIn }); - - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - verification: { - flow: VerificationCodeType.SignIn, - email: 'a@a.com', - expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(), - }, - }) - ); - - expect(emailSignInActionSpy).toBeCalled(); - }); - - it('should call interactionResult (with flow `register`)', async () => { - const fakeTime = new Date(); - jest.useFakeTimers().setSystemTime(fakeTime); - - await sessionRequest - .post('/session/passwordless/email/verify') - .send({ email: 'a@a.com', code: '1234', flow: VerificationCodeType.Register }); - - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - verification: { - flow: VerificationCodeType.Register, - email: 'a@a.com', - expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(), - }, - }) - ); - - expect(emailRegisterActionSpy).toBeCalled(); - }); - - it('should call interactionResult (with flow `forgot-password`)', async () => { - const fakeTime = new Date(); - jest.useFakeTimers().setSystemTime(fakeTime); - - const response = await sessionRequest - .post('/session/passwordless/email/verify') - .send({ email: 'a@a.com', code: '1234', flow: VerificationCodeType.ForgotPassword }); - - expect(response.statusCode).toEqual(204); - - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - verification: { - userId: mockUser.id, - expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(), - flow: VerificationCodeType.ForgotPassword, - }, - }) - ); - }); - - it('throw 404 (with flow `forgot-password`)', async () => { - const fakeTime = new Date(); - jest.useFakeTimers().setSystemTime(fakeTime); - findUserByEmail.mockResolvedValueOnce(null); - const response = await sessionRequest - .post('/session/passwordless/email/verify') - .send({ email: 'b@a.com', code: '1234', flow: VerificationCodeType.ForgotPassword }); - expect(response.statusCode).toEqual(404); - expect(interactionResult).toHaveBeenCalledTimes(0); - }); - - it('throw when code is wrong', async () => { - const response = await sessionRequest - .post('/session/passwordless/email/verify') - .send({ email: 'a@a.com', code: '1231', flow: VerificationCodeType.SignIn }); - expect(response.statusCode).toEqual(400); - }); - }); - - describe('POST /session/sign-in/passwordless/sms', () => { - it('should call interactionResult (with flow `sign-in`)', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - phone: '13000000000', - flow: VerificationCodeType.SignIn, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${signInRoute}/sms`); - - expect(response.statusCode).toEqual(200); - - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - login: { accountId: mockUser.id }, - }), - expect.anything() - ); - }); - - it('should call interactionResult (with flow `register`)', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - phone: '13000000000', - flow: VerificationCodeType.Register, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${signInRoute}/sms`); - expect(response.statusCode).toEqual(200); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - login: { accountId: mockUser.id }, - }), - expect.anything() - ); - }); - - it('throw when verification session invalid', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - phone: '13000000000', - expiresAt: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${signInRoute}/sms`); - expect(response.statusCode).toEqual(404); - }); - - it('throw when flow is not `sign-in` and `register`', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - phone: '13000000000', - flow: VerificationCodeType.ForgotPassword, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${signInRoute}/sms`); - expect(response.statusCode).toEqual(404); - }); - - it('throw when expiresAt is not valid ISO date string', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - phone: '13000000000', - flow: VerificationCodeType.SignIn, - expiresAt: 'invalid date string', - }, - }, - }); - const response = await sessionRequest.post(`${signInRoute}/sms`); - expect(response.statusCode).toEqual(401); - }); - - it('throw when validation expired', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - phone: '13000000000', - flow: VerificationCodeType.SignIn, - expiresAt: subDays(Date.now(), 1).toISOString(), - }, - }, - }); - const response = await sessionRequest.post(`${signInRoute}/sms`); - expect(response.statusCode).toEqual(401); - }); - - it('throw when phone not exist', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - email: 'XX@foo', - flow: VerificationCodeType.SignIn, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - 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: VerificationCodeType.SignIn, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - findUserByPhone.mockResolvedValueOnce(null); - const response = await sessionRequest.post(`${signInRoute}/sms`); - expect(response.statusCode).toEqual(404); - }); - - it('throw when user is suspended', async () => { - findUserByPhone.mockResolvedValueOnce({ - ...mockUser, - isSuspended: true, - }); - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - phone: '13000000000', - flow: VerificationCodeType.SignIn, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${signInRoute}/sms`); - expect(response.statusCode).toEqual(401); - }); - - it('throw error if sign in method is not enabled', async () => { - findDefaultSignInExperience.mockResolvedValueOnce({ - ...mockSignInExperience, - signIn: { - methods: [ - { - ...mockSignInMethod, - identifier: SignInIdentifier.Username, - }, - ], - }, - }); - const response = await sessionRequest.post(`${signInRoute}/sms`); - expect(response.statusCode).toEqual(422); - }); - }); - - describe('POST /session/sign-in/passwordless/email', () => { - beforeEach(() => { - findDefaultSignInExperience.mockResolvedValue({ - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Email], - password: false, - verify: true, - }, - }); - }); - - afterEach(() => { - findDefaultSignInExperience.mockClear(); - }); - - it('should call interactionResult (with flow `sign-in`)', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - email: 'a@a.com', - flow: VerificationCodeType.SignIn, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - - const response = await sessionRequest.post(`${signInRoute}/email`); - - expect(response.statusCode).toEqual(200); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - login: { accountId: mockUser.id }, - }), - expect.anything() - ); - }); - - it('should call interactionResult (with flow `register`)', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - email: 'a@a.com', - flow: VerificationCodeType.Register, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - - const response = await sessionRequest.post(`${signInRoute}/email`); - - expect(response.statusCode).toEqual(200); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - login: { accountId: mockUser.id }, - }), - expect.anything() - ); - }); - - it('throw when verification session invalid', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - email: 'a@a.com', - expiresAt: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${signInRoute}/email`); - expect(response.statusCode).toEqual(404); - }); - - it('throw when flow is not `sign-in` and `register`', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - email: 'a@a.com', - flow: VerificationCodeType.ForgotPassword, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${signInRoute}/email`); - expect(response.statusCode).toEqual(404); - }); - - it('throw when email not exist', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - flow: VerificationCodeType.SignIn, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - 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: VerificationCodeType.SignIn, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - findUserByEmail.mockResolvedValueOnce(null); - const response = await sessionRequest.post(`${signInRoute}/email`); - expect(response.statusCode).toEqual(404); - }); - - it('throw when user is suspended', async () => { - findUserByEmail.mockResolvedValueOnce({ - ...mockUser, - isSuspended: true, - }); - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - email: 'a@a.com', - flow: VerificationCodeType.SignIn, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${signInRoute}/email`); - expect(response.statusCode).toEqual(401); - }); - - it('throw error if sign in method is not enabled', async () => { - findDefaultSignInExperience.mockResolvedValueOnce({ - ...mockSignInExperience, - signIn: { - methods: [ - { - ...mockSignInMethod, - identifier: SignInIdentifier.Username, - }, - ], - }, - }); - const response = await sessionRequest.post(`${signInRoute}/email`); - expect(response.statusCode).toEqual(422); - }); - }); - - describe('POST /session/register/passwordless/sms', () => { - beforeAll(() => { - findDefaultSignInExperience.mockResolvedValue({ - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Phone], - password: false, - }, - }); - }); - - afterAll(() => { - findDefaultSignInExperience.mockClear(); - }); - - it('should call interactionResult (with flow `register`)', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - phone: '13000000001', - flow: VerificationCodeType.Register, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - 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('should call interactionResult (with flow `sign-in`)', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - phone: '13000000001', - flow: VerificationCodeType.SignIn, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - 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: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${registerRoute}/sms`); - expect(response.statusCode).toEqual(404); - }); - - it('throw when flow is not `register` and `sign-in`', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - phone: '13000000001', - flow: VerificationCodeType.ForgotPassword, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${registerRoute}/sms`); - expect(response.statusCode).toEqual(404); - }); - - it('throw when phone not exist', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - flow: VerificationCodeType.Register, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - 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: VerificationCodeType.Register, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${registerRoute}/sms`); - expect(response.statusCode).toEqual(422); - }); - - it('throws if sign up identifier does not contain phone', async () => { - findDefaultSignInExperience.mockResolvedValueOnce({ - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Email], - }, - }); - - const response = await sessionRequest.post(`${registerRoute}/sms`); - expect(response.statusCode).toEqual(422); - }); - }); - - describe('POST /session/register/passwordless/email', () => { - beforeAll(() => { - findDefaultSignInExperience.mockResolvedValue({ - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Email], - password: false, - }, - }); - }); - - afterAll(() => { - findDefaultSignInExperience.mockClear(); - }); - - it('should call interactionResult (with flow `register`)', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - email: 'b@a.com', - flow: VerificationCodeType.Register, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - 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('should call interactionResult (with flow `sign-in`)', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - email: 'b@a.com', - flow: VerificationCodeType.SignIn, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - 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: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${registerRoute}/email`); - expect(response.statusCode).toEqual(404); - }); - - it('throw when flow is not `register` and `sign-in`', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - email: 'b@a.com', - flow: VerificationCodeType.ForgotPassword, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${registerRoute}/email`); - expect(response.statusCode).toEqual(404); - }); - - it('throw when email not exist', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - verification: { - flow: VerificationCodeType.Register, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - 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: VerificationCodeType.Register, - expiresAt: getTomorrowIsoString(), - }, - }, - }); - const response = await sessionRequest.post(`${registerRoute}/email`); - expect(response.statusCode).toEqual(422); - }); - - it('throws if sign up identifier does not contain email', async () => { - findDefaultSignInExperience.mockResolvedValueOnce({ - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Phone], - }, - }); - - const response = await sessionRequest.post(`${registerRoute}/email`); - expect(response.statusCode).toEqual(422); - }); - }); -}); -/* eslint-enable max-lines */ diff --git a/packages/core/src/routes/session/passwordless.ts b/packages/core/src/routes/session/passwordless.ts deleted file mode 100644 index b317b3814..000000000 --- a/packages/core/src/routes/session/passwordless.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { VerificationCodeType } from '@logto/connector-kit'; -import { emailRegEx, phoneRegEx } from '@logto/core-kit'; -import type Provider from 'oidc-provider'; -import { object, string } from 'zod'; - -import RequestError from '#src/errors/RequestError/index.js'; -import { createPasscode, sendPasscode, verifyPasscode } from '#src/libraries/passcode.js'; -import koaGuard from '#src/middleware/koa-guard.js'; -import { findUserByEmail, findUserByPhone } from '#src/queries/user.js'; -import assertThat from '#src/utils/assert-that.js'; - -import type { AnonymousRouterLegacy } from '../types.js'; -import { - smsSignInAction, - emailSignInAction, - smsRegisterAction, - emailRegisterAction, -} from './middleware/passwordless-action.js'; -import { flowTypeGuard } from './types.js'; -import { - assignVerificationResult, - getPasswordlessRelatedLogType, - getRoutePrefix, -} from './utils.js'; - -export const registerRoute = getRoutePrefix('register', 'passwordless'); -export const signInRoute = getRoutePrefix('sign-in', 'passwordless'); - -export default function passwordlessRoutes( - router: T, - provider: Provider -) { - router.post( - '/session/passwordless/sms/send', - koaGuard({ - body: object({ - phone: string().regex(phoneRegEx), - flow: flowTypeGuard, - }), - }), - async (ctx, next) => { - const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - const { - body: { phone, flow }, - } = ctx.guard; - - const type = getPasswordlessRelatedLogType(flow, 'sms', 'send'); - ctx.log(type, { phone }); - - const passcode = await createPasscode(jti, flow, { phone }); - const { dbEntry } = await sendPasscode(passcode); - ctx.log(type, { connectorId: dbEntry.id }); - ctx.status = 204; - - return next(); - } - ); - - router.post( - '/session/passwordless/email/send', - koaGuard({ - body: object({ - email: string().regex(emailRegEx), - flow: flowTypeGuard, - }), - }), - async (ctx, next) => { - const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - const { - body: { email, flow }, - } = ctx.guard; - - const type = getPasswordlessRelatedLogType(flow, 'email', 'send'); - ctx.log(type, { email }); - - const passcode = await createPasscode(jti, flow, { 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 }); - - await verifyPasscode(jti, flow, code, { phone }); - - if (flow === VerificationCodeType.ForgotPassword) { - const user = await findUserByPhone(phone); - assertThat(user, new RequestError({ code: 'user.phone_not_exist', status: 404 })); - - await assignVerificationResult(ctx, provider, { flow, userId: user.id }); - ctx.status = 204; - - return next(); - } - - if (flow === VerificationCodeType.SignIn) { - await assignVerificationResult(ctx, provider, { flow, phone }); - - return smsSignInAction(provider)(ctx, next); - } - - if (flow === VerificationCodeType.Register) { - await assignVerificationResult(ctx, provider, { flow, phone }); - - return smsRegisterAction(provider)(ctx, next); - } - - await assignVerificationResult(ctx, provider, { flow, 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 }); - - await verifyPasscode(jti, flow, code, { email }); - - if (flow === VerificationCodeType.ForgotPassword) { - const user = await findUserByEmail(email); - - assertThat(user, new RequestError({ code: 'user.email_not_exist', status: 404 })); - - await assignVerificationResult(ctx, provider, { flow, userId: user.id }); - ctx.status = 204; - - return next(); - } - - if (flow === VerificationCodeType.SignIn) { - await assignVerificationResult(ctx, provider, { flow, email }); - - return emailSignInAction(provider)(ctx, next); - } - - if (flow === VerificationCodeType.Register) { - await assignVerificationResult(ctx, provider, { flow, email }); - - return emailRegisterAction(provider)(ctx, next); - } - - await assignVerificationResult(ctx, provider, { flow, email }); - ctx.status = 204; - - return next(); - } - ); - - router.post(`${signInRoute}/sms`, smsSignInAction(provider)); - - router.post(`${signInRoute}/email`, emailSignInAction(provider)); - - router.post(`${registerRoute}/sms`, smsRegisterAction(provider)); - - router.post(`${registerRoute}/email`, emailRegisterAction(provider)); -} diff --git a/packages/core/src/routes/session/social.bind-social.test.ts b/packages/core/src/routes/session/social.bind-social.test.ts deleted file mode 100644 index e9756918c..000000000 --- a/packages/core/src/routes/session/social.bind-social.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { ConnectorType } from '@logto/connector-kit'; -import type { User } from '@logto/schemas'; -import Provider from 'oidc-provider'; - -import { mockLogtoConnectorList, mockSignInExperience, mockUser } from '#src/__mocks__/index.js'; -import RequestError from '#src/errors/RequestError/index.js'; -import { getLogtoConnectorById } from '#src/libraries/connector.js'; -import { createRequester } from '#src/utils/test-utils.js'; - -import socialRoutes, { registerRoute } from './social.js'; - -const findSocialRelatedUser = jest.fn(async () => [ - 'phone', - { id: 'user1', identities: {}, isSuspended: false }, -]); -jest.mock('#src/libraries/social.js', () => ({ - ...jest.requireActual('#src/libraries/social.js'), - findSocialRelatedUser: async () => findSocialRelatedUser(), - async getUserInfoByAuthCode(connectorId: string, data: { code: string }) { - if (connectorId === '_connectorId') { - throw new RequestError({ - code: 'session.invalid_connector_id', - status: 422, - connectorId, - }); - } - - if (data.code === '123456') { - return { id: mockUser.id }; - } - - // This mocks the case that can not get userInfo with access token and auth code - // (most likely third-party social connectors' problem). - throw new Error(' '); - }, -})); -const insertUser = jest.fn(async (..._args: unknown[]) => mockUser); -const findUserById = jest.fn(async (): Promise => mockUser); -const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser); -const findUserByIdentity = jest.fn(async () => mockUser); - -jest.mock('#src/queries/user.js', () => ({ - findUserById: async () => findUserById(), - findUserByIdentity: async () => findUserByIdentity(), - updateUserById: async (...args: unknown[]) => updateUserById(...args), - hasUserWithIdentity: async (target: string, userId: string) => - target === 'connectorTarget' && userId === mockUser.id, -})); - -jest.mock('#src/libraries/user.js', () => ({ - generateUserId: () => 'user1', - insertUser: async (...args: unknown[]) => insertUser(...args), -})); - -jest.mock('#src/queries/sign-in-experience.js', () => ({ - findDefaultSignInExperience: async () => ({ - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [], - }, - }), -})); - -const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => { - const database = { - enabled: connectorId === 'social_enabled', - }; - const metadata = { - id: - connectorId === 'social_enabled' - ? 'social_enabled' - : connectorId === 'social_disabled' - ? 'social_disabled' - : 'others', - }; - - return { - dbEntry: database, - metadata, - type: connectorId.startsWith('social') ? ConnectorType.Social : ConnectorType.Sms, - getAuthorizationUri: jest.fn(async () => ''), - }; -}); - -jest.mock('#src/libraries/connector.js', () => ({ - getLogtoConnectors: jest.fn(async () => mockLogtoConnectorList), - getLogtoConnectorById: jest.fn(async (connectorId: string) => { - const connector = await getLogtoConnectorByIdHelper(connectorId); - - if (connector.type !== ConnectorType.Social) { - throw new RequestError({ - code: 'entity.not_found', - status: 404, - }); - } - - return connector; - }), -})); - -const interactionResult = jest.fn(async () => 'redirectTo'); -const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({})); - -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - interactionDetails, - interactionResult, - })), -})); - -afterEach(() => { - interactionResult.mockClear(); -}); - -describe('session -> socialRoutes', () => { - const sessionRequest = createRequester({ - // @ts-expect-error will remove once interaction refactor finished - anonymousRoutes: socialRoutes, - provider: new Provider(''), - middlewares: [ - async (ctx, next) => { - ctx.addLogContext = jest.fn(); - ctx.log = jest.fn(); - - return next(); - }, - ], - }); - - describe('POST /session/register/social', () => { - beforeEach(() => { - const mockGetLogtoConnectorById = getLogtoConnectorById as jest.Mock; - mockGetLogtoConnectorById.mockResolvedValueOnce({ - metadata: { target: 'connectorTarget' }, - }); - }); - - it('register with social, assign result and redirect', async () => { - interactionDetails.mockResolvedValueOnce({ - jti: 'jti', - result: { - socialUserInfo: { connectorId: 'connectorId', userInfo: { id: 'user1' } }, - }, - }); - const response = await sessionRequest - .post(`${registerRoute}`) - .send({ connectorId: 'connectorId' }); - expect(insertUser).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'user1', - identities: { connectorTarget: { userId: 'user1', details: { id: 'user1' } } }, - }) - ); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: 'user1' } }), - expect.anything() - ); - }); - - it('throw error if no result can be found in interactionResults', async () => { - interactionDetails.mockResolvedValueOnce({}); - const response = await sessionRequest - .post(`${registerRoute}`) - .send({ connectorId: 'connectorId' }); - expect(response.statusCode).toEqual(400); - }); - - it('throw error if result parsing fails', async () => { - interactionDetails.mockResolvedValueOnce({ result: { login: { accountId: mockUser.id } } }); - const response = await sessionRequest - .post(`${registerRoute}`) - .send({ connectorId: 'connectorId' }); - expect(response.statusCode).toEqual(400); - }); - - it('throw error when user with identity exists', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - login: { accountId: 'user1' }, - socialUserInfo: { connectorId: 'connectorId', userInfo: { id: mockUser.id } }, - }, - }); - const response = await sessionRequest - .post(`${registerRoute}`) - .send({ connectorId: 'connectorId' }); - expect(response.statusCode).toEqual(400); - }); - }); -}); diff --git a/packages/core/src/routes/session/social.test.ts b/packages/core/src/routes/session/social.test.ts deleted file mode 100644 index 7a6cf5c73..000000000 --- a/packages/core/src/routes/session/social.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { ConnectorType } from '@logto/connector-kit'; -import type { SocialUserInfo } from '@logto/connector-kit'; -import type { User } from '@logto/schemas'; -import Provider from 'oidc-provider'; - -import { mockSignInExperience, mockUser } from '#src/__mocks__/index.js'; -import RequestError from '#src/errors/RequestError/index.js'; -import { getLogtoConnectorById } from '#src/libraries/connector.js'; -import { createRequester } from '#src/utils/test-utils.js'; - -import socialRoutes, { signInRoute } from './social.js'; - -const findSocialRelatedUser = jest.fn(async () => [ - 'phone', - { id: 'user1', identities: {}, isSuspended: false }, -]); - -const getUserInfoByAuthCode = jest.fn( - async (connectorId: string, data: { code: string }): Promise => { - if (connectorId === '_connectorId') { - throw new RequestError({ - code: 'session.invalid_connector_id', - status: 422, - connectorId, - }); - } - - if (data.code === '123456') { - return { id: mockUser.id }; - } - - // This mocks the case that can not get userInfo with access token and auth code - // (most likely third-party social connectors' problem). - throw new Error(' '); - } -); - -jest.mock('#src/libraries/social.js', () => ({ - ...jest.requireActual('#src/libraries/social.js'), - findSocialRelatedUser: async () => findSocialRelatedUser(), - getUserInfoByAuthCode: async (connectorId: string, data: { code: string }) => - getUserInfoByAuthCode(connectorId, data), -})); -const insertUser = jest.fn(async (..._args: unknown[]) => mockUser); -const findUserById = jest.fn(async (): Promise => mockUser); -const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser); -const findUserByIdentity = jest.fn().mockResolvedValue(mockUser); - -jest.mock('#src/queries/user.js', () => ({ - findUserById: async () => findUserById(), - findUserByIdentity: async () => findUserByIdentity(), - updateUserById: async (...args: unknown[]) => updateUserById(...args), - hasUserWithIdentity: async (target: string, userId: string) => - target === 'connectorTarget' && userId === mockUser.id, -})); - -jest.mock('#src/libraries/user.js', () => ({ - generateUserId: () => 'user1', - insertUser: async (...args: unknown[]) => insertUser(...args), -})); - -jest.mock('#src/queries/sign-in-experience.js', () => ({ - findDefaultSignInExperience: async () => ({ - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [], - }, - }), -})); - -const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => { - const database = { - enabled: connectorId === 'social_enabled', - }; - const metadata = { - id: - connectorId === 'social_enabled' - ? 'social_enabled' - : connectorId === 'social_disabled' - ? 'social_disabled' - : 'others', - }; - - return { - dbEntry: database, - metadata, - type: connectorId.startsWith('social') ? ConnectorType.Social : ConnectorType.Sms, - getAuthorizationUri: jest.fn(async () => ''), - }; -}); - -jest.mock('#src/libraries/connector.js', () => ({ - getLogtoConnectorById: jest.fn(async (connectorId: string) => { - const connector = await getLogtoConnectorByIdHelper(connectorId); - - if (connector.type !== ConnectorType.Social) { - throw new RequestError({ - code: 'entity.not_found', - status: 404, - }); - } - - return connector; - }), -})); - -const interactionResult = jest.fn(async () => 'redirectTo'); -const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({})); - -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - interactionDetails, - interactionResult, - })), -})); - -afterEach(() => { - interactionResult.mockClear(); -}); - -describe('session -> socialRoutes', () => { - const sessionRequest = createRequester({ - // @ts-expect-error will remove once interaction refactor finished - anonymousRoutes: socialRoutes, - provider: new Provider(''), - middlewares: [ - async (ctx, next) => { - ctx.addLogContext = jest.fn(); - ctx.log = jest.fn(); - - return next(); - }, - ], - }); - - describe('POST /session/sign-in/social', () => { - it('should throw when redirectURI is invalid', async () => { - const response = await sessionRequest.post(`${signInRoute}`).send({ - connectorId: 'social_enabled', - state: 'state', - redirectUri: 'logto.dev', - }); - expect(response.statusCode).toEqual(400); - }); - - it('sign-in with social and redirect', async () => { - const response = await sessionRequest.post(`${signInRoute}`).send({ - connectorId: 'social_enabled', - state: 'state', - redirectUri: 'https://logto.dev', - }); - expect(response.body).toHaveProperty('redirectTo', ''); - }); - - it('throw error when sign-in with social but miss state', async () => { - const response = await sessionRequest.post(`${signInRoute}`).send({ - connectorId: 'social_enabled', - redirectUri: 'https://logto.dev', - }); - expect(response.statusCode).toEqual(400); - }); - - it('throw error when sign-in with social but miss redirectUri', async () => { - const response = await sessionRequest.post(`${signInRoute}`).send({ - connectorId: 'social_enabled', - state: 'state', - }); - expect(response.statusCode).toEqual(400); - }); - - it('throw error when no social connector is found', async () => { - const response = await sessionRequest.post(`${signInRoute}`).send({ - connectorId: 'others', - state: 'state', - redirectUri: 'https://logto.dev', - }); - expect(response.statusCode).toEqual(404); - }); - }); - - describe('POST /session/sign-in/social/auth', () => { - const connectorTarget = 'connectorTarget'; - afterEach(() => { - jest.clearAllMocks(); - }); - - it('throw error when auth code is wrong', async () => { - (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ - metadata: { target: connectorTarget }, - dbEntry: { syncProfile: false }, - }); - const response = await sessionRequest.post(`${signInRoute}/auth`).send({ - connectorId: 'connectorId', - state: 'state', - redirectUri: 'https://logto.dev', - code: '123455', - }); - expect(response.statusCode).toEqual(500); - }); - - it('throw error when code is provided but connector can not be found', async () => { - (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ - metadata: { target: connectorTarget }, - dbEntry: { syncProfile: false }, - }); - const response = await sessionRequest.post(`${signInRoute}/auth`).send({ - connectorId: '_connectorId', - state: 'state', - redirectUri: 'https://logto.dev', - code: '123456', - }); - expect(response.statusCode).toEqual(422); - }); - - it('get and add user info with auth code, as well as assign result and redirect', async () => { - (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ - metadata: { target: connectorTarget }, - dbEntry: { syncProfile: false }, - }); - const response = await sessionRequest.post(`${signInRoute}/auth`).send({ - connectorId: 'connectorId', - data: { - state: 'state', - redirectUri: 'https://logto.dev', - code: '123456', - }, - }); - expect(updateUserById).toHaveBeenCalledWith( - mockUser.id, - expect.objectContaining({ - identities: { - ...mockUser.identities, - connectorTarget: { userId: mockUser.id, details: { id: mockUser.id } }, - }, - }) - ); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: mockUser.id } }), - expect.anything() - ); - }); - - it('throw error when user is suspended', async () => { - (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ - metadata: { target: connectorTarget }, - dbEntry: { syncProfile: false }, - }); - findUserByIdentity.mockResolvedValueOnce({ - ...mockUser, - isSuspended: true, - }); - const response = await sessionRequest.post(`${signInRoute}/auth`).send({ - connectorId: 'connectorId', - data: { - state: 'state', - redirectUri: 'https://logto.dev', - code: '123456', - }, - }); - expect(response.statusCode).toEqual(401); - }); - - it('throw error when identity exists', async () => { - findUserByIdentity.mockResolvedValueOnce(null); - (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ - metadata: { target: connectorTarget }, - dbEntry: { syncProfile: false }, - }); - const response = await sessionRequest.post(`${signInRoute}/auth`).send({ - connectorId: '_connectorId_', - data: { - state: 'state', - redirectUri: 'https://logto.dev', - code: '123456', - }, - }); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - socialUserInfo: { connectorId: '_connectorId_', userInfo: { id: mockUser.id } }, - }), - expect.anything() - ); - expect(response.statusCode).toEqual(422); - }); - - it('should update `name` and `avatar` if exists when `syncProfile` is set to be true', async () => { - (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ - metadata: { target: connectorTarget }, - dbEntry: { syncProfile: true }, - }); - findUserByIdentity.mockResolvedValueOnce(mockUser); - getUserInfoByAuthCode.mockResolvedValueOnce({ - ...mockUser, - name: 'new_name', - avatar: 'new_avatar', - }); - await sessionRequest.post(`${signInRoute}/auth`).send({ - connectorId: 'connectorId', - data: { - state: 'state', - redirectUri: 'https://logto.dev', - code: '123456', - }, - }); - expect(updateUserById).toHaveBeenCalledWith( - mockUser.id, - expect.objectContaining({ name: 'new_name', avatar: 'new_avatar' }) - ); - }); - - it('should not update `name` and `avatar` if exists when `syncProfile` is set to be false', async () => { - (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ - metadata: { target: connectorTarget }, - dbEntry: { syncProfile: true }, - }); - findUserByIdentity.mockResolvedValueOnce(mockUser); - getUserInfoByAuthCode.mockResolvedValueOnce({ - ...mockUser, - name: 'new_name', - avatar: 'new_avatar', - }); - await sessionRequest.post(`${signInRoute}/auth`).send({ - connectorId: 'connectorId', - data: { - state: 'state', - redirectUri: 'https://logto.dev', - code: '123456', - }, - }); - expect(updateUserById).not.toHaveBeenCalledWith(mockUser.id, { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - identities: expect.anything(), - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - lastSignInAt: expect.anything(), - }); - }); - }); - - describe('POST /session/sign-in/bind-social-related-user', () => { - beforeEach(() => { - const mockGetLogtoConnectorById = getLogtoConnectorById as jest.Mock; - mockGetLogtoConnectorById.mockResolvedValueOnce({ - metadata: { target: 'connectorTarget' }, - }); - }); - it('throw if session is not authorized', async () => { - await expect( - sessionRequest - .post('/session/sign-in/bind-social-related-user') - .send({ connectorId: 'connectorId' }) - ).resolves.toHaveProperty('statusCode', 400); - }); - it('throw if no social info in session', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { login: { accountId: 'user1' } }, - }); - await expect( - sessionRequest - .post('/session/sign-in/bind-social-related-user') - .send({ connectorId: 'connectorId' }) - ).resolves.toHaveProperty('statusCode', 400); - }); - it('throw error when user is suspended', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - login: { accountId: 'user1' }, - socialUserInfo: { - connectorId: 'connectorId', - userInfo: { id: 'connectorUser', phone: 'phone' }, - }, - }, - }); - findSocialRelatedUser.mockResolvedValueOnce([ - 'phone', - { - ...mockUser, - isSuspended: true, - }, - ]); - const response = await sessionRequest.post('/session/sign-in/bind-social-related-user').send({ - connectorId: 'connectorId', - }); - expect(response.statusCode).toEqual(401); - }); - it('updates user identities and sign in', async () => { - interactionDetails.mockResolvedValueOnce({ - result: { - login: { accountId: 'user1' }, - socialUserInfo: { - connectorId: 'connectorId', - userInfo: { id: 'connectorUser', phone: 'phone' }, - }, - }, - }); - const response = await sessionRequest.post('/session/sign-in/bind-social-related-user').send({ - connectorId: 'connectorId', - }); - expect(response.statusCode).toEqual(200); - expect(updateUserById).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - identities: { - connectorTarget: { - details: { id: 'connectorUser', phone: 'phone' }, - userId: 'connectorUser', - }, - }, - }) - ); - expect(response.body).toHaveProperty('redirectTo'); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: 'user1' } }), - expect.anything() - ); - }); - }); -}); diff --git a/packages/core/src/routes/session/social.ts b/packages/core/src/routes/session/social.ts deleted file mode 100644 index d9569ca76..000000000 --- a/packages/core/src/routes/session/social.ts +++ /dev/null @@ -1,275 +0,0 @@ -import type { ConnectorSession } from '@logto/connector-kit'; -import { validateRedirectUrl } from '@logto/core-kit'; -import { ConnectorType, userInfoSelectFields } from '@logto/schemas'; -import { conditional, pick } from '@silverhand/essentials'; -import type Provider from 'oidc-provider'; -import { object, string, unknown } from 'zod'; - -import RequestError from '#src/errors/RequestError/index.js'; -import { getLogtoConnectorById } from '#src/libraries/connector.js'; -import { - assignInteractionResults, - getApplicationIdFromInteraction, -} from '#src/libraries/session.js'; -import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js'; -import { - findSocialRelatedUser, - getUserInfoByAuthCode, - getUserInfoFromInteractionResult, -} from '#src/libraries/social.js'; -import { generateUserId, insertUser } from '#src/libraries/user.js'; -import koaGuard from '#src/middleware/koa-guard.js'; -import { - hasUserWithIdentity, - findUserById, - updateUserById, - findUserByIdentity, -} from '#src/queries/user.js'; -import { - assignConnectorSessionResult, - getConnectorSessionResult, -} from '#src/routes/interaction/utils/interaction.js'; -import assertThat from '#src/utils/assert-that.js'; -import { maskUserInfo } from '#src/utils/format.js'; - -import type { AnonymousRouterLegacy } from '../types.js'; -import { checkRequiredProfile, getRoutePrefix } from './utils.js'; - -export const registerRoute = getRoutePrefix('register', 'social'); -export const signInRoute = getRoutePrefix('sign-in', 'social'); - -export default function socialRoutes( - router: T, - provider: Provider -) { - router.post( - `${signInRoute}`, - koaGuard({ - body: object({ - connectorId: string(), - state: string(), - redirectUri: string().refine((url) => validateRedirectUrl(url, 'web')), - }), - }), - async (ctx, next) => { - const { - headers: { 'user-agent': userAgent }, - } = ctx.request; - await provider.interactionDetails(ctx.req, ctx.res); - const { connectorId, state, redirectUri } = ctx.guard.body; - assertThat(state && redirectUri, 'session.insufficient_info'); - const connector = await getLogtoConnectorById(connectorId); - assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type'); - const redirectTo = await connector.getAuthorizationUri( - { state, redirectUri, headers: { userAgent } }, - async (connectorStorage: ConnectorSession) => - assignConnectorSessionResult(ctx, provider, connectorStorage) - ); - ctx.body = { redirectTo }; - - return next(); - } - ); - - router.post( - `${signInRoute}/auth`, - koaGuard({ - body: object({ - connectorId: string(), - data: unknown(), - }), - }), - async (ctx, next) => { - await provider.interactionDetails(ctx.req, ctx.res); - - const { connectorId, data } = ctx.guard.body; - const type = 'SignInSocial'; - ctx.log(type, { connectorId, data }); - const { - metadata: { target }, - dbEntry: { syncProfile }, - } = await getLogtoConnectorById(connectorId); - - const userInfo = await getUserInfoByAuthCode(connectorId, data, async () => - getConnectorSessionResult(ctx, provider) - ); - ctx.log(type, { userInfo }); - - const user = await findUserByIdentity(target, userInfo.id); - - // User with identity not found - if (!user) { - await assignInteractionResults( - ctx, - provider, - { socialUserInfo: { connectorId, userInfo } }, - true - ); - const relatedInfo = await findSocialRelatedUser(userInfo); - - throw new RequestError( - { - code: 'user.identity_not_exist', - status: 422, - }, - relatedInfo && { relatedUser: maskUserInfo(relatedInfo[0]) } - ); - } - - const { id, identities, isSuspended } = user; - assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); - ctx.log(type, { userId: id }); - - const { name, avatar } = userInfo; - const profileUpdate = Object.fromEntries( - Object.entries({ - name: conditional(syncProfile && name), - avatar: conditional(syncProfile && avatar), - }).filter(([_key, value]) => value !== undefined) - ); - - // Update social connector's user info - await updateUserById(id, { - identities: { ...identities, [target]: { userId: userInfo.id, details: userInfo } }, - lastSignInAt: Date.now(), - ...profileUpdate, - }); - - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - await checkRequiredProfile(ctx, provider, user, signInExperience); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - - return next(); - } - ); - - router.post( - '/session/sign-in/bind-social-related-user', - koaGuard({ - body: object({ connectorId: string() }), - }), - async (ctx, next) => { - const { result } = await provider.interactionDetails(ctx.req, ctx.res); - assertThat(result, 'session.connector_session_not_found'); - - const { connectorId } = ctx.guard.body; - const type = 'SignInSocialBind'; - ctx.log(type, { connectorId }); - const { - metadata: { target }, - } = await getLogtoConnectorById(connectorId); - - const userInfo = await getUserInfoFromInteractionResult(connectorId, result); - ctx.log(type, { userInfo }); - - const relatedInfo = await findSocialRelatedUser(userInfo); - assertThat(relatedInfo, 'session.connector_session_not_found'); - - const { id, identities, isSuspended } = relatedInfo[1]; - assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); - ctx.log(type, { userId: id }); - - const user = await updateUserById(id, { - identities: { ...identities, [target]: { userId: userInfo.id, details: userInfo } }, - lastSignInAt: Date.now(), - }); - - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - await checkRequiredProfile(ctx, provider, user, signInExperience); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - - return next(); - } - ); - - router.post( - registerRoute, - koaGuard({ - body: object({ - connectorId: string(), - }), - }), - async (ctx, next) => { - const { result } = await provider.interactionDetails(ctx.req, ctx.res); - // User can not register with social directly, - // need to try to sign in with social first, then confirm to register and continue, - // so the result is expected to be exists. - assertThat(result, 'session.connector_session_not_found'); - - const { connectorId } = ctx.guard.body; - const type = 'RegisterSocial'; - ctx.log(type, { connectorId }); - const { - metadata: { target }, - } = await getLogtoConnectorById(connectorId); - - const userInfo = await getUserInfoFromInteractionResult(connectorId, result); - ctx.log(type, { userInfo }); - assertThat(!(await hasUserWithIdentity(target, userInfo.id)), 'user.identity_already_in_use'); - - const id = await generateUserId(); - const user = await insertUser({ - id, - name: userInfo.name ?? null, - avatar: userInfo.avatar ?? null, - identities: { - [target]: { - userId: userInfo.id, - details: userInfo, - }, - }, - lastSignInAt: Date.now(), - }); - ctx.log(type, { userId: id }); - - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - await checkRequiredProfile(ctx, provider, user, signInExperience); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }); - - return next(); - } - ); - - router.post( - '/session/bind-social', - koaGuard({ - body: object({ - connectorId: string(), - }), - }), - async (ctx, next) => { - const { result } = await provider.interactionDetails(ctx.req, ctx.res); - assertThat(result, 'session.connector_session_not_found'); - const userId = result.login?.accountId; - assertThat(userId, 'session.unauthorized'); - - const { connectorId } = ctx.guard.body; - const type = 'RegisterSocialBind'; - ctx.log(type, { connectorId, userId }); - const { - metadata: { target }, - } = await getLogtoConnectorById(connectorId); - - const userInfo = await getUserInfoFromInteractionResult(connectorId, result); - ctx.log(type, { userInfo }); - - const user = await findUserById(userId); - const updatedUser = await updateUserById(userId, { - identities: { - ...user.identities, - [target]: { userId: userInfo.id, details: userInfo }, - }, - }); - - ctx.body = pick(updatedUser, ...userInfoSelectFields); - - return next(); - } - ); -} diff --git a/packages/core/src/routes/session/types.ts b/packages/core/src/routes/session/types.ts deleted file mode 100644 index 9866f99b7..000000000 --- a/packages/core/src/routes/session/types.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { VerificationCodeType } from '@logto/connector-kit'; -import { pick } from '@silverhand/essentials'; -import { z } from 'zod'; - -export const flowTypeGuard = z.nativeEnum( - pick(VerificationCodeType, 'Continue', 'ForgotPassword', 'Register', 'SignIn') -); - -export const methodGuard = z.enum(['email', 'sms']); - -export type Method = z.infer; - -export const operationGuard = z.enum(['send', 'verify']); - -export type Operation = z.infer; - -const smsSessionStorageGuard = z.object({ - flow: z.literal(VerificationCodeType.SignIn).or(z.literal(VerificationCodeType.Register)), - expiresAt: z.string(), - phone: z.string(), -}); - -export type SmsSessionStorage = z.infer; - -export const smsSessionResultGuard = z.object({ verification: smsSessionStorageGuard }); - -const emailSessionStorageGuard = z.object({ - flow: z.literal(VerificationCodeType.SignIn).or(z.literal(VerificationCodeType.Register)), - expiresAt: z.string(), - email: z.string(), -}); - -export type EmailSessionStorage = z.infer; - -export const emailSessionResultGuard = z.object({ - verification: emailSessionStorageGuard, -}); - -const forgotPasswordSessionStorageGuard = z.object({ - flow: z.literal(VerificationCodeType.ForgotPassword), - expiresAt: z.string(), - userId: z.string(), -}); - -export type ForgotPasswordSessionStorage = z.infer; - -export const forgotPasswordSessionResultGuard = z.object({ - verification: forgotPasswordSessionStorageGuard, -}); - -const continueEmailSessionStorageGuard = z.object({ - flow: z.literal(VerificationCodeType.Continue), - expiresAt: z.string(), - email: z.string(), -}); - -export type ContinueEmailSessionStorage = z.infer; - -export const continueEmailSessionResultGuard = z.object({ - verification: continueEmailSessionStorageGuard, -}); - -const continueSmsSessionStorageGuard = z.object({ - flow: z.literal(VerificationCodeType.Continue), - expiresAt: z.string(), - phone: z.string(), -}); - -export type ContinueSmsSessionStorage = z.infer; - -export const continueSmsSessionResultGuard = z.object({ - verification: continueSmsSessionStorageGuard, -}); - -export type VerificationStorage = - | SmsSessionStorage - | EmailSessionStorage - | ForgotPasswordSessionStorage - | ContinueEmailSessionStorage - | ContinueSmsSessionStorage; - -export type VerificationResult = { verification: T }; - -export const continueSignInStorageGuard = z.object({ - userId: z.string(), - expiresAt: z.string(), -}); - -export type ContinueSignInStorage = z.infer; diff --git a/packages/core/src/routes/session/utils.test.ts b/packages/core/src/routes/session/utils.test.ts deleted file mode 100644 index 7557b16e9..000000000 --- a/packages/core/src/routes/session/utils.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -import type { User } from '@logto/schemas'; -import { UserRole, SignInIdentifier } from '@logto/schemas'; -import type { Nullable } from '@silverhand/essentials'; -import Provider from 'oidc-provider'; - -import { mockSignInExperience, mockSignInMethod, mockUser } from '#src/__mocks__/index.js'; -import RequestError from '#src/errors/RequestError/index.js'; -import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js'; - -import { checkRequiredProfile, signInWithPassword } from './utils.js'; - -const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); -const findUserById = jest.fn(async (): Promise => mockUser); -const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); -const hasActiveUsers = jest.fn(async () => true); -const findDefaultSignInExperience = jest.fn(async () => ({ - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Username], - }, -})); - -jest.mock('#src/queries/user.js', () => ({ - findUserById: async () => findUserById(), - findUserByIdentity: async () => ({ id: 'id', identities: {} }), - findUserByPhone: async () => ({ id: 'id' }), - findUserByEmail: async () => ({ id: 'id' }), - updateUserById: async (...args: unknown[]) => updateUserById(...args), - hasUser: async (username: string) => username === 'username1', - hasUserWithIdentity: async (connectorId: string, userId: string) => - connectorId === 'connectorId' && userId === 'id', - hasUserWithPhone: async (phone: string) => phone === '13000000000', - hasUserWithEmail: async (email: string) => email === 'a@a.com', - hasActiveUsers: async () => hasActiveUsers(), - async findUserByUsername(username: string) { - const roleNames = username === 'admin' ? [UserRole.Admin] : []; - - return { id: 'user1', username, roleNames }; - }, -})); - -jest.mock('#src/queries/sign-in-experience.js', () => ({ - findDefaultSignInExperience: async () => findDefaultSignInExperience(), -})); - -jest.mock('#src/libraries/user.js', () => ({ - async verifyUserPassword(user: Nullable, password: string) { - if (!user) { - throw new RequestError('session.invalid_credentials'); - } - - if (password !== 'password') { - throw new RequestError('session.invalid_credentials'); - } - - return user; - }, - generateUserId: () => 'user1', - encryptUserPassword: (password: string) => ({ - passwordEncrypted: password + '_user1', - passwordEncryptionMethod: 'Argon2i', - }), - updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args), - insertUser: async (...args: unknown[]) => insertUser(...args), -})); - -const grantSave = jest.fn(async () => 'finalGrantId'); -const grantAddOIDCScope = jest.fn(); -const grantAddResourceScope = jest.fn(); -const interactionResult = jest.fn(async () => 'redirectTo'); -const interactionDetails: jest.MockedFunction<() => Promise> = jest.fn(async () => ({})); - -class Grant { - static async find(id: string) { - return id === 'exists' ? new Grant() : undefined; - } - - save: typeof grantSave; - addOIDCScope: typeof grantAddOIDCScope; - addResourceScope: typeof grantAddResourceScope; - - constructor() { - this.save = grantSave; - this.addOIDCScope = grantAddOIDCScope; - this.addResourceScope = grantAddResourceScope; - } -} - -const createContext = () => ({ - ...createMockContext(), - addLogContext: jest.fn(), - log: jest.fn(), -}); - -const createProvider = () => new Provider(''); - -jest.mock('oidc-provider', () => ({ - Provider: jest.fn(() => ({ - Grant, - interactionDetails, - interactionResult, - })), -})); - -afterEach(() => { - grantSave.mockClear(); - interactionResult.mockClear(); -}); - -describe('checkRequiredProfile', () => { - // eslint-disable-next-line @silverhand/fp/no-let - let mockDate: jest.SpyInstance; - const mockedExpiredAt = '2022-02-02'; - beforeEach(() => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - // eslint-disable-next-line @silverhand/fp/no-mutation - mockDate = jest.spyOn(Date.prototype, 'toISOString').mockReturnValue(mockedExpiredAt); - }); - - afterEach(() => { - mockDate.mockRestore(); - }); - - it("throw if password is required but the user's password is not set", async () => { - const user = { - ...mockUser, - passwordEncrypted: null, - passwordEncryptionMethod: null, - identities: {}, - }; - - const signInExperience = { - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - password: true, - }, - }; - - await expect( - checkRequiredProfile(createContext(), createProvider(), user, signInExperience) - ).rejects.toThrowError( - new RequestError({ code: 'user.password_required_in_profile', status: 422 }) - ); - - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } }) - ); - }); - - it("throw if the sign up identifier is ['username'] but the user's username is missing", async () => { - const user = { - ...mockUser, - username: null, - }; - const signInExperience = { - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Username], - password: true, - verify: false, - }, - }; - - await expect( - checkRequiredProfile(createContext(), createProvider(), user, signInExperience) - ).rejects.toThrowError( - new RequestError({ code: 'user.username_required_in_profile', status: 422 }) - ); - - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } }) - ); - }); - - it("throw if the sign up identifier is ['email'] but the user's email is missing", async () => { - const user = { - ...mockUser, - primaryEmail: null, - }; - const signInExperience = { - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Email], - password: true, - verify: true, - }, - }; - - await expect( - checkRequiredProfile(createContext(), createProvider(), user, signInExperience) - ).rejects.toThrowError( - new RequestError({ code: 'user.email_required_in_profile', status: 422 }) - ); - - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } }) - ); - }); - - it("throw if the sign up identifier is ['sms'] but the user's phone is missing", async () => { - const user = { - ...mockUser, - primaryPhone: null, - }; - const signInExperience = { - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Phone], - password: true, - verify: true, - }, - }; - - await expect( - checkRequiredProfile(createContext(), createProvider(), user, signInExperience) - ).rejects.toThrowError( - new RequestError({ code: 'user.phone_required_in_profile', status: 422 }) - ); - - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } }) - ); - }); - - it("throw if the sign up identifier is ['email', 'sms'] but the user's email and phone are missing", async () => { - const user = { - ...mockUser, - primaryEmail: null, - primaryPhone: null, - }; - const signInExperience = { - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone], - password: true, - verify: true, - }, - }; - - await expect( - checkRequiredProfile(createContext(), createProvider(), user, signInExperience) - ).rejects.toThrowError( - new RequestError({ code: 'user.email_or_phone_required_in_profile', status: 422 }) - ); - - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } }) - ); - }); - - it.each([{ primaryEmail: null }, { primaryPhone: null }])( - "check successfully if the sign up identifier is ['email', 'sms'] and the user has an email or phone", - async (userProfile) => { - const user = { - ...mockUser, - ...userProfile, - }; - const signInExperience = { - ...mockSignInExperience, - signUp: { - ...mockSignInExperience.signUp, - identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone], - password: true, - verify: true, - }, - }; - - await expect( - checkRequiredProfile(createContext(), createProvider(), user, signInExperience) - ).resolves.not.toThrow(); - - expect(interactionResult).not.toBeCalled(); - } - ); -}); - -describe('signInWithPassword()', () => { - it('assign result', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - await signInWithPassword(createContext(), createProvider(), { - identifier: SignInIdentifier.Username, - password: 'password', - findUser: jest.fn(async () => mockUser), - logType: 'SignInUsernamePassword', - logPayload: { username: 'username' }, - }); - expect(interactionResult).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ login: { accountId: mockUser.id } }), - expect.anything() - ); - }); - - it('throw if user not found', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - await expect( - signInWithPassword(createContext(), createProvider(), { - identifier: SignInIdentifier.Username, - password: 'password', - findUser: jest.fn(async () => null), - logType: 'SignInUsernamePassword', - logPayload: { username: 'username' }, - }) - ).rejects.toThrowError(new RequestError('session.invalid_credentials')); - }); - - it('throw if user found but wrong password', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - await expect( - signInWithPassword(createContext(), createProvider(), { - identifier: SignInIdentifier.Username, - password: '_password', - findUser: jest.fn(async () => mockUser), - logType: 'SignInUsernamePassword', - logPayload: { username: 'username' }, - }) - ).rejects.toThrowError(new RequestError('session.invalid_credentials')); - }); - - it('throw if user is suspended', async () => { - interactionDetails.mockResolvedValueOnce({ params: {} }); - await expect( - signInWithPassword(createContext(), createProvider(), { - identifier: SignInIdentifier.Username, - password: 'password', - findUser: jest.fn(async () => ({ - ...mockUser, - isSuspended: true, - })), - logType: 'SignInUsernamePassword', - logPayload: { username: 'username' }, - }) - ).rejects.toThrowError(new RequestError('user.suspended')); - }); - - it('throw if sign in method is not enabled', async () => { - findDefaultSignInExperience.mockResolvedValueOnce({ - ...mockSignInExperience, - signIn: { - methods: [ - { - ...mockSignInMethod, - identifier: SignInIdentifier.Phone, - password: false, - }, - ], - }, - }); - interactionDetails.mockResolvedValueOnce({ params: {} }); - await expect( - signInWithPassword(createContext(), createProvider(), { - identifier: SignInIdentifier.Username, - password: 'password', - findUser: jest.fn(async () => mockUser), - logType: 'SignInUsernamePassword', - logPayload: { username: 'username' }, - }) - ).rejects.toThrowError( - new RequestError({ - code: 'user.sign_in_method_not_enabled', - status: 422, - }) - ); - }); -}); diff --git a/packages/core/src/routes/session/utils.ts b/packages/core/src/routes/session/utils.ts deleted file mode 100644 index 9ffd1c393..000000000 --- a/packages/core/src/routes/session/utils.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { VerificationCodeType } from '@logto/connector-kit'; -import type { SignInExperience, User } from '@logto/schemas'; -import { SignInIdentifier } from '@logto/schemas'; -import type { LogPayload, LogType } from '@logto/schemas/lib/types/log-legacy.js'; -import { logTypeGuard } from '@logto/schemas/lib/types/log-legacy.js'; -import type { Nullable, Truthy } from '@silverhand/essentials'; -import { isSameArray } from '@silverhand/essentials'; -import { addSeconds, isAfter, isValid } from 'date-fns'; -import type { Context } from 'koa'; -import type Provider from 'oidc-provider'; -import type { ZodType } from 'zod'; -import { z } from 'zod'; - -import RequestError from '#src/errors/RequestError/index.js'; -import { - assignInteractionResults, - getApplicationIdFromInteraction, -} from '#src/libraries/session.js'; -import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js'; -import { verifyUserPassword } from '#src/libraries/user.js'; -import type { LogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js'; -import { updateUserById } from '#src/queries/user.js'; -import assertThat from '#src/utils/assert-that.js'; - -import { continueSignInTimeout, verificationTimeout } from '../consts.js'; -import type { Method, Operation, VerificationResult, VerificationStorage } from './types.js'; -import { continueSignInStorageGuard } from './types.js'; - -export const getRoutePrefix = ( - type: 'sign-in' | 'register' | 'forgot-password', - method?: 'passwordless' | 'password' | 'social' | 'continue' -) => { - return ['session', type, method] - .filter((value): value is Truthy => value !== undefined) - .map((value) => '/' + value) - .join(''); -}; - -export const getPasswordlessRelatedLogType = ( - flow: VerificationCodeType, - method: Method, - operation?: Operation -): LogType => { - const body = method === 'email' ? 'Email' : 'Sms'; - const suffix = operation === 'send' ? 'SendPasscode' : ''; - - const result = logTypeGuard.safeParse(flow + body + suffix); - assertThat(result.success, new RequestError('log.invalid_type')); - - return result.data; -}; - -export const getVerificationStorageFromInteraction = async ( - ctx: Context, - provider: Provider, - resultGuard: ZodType> -): Promise => { - const { result } = await provider.interactionDetails(ctx.req, ctx.res); - - const verificationResult = resultGuard.safeParse(result); - - if (!verificationResult.success) { - throw new RequestError( - { - code: 'session.verification_session_not_found', - status: 404, - }, - verificationResult.error - ); - } - - return verificationResult.data.verification; -}; - -export const checkValidateExpiration = (expiresAt: string) => { - const parsed = new Date(expiresAt); - assertThat( - isValid(parsed) && isAfter(parsed, Date.now()), - new RequestError({ code: 'session.verification_expired', status: 401 }) - ); -}; - -type DistributiveOmit = T extends unknown ? Omit : never; - -export const assignVerificationResult = async ( - ctx: Context, - provider: Provider, - verificationData: DistributiveOmit -) => { - const verification: VerificationStorage = { - ...verificationData, - expiresAt: addSeconds(Date.now(), verificationTimeout).toISOString(), - }; - - const details = await provider.interactionDetails(ctx.req, ctx.res); - await provider.interactionResult(ctx.req, ctx.res, { - ...details.result, - verification, - }); -}; - -export const clearVerificationResult = async (ctx: Context, provider: Provider) => { - const { result } = await provider.interactionDetails(ctx.req, ctx.res); - - const verificationGuard = z.object({ verification: z.unknown() }); - const verificationGuardResult = verificationGuard.safeParse(result); - - if (result && verificationGuardResult.success) { - const { verification, ...rest } = result; - await provider.interactionResult(ctx.req, ctx.res, rest); - } -}; - -export const assignContinueSignInResult = async ( - ctx: Context, - provider: Provider, - payload: { userId: string } -) => { - const details = await provider.interactionDetails(ctx.req, ctx.res); - await provider.interactionResult(ctx.req, ctx.res, { - ...details.result, - continueSignIn: { - ...payload, - expiresAt: addSeconds(Date.now(), continueSignInTimeout).toISOString(), - }, - }); -}; - -export const getContinueSignInResult = async ( - ctx: Context, - provider: Provider -): Promise<{ userId: string }> => { - const { result } = await provider.interactionDetails(ctx.req, ctx.res); - - const signInResult = z - .object({ - continueSignIn: continueSignInStorageGuard, - }) - .safeParse(result); - - if (!signInResult.success) { - throw new RequestError({ - code: 'session.unauthorized', - status: 401, - }); - } - - const { expiresAt, ...rest } = signInResult.data.continueSignIn; - - const parsed = new Date(expiresAt); - assertThat( - isValid(parsed) && isAfter(parsed, Date.now()), - new RequestError({ code: 'session.unauthorized', status: 401 }) - ); - - return rest; -}; - -export const isUserPasswordSet = ({ - passwordEncrypted, - identities, -}: Pick): boolean => { - return Boolean(passwordEncrypted) || Object.keys(identities).length > 0; -}; - -/* eslint-disable complexity */ -export const checkRequiredProfile = async ( - ctx: Context, - provider: Provider, - user: User, - signInExperience: SignInExperience -) => { - const { signUp } = signInExperience; - const { id, username, primaryEmail, primaryPhone } = user; - - // If check failed, save the sign in result, the user can continue after requirements are meet - - if (signUp.password && !isUserPasswordSet(user)) { - await assignContinueSignInResult(ctx, provider, { userId: id }); - throw new RequestError({ code: 'user.password_required_in_profile', status: 422 }); - } - - if (isSameArray(signUp.identifiers, [SignInIdentifier.Username]) && !username) { - await assignContinueSignInResult(ctx, provider, { userId: id }); - throw new RequestError({ code: 'user.username_required_in_profile', status: 422 }); - } - - if (isSameArray(signUp.identifiers, [SignInIdentifier.Email]) && !primaryEmail) { - await assignContinueSignInResult(ctx, provider, { userId: id }); - throw new RequestError({ code: 'user.email_required_in_profile', status: 422 }); - } - - if (isSameArray(signUp.identifiers, [SignInIdentifier.Phone]) && !primaryPhone) { - await assignContinueSignInResult(ctx, provider, { userId: id }); - throw new RequestError({ code: 'user.phone_required_in_profile', status: 422 }); - } - - if ( - isSameArray(signUp.identifiers, [SignInIdentifier.Email, SignInIdentifier.Phone]) && - !primaryEmail && - !primaryPhone - ) { - await assignContinueSignInResult(ctx, provider, { userId: id }); - throw new RequestError({ code: 'user.email_or_phone_required_in_profile', status: 422 }); - } -}; -/* eslint-enable complexity */ - -type SignInWithPasswordParameter = { - identifier: SignInIdentifier; - password: string; - logType: LogType; - logPayload: LogPayload; - findUser: () => Promise>; -}; - -export const signInWithPassword = async ( - ctx: Context & LogContextLegacy, - provider: Provider, - { identifier, findUser, password, logType, logPayload }: SignInWithPasswordParameter -) => { - const signInExperience = await getSignInExperienceForApplication( - await getApplicationIdFromInteraction(ctx, provider) - ); - assertThat( - signInExperience.signIn.methods.some( - (method) => method.password && method.identifier === identifier - ), - new RequestError({ - code: 'user.sign_in_method_not_enabled', - status: 422, - }) - ); - - await provider.interactionDetails(ctx.req, ctx.res); - ctx.log(logType, logPayload); - - const user = await findUser(); - const verifiedUser = await verifyUserPassword(user, password); - const { id, isSuspended } = verifiedUser; - assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); - - ctx.log(logType, { userId: id }); - await updateUserById(id, { lastSignInAt: Date.now() }); - await checkRequiredProfile(ctx, provider, verifiedUser, signInExperience); - await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true); -}; diff --git a/packages/core/src/routes/types.ts b/packages/core/src/routes/types.ts index 2382e68ab..f65388c96 100644 --- a/packages/core/src/routes/types.ts +++ b/packages/core/src/routes/types.ts @@ -1,7 +1,6 @@ import type { ExtendableContext } from 'koa'; import type Router from 'koa-router'; -import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js'; import type { WithLogContext } from '#src/middleware/koa-audit-log.js'; import type { WithAuthContext } from '#src/middleware/koa-auth.js'; import type { WithI18nContext } from '#src/middleware/koa-i18next.js'; @@ -9,9 +8,6 @@ import type TenantContext from '#src/tenants/TenantContext.js'; export type AnonymousRouter = Router; -/** @deprecated This will be removed soon. Use `kua-log-session.js` instead. */ -export type AnonymousRouterLegacy = Router; - export type AuthedRouter = Router< unknown, WithAuthContext & WithLogContext & WithI18nContext & ExtendableContext diff --git a/packages/ui/src/apis/social.ts b/packages/ui/src/apis/social.ts deleted file mode 100644 index 4ba7d0a6f..000000000 --- a/packages/ui/src/apis/social.ts +++ /dev/null @@ -1,11 +0,0 @@ -import api from './api'; - -export const bindSocialAccount = async (connectorId: string) => { - return api - .post('/api/session/bind-social', { - json: { - connectorId, - }, - }) - .json(); -};