From e27056b3c328d952f4d287e66d80f3200c912417 Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Fri, 6 Sep 2024 12:28:29 +0200 Subject: [PATCH] feat: Reset password (#8962) * wip * more work * wip * more work * wrap up first iteration * work on new approach * more work * move middleware func to route * cleanup * more work * wrap up * more work * fix workflow * minor tweaks * finalize * Use JWT secret instead --- .../http/__tests__/auth/admin/auth.spec.ts | 311 +++++++++++++----- packages/core/core-flows/src/auth/index.ts | 2 + .../src/auth/steps/set-auth-app-metadata.ts | 4 +- .../generate-reset-password-token.ts | 61 ++++ .../core-flows/src/auth/workflows/index.ts | 1 + .../types/src/auth/common/auth-identity.ts | 10 + packages/core/types/src/auth/provider.ts | 4 + packages/core/types/src/auth/service.ts | 19 +- .../utils/src/auth/abstract-auth-provider.ts | 11 +- packages/core/utils/src/core-flows/events.ts | 10 + packages/core/utils/src/user/events.ts | 6 - .../medusa/src/api/admin/users/middlewares.ts | 24 +- .../medusa/src/api/admin/users/validators.ts | 1 + .../[auth_provider]/callback/route.ts | 21 +- .../[auth_provider]/register/route.ts | 12 - .../[auth_provider]/reset-password/route.ts | 31 ++ .../[actor_type]/[auth_provider]/route.ts | 12 - .../[auth_provider]/update/route.ts | 26 ++ packages/medusa/src/api/auth/middlewares.ts | 20 +- .../validate-scope-provider-association.ts | 35 ++ .../src/api/auth/utils/validate-token.ts | 72 ++++ packages/medusa/src/api/utils/unless-path.ts | 2 +- packages/medusa/src/commands/user.ts | 2 +- .../auth-module-service/auth-identity.spec.ts | 12 +- .../auth-module-service/index.spec.ts | 20 +- packages/modules/auth/src/joiner-config.ts | 4 +- .../modules/auth/src/services/auth-module.ts | 37 ++- .../auth/src/services/auth-provider.ts | 11 +- .../auth-emailpass/src/services/emailpass.ts | 46 ++- 29 files changed, 633 insertions(+), 194 deletions(-) create mode 100644 packages/core/core-flows/src/auth/workflows/generate-reset-password-token.ts create mode 100644 packages/core/core-flows/src/auth/workflows/index.ts create mode 100644 packages/medusa/src/api/auth/[actor_type]/[auth_provider]/reset-password/route.ts create mode 100644 packages/medusa/src/api/auth/[actor_type]/[auth_provider]/update/route.ts create mode 100644 packages/medusa/src/api/auth/utils/validate-scope-provider-association.ts create mode 100644 packages/medusa/src/api/auth/utils/validate-token.ts diff --git a/integration-tests/http/__tests__/auth/admin/auth.spec.ts b/integration-tests/http/__tests__/auth/admin/auth.spec.ts index 67d3d7846d..49cc8d8223 100644 --- a/integration-tests/http/__tests__/auth/admin/auth.spec.ts +++ b/integration-tests/http/__tests__/auth/admin/auth.spec.ts @@ -1,3 +1,4 @@ +import { generateResetPasswordTokenWorkflow } from "@medusajs/core-flows" import { medusaIntegrationTestRunner } from "medusa-test-utils" import { adminHeaders, @@ -8,120 +9,250 @@ jest.setTimeout(30000) medusaIntegrationTestRunner({ testSuite: ({ dbConnection, getContainer, api }) => { + let container beforeEach(async () => { - await createAdminUser(dbConnection, adminHeaders, getContainer()) + container = getContainer() + await createAdminUser(dbConnection, adminHeaders, container) }) - it("Invite + registration + authentication flow", async () => { - // Create invite - const { token: inviteToken } = ( - await api.post( - "/admin/invites", - { email: "newadmin@medusa.js" }, - adminHeaders - ) - ).data.invite + afterEach(() => { + jest.useRealTimers() + }) - // Register identity - const signup = await api.post("/auth/user/emailpass/register", { - email: "newadmin@medusa.js", - password: "secret_password", - }) + describe("Full authentication lifecycle", () => { + it("Invite + registration + authentication flow", async () => { + // Create invite + const { token: inviteToken } = ( + await api.post( + "/admin/invites", + { email: "newadmin@medusa.js" }, + adminHeaders + ) + ).data.invite - expect(signup.status).toEqual(200) - expect(signup.data).toEqual({ token: expect.any(String) }) - - // Accept invite - const response = await api.post( - `/admin/invites/accept?token=${inviteToken}`, - { + // Register identity + const signup = await api.post("/auth/user/emailpass/register", { email: "newadmin@medusa.js", - first_name: "John", - last_name: "Doe", - }, - { - headers: { - authorization: `Bearer ${signup.data.token}`, + password: "secret_password", + }) + + expect(signup.status).toEqual(200) + expect(signup.data).toEqual({ token: expect.any(String) }) + + // Accept invite + const response = await api.post( + `/admin/invites/accept?token=${inviteToken}`, + { + email: "newadmin@medusa.js", + first_name: "John", + last_name: "Doe", }, - } - ) + { + headers: { + authorization: `Bearer ${signup.data.token}`, + }, + } + ) - expect(response.status).toEqual(200) - expect(response.data).toEqual({ - user: expect.objectContaining({ + expect(response.status).toEqual(200) + expect(response.data).toEqual({ + user: expect.objectContaining({ + email: "newadmin@medusa.js", + first_name: "John", + last_name: "Doe", + }), + }) + + // Sign in + const login = await api.post("/auth/user/emailpass", { email: "newadmin@medusa.js", - first_name: "John", - last_name: "Doe", - }), + password: "secret_password", + }) + expect(login.status).toEqual(200) + expect(login.data).toEqual({ token: expect.any(String) }) + + // Convert token to session + const createSession = await api.post( + "/auth/session", + {}, + { headers: { authorization: `Bearer ${login.data.token}` } } + ) + expect(createSession.status).toEqual(200) + + // Extract cookie + const [cookie] = createSession.headers["set-cookie"][0].split(";") + expect(cookie).toEqual(expect.stringContaining("connect.sid")) + + const cookieHeader = { + headers: { Cookie: cookie }, + } + + // Perform cookie authenticated request + const authedRequest = await api.get( + "/admin/products?limit=1", + cookieHeader + ) + expect(authedRequest.status).toEqual(200) + + // Sign out + const signOutRequest = await api.delete("/auth/session", cookieHeader) + expect(signOutRequest.status).toEqual(200) + + // Attempt to perform authenticated request + const unAuthedRequest = await api + .get("/admin/products?limit=1", cookieHeader) + .catch((e) => e) + + expect(unAuthedRequest.response.status).toEqual(401) }) - // Sign in - const login = await api.post("/auth/user/emailpass", { - email: "newadmin@medusa.js", - password: "secret_password", + it("should respond with 401 on register, if email already exists", async () => { + const signup = await api + .post("/auth/user/emailpass/register", { + email: "admin@medusa.js", + password: "secret_password", + }) + .catch((e) => e) + + expect(signup.response.status).toEqual(401) + expect(signup.response.data.message).toEqual( + "Identity with email already exists" + ) }) - expect(login.status).toEqual(200) - expect(login.data).toEqual({ token: expect.any(String) }) - // Convert token to session - const createSession = await api.post( - "/auth/session", - {}, - { headers: { authorization: `Bearer ${login.data.token}` } } - ) - expect(createSession.status).toEqual(200) + it("should respond with 401 on sign in, if email does not exist", async () => { + const signup = await api + .post("/auth/user/emailpass", { + email: "john@doe.com", + password: "secret_password", + }) + .catch((e) => e) - // Extract cookie - const [cookie] = createSession.headers["set-cookie"][0].split(";") - expect(cookie).toEqual(expect.stringContaining("connect.sid")) - - const cookieHeader = { - headers: { Cookie: cookie }, - } - - // Perform cookie authenticated request - const authedRequest = await api.get( - "/admin/products?limit=1", - cookieHeader - ) - expect(authedRequest.status).toEqual(200) - - // Sign out - const signOutRequest = await api.delete("/auth/session", cookieHeader) - expect(signOutRequest.status).toEqual(200) - - // Attempt to perform authenticated request - const unAuthedRequest = await api - .get("/admin/products?limit=1", cookieHeader) - .catch((e) => e) - - expect(unAuthedRequest.response.status).toEqual(401) + expect(signup.response.status).toEqual(401) + expect(signup.response.data.message).toEqual( + "Invalid email or password" + ) + }) }) - it("should respond with 401 on register, if email already exists", async () => { - const signup = await api - .post("/auth/user/emailpass/register", { + describe("Reset password flows", () => { + it("should generate a reset password token", async () => { + const response = await api.post("/auth/user/emailpass/reset-password", { email: "admin@medusa.js", + }) + + expect(response.status).toEqual(201) + }) + + it("should fail to generate token for non-existing user, but still respond with 201", async () => { + const response = await api.post("/auth/user/emailpass/reset-password", { + email: "non-existing-user@medusa.js", + }) + + expect(response.status).toEqual(201) + }) + + it("should fail to generate token for existing user but no provider, but still respond with 201", async () => { + const response = await api.post( + "/auth/user/non-existing-provider/reset-password", + { email: "admin@medusa.js" } + ) + + expect(response.status).toEqual(201) + }) + + it("should fail to generate token for existing user but no provider, but still respond with 201", async () => { + const response = await api.post( + "/auth/user/non-existing-provider/reset-password", + { email: "admin@medusa.js" } + ) + + expect(response.status).toEqual(201) + }) + + it("should successfully reset password", async () => { + // Register user + await api.post("/auth/user/emailpass/register", { + email: "test@medusa-commerce.com", password: "secret_password", }) - .catch((e) => e) - expect(signup.response.status).toEqual(401) - expect(signup.response.data.message).toEqual( - "Identity with email already exists" - ) - }) + // The token won't be part of the Rest API response, so we need to generate it manually + const { result } = await generateResetPasswordTokenWorkflow( + container + ).run({ + input: { + entityId: "test@medusa-commerce.com", + provider: "emailpass", + secret: "test", + }, + }) - it("should respond with 401 on sign in, if email does not exist", async () => { - const signup = await api - .post("/auth/user/emailpass", { - email: "john@doe.com", + const response = await api.post( + `/auth/user/emailpass/update?token=${result}`, + { + email: "test@medusa-commerce.com", + password: "new_password", + } + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual({ success: true }) + + const failedLogin = await api + .post("/auth/user/emailpass", { + email: "test@medusa-commerce.com", + password: "secret_password", + }) + .catch((e) => e) + + expect(failedLogin.response.status).toEqual(401) + expect(failedLogin.response.data.message).toEqual( + "Invalid email or password" + ) + + const login = await api.post("/auth/user/emailpass", { + email: "test@medusa-commerce.com", + password: "new_password", + }) + + expect(login.status).toEqual(200) + expect(login.data).toEqual({ token: expect.any(String) }) + }) + + it("should fail if token has expired", async () => { + jest.useFakeTimers() + + // Register user + await api.post("/auth/user/emailpass/register", { + email: "test@medusa-commerce.com", password: "secret_password", }) - .catch((e) => e) - expect(signup.response.status).toEqual(401) - expect(signup.response.data.message).toEqual("Invalid email or password") + // The token won't be part of the Rest API response, so we need to generate it manually + const { result } = await generateResetPasswordTokenWorkflow( + container + ).run({ + input: { + entityId: "test@medusa-commerce.com", + provider: "emailpass", + secret: "test", + }, + }) + + // Advance time by 15 minutes + jest.advanceTimersByTime(15 * 60 * 1000) + + const response = await api + .post(`/auth/user/emailpass/update?token=${result}`, { + email: "test@medusa-commerce.com", + password: "new_password", + }) + .catch((e) => e) + + expect(response.response.status).toEqual(401) + expect(response.response.data.message).toEqual("Invalid token") + }) }) }, }) diff --git a/packages/core/core-flows/src/auth/index.ts b/packages/core/core-flows/src/auth/index.ts index c1f49c23fa..e58562ad24 100644 --- a/packages/core/core-flows/src/auth/index.ts +++ b/packages/core/core-flows/src/auth/index.ts @@ -1 +1,3 @@ export * from "./steps" +export * from "./workflows" + diff --git a/packages/core/core-flows/src/auth/steps/set-auth-app-metadata.ts b/packages/core/core-flows/src/auth/steps/set-auth-app-metadata.ts index 164d36fb4c..3bd25be381 100644 --- a/packages/core/core-flows/src/auth/steps/set-auth-app-metadata.ts +++ b/packages/core/core-flows/src/auth/steps/set-auth-app-metadata.ts @@ -30,7 +30,7 @@ export const setAuthAppMetadataStep = createStep( appMetadata[key] = data.value - await service.updateAuthIdentites({ + await service.updateAuthIdentities({ id: authIdentity.id, app_metadata: appMetadata, }) @@ -58,7 +58,7 @@ export const setAuthAppMetadataStep = createStep( delete appMetadata[key] } - await service.updateAuthIdentites({ + await service.updateAuthIdentities({ id: authIdentity.id, app_metadata: appMetadata, }) diff --git a/packages/core/core-flows/src/auth/workflows/generate-reset-password-token.ts b/packages/core/core-flows/src/auth/workflows/generate-reset-password-token.ts new file mode 100644 index 0000000000..e84b0f87ca --- /dev/null +++ b/packages/core/core-flows/src/auth/workflows/generate-reset-password-token.ts @@ -0,0 +1,61 @@ +import { + AuthWorkflowEvents, + generateJwtToken, + MedusaError, +} from "@medusajs/utils"; +import { + createWorkflow, + transform, + WorkflowResponse, +} from "@medusajs/workflows-sdk"; +import { emitEventStep, useRemoteQueryStep } from "../../common"; + +export const generateResetPasswordTokenWorkflow = createWorkflow( + "generate-reset-password-token", + (input: { entityId: string; provider: string; secret: string }) => { + const providerIdentities = useRemoteQueryStep({ + entry_point: "provider_identity", + fields: ["auth_identity_id", "provider_metadata"], + variables: { + filters: { + entity_id: input.entityId, + provider: input.provider, + }, + }, + }) + + const token = transform( + { input, providerIdentities }, + ({ input, providerIdentities }) => { + const providerIdentity = providerIdentities?.[0] + + if (!providerIdentity) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Provider identity with entity_id ${input.entityId} and provider ${input.provider} not found` + ) + } + + const token = generateJwtToken( + { + entity_id: input.entityId, + provider: input.provider, + }, + { + secret: input.secret, + expiresIn: "15m", + } + ) + + return token + } + ) + + emitEventStep({ + eventName: AuthWorkflowEvents.PASSWORD_RESET, + data: { entity_id: input.entityId, token }, + }) + + return new WorkflowResponse(token) + } +) diff --git a/packages/core/core-flows/src/auth/workflows/index.ts b/packages/core/core-flows/src/auth/workflows/index.ts new file mode 100644 index 0000000000..074b811abd --- /dev/null +++ b/packages/core/core-flows/src/auth/workflows/index.ts @@ -0,0 +1 @@ +export * from "./generate-reset-password-token" diff --git a/packages/core/types/src/auth/common/auth-identity.ts b/packages/core/types/src/auth/common/auth-identity.ts index b1308874a1..22fb15e3fa 100644 --- a/packages/core/types/src/auth/common/auth-identity.ts +++ b/packages/core/types/src/auth/common/auth-identity.ts @@ -83,6 +83,11 @@ export type ProviderIdentityDTO = { */ entity_id: string + /** + * The ID of the auth identity linked to the provider identity. + */ + auth_identity_id?: string + /** * The auth identity linked to the provider identity. */ @@ -205,6 +210,11 @@ export interface FilterableProviderIdentityProps */ entity_id?: string + /** + * Filter the provider identities by the ID of the auth identity they are linked to. + */ + auth_identity_id?: string + /** * The provider handle to filter the provider identity by. */ diff --git a/packages/core/types/src/auth/provider.ts b/packages/core/types/src/auth/provider.ts index d0a5ba4a4e..24176b2ddd 100644 --- a/packages/core/types/src/auth/provider.ts +++ b/packages/core/types/src/auth/provider.ts @@ -41,4 +41,8 @@ export interface IAuthProvider { data: AuthenticationInput, authIdentityProviderService: AuthIdentityProviderService ): Promise + update: ( + data: Record, + authIdentityProviderService: AuthIdentityProviderService + ) => Promise } diff --git a/packages/core/types/src/auth/service.ts b/packages/core/types/src/auth/service.ts index 4deebbbe79..2dbaa38d6e 100644 --- a/packages/core/types/src/auth/service.ts +++ b/packages/core/types/src/auth/service.ts @@ -54,6 +54,11 @@ export interface IAuthModuleService extends IModuleService { providerData: AuthenticationInput ): Promise + updateProvider( + provider: string, + providerData: Record + ): Promise + /** * When authenticating users with a third-party provider, such as Google, the user performs an * action to finish the authentication, such as enter their credentials in Google's sign-in @@ -244,13 +249,13 @@ export interface IAuthModuleService extends IModuleService { * @returns {Promise} The updated auths. * * @example - * const authIdentities = await authModuleService.updateAuthIdentites([ + * const authIdentities = await authModuleService.updateAuthIdentities([ * { * id: "authusr_123", * }, * ]) */ - updateAuthIdentites( + updateAuthIdentities( data: UpdateAuthIdentityDTO[], sharedContext?: Context ): Promise @@ -263,11 +268,11 @@ export interface IAuthModuleService extends IModuleService { * @returns {Promise} The updated auth. * * @example - * const authIdentity = await authModuleService.updateAuthIdentites({ + * const authIdentity = await authModuleService.updateAuthIdentities({ * id: "authusr_123", * }) */ - updateAuthIdentites( + updateAuthIdentities( data: UpdateAuthIdentityDTO, sharedContext?: Context ): Promise @@ -399,7 +404,7 @@ export interface IAuthModuleService extends IModuleService { * }, * ]) */ - updateProviderIdentites( + updateProviderIdentities( data: UpdateProviderIdentityDTO[], sharedContext?: Context ): Promise @@ -412,11 +417,11 @@ export interface IAuthModuleService extends IModuleService { * @returns {Promise} The updated provider identity. * * @example - * const providerIdentity = await authModuleService.updateProviderIdentites({ + * const providerIdentity = await authModuleService.updateProviderIdentities({ * id: "provider_123", * }) */ - updateProviderIdentites( + updateProviderIdentities( data: UpdateProviderIdentityDTO, sharedContext?: Context ): Promise diff --git a/packages/core/utils/src/auth/abstract-auth-provider.ts b/packages/core/utils/src/auth/abstract-auth-provider.ts index 40da662c04..3484c0c76a 100644 --- a/packages/core/utils/src/auth/abstract-auth-provider.ts +++ b/packages/core/utils/src/auth/abstract-auth-provider.ts @@ -2,7 +2,7 @@ import { AuthenticationInput, AuthenticationResponse, AuthIdentityProviderService, - IAuthProvider, + IAuthProvider } from "@medusajs/types" /** @@ -238,6 +238,15 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider { ) } + update( + data: Record, + authIdentityProviderService: AuthIdentityProviderService + ): Promise { + throw new Error( + `Method 'update' not implemented for provider ${this.provider}` + ) + } + /** * This method validates the callback of an authentication request. * diff --git a/packages/core/utils/src/core-flows/events.ts b/packages/core/utils/src/core-flows/events.ts index 84d361b3be..7cd403d5a8 100644 --- a/packages/core/utils/src/core-flows/events.ts +++ b/packages/core/utils/src/core-flows/events.ts @@ -10,6 +10,16 @@ export const OrderWorkflowEvents = { COMPLETED: "order.completed", } +export const UserWorkflowEvents = { + CREATED: "user.created", + UPDATED: "user.updated", + DELETED: "user.deleted", +} + +export const AuthWorkflowEvents = { + PASSWORD_RESET: "auth.password_reset", +} + export const SalesChannelWorkflowEvents = { CREATED: "sales-channel.created", UPDATED: "sales-channel.updated", diff --git a/packages/core/utils/src/user/events.ts b/packages/core/utils/src/user/events.ts index 66e9cfcc5e..49a70d339a 100644 --- a/packages/core/utils/src/user/events.ts +++ b/packages/core/utils/src/user/events.ts @@ -7,9 +7,3 @@ export const UserEvents = { ...buildEventNamesFromEntityName(eventBaseNames, Modules.USER), INVITE_TOKEN_GENERATED: `${Modules.USER}.user.invite.token_generated`, } - -export const UserWorkflowEvents = { - CREATED: "user.created", - UPDATED: "user.updated", - DELETED: "user.deleted", -} diff --git a/packages/medusa/src/api/admin/users/middlewares.ts b/packages/medusa/src/api/admin/users/middlewares.ts index 70be5773fd..d03cad05c0 100644 --- a/packages/medusa/src/api/admin/users/middlewares.ts +++ b/packages/medusa/src/api/admin/users/middlewares.ts @@ -1,16 +1,14 @@ +import { MiddlewareRoute } from "@medusajs/framework" +import { authenticate } from "../../../utils/middlewares/authenticate-middleware" +import { validateAndTransformBody } from "../../utils/validate-body" +import { validateAndTransformQuery } from "../../utils/validate-query" import * as QueryConfig from "./query-config" - import { AdminGetUserParams, AdminGetUsersParams, - AdminUpdateUser, + AdminUpdateUser } from "./validators" -import { MiddlewareRoute } from "@medusajs/framework" -import { authenticate } from "../../../utils/middlewares/authenticate-middleware" -import { validateAndTransformQuery } from "../../utils/validate-query" -import { validateAndTransformBody } from "../../utils/validate-body" - // TODO: Due to issues with our routing (and using router.use for applying middlewares), we have to opt-out of global auth in all routes, and then reapply it here. // See https://medusacorp.slack.com/archives/C025KMS13SA/p1716455350491879 for details. export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [ @@ -31,8 +29,8 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [ middlewares: [ authenticate("user", ["bearer", "session"]), validateAndTransformQuery( - AdminGetUserParams, - QueryConfig.retrieveTransformQueryConfig + AdminGetUserParams, + QueryConfig.retrieveTransformQueryConfig ), ], }, @@ -42,8 +40,8 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [ middlewares: [ authenticate("user", ["bearer", "session"]), validateAndTransformQuery( - AdminGetUserParams, - QueryConfig.retrieveTransformQueryConfig + AdminGetUserParams, + QueryConfig.retrieveTransformQueryConfig ), ], }, @@ -54,8 +52,8 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [ authenticate("user", ["bearer", "session"]), validateAndTransformBody(AdminUpdateUser), validateAndTransformQuery( - AdminGetUserParams, - QueryConfig.retrieveTransformQueryConfig + AdminGetUserParams, + QueryConfig.retrieveTransformQueryConfig ), ], }, diff --git a/packages/medusa/src/api/admin/users/validators.ts b/packages/medusa/src/api/admin/users/validators.ts index 49b6430af3..23e080ffcc 100644 --- a/packages/medusa/src/api/admin/users/validators.ts +++ b/packages/medusa/src/api/admin/users/validators.ts @@ -38,3 +38,4 @@ export const AdminUpdateUser = z.object({ last_name: z.string().nullish(), avatar_url: z.string().nullish(), }) + diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts index e658d5761c..60e40722a4 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts @@ -1,8 +1,4 @@ -import { - AuthenticationInput, - ConfigModule, - IAuthModuleService, -} from "@medusajs/types" +import { AuthenticationInput, IAuthModuleService } from "@medusajs/types" import { ContainerRegistrationKeys, MedusaError, @@ -13,21 +9,6 @@ import { MedusaRequest, MedusaResponse } from "../../../../../types/routing" export const GET = async (req: MedusaRequest, res: MedusaResponse) => { const { actor_type, auth_provider } = req.params - const config: ConfigModule = req.scope.resolve( - ContainerRegistrationKeys.CONFIG_MODULE - ) - - const authMethodsPerActor = - config.projectConfig?.http?.authMethodsPerActor ?? {} - // Not having the config defined would allow for all auth providers for the particular actor. - if (authMethodsPerActor[actor_type]) { - if (!authMethodsPerActor[actor_type].includes(auth_provider)) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `The actor type ${actor_type} is not allowed to use the auth provider ${auth_provider}` - ) - } - } const service: IAuthModuleService = req.scope.resolve( ModuleRegistrationName.AUTH diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts index 2c7c56847a..b29cc5baa0 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts @@ -17,18 +17,6 @@ export const POST = async (req: MedusaRequest, res: MedusaResponse) => { ContainerRegistrationKeys.CONFIG_MODULE ) - const authMethodsPerActor = - config.projectConfig?.http?.authMethodsPerActor ?? {} - // Not having the config defined would allow for all auth providers for the particular actor. - if (authMethodsPerActor[actor_type]) { - if (!authMethodsPerActor[actor_type].includes(auth_provider)) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `The actor type ${actor_type} is not allowed to use the auth provider ${auth_provider}` - ) - } - } - const service: IAuthModuleService = req.scope.resolve( ModuleRegistrationName.AUTH ) diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/reset-password/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/reset-password/route.ts new file mode 100644 index 0000000000..875f5f7e8b --- /dev/null +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/reset-password/route.ts @@ -0,0 +1,31 @@ +import { generateResetPasswordTokenWorkflow } from "@medusajs/core-flows" +import { ContainerRegistrationKeys } from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { auth_provider } = req.params + const { identifier } = req.body + + const { http } = req.scope.resolve( + ContainerRegistrationKeys.CONFIG_MODULE + ).projectConfig + + await generateResetPasswordTokenWorkflow(req.scope).run({ + input: { + entityId: identifier, + provider: auth_provider, + secret: http.jwtSecret as string, + }, + throwOnError: false, // we don't want to throw on error to avoid leaking information about non-existing identities + }) + + res.sendStatus(201) +} + +export const AUTHENTICATE = false diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts index 94f197df36..db84f16e46 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts @@ -17,18 +17,6 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { ContainerRegistrationKeys.CONFIG_MODULE ) - const authMethodsPerActor = - config.projectConfig?.http?.authMethodsPerActor ?? {} - // Not having the config defined would allow for all auth providers for the particular actor. - if (authMethodsPerActor[actor_type]) { - if (!authMethodsPerActor[actor_type].includes(auth_provider)) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - `The actor type ${actor_type} is not allowed to use the auth provider ${auth_provider}` - ) - } - } - const service: IAuthModuleService = req.scope.resolve( ModuleRegistrationName.AUTH ) diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/update/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/update/route.ts new file mode 100644 index 0000000000..d3665ce40b --- /dev/null +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/update/route.ts @@ -0,0 +1,26 @@ +import { AuthenticatedMedusaRequest } from "@medusajs/framework" +import { IAuthModuleService } from "@medusajs/types" +import { MedusaError, ModuleRegistrationName } from "@medusajs/utils" +import { MedusaResponse } from "../../../../../types/routing" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { auth_provider } = req.params + + const authService = req.scope.resolve( + ModuleRegistrationName.AUTH + ) + + const { authIdentity, success, error } = await authService.updateProvider( + auth_provider, + req.body as Record + ) + + if (success && authIdentity) { + return res.status(200).json({ success: true }) + } + + throw new MedusaError(MedusaError.Types.UNAUTHORIZED, error || "Unauthorized") +} diff --git a/packages/medusa/src/api/auth/middlewares.ts b/packages/medusa/src/api/auth/middlewares.ts index 4a393395a3..8d28376eed 100644 --- a/packages/medusa/src/api/auth/middlewares.ts +++ b/packages/medusa/src/api/auth/middlewares.ts @@ -1,5 +1,7 @@ import { MiddlewareRoute } from "@medusajs/framework" import { authenticate } from "../../utils/middlewares/authenticate-middleware" +import { validateScopeProviderAssociation } from "./utils/validate-scope-provider-association" +import { validateToken } from "./utils/validate-token" export const authRoutesMiddlewares: MiddlewareRoute[] = [ { @@ -15,21 +17,31 @@ export const authRoutesMiddlewares: MiddlewareRoute[] = [ { method: ["POST"], matcher: "/auth/:actor_type/:auth_provider/callback", - middlewares: [], + middlewares: [validateScopeProviderAssociation()], }, { method: ["POST"], matcher: "/auth/:actor_type/:auth_provider/register", - middlewares: [], + middlewares: [validateScopeProviderAssociation()], }, { method: ["POST"], matcher: "/auth/:actor_type/:auth_provider", - middlewares: [], + middlewares: [validateScopeProviderAssociation()], }, { method: ["GET"], matcher: "/auth/:actor_type/:auth_provider", - middlewares: [], + middlewares: [validateScopeProviderAssociation()], + }, + { + method: ["POST"], + matcher: "/auth/:actor_type/:auth_provider/reset-password", + middlewares: [validateScopeProviderAssociation()], + }, + { + method: ["POST"], + matcher: "/auth/:actor_type/:auth_provider/update", + middlewares: [validateScopeProviderAssociation(), validateToken()], }, ] diff --git a/packages/medusa/src/api/auth/utils/validate-scope-provider-association.ts b/packages/medusa/src/api/auth/utils/validate-scope-provider-association.ts new file mode 100644 index 0000000000..d265cbf99e --- /dev/null +++ b/packages/medusa/src/api/auth/utils/validate-scope-provider-association.ts @@ -0,0 +1,35 @@ +import { + MedusaNextFunction, + MedusaRequest, + MedusaResponse, +} from "@medusajs/framework" +import { ConfigModule } from "@medusajs/types" +import { ContainerRegistrationKeys, MedusaError } from "@medusajs/utils" + +// Middleware to validate that a scope is associated with a provider +export const validateScopeProviderAssociation = () => { + return async ( + req: MedusaRequest, + _: MedusaResponse, + next: MedusaNextFunction + ) => { + const { actor_type, auth_provider } = req.params + const config: ConfigModule = req.scope.resolve( + ContainerRegistrationKeys.CONFIG_MODULE + ) + + const authMethodsPerActor = + config.projectConfig?.http?.authMethodsPerActor ?? {} + // Not having the config defined would allow for all auth providers for the particular actor. + if (authMethodsPerActor[actor_type]) { + if (!authMethodsPerActor[actor_type].includes(auth_provider)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `The actor type ${actor_type} is not allowed to use the auth provider ${auth_provider}` + ) + } + } + + next() + } +} diff --git a/packages/medusa/src/api/auth/utils/validate-token.ts b/packages/medusa/src/api/auth/utils/validate-token.ts new file mode 100644 index 0000000000..88f5d0c29d --- /dev/null +++ b/packages/medusa/src/api/auth/utils/validate-token.ts @@ -0,0 +1,72 @@ +import { + AuthenticatedMedusaRequest, + MedusaNextFunction, + MedusaRequest, + MedusaResponse, +} from "@medusajs/framework" +import { ConfigModule, IAuthModuleService } from "@medusajs/types" +import { + ContainerRegistrationKeys, + ModuleRegistrationName, +} from "@medusajs/utils" +import { decode, JwtPayload, verify } from "jsonwebtoken" + +// Middleware to validate that a token is valid +export const validateToken = () => { + return async ( + req: MedusaRequest, + res: MedusaResponse, + next: MedusaNextFunction + ) => { + const { actor_type, auth_provider } = req.params + const { token } = req.query + + const req_ = req as AuthenticatedMedusaRequest + + if (!token) { + return next() + } + + // @ts-ignore + const { http } = req_.scope.resolve( + ContainerRegistrationKeys.CONFIG_MODULE + ).projectConfig + + const authModule = req.scope.resolve( + ModuleRegistrationName.AUTH + ) + + let decoded = decode(token as string) as JwtPayload + + const [providerIdentity] = await authModule.listProviderIdentities( + { + entity_id: decoded.entity_id, + provider: auth_provider, + }, + { + select: ["provider_metadata", "auth_identity_id", "entity_id"], + } + ) + + if (!providerIdentity) { + return res.status(401).json({ message: "Invalid token" }) + } + + let verified: JwtPayload | null = null + + try { + verified = verify(token as string, http.jwtSecret as string) as JwtPayload + } catch (error) { + return res.status(401).json({ message: "Invalid token" }) + } + + req_.auth_context = { + actor_type, + auth_identity_id: verified.auth_identity_id!, + actor_id: providerIdentity.entity_id, + app_metadata: {}, + } + + return next() + } +} diff --git a/packages/medusa/src/api/utils/unless-path.ts b/packages/medusa/src/api/utils/unless-path.ts index 59fe522e6e..87e4177e49 100644 --- a/packages/medusa/src/api/utils/unless-path.ts +++ b/packages/medusa/src/api/utils/unless-path.ts @@ -1,6 +1,6 @@ +import { MiddlewareFunction } from "@medusajs/framework" import { NextFunction } from "express" import { MedusaRequest, MedusaResponse } from "../../types/routing" -import { MiddlewareFunction } from "@medusajs/framework" // Due to how our route loader works, where we load all middlewares before routes, ambiguous routes end up having all middlewares on different routes executed before the route handler is. // This function allows us to skip middlewares for particular routes, so we can temporarily solve this without completely breaking the route loader for everyone. diff --git a/packages/medusa/src/commands/user.ts b/packages/medusa/src/commands/user.ts index 9b57d4d913..b324258a76 100644 --- a/packages/medusa/src/commands/user.ts +++ b/packages/medusa/src/commands/user.ts @@ -54,7 +54,7 @@ export default async function ({ } // We know the authIdentity is not undefined - await authService.updateAuthIdentites({ + await authService.updateAuthIdentities({ id: authIdentity!.id, app_metadata: { user_id: user.id, diff --git a/packages/modules/auth/integration-tests/__tests__/auth-module-service/auth-identity.spec.ts b/packages/modules/auth/integration-tests/__tests__/auth-module-service/auth-identity.spec.ts index 1d40f2db68..bceab502cf 100644 --- a/packages/modules/auth/integration-tests/__tests__/auth-module-service/auth-identity.spec.ts +++ b/packages/modules/auth/integration-tests/__tests__/auth-module-service/auth-identity.spec.ts @@ -215,7 +215,7 @@ moduleIntegrationTestRunner({ let error try { - await service.updateAuthIdentites([ + await service.updateAuthIdentities([ { id: "does-not-exist", }, @@ -230,7 +230,7 @@ moduleIntegrationTestRunner({ }) it("should update authIdentity", async () => { - await service.updateAuthIdentites([ + await service.updateAuthIdentities([ { id, app_metadata: { email: "test@email.com" }, @@ -364,7 +364,7 @@ moduleIntegrationTestRunner({ let error try { - await service.updateProviderIdentites([ + await service.updateProviderIdentities([ { id: "does-not-exist", }, @@ -382,18 +382,18 @@ moduleIntegrationTestRunner({ let [providerIdentity] = await service.listProviderIdentities({ entity_id, }) - await service.updateProviderIdentites([ + await service.updateProviderIdentities([ { id: providerIdentity.id, provider_metadata: { email: "test@email.com" }, }, ]) - const providerIdentites = await service.listProviderIdentities({ + const providerIdentities = await service.listProviderIdentities({ id: [providerIdentity.id], }) - expect(providerIdentites[0]).toEqual( + expect(providerIdentities[0]).toEqual( expect.objectContaining({ provider_metadata: expect.objectContaining({ email: "test@email.com", diff --git a/packages/modules/auth/integration-tests/__tests__/auth-module-service/index.spec.ts b/packages/modules/auth/integration-tests/__tests__/auth-module-service/index.spec.ts index bccf1ff438..a3cd550813 100644 --- a/packages/modules/auth/integration-tests/__tests__/auth-module-service/index.spec.ts +++ b/packages/modules/auth/integration-tests/__tests__/auth-module-service/index.spec.ts @@ -1,8 +1,8 @@ import { IAuthModuleService } from "@medusajs/types" -import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils" -import { resolve } from "path" import { Module, Modules } from "@medusajs/utils" import { AuthModuleService } from "@services" +import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils" +import { resolve } from "path" let moduleOptions = { providers: [ @@ -42,7 +42,10 @@ moduleIntegrationTestRunner({ service: AuthModuleService, }).linkable - expect(Object.keys(linkable)).toEqual(["authIdentity"]) + expect(Object.keys(linkable)).toEqual([ + "authIdentity", + "providerIdentity", + ]) linkable.authIdentity.toJSON = undefined @@ -54,6 +57,17 @@ moduleIntegrationTestRunner({ field: "authIdentity", }, }) + + linkable.providerIdentity.toJSON = undefined + + expect(linkable.providerIdentity).toEqual({ + id: { + linkable: "provider_identity_id", + primaryKey: "id", + serviceName: "auth", + field: "providerIdentity", + }, + }) }) it("it fails if the provider does not exist", async () => { diff --git a/packages/modules/auth/src/joiner-config.ts b/packages/modules/auth/src/joiner-config.ts index 33a3481f29..b5868a6e0a 100644 --- a/packages/modules/auth/src/joiner-config.ts +++ b/packages/modules/auth/src/joiner-config.ts @@ -1,6 +1,6 @@ -import { AuthIdentity } from "@models" import { defineJoinerConfig, Modules } from "@medusajs/utils" +import { AuthIdentity, ProviderIdentity } from "@models" export const joinerConfig = defineJoinerConfig(Modules.AUTH, { - models: [AuthIdentity], + models: [AuthIdentity, ProviderIdentity], }) diff --git a/packages/modules/auth/src/services/auth-module.ts b/packages/modules/auth/src/services/auth-module.ts index e39ca7fae7..dfdbd79d76 100644 --- a/packages/modules/auth/src/services/auth-module.ts +++ b/packages/modules/auth/src/services/auth-module.ts @@ -9,17 +9,14 @@ import { ModuleJoinerConfig, ModulesSdkTypes, } from "@medusajs/types" - -import { AuthIdentity, ProviderIdentity } from "@models" - -import { joinerConfig } from "../joiner-config" - import { InjectManager, MedusaContext, MedusaError, MedusaService, } from "@medusajs/utils" +import { AuthIdentity, ProviderIdentity } from "@models" +import { joinerConfig } from "../joiner-config" import AuthProviderService from "./auth-provider" type InjectedDependencies = { @@ -92,18 +89,19 @@ export default class AuthModuleService } // TODO: Update to follow convention - updateAuthIdentites( + // @ts-expect-error + updateAuthIdentities( data: AuthTypes.UpdateAuthIdentityDTO[], sharedContext?: Context ): Promise - updateAuthIdentites( + updateAuthIdentities( data: AuthTypes.UpdateAuthIdentityDTO, sharedContext?: Context ): Promise @InjectManager("baseRepository_") - async updateAuthIdentites( + async updateAuthIdentities( data: AuthTypes.UpdateAuthIdentityDTO | AuthTypes.UpdateAuthIdentityDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { @@ -135,6 +133,7 @@ export default class AuthModuleService return { success: false, error: error.message } } } + // @ts-expect-error createProviderIdentities( data: AuthTypes.CreateProviderIdentityDTO[], @@ -163,18 +162,19 @@ export default class AuthModuleService >(providerIdentities) } - updateProviderIdentites( + // @ts-expect-error + updateProviderIdentities( data: AuthTypes.UpdateProviderIdentityDTO[], sharedContext?: Context ): Promise - updateProviderIdentites( + updateProviderIdentities( data: AuthTypes.UpdateProviderIdentityDTO, sharedContext?: Context ): Promise @InjectManager("baseRepository_") - async updateProviderIdentites( + async updateProviderIdentities( data: | AuthTypes.UpdateProviderIdentityDTO | AuthTypes.UpdateProviderIdentityDTO[], @@ -192,6 +192,21 @@ export default class AuthModuleService return Array.isArray(data) ? serializedProviders : serializedProviders[0] } + async updateProvider( + provider: string, + data: Record + ): Promise { + try { + return await this.authProviderService_.update( + provider, + data, + this.getAuthIdentityProviderService(provider) + ) + } catch (error) { + return { success: false, error: error.message } + } + } + async authenticate( provider: string, authenticationData: AuthenticationInput diff --git a/packages/modules/auth/src/services/auth-provider.ts b/packages/modules/auth/src/services/auth-provider.ts index 4f5efcf235..3c0532c380 100644 --- a/packages/modules/auth/src/services/auth-provider.ts +++ b/packages/modules/auth/src/services/auth-provider.ts @@ -2,7 +2,7 @@ import { AuthIdentityProviderService, AuthTypes, AuthenticationInput, - AuthenticationResponse, + AuthenticationResponse } from "@medusajs/types" import { MedusaError } from "@medusajs/utils" import { AuthProviderRegistrationPrefix } from "@types" @@ -51,6 +51,15 @@ export default class AuthProviderService { return await providerHandler.register(auth, authIdentityProviderService) } + async update( + provider: string, + data: Record, + authIdentityProviderService: AuthIdentityProviderService + ): Promise { + const providerHandler = this.retrieveProviderRegistration(provider) + return await providerHandler.update(data, authIdentityProviderService) + } + async validateCallback( provider: string, auth: AuthenticationInput, diff --git a/packages/modules/providers/auth-emailpass/src/services/emailpass.ts b/packages/modules/providers/auth-emailpass/src/services/emailpass.ts index 02c62f0c58..ae060cda9c 100644 --- a/packages/modules/providers/auth-emailpass/src/services/emailpass.ts +++ b/packages/modules/providers/auth-emailpass/src/services/emailpass.ts @@ -35,14 +35,56 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider { this.logger_ = logger } - protected async createAuthIdentity({ email, password, authIdentityService }) { + protected async hashPassword(password: string) { const hashConfig = this.config_.hashConfig ?? { logN: 15, r: 8, p: 1 } const passwordHash = await Scrypt.kdf(password, hashConfig) + return passwordHash.toString("base64") + } + + async update( + data: { email: string; password: string }, + authIdentityService: AuthIdentityProviderService + ) { + const { email, password } = data ?? {} + + if (!email || !isString(email)) { + return { + success: false, + error: `Cannot update ${this.provider} provider identity without email`, + } + } + + if (!password || !isString(password)) { + return { success: true } + } + + let authIdentity + + try { + const passwordHash = await this.hashPassword(password) + + authIdentity = await authIdentityService.update(email, { + provider_metadata: { + password: passwordHash, + }, + }) + } catch (error) { + return { success: false, error: error.message } + } + + return { + success: true, + authIdentity, + } + } + + protected async createAuthIdentity({ email, password, authIdentityService }) { + const passwordHash = await this.hashPassword(password) const createdAuthIdentity = await authIdentityService.create({ entity_id: email, provider_metadata: { - password: passwordHash.toString("base64"), + password: passwordHash, }, })