diff --git a/integration-tests/http/__tests__/auth/admin/auth.spec.ts b/integration-tests/http/__tests__/auth/admin/auth.spec.ts index 6c96e3314c..67d3d7846d 100644 --- a/integration-tests/http/__tests__/auth/admin/auth.spec.ts +++ b/integration-tests/http/__tests__/auth/admin/auth.spec.ts @@ -1,8 +1,8 @@ +import { medusaIntegrationTestRunner } from "medusa-test-utils" import { adminHeaders, createAdminUser, } from "../../../../helpers/create-admin-user" -import { medusaIntegrationTestRunner } from "medusa-test-utils" jest.setTimeout(30000) @@ -12,27 +12,50 @@ medusaIntegrationTestRunner({ await createAdminUser(dbConnection, adminHeaders, getContainer()) }) - // TODO: This test won't work since we don't allow creating a user through HTTP. We need to have the invite flow plugged in here. - it.skip("test the entire authentication flow", async () => { - // BREAKING: `/admin/auth` changes to `/auth/user/emailpass` - const signup = await api.post("/auth/user/emailpass", { + it("Invite + registration + authentication flow", async () => { + // Create invite + const { token: inviteToken } = ( + await api.post( + "/admin/invites", + { email: "newadmin@medusa.js" }, + adminHeaders + ) + ).data.invite + + // Register identity + const signup = await api.post("/auth/user/emailpass/register", { email: "newadmin@medusa.js", password: "secret_password", }) - //BREAKING: In V2, we respond with a JWT token instead of the user object, and a session is not created. you need to call `/auth/session` to create a session expect(signup.status).toEqual(200) expect(signup.data).toEqual({ token: expect.any(String) }) - // BREAKING: IN V2 creating a user is separated from creating an auth identity - const createdUser = await api.post( - "/admin/users", - { email: "newadmin@medusa.js" }, - { headers: { authorization: `Bearer ${signup.data.token}` } } + // 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(createdUser.status).toEqual(200) - expect(createdUser.data.user.email).toEqual("newadmin@medusa.js") + 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", password: "secret_password", @@ -40,6 +63,7 @@ medusaIntegrationTestRunner({ expect(login.status).toEqual(200) expect(login.data).toEqual({ token: expect.any(String) }) + // Convert token to session const createSession = await api.post( "/auth/session", {}, @@ -47,7 +71,7 @@ medusaIntegrationTestRunner({ ) expect(createSession.status).toEqual(200) - // extract cookie + // Extract cookie const [cookie] = createSession.headers["set-cookie"][0].split(";") expect(cookie).toEqual(expect.stringContaining("connect.sid")) @@ -55,23 +79,49 @@ medusaIntegrationTestRunner({ headers: { Cookie: cookie }, } - // perform cookie authenticated request + // Perform cookie authenticated request const authedRequest = await api.get( "/admin/products?limit=1", cookieHeader ) expect(authedRequest.status).toEqual(200) - // sign out + // Sign out const signOutRequest = await api.delete("/auth/session", cookieHeader) expect(signOutRequest.status).toEqual(200) - // attempt to perform authenticated request + // Attempt to perform authenticated request const unAuthedRequest = await api .get("/admin/products?limit=1", cookieHeader) .catch((e) => e) expect(unAuthedRequest.response.status).toEqual(401) }) + + 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" + ) + }) + + 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) + + expect(signup.response.status).toEqual(401) + expect(signup.response.data.message).toEqual("Invalid email or password") + }) }, }) diff --git a/integration-tests/http/__tests__/invite/admin/invite.spec.ts b/integration-tests/http/__tests__/invite/admin/invite.spec.ts index a7321d7bc2..6f0d280d4a 100644 --- a/integration-tests/http/__tests__/invite/admin/invite.spec.ts +++ b/integration-tests/http/__tests__/invite/admin/invite.spec.ts @@ -64,7 +64,7 @@ medusaIntegrationTestRunner({ }) ) - const signup = await api.post("/auth/user/emailpass", { + const signup = await api.post("/auth/user/emailpass/register", { email: "test@medusa-commerce.com", password: "secret_password", }) @@ -92,7 +92,7 @@ medusaIntegrationTestRunner({ it("should fail to accept an invite given an invalid token", async () => { expect.assertions(2) - const signup = await api.post("/auth/user/emailpass", { + const signup = await api.post("/auth/user/emailpass/register", { email: "test@medusa-commerce.com", password: "secret_password", }) @@ -119,7 +119,7 @@ medusaIntegrationTestRunner({ }) it("should fail to accept an already accepted invite ", async () => { - const signup = await api.post("/auth/user/emailpass", { + const signup = await api.post("/auth/user/emailpass/register", { email: "test@medusa-commerce.com", password: "secret_password", }) @@ -135,7 +135,7 @@ medusaIntegrationTestRunner({ } ) - const signupAgain = await api.post("/auth/user/emailpass", { + const signupAgain = await api.post("/auth/user/emailpass/register", { email: "another-test@medusa-commerce.com", password: "secret_password", }) diff --git a/integration-tests/modules/__tests__/invites/accept-invite.spec.ts b/integration-tests/modules/__tests__/invites/accept-invite.spec.ts index 971f51fda1..5b26fdcab2 100644 --- a/integration-tests/modules/__tests__/invites/accept-invite.spec.ts +++ b/integration-tests/modules/__tests__/invites/accept-invite.spec.ts @@ -27,7 +27,7 @@ medusaIntegrationTestRunner({ }) it("should fail to accept an invite with an invalid invite token", async () => { - const authResponse = await api.post(`/auth/user/emailpass`, { + const authResponse = await api.post(`/auth/user/emailpass/register`, { email: "potential_member@test.com", password: "supersecret", }) @@ -58,7 +58,7 @@ medusaIntegrationTestRunner({ email: "potential_member@test.com", }) - const authResponse = await api.post(`/auth/user/emailpass`, { + const authResponse = await api.post(`/auth/user/emailpass/register`, { email: "potential_member@test.com", password: "supersecret", }) @@ -92,7 +92,7 @@ medusaIntegrationTestRunner({ email: "potential_member@test.com", }) - const authResponse = await api.post(`/auth/user/emailpass`, { + const authResponse = await api.post(`/auth/user/emailpass/register`, { email: "some-email@test.com", password: "supersecret", }) diff --git a/packages/admin-next/dashboard/src/hooks/api/auth.tsx b/packages/admin-next/dashboard/src/hooks/api/auth.tsx index 3428798b97..03d6f90b1e 100644 --- a/packages/admin-next/dashboard/src/hooks/api/auth.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/auth.tsx @@ -3,7 +3,7 @@ import { UseMutationOptions, useMutation } from "@tanstack/react-query" import { sdk } from "../../lib/client" import { EmailPassReq } from "../../types/api-payloads" -export const useEmailPassLogin = ( +export const useSignInWithEmailPassword = ( options?: UseMutationOptions ) => { return useMutation({ @@ -15,21 +15,21 @@ export const useEmailPassLogin = ( }) } +export const useSignUpWithEmailPass = ( + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (payload) => sdk.auth.register("user", "emailpass", payload), + onSuccess: async (data, variables, context) => { + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + export const useLogout = (options?: UseMutationOptions) => { return useMutation({ mutationFn: () => sdk.auth.logout(), ...options, }) } - -export const useCreateAuthUser = ( - options?: UseMutationOptions<{ token: string }, Error, EmailPassReq> -) => { - return useMutation({ - mutationFn: (payload) => sdk.auth.create("user", "emailpass", payload), - onSuccess: async (data, variables, context) => { - options?.onSuccess?.(data, variables, context) - }, - ...options, - }) -} diff --git a/packages/admin-next/dashboard/src/routes/invite/invite.tsx b/packages/admin-next/dashboard/src/routes/invite/invite.tsx index 65ad1761b0..0333705314 100644 --- a/packages/admin-next/dashboard/src/routes/invite/invite.tsx +++ b/packages/admin-next/dashboard/src/routes/invite/invite.tsx @@ -10,7 +10,7 @@ import { Link, useSearchParams } from "react-router-dom" import * as z from "zod" import { Form } from "../../components/common/form" import { LogoBox } from "../../components/common/logo-box" -import { useCreateAuthUser } from "../../hooks/api/auth" +import { useSignUpWithEmailPass } from "../../hooks/api/auth" import { useAcceptInvite } from "../../hooks/api/invites" import { isFetchError } from "../../lib/is-fetch-error" @@ -204,15 +204,15 @@ const CreateView = ({ }, }) - const { mutateAsync: createAuthUser, isPending: isCreatingAuthUser } = - useCreateAuthUser() + const { mutateAsync: signUpEmailPass, isPending: isCreatingAuthUser } = + useSignUpWithEmailPass() const { mutateAsync: acceptInvite, isPending: isAcceptingInvite } = useAcceptInvite(token) const handleSubmit = form.handleSubmit(async (data) => { try { - const { token: authToken } = await createAuthUser({ + const authToken = await signUpEmailPass({ email: data.email, password: data.password, }) diff --git a/packages/admin-next/dashboard/src/routes/login/login.tsx b/packages/admin-next/dashboard/src/routes/login/login.tsx index 3829d21849..0009e892dc 100644 --- a/packages/admin-next/dashboard/src/routes/login/login.tsx +++ b/packages/admin-next/dashboard/src/routes/login/login.tsx @@ -8,7 +8,7 @@ import * as z from "zod" import { Divider } from "../../components/common/divider" import { Form } from "../../components/common/form" import { LogoBox } from "../../components/common/logo-box" -import { useEmailPassLogin } from "../../hooks/api/auth" +import { useSignInWithEmailPassword } from "../../hooks/api/auth" import { isAxiosError } from "../../lib/is-axios-error" import after from "virtual:medusa/widgets/login/after" @@ -34,8 +34,7 @@ export const Login = () => { }, }) - // TODO: Update when more than emailpass is supported - const { mutateAsync, isPending } = useEmailPassLogin() + const { mutateAsync, isPending } = useSignInWithEmailPassword() const handleSubmit = form.handleSubmit(async ({ email, password }) => { try { diff --git a/packages/core/js-sdk/src/auth/index.ts b/packages/core/js-sdk/src/auth/index.ts index c3bf8469b2..b21c19587e 100644 --- a/packages/core/js-sdk/src/auth/index.ts +++ b/packages/core/js-sdk/src/auth/index.ts @@ -10,6 +10,24 @@ export class Auth { this.config = config } + register = async ( + actor: "customer" | "user", + method: "emailpass", + payload: { email: string; password: string } + ) => { + const { token } = await this.client.fetch<{ token: string }>( + `/auth/${actor}/${method}/register`, + { + method: "POST", + body: payload, + } + ) + + this.client.setToken(token) + + return token + } + login = async ( actor: "customer" | "user", method: "emailpass", diff --git a/packages/core/types/src/auth/provider.ts b/packages/core/types/src/auth/provider.ts index 639e4f4dac..c312d4a7f1 100644 --- a/packages/core/types/src/auth/provider.ts +++ b/packages/core/types/src/auth/provider.ts @@ -26,6 +26,10 @@ export interface IAuthProvider { data: AuthenticationInput, authIdentityProviderService: AuthIdentityProviderService ): Promise + register( + data: AuthenticationInput, + authIdentityProviderService: AuthIdentityProviderService + ): Promise validateCallback( data: AuthenticationInput, authIdentityProviderService: AuthIdentityProviderService diff --git a/packages/core/types/src/auth/service.ts b/packages/core/types/src/auth/service.ts index 370d24b06c..0dbeac7f5d 100644 --- a/packages/core/types/src/auth/service.ts +++ b/packages/core/types/src/auth/service.ts @@ -46,7 +46,12 @@ export interface IAuthModuleService extends IModuleService { */ authenticate( provider: string, - providerData: AuthenticationInput + providerData: AuthenticationInput, + ): Promise + + register( + provider: string, + providerData: AuthenticationInput, ): Promise /** diff --git a/packages/core/utils/src/auth/abstract-auth-provider.ts b/packages/core/utils/src/auth/abstract-auth-provider.ts index d3ef1c9e96..0e72a5ca29 100644 --- a/packages/core/utils/src/auth/abstract-auth-provider.ts +++ b/packages/core/utils/src/auth/abstract-auth-provider.ts @@ -7,39 +7,39 @@ import { /** * ### constructor - * + * * The constructor allows you to access resources from the module's container using the first parameter, * and the module's options using the second parameter. - * + * * If you're creating a client or establishing a connection with a third-party service, do it in the constructor. - * + * * In the constructor, you must pass to the parent constructor two parameters: - * + * * 1. The first one is an empty object. * 2. The second is an object having two properties: * - `provider`: The ID of the provider. For example, `emailpass`. * - `displayName`: The label or displayable name of the provider. For example, `Email and Password Authentication`. - * + * * #### Example - * + * * ```ts * import { AbstractAuthModuleProvider } from "@medusajs/utils" * import { Logger } from "@medusajs/types" - * + * * type InjectedDependencies = { * logger: Logger * } - * + * * type Options = { * apiKey: string * } - * + * * class MyAuthProviderService extends AbstractAuthModuleProvider { * protected logger_: Logger * protected options_: Options * // assuming you're initializing a client * protected client - * + * * constructor ( * { logger }: InjectedDependencies, * options: Options @@ -51,17 +51,17 @@ import { * displayName: "My Custom Authentication" * } * ) - * + * * this.logger_ = logger * this.options_ = options - * + * * // assuming you're initializing a client * this.client = new Client(options) * } - * + * * // ... * } - * + * * export default MyAuthProviderService * ``` */ @@ -93,7 +93,7 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider { /** * @ignore - * + * * @privateRemarks * Documenting the constructor in the class's TSDocs as it's difficult to relay * the necessary information with this constructor's signature. @@ -108,41 +108,41 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider { /** * This method authenticates the user. - * - * The authentication happens either by directly authenticating or returning a redirect URL to continue + * + * The authentication happens either by directly authenticating or returning a redirect URL to continue * the authentication with a third party provider. - * + * * @param {AuthenticationInput} data - The details of the authentication request. - * @param {AuthIdentityProviderService} authIdentityProviderService - The service used to retrieve or + * @param {AuthIdentityProviderService} authIdentityProviderService - The service used to retrieve or * create an auth identity. It has two methods: `create` to create an auth identity, * and `retrieve` to retrieve an auth identity. When you authenticate the user, you can create an auth identity * using this service. * @returns {Promise} The authentication response. - * + * * @privateRemarks * TODO add a link to the authentication flow document once it's public. - * + * * @example * For example, if your authentication provider doesn't require validating a callback: - * + * * ```ts - * import { - * AuthIdentityProviderService, - * AuthenticationInput, + * import { + * AuthIdentityProviderService, + * AuthenticationInput, * AuthenticationResponse * } from "@medusajs/types" * // ... - * + * * class MyAuthProviderService extends AbstractAuthModuleProvider { * // ... * async authenticate( - * data: AuthenticationInput, + * data: AuthenticationInput, * authIdentityProviderService: AuthIdentityProviderService * ): Promise { * const isAuthenticated = false * // TODO perform custom logic to authenticate the user * // ... - * + * * if (!isAuthenticated) { * // if the authentication didn't succeed, return * // an object of the following format @@ -151,11 +151,11 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider { * error: "Incorrect credentials" * } * } - * + * * // authentication is successful, create an auth identity * // if doesn't exist * let authIdentity - * + * * try { * authIdentity = await authIdentityProviderService.retrieve({ * entity_id: data.body.email, // email or some ID @@ -171,7 +171,7 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider { * } * }) * } - * + * * return { * success: true, * authIdentity @@ -179,27 +179,27 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider { * } * } * ``` - * + * * If your authentication provider requires validating callback: - * + * * ```ts - * import { - * AuthIdentityProviderService, - * AuthenticationInput, + * import { + * AuthIdentityProviderService, + * AuthenticationInput, * AuthenticationResponse * } from "@medusajs/types" * // ... - * + * * class MyAuthProviderService extends AbstractAuthModuleProvider { * // ... * async authenticate( - * data: AuthenticationInput, + * data: AuthenticationInput, * authIdentityProviderService: AuthIdentityProviderService * ): Promise { * const isAuthenticated = false * // TODO perform custom logic to authenticate the user * // ... - * + * * if (!isAuthenticated) { * // if the authentication didn't succeed, return * // an object of the following format @@ -208,7 +208,7 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider { * error: "Incorrect credentials" * } * } - * + * * return { * success: true, * location: "some-url.com" @@ -222,43 +222,52 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider { authIdentityProviderService: AuthIdentityProviderService ): Promise + register( + data: AuthenticationInput, + authIdentityProviderService: AuthIdentityProviderService + ): Promise { + throw new Error( + `Method 'register' not implemented for provider ${this.provider}` + ) + } + /** * This method validates the callback of an authentication request. - * + * * In an authentication flow that requires performing an action with a third-party service, such as login * with a social account, the {@link authenticate} method is called first. - * - * Then, the third-party service redirects to the Medusa application's validate callback API route. + * + * Then, the third-party service redirects to the Medusa application's validate callback API route. * That route uses this method to authenticate the user. - * + * * @param {AuthenticationInput} data - The details of the authentication request. - * @param {AuthIdentityProviderService} authIdentityProviderService - The service used to retrieve or + * @param {AuthIdentityProviderService} authIdentityProviderService - The service used to retrieve or * create an auth identity. It has two methods: `create` to create an auth identity, * and `retrieve` to retrieve an auth identity. When you authenticate the user, you can create an auth identity * using this service. * @returns {Promise} The authentication response. - * + * * @privateRemarks * TODO add a link to the authentication flow document once it's public. - * + * * @example - * import { - * AuthIdentityProviderService, - * AuthenticationInput, + * import { + * AuthIdentityProviderService, + * AuthenticationInput, * AuthenticationResponse * } from "@medusajs/types" * // ... - * + * * class MyAuthProviderService extends AbstractAuthModuleProvider { * // ... * async validateCallback( - * data: AuthenticationInput, + * data: AuthenticationInput, * authIdentityProviderService: AuthIdentityProviderService * ): Promise { * const isAuthenticated = false * // TODO perform custom logic to authenticate the user * // ... - * + * * if (!isAuthenticated) { * // if the authentication didn't succeed, return * // an object of the following format @@ -267,11 +276,11 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider { * error: "Something went wrong" * } * } - * + * * // authentication is successful, create an auth identity * // if doesn't exist * let authIdentity - * + * * try { * authIdentity = await authIdentityProviderService.retrieve({ * entity_id: data.body.email, // email or some ID @@ -287,7 +296,7 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider { * } * }) * } - * + * * return { * success: true, * authIdentity diff --git a/packages/framework/framework/src/http/middlewares/authenticate-middleware.ts b/packages/framework/framework/src/http/middlewares/authenticate-middleware.ts index ee8c7d7713..d7a0aa781a 100644 --- a/packages/framework/framework/src/http/middlewares/authenticate-middleware.ts +++ b/packages/framework/framework/src/http/middlewares/authenticate-middleware.ts @@ -5,13 +5,13 @@ import { } from "@medusajs/utils" import { NextFunction, RequestHandler } from "express" import { JwtPayload, verify } from "jsonwebtoken" +import { ConfigModule } from "../../config" import { AuthContext, AuthenticatedMedusaRequest, MedusaRequest, MedusaResponse, } from "../types" -import { ConfigModule } from "../../config" const SESSION_AUTH = "session" const BEARER_AUTH = "bearer" 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 new file mode 100644 index 0000000000..2c7c56847a --- /dev/null +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts @@ -0,0 +1,70 @@ +import { + AuthenticationInput, + ConfigModule, + IAuthModuleService, +} from "@medusajs/types" +import { + ContainerRegistrationKeys, + MedusaError, + ModuleRegistrationName, +} from "@medusajs/utils" +import { MedusaRequest, MedusaResponse } from "../../../../../types/routing" +import { generateJwtTokenForAuthIdentity } from "../../../utils/generate-jwt-token" + +export const POST = 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 + ) + + const authData = { + url: req.url, + headers: req.headers, + query: req.query, + body: req.body, + protocol: req.protocol, + } as AuthenticationInput + + const { success, error, authIdentity } = await service.register( + auth_provider, + authData + ) + + if (success) { + const { http } = config.projectConfig + + const token = generateJwtTokenForAuthIdentity( + { + authIdentity, + actorType: actor_type, + }, + { + secret: http.jwtSecret, + expiresIn: http.jwtExpiresIn, + } + ) + + return res.status(200).json({ token }) + } + + throw new MedusaError( + MedusaError.Types.UNAUTHORIZED, + error || "Authentication failed" + ) +} 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 4594936679..1143bf3454 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 @@ -7,9 +7,9 @@ import { ContainerRegistrationKeys, MedusaError, ModuleRegistrationName, - generateJwtToken, } from "@medusajs/utils" import { MedusaRequest, MedusaResponse } from "../../../../types/routing" +import { generateJwtTokenForAuthIdentity } from "../../utils/generate-jwt-token" export const GET = async (req: MedusaRequest, res: MedusaResponse) => { const { actor_type, auth_provider } = req.params @@ -52,30 +52,16 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { } if (success) { - const { http } = req.scope.resolve( - ContainerRegistrationKeys.CONFIG_MODULE - ).projectConfig + const { http } = config.projectConfig - const entityIdKey = `${actor_type}_id` - const entityId = authIdentity?.app_metadata?.[entityIdKey] as - | string - | undefined - const { jwtSecret, jwtExpiresIn } = http - - const token = generateJwtToken( + const token = generateJwtTokenForAuthIdentity( { - actor_id: entityId ?? "", - actor_type, - auth_identity_id: authIdentity?.id ?? "", - app_metadata: { - [entityIdKey]: entityId, - }, + authIdentity, + actorType: actor_type, }, { - // @ts-expect-error - secret: jwtSecret, - // @ts-expect-error - expiresIn: jwtExpiresIn, + secret: http.jwtSecret, + expiresIn: http.jwtExpiresIn, } ) diff --git a/packages/medusa/src/api/auth/middlewares.ts b/packages/medusa/src/api/auth/middlewares.ts index 0a580964fe..4a393395a3 100644 --- a/packages/medusa/src/api/auth/middlewares.ts +++ b/packages/medusa/src/api/auth/middlewares.ts @@ -19,6 +19,16 @@ export const authRoutesMiddlewares: MiddlewareRoute[] = [ }, { method: ["POST"], + matcher: "/auth/:actor_type/:auth_provider/register", + middlewares: [], + }, + { + method: ["POST"], + matcher: "/auth/:actor_type/:auth_provider", + middlewares: [], + }, + { + method: ["GET"], matcher: "/auth/:actor_type/:auth_provider", middlewares: [], }, diff --git a/packages/medusa/src/api/auth/utils/generate-jwt-token.ts b/packages/medusa/src/api/auth/utils/generate-jwt-token.ts new file mode 100644 index 0000000000..6a2927ac45 --- /dev/null +++ b/packages/medusa/src/api/auth/utils/generate-jwt-token.ts @@ -0,0 +1,26 @@ +import { generateJwtToken } from "@medusajs/utils" + +export function generateJwtTokenForAuthIdentity( + { authIdentity, actorType }, + { secret, expiresIn } +) { + const entityIdKey = `${actorType}_id` + const entityId = authIdentity?.app_metadata?.[entityIdKey] as + | string + | undefined + + return generateJwtToken( + { + actor_id: entityId ?? "", + actor_type: actorType, + auth_identity_id: authIdentity?.id ?? "", + app_metadata: { + [entityIdKey]: entityId, + }, + }, + { + secret, + expiresIn, + } + ) +} diff --git a/packages/medusa/src/commands/user.ts b/packages/medusa/src/commands/user.ts index e350f06ce7..9b57d4d913 100644 --- a/packages/medusa/src/commands/user.ts +++ b/packages/medusa/src/commands/user.ts @@ -41,7 +41,7 @@ export default async function ({ } else { const user = await userService.createUsers({ email }) - const { authIdentity, error } = await authService.authenticate(provider, { + const { authIdentity, error } = await authService.register(provider, { body: { email, password, diff --git a/packages/modules/auth/src/services/auth-module.ts b/packages/modules/auth/src/services/auth-module.ts index 78eb4a39d7..16631e906d 100644 --- a/packages/modules/auth/src/services/auth-module.ts +++ b/packages/modules/auth/src/services/auth-module.ts @@ -121,6 +121,20 @@ export default class AuthModuleService return Array.isArray(data) ? serializedUsers : serializedUsers[0] } + async register( + provider: string, + authenticationData: AuthenticationInput + ): Promise { + try { + return await this.authProviderService_.register( + provider, + authenticationData, + this.getAuthIdentityProviderService(provider) + ) + } catch (error) { + return { success: false, error: error.message } + } + } // @ts-expect-error createProviderIdentities( data: AuthTypes.CreateProviderIdentityDTO[], diff --git a/packages/modules/auth/src/services/auth-provider.ts b/packages/modules/auth/src/services/auth-provider.ts index 3daa12cdd8..4f5efcf235 100644 --- a/packages/modules/auth/src/services/auth-provider.ts +++ b/packages/modules/auth/src/services/auth-provider.ts @@ -1,7 +1,7 @@ import { + AuthIdentityProviderService, AuthTypes, AuthenticationInput, - AuthIdentityProviderService, AuthenticationResponse, } from "@medusajs/types" import { MedusaError } from "@medusajs/utils" @@ -42,6 +42,15 @@ export default class AuthProviderService { return await providerHandler.authenticate(auth, authIdentityProviderService) } + async register( + provider: string, + auth: AuthenticationInput, + authIdentityProviderService: AuthIdentityProviderService + ): Promise { + const providerHandler = this.retrieveProviderRegistration(provider) + return await providerHandler.register(auth, authIdentityProviderService) + } + async validateCallback( provider: string, auth: AuthenticationInput, diff --git a/packages/modules/providers/auth-emailpass/integration-tests/__tests__/services.spec.ts b/packages/modules/providers/auth-emailpass/integration-tests/__tests__/services.spec.ts index 8afc8fa228..33ab90d414 100644 --- a/packages/modules/providers/auth-emailpass/integration-tests/__tests__/services.spec.ts +++ b/packages/modules/providers/auth-emailpass/integration-tests/__tests__/services.spec.ts @@ -136,7 +136,7 @@ describe("Email password auth provider", () => { }), } - const resp = await emailpassService.authenticate( + const resp = await emailpassService.register( { body: { email: "test@admin.com", password: "test" } }, authServiceSpies ) @@ -151,4 +151,52 @@ describe("Email password auth provider", () => { }) ) }) + + it("throw if auth identity with email already exists", async () => { + const authServiceSpies = { + retrieve: jest.fn().mockImplementation(() => { + return { success: true } + }), + create: jest.fn().mockImplementation(() => { + return { + provider_identities: [ + { + entity_id: "test@admin.com", + provider: "emailpass", + provider_metadata: { + password: "somehash", + }, + }, + ], + } + }), + } + + const resp = await emailpassService.register( + { body: { email: "test@admin.com", password: "test" } }, + authServiceSpies + ) + + expect(authServiceSpies.retrieve).toHaveBeenCalled() + + expect(resp.error).toEqual("Identity with email already exists") + }) + + it("throws if auth identity with email doesn't exist", async () => { + const authServiceSpies = { + retrieve: jest.fn().mockImplementation(() => { + throw new MedusaError(MedusaError.Types.NOT_FOUND, "Not found") + }), + create: jest.fn().mockImplementation(() => {}), + } + + const resp = await emailpassService.authenticate( + { body: { email: "test@admin.com", password: "test" } }, + authServiceSpies + ) + + expect(authServiceSpies.retrieve).toHaveBeenCalled() + + expect(resp.error).toEqual("Invalid email or password") + }) }) diff --git a/packages/modules/providers/auth-emailpass/src/services/emailpass.ts b/packages/modules/providers/auth-emailpass/src/services/emailpass.ts index 247c219da4..02c62f0c58 100644 --- a/packages/modules/providers/auth-emailpass/src/services/emailpass.ts +++ b/packages/modules/providers/auth-emailpass/src/services/emailpass.ts @@ -1,15 +1,15 @@ import { - Logger, - EmailPassAuthProviderOptions, - AuthenticationResponse, AuthenticationInput, - AuthIdentityProviderService, + AuthenticationResponse, AuthIdentityDTO, + AuthIdentityProviderService, + EmailPassAuthProviderOptions, + Logger, } from "@medusajs/types" import { AbstractAuthModuleProvider, - MedusaError, isString, + MedusaError, } from "@medusajs/utils" import Scrypt from "scrypt-kdf" @@ -35,6 +35,26 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider { this.logger_ = logger } + protected async createAuthIdentity({ email, password, authIdentityService }) { + const hashConfig = this.config_.hashConfig ?? { logN: 15, r: 8, p: 1 } + const passwordHash = await Scrypt.kdf(password, hashConfig) + + const createdAuthIdentity = await authIdentityService.create({ + entity_id: email, + provider_metadata: { + password: passwordHash.toString("base64"), + }, + }) + + const copy = JSON.parse(JSON.stringify(createdAuthIdentity)) + const providerIdentity = copy.provider_identities?.find( + (pi) => pi.provider === this.provider + )! + delete providerIdentity.provider_metadata?.password + + return copy + } + async authenticate( userData: AuthenticationInput, authIdentityService: AuthIdentityProviderService @@ -54,6 +74,7 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider { error: "Email should be a string", } } + let authIdentity: AuthIdentityDTO | undefined try { @@ -62,25 +83,9 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider { }) } catch (error) { if (error.type === MedusaError.Types.NOT_FOUND) { - const config = this.config_.hashConfig ?? { logN: 15, r: 8, p: 1 } - const passwordHash = await Scrypt.kdf(password, config) - - const createdAuthIdentity = await authIdentityService.create({ - entity_id: email, - provider_metadata: { - password: passwordHash.toString("base64"), - }, - }) - - const copy = JSON.parse(JSON.stringify(createdAuthIdentity)) - const providerIdentity = copy.provider_identities?.find( - (pi) => pi.provider === this.provider - )! - delete providerIdentity.provider_metadata?.password - return { - success: true, - authIdentity: copy, + success: false, + error: "Invalid email or password", } } @@ -115,4 +120,51 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider { error: "Invalid email or password", } } + + async register( + userData: AuthenticationInput, + authIdentityService: AuthIdentityProviderService + ): Promise { + const { email, password } = userData.body ?? {} + + if (!password || !isString(password)) { + return { + success: false, + error: "Password should be a string", + } + } + + if (!email || !isString(email)) { + return { + success: false, + error: "Email should be a string", + } + } + + try { + await authIdentityService.retrieve({ + entity_id: email, + }) + + return { + success: false, + error: "Identity with email already exists", + } + } catch (error) { + if (error.type === MedusaError.Types.NOT_FOUND) { + const createdAuthIdentity = await this.createAuthIdentity({ + email, + password, + authIdentityService, + }) + + return { + success: true, + authIdentity: createdAuthIdentity, + } + } + + return { success: false, error: error.message } + } + } } diff --git a/packages/modules/providers/auth-google/src/services/google.ts b/packages/modules/providers/auth-google/src/services/google.ts index 466a67a26c..05cf5e1498 100644 --- a/packages/modules/providers/auth-google/src/services/google.ts +++ b/packages/modules/providers/auth-google/src/services/google.ts @@ -1,9 +1,9 @@ import { - Logger, - GoogleAuthProviderOptions, - AuthenticationResponse, AuthenticationInput, + AuthenticationResponse, AuthIdentityProviderService, + GoogleAuthProviderOptions, + Logger, } from "@medusajs/types" import { AbstractAuthModuleProvider, MedusaError } from "@medusajs/utils" import jwt, { JwtPayload } from "jsonwebtoken" @@ -29,6 +29,13 @@ export class GoogleAuthService extends AbstractAuthModuleProvider { this.logger_ = logger } + async register(_): Promise { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Google does not support registration. Use method `authenticate` instead." + ) + } + async authenticate( req: AuthenticationInput ): Promise {