feat: Separate registration from authentication in auth domain (#8683)

* wip

* feat: Introduce register

* fix: user command

* fix: Invite HTTP tests

* fix: Auth tests

* fix: Invite modules tests
This commit is contained in:
Oli Juhl
2024-08-27 13:44:52 +02:00
committed by GitHub
parent c6eba80af6
commit c11ef01c15
21 changed files with 459 additions and 152 deletions
@@ -1,8 +1,8 @@
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import { import {
adminHeaders, adminHeaders,
createAdminUser, createAdminUser,
} from "../../../../helpers/create-admin-user" } from "../../../../helpers/create-admin-user"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -12,27 +12,50 @@ medusaIntegrationTestRunner({
await createAdminUser(dbConnection, adminHeaders, getContainer()) 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("Invite + registration + authentication flow", async () => {
it.skip("test the entire authentication flow", async () => { // Create invite
// BREAKING: `/admin/auth` changes to `/auth/user/emailpass` const { token: inviteToken } = (
const signup = await api.post("/auth/user/emailpass", { 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", email: "newadmin@medusa.js",
password: "secret_password", 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.status).toEqual(200)
expect(signup.data).toEqual({ token: expect.any(String) }) expect(signup.data).toEqual({ token: expect.any(String) })
// BREAKING: IN V2 creating a user is separated from creating an auth identity // Accept invite
const createdUser = await api.post( const response = await api.post(
"/admin/users", `/admin/invites/accept?token=${inviteToken}`,
{ email: "newadmin@medusa.js" }, {
{ headers: { authorization: `Bearer ${signup.data.token}` } } 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", { const login = await api.post("/auth/user/emailpass", {
email: "newadmin@medusa.js", email: "newadmin@medusa.js",
password: "secret_password", password: "secret_password",
@@ -40,6 +63,7 @@ medusaIntegrationTestRunner({
expect(login.status).toEqual(200) expect(login.status).toEqual(200)
expect(login.data).toEqual({ token: expect.any(String) }) expect(login.data).toEqual({ token: expect.any(String) })
// Convert token to session
const createSession = await api.post( const createSession = await api.post(
"/auth/session", "/auth/session",
{}, {},
@@ -47,7 +71,7 @@ medusaIntegrationTestRunner({
) )
expect(createSession.status).toEqual(200) expect(createSession.status).toEqual(200)
// extract cookie // Extract cookie
const [cookie] = createSession.headers["set-cookie"][0].split(";") const [cookie] = createSession.headers["set-cookie"][0].split(";")
expect(cookie).toEqual(expect.stringContaining("connect.sid")) expect(cookie).toEqual(expect.stringContaining("connect.sid"))
@@ -55,23 +79,49 @@ medusaIntegrationTestRunner({
headers: { Cookie: cookie }, headers: { Cookie: cookie },
} }
// perform cookie authenticated request // Perform cookie authenticated request
const authedRequest = await api.get( const authedRequest = await api.get(
"/admin/products?limit=1", "/admin/products?limit=1",
cookieHeader cookieHeader
) )
expect(authedRequest.status).toEqual(200) expect(authedRequest.status).toEqual(200)
// sign out // Sign out
const signOutRequest = await api.delete("/auth/session", cookieHeader) const signOutRequest = await api.delete("/auth/session", cookieHeader)
expect(signOutRequest.status).toEqual(200) expect(signOutRequest.status).toEqual(200)
// attempt to perform authenticated request // Attempt to perform authenticated request
const unAuthedRequest = await api const unAuthedRequest = await api
.get("/admin/products?limit=1", cookieHeader) .get("/admin/products?limit=1", cookieHeader)
.catch((e) => e) .catch((e) => e)
expect(unAuthedRequest.response.status).toEqual(401) 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")
})
}, },
}) })
@@ -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", email: "test@medusa-commerce.com",
password: "secret_password", password: "secret_password",
}) })
@@ -92,7 +92,7 @@ medusaIntegrationTestRunner({
it("should fail to accept an invite given an invalid token", async () => { it("should fail to accept an invite given an invalid token", async () => {
expect.assertions(2) 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", email: "test@medusa-commerce.com",
password: "secret_password", password: "secret_password",
}) })
@@ -119,7 +119,7 @@ medusaIntegrationTestRunner({
}) })
it("should fail to accept an already accepted invite ", async () => { 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", email: "test@medusa-commerce.com",
password: "secret_password", 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", email: "another-test@medusa-commerce.com",
password: "secret_password", password: "secret_password",
}) })
@@ -27,7 +27,7 @@ medusaIntegrationTestRunner({
}) })
it("should fail to accept an invite with an invalid invite token", async () => { 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", email: "potential_member@test.com",
password: "supersecret", password: "supersecret",
}) })
@@ -58,7 +58,7 @@ medusaIntegrationTestRunner({
email: "potential_member@test.com", 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", email: "potential_member@test.com",
password: "supersecret", password: "supersecret",
}) })
@@ -92,7 +92,7 @@ medusaIntegrationTestRunner({
email: "potential_member@test.com", 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", email: "some-email@test.com",
password: "supersecret", password: "supersecret",
}) })
@@ -3,7 +3,7 @@ import { UseMutationOptions, useMutation } from "@tanstack/react-query"
import { sdk } from "../../lib/client" import { sdk } from "../../lib/client"
import { EmailPassReq } from "../../types/api-payloads" import { EmailPassReq } from "../../types/api-payloads"
export const useEmailPassLogin = ( export const useSignInWithEmailPassword = (
options?: UseMutationOptions<void, Error, EmailPassReq> options?: UseMutationOptions<void, Error, EmailPassReq>
) => { ) => {
return useMutation({ return useMutation({
@@ -15,21 +15,21 @@ export const useEmailPassLogin = (
}) })
} }
export const useSignUpWithEmailPass = (
options?: UseMutationOptions<string, Error, EmailPassReq>
) => {
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<void, Error>) => { export const useLogout = (options?: UseMutationOptions<void, Error>) => {
return useMutation({ return useMutation({
mutationFn: () => sdk.auth.logout(), mutationFn: () => sdk.auth.logout(),
...options, ...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,
})
}
@@ -10,7 +10,7 @@ import { Link, useSearchParams } from "react-router-dom"
import * as z from "zod" import * as z from "zod"
import { Form } from "../../components/common/form" import { Form } from "../../components/common/form"
import { LogoBox } from "../../components/common/logo-box" 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 { useAcceptInvite } from "../../hooks/api/invites"
import { isFetchError } from "../../lib/is-fetch-error" import { isFetchError } from "../../lib/is-fetch-error"
@@ -204,15 +204,15 @@ const CreateView = ({
}, },
}) })
const { mutateAsync: createAuthUser, isPending: isCreatingAuthUser } = const { mutateAsync: signUpEmailPass, isPending: isCreatingAuthUser } =
useCreateAuthUser() useSignUpWithEmailPass()
const { mutateAsync: acceptInvite, isPending: isAcceptingInvite } = const { mutateAsync: acceptInvite, isPending: isAcceptingInvite } =
useAcceptInvite(token) useAcceptInvite(token)
const handleSubmit = form.handleSubmit(async (data) => { const handleSubmit = form.handleSubmit(async (data) => {
try { try {
const { token: authToken } = await createAuthUser({ const authToken = await signUpEmailPass({
email: data.email, email: data.email,
password: data.password, password: data.password,
}) })
@@ -8,7 +8,7 @@ import * as z from "zod"
import { Divider } from "../../components/common/divider" import { Divider } from "../../components/common/divider"
import { Form } from "../../components/common/form" import { Form } from "../../components/common/form"
import { LogoBox } from "../../components/common/logo-box" 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 { isAxiosError } from "../../lib/is-axios-error"
import after from "virtual:medusa/widgets/login/after" 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 } = useSignInWithEmailPassword()
const { mutateAsync, isPending } = useEmailPassLogin()
const handleSubmit = form.handleSubmit(async ({ email, password }) => { const handleSubmit = form.handleSubmit(async ({ email, password }) => {
try { try {
+18
View File
@@ -10,6 +10,24 @@ export class Auth {
this.config = config 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 ( login = async (
actor: "customer" | "user", actor: "customer" | "user",
method: "emailpass", method: "emailpass",
+4
View File
@@ -26,6 +26,10 @@ export interface IAuthProvider {
data: AuthenticationInput, data: AuthenticationInput,
authIdentityProviderService: AuthIdentityProviderService authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse> ): Promise<AuthenticationResponse>
register(
data: AuthenticationInput,
authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse>
validateCallback( validateCallback(
data: AuthenticationInput, data: AuthenticationInput,
authIdentityProviderService: AuthIdentityProviderService authIdentityProviderService: AuthIdentityProviderService
+6 -1
View File
@@ -46,7 +46,12 @@ export interface IAuthModuleService extends IModuleService {
*/ */
authenticate( authenticate(
provider: string, provider: string,
providerData: AuthenticationInput providerData: AuthenticationInput,
): Promise<AuthenticationResponse>
register(
provider: string,
providerData: AuthenticationInput,
): Promise<AuthenticationResponse> ): Promise<AuthenticationResponse>
/** /**
@@ -7,39 +7,39 @@ import {
/** /**
* ### constructor * ### constructor
* *
* The constructor allows you to access resources from the module's container using the first parameter, * 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. * 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. * 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: * In the constructor, you must pass to the parent constructor two parameters:
* *
* 1. The first one is an empty object. * 1. The first one is an empty object.
* 2. The second is an object having two properties: * 2. The second is an object having two properties:
* - `provider`: The ID of the provider. For example, `emailpass`. * - `provider`: The ID of the provider. For example, `emailpass`.
* - `displayName`: The label or displayable name of the provider. For example, `Email and Password Authentication`. * - `displayName`: The label or displayable name of the provider. For example, `Email and Password Authentication`.
* *
* #### Example * #### Example
* *
* ```ts * ```ts
* import { AbstractAuthModuleProvider } from "@medusajs/utils" * import { AbstractAuthModuleProvider } from "@medusajs/utils"
* import { Logger } from "@medusajs/types" * import { Logger } from "@medusajs/types"
* *
* type InjectedDependencies = { * type InjectedDependencies = {
* logger: Logger * logger: Logger
* } * }
* *
* type Options = { * type Options = {
* apiKey: string * apiKey: string
* } * }
* *
* class MyAuthProviderService extends AbstractAuthModuleProvider { * class MyAuthProviderService extends AbstractAuthModuleProvider {
* protected logger_: Logger * protected logger_: Logger
* protected options_: Options * protected options_: Options
* // assuming you're initializing a client * // assuming you're initializing a client
* protected client * protected client
* *
* constructor ( * constructor (
* { logger }: InjectedDependencies, * { logger }: InjectedDependencies,
* options: Options * options: Options
@@ -51,17 +51,17 @@ import {
* displayName: "My Custom Authentication" * displayName: "My Custom Authentication"
* } * }
* ) * )
* *
* this.logger_ = logger * this.logger_ = logger
* this.options_ = options * this.options_ = options
* *
* // assuming you're initializing a client * // assuming you're initializing a client
* this.client = new Client(options) * this.client = new Client(options)
* } * }
* *
* // ... * // ...
* } * }
* *
* export default MyAuthProviderService * export default MyAuthProviderService
* ``` * ```
*/ */
@@ -93,7 +93,7 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider {
/** /**
* @ignore * @ignore
* *
* @privateRemarks * @privateRemarks
* Documenting the constructor in the class's TSDocs as it's difficult to relay * Documenting the constructor in the class's TSDocs as it's difficult to relay
* the necessary information with this constructor's signature. * the necessary information with this constructor's signature.
@@ -108,41 +108,41 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider {
/** /**
* This method authenticates the user. * 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. * the authentication with a third party provider.
* *
* @param {AuthenticationInput} data - The details of the authentication request. * @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, * 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 * and `retrieve` to retrieve an auth identity. When you authenticate the user, you can create an auth identity
* using this service. * using this service.
* @returns {Promise<AuthenticationResponse>} The authentication response. * @returns {Promise<AuthenticationResponse>} The authentication response.
* *
* @privateRemarks * @privateRemarks
* TODO add a link to the authentication flow document once it's public. * TODO add a link to the authentication flow document once it's public.
* *
* @example * @example
* For example, if your authentication provider doesn't require validating a callback: * For example, if your authentication provider doesn't require validating a callback:
* *
* ```ts * ```ts
* import { * import {
* AuthIdentityProviderService, * AuthIdentityProviderService,
* AuthenticationInput, * AuthenticationInput,
* AuthenticationResponse * AuthenticationResponse
* } from "@medusajs/types" * } from "@medusajs/types"
* // ... * // ...
* *
* class MyAuthProviderService extends AbstractAuthModuleProvider { * class MyAuthProviderService extends AbstractAuthModuleProvider {
* // ... * // ...
* async authenticate( * async authenticate(
* data: AuthenticationInput, * data: AuthenticationInput,
* authIdentityProviderService: AuthIdentityProviderService * authIdentityProviderService: AuthIdentityProviderService
* ): Promise<AuthenticationResponse> { * ): Promise<AuthenticationResponse> {
* const isAuthenticated = false * const isAuthenticated = false
* // TODO perform custom logic to authenticate the user * // TODO perform custom logic to authenticate the user
* // ... * // ...
* *
* if (!isAuthenticated) { * if (!isAuthenticated) {
* // if the authentication didn't succeed, return * // if the authentication didn't succeed, return
* // an object of the following format * // an object of the following format
@@ -151,11 +151,11 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider {
* error: "Incorrect credentials" * error: "Incorrect credentials"
* } * }
* } * }
* *
* // authentication is successful, create an auth identity * // authentication is successful, create an auth identity
* // if doesn't exist * // if doesn't exist
* let authIdentity * let authIdentity
* *
* try { * try {
* authIdentity = await authIdentityProviderService.retrieve({ * authIdentity = await authIdentityProviderService.retrieve({
* entity_id: data.body.email, // email or some ID * entity_id: data.body.email, // email or some ID
@@ -171,7 +171,7 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider {
* } * }
* }) * })
* } * }
* *
* return { * return {
* success: true, * success: true,
* authIdentity * authIdentity
@@ -179,27 +179,27 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider {
* } * }
* } * }
* ``` * ```
* *
* If your authentication provider requires validating callback: * If your authentication provider requires validating callback:
* *
* ```ts * ```ts
* import { * import {
* AuthIdentityProviderService, * AuthIdentityProviderService,
* AuthenticationInput, * AuthenticationInput,
* AuthenticationResponse * AuthenticationResponse
* } from "@medusajs/types" * } from "@medusajs/types"
* // ... * // ...
* *
* class MyAuthProviderService extends AbstractAuthModuleProvider { * class MyAuthProviderService extends AbstractAuthModuleProvider {
* // ... * // ...
* async authenticate( * async authenticate(
* data: AuthenticationInput, * data: AuthenticationInput,
* authIdentityProviderService: AuthIdentityProviderService * authIdentityProviderService: AuthIdentityProviderService
* ): Promise<AuthenticationResponse> { * ): Promise<AuthenticationResponse> {
* const isAuthenticated = false * const isAuthenticated = false
* // TODO perform custom logic to authenticate the user * // TODO perform custom logic to authenticate the user
* // ... * // ...
* *
* if (!isAuthenticated) { * if (!isAuthenticated) {
* // if the authentication didn't succeed, return * // if the authentication didn't succeed, return
* // an object of the following format * // an object of the following format
@@ -208,7 +208,7 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider {
* error: "Incorrect credentials" * error: "Incorrect credentials"
* } * }
* } * }
* *
* return { * return {
* success: true, * success: true,
* location: "some-url.com" * location: "some-url.com"
@@ -222,43 +222,52 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider {
authIdentityProviderService: AuthIdentityProviderService authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse> ): Promise<AuthenticationResponse>
register(
data: AuthenticationInput,
authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse> {
throw new Error(
`Method 'register' not implemented for provider ${this.provider}`
)
}
/** /**
* This method validates the callback of an authentication request. * 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 * 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. * 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. * That route uses this method to authenticate the user.
* *
* @param {AuthenticationInput} data - The details of the authentication request. * @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, * 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 * and `retrieve` to retrieve an auth identity. When you authenticate the user, you can create an auth identity
* using this service. * using this service.
* @returns {Promise<AuthenticationResponse>} The authentication response. * @returns {Promise<AuthenticationResponse>} The authentication response.
* *
* @privateRemarks * @privateRemarks
* TODO add a link to the authentication flow document once it's public. * TODO add a link to the authentication flow document once it's public.
* *
* @example * @example
* import { * import {
* AuthIdentityProviderService, * AuthIdentityProviderService,
* AuthenticationInput, * AuthenticationInput,
* AuthenticationResponse * AuthenticationResponse
* } from "@medusajs/types" * } from "@medusajs/types"
* // ... * // ...
* *
* class MyAuthProviderService extends AbstractAuthModuleProvider { * class MyAuthProviderService extends AbstractAuthModuleProvider {
* // ... * // ...
* async validateCallback( * async validateCallback(
* data: AuthenticationInput, * data: AuthenticationInput,
* authIdentityProviderService: AuthIdentityProviderService * authIdentityProviderService: AuthIdentityProviderService
* ): Promise<AuthenticationResponse> { * ): Promise<AuthenticationResponse> {
* const isAuthenticated = false * const isAuthenticated = false
* // TODO perform custom logic to authenticate the user * // TODO perform custom logic to authenticate the user
* // ... * // ...
* *
* if (!isAuthenticated) { * if (!isAuthenticated) {
* // if the authentication didn't succeed, return * // if the authentication didn't succeed, return
* // an object of the following format * // an object of the following format
@@ -267,11 +276,11 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider {
* error: "Something went wrong" * error: "Something went wrong"
* } * }
* } * }
* *
* // authentication is successful, create an auth identity * // authentication is successful, create an auth identity
* // if doesn't exist * // if doesn't exist
* let authIdentity * let authIdentity
* *
* try { * try {
* authIdentity = await authIdentityProviderService.retrieve({ * authIdentity = await authIdentityProviderService.retrieve({
* entity_id: data.body.email, // email or some ID * entity_id: data.body.email, // email or some ID
@@ -287,7 +296,7 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider {
* } * }
* }) * })
* } * }
* *
* return { * return {
* success: true, * success: true,
* authIdentity * authIdentity
@@ -5,13 +5,13 @@ import {
} from "@medusajs/utils" } from "@medusajs/utils"
import { NextFunction, RequestHandler } from "express" import { NextFunction, RequestHandler } from "express"
import { JwtPayload, verify } from "jsonwebtoken" import { JwtPayload, verify } from "jsonwebtoken"
import { ConfigModule } from "../../config"
import { import {
AuthContext, AuthContext,
AuthenticatedMedusaRequest, AuthenticatedMedusaRequest,
MedusaRequest, MedusaRequest,
MedusaResponse, MedusaResponse,
} from "../types" } from "../types"
import { ConfigModule } from "../../config"
const SESSION_AUTH = "session" const SESSION_AUTH = "session"
const BEARER_AUTH = "bearer" const BEARER_AUTH = "bearer"
@@ -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"
)
}
@@ -7,9 +7,9 @@ import {
ContainerRegistrationKeys, ContainerRegistrationKeys,
MedusaError, MedusaError,
ModuleRegistrationName, ModuleRegistrationName,
generateJwtToken,
} from "@medusajs/utils" } from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../../types/routing" import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
import { generateJwtTokenForAuthIdentity } from "../../utils/generate-jwt-token"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => { export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { actor_type, auth_provider } = req.params const { actor_type, auth_provider } = req.params
@@ -52,30 +52,16 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
} }
if (success) { if (success) {
const { http } = req.scope.resolve( const { http } = config.projectConfig
ContainerRegistrationKeys.CONFIG_MODULE
).projectConfig
const entityIdKey = `${actor_type}_id` const token = generateJwtTokenForAuthIdentity(
const entityId = authIdentity?.app_metadata?.[entityIdKey] as
| string
| undefined
const { jwtSecret, jwtExpiresIn } = http
const token = generateJwtToken(
{ {
actor_id: entityId ?? "", authIdentity,
actor_type, actorType: actor_type,
auth_identity_id: authIdentity?.id ?? "",
app_metadata: {
[entityIdKey]: entityId,
},
}, },
{ {
// @ts-expect-error secret: http.jwtSecret,
secret: jwtSecret, expiresIn: http.jwtExpiresIn,
// @ts-expect-error
expiresIn: jwtExpiresIn,
} }
) )
@@ -19,6 +19,16 @@ export const authRoutesMiddlewares: MiddlewareRoute[] = [
}, },
{ {
method: ["POST"], 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", matcher: "/auth/:actor_type/:auth_provider",
middlewares: [], middlewares: [],
}, },
@@ -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,
}
)
}
+1 -1
View File
@@ -41,7 +41,7 @@ export default async function ({
} else { } else {
const user = await userService.createUsers({ email }) const user = await userService.createUsers({ email })
const { authIdentity, error } = await authService.authenticate(provider, { const { authIdentity, error } = await authService.register(provider, {
body: { body: {
email, email,
password, password,
@@ -121,6 +121,20 @@ export default class AuthModuleService
return Array.isArray(data) ? serializedUsers : serializedUsers[0] return Array.isArray(data) ? serializedUsers : serializedUsers[0]
} }
async register(
provider: string,
authenticationData: AuthenticationInput
): Promise<AuthenticationResponse> {
try {
return await this.authProviderService_.register(
provider,
authenticationData,
this.getAuthIdentityProviderService(provider)
)
} catch (error) {
return { success: false, error: error.message }
}
}
// @ts-expect-error // @ts-expect-error
createProviderIdentities( createProviderIdentities(
data: AuthTypes.CreateProviderIdentityDTO[], data: AuthTypes.CreateProviderIdentityDTO[],
@@ -1,7 +1,7 @@
import { import {
AuthIdentityProviderService,
AuthTypes, AuthTypes,
AuthenticationInput, AuthenticationInput,
AuthIdentityProviderService,
AuthenticationResponse, AuthenticationResponse,
} from "@medusajs/types" } from "@medusajs/types"
import { MedusaError } from "@medusajs/utils" import { MedusaError } from "@medusajs/utils"
@@ -42,6 +42,15 @@ export default class AuthProviderService {
return await providerHandler.authenticate(auth, authIdentityProviderService) return await providerHandler.authenticate(auth, authIdentityProviderService)
} }
async register(
provider: string,
auth: AuthenticationInput,
authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse> {
const providerHandler = this.retrieveProviderRegistration(provider)
return await providerHandler.register(auth, authIdentityProviderService)
}
async validateCallback( async validateCallback(
provider: string, provider: string,
auth: AuthenticationInput, auth: AuthenticationInput,
@@ -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" } }, { body: { email: "test@admin.com", password: "test" } },
authServiceSpies 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")
})
}) })
@@ -1,15 +1,15 @@
import { import {
Logger,
EmailPassAuthProviderOptions,
AuthenticationResponse,
AuthenticationInput, AuthenticationInput,
AuthIdentityProviderService, AuthenticationResponse,
AuthIdentityDTO, AuthIdentityDTO,
AuthIdentityProviderService,
EmailPassAuthProviderOptions,
Logger,
} from "@medusajs/types" } from "@medusajs/types"
import { import {
AbstractAuthModuleProvider, AbstractAuthModuleProvider,
MedusaError,
isString, isString,
MedusaError,
} from "@medusajs/utils" } from "@medusajs/utils"
import Scrypt from "scrypt-kdf" import Scrypt from "scrypt-kdf"
@@ -35,6 +35,26 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider {
this.logger_ = logger 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( async authenticate(
userData: AuthenticationInput, userData: AuthenticationInput,
authIdentityService: AuthIdentityProviderService authIdentityService: AuthIdentityProviderService
@@ -54,6 +74,7 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider {
error: "Email should be a string", error: "Email should be a string",
} }
} }
let authIdentity: AuthIdentityDTO | undefined let authIdentity: AuthIdentityDTO | undefined
try { try {
@@ -62,25 +83,9 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider {
}) })
} catch (error) { } catch (error) {
if (error.type === MedusaError.Types.NOT_FOUND) { 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 { return {
success: true, success: false,
authIdentity: copy, error: "Invalid email or password",
} }
} }
@@ -115,4 +120,51 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider {
error: "Invalid email or password", error: "Invalid email or password",
} }
} }
async register(
userData: AuthenticationInput,
authIdentityService: AuthIdentityProviderService
): Promise<AuthenticationResponse> {
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 }
}
}
} }
@@ -1,9 +1,9 @@
import { import {
Logger,
GoogleAuthProviderOptions,
AuthenticationResponse,
AuthenticationInput, AuthenticationInput,
AuthenticationResponse,
AuthIdentityProviderService, AuthIdentityProviderService,
GoogleAuthProviderOptions,
Logger,
} from "@medusajs/types" } from "@medusajs/types"
import { AbstractAuthModuleProvider, MedusaError } from "@medusajs/utils" import { AbstractAuthModuleProvider, MedusaError } from "@medusajs/utils"
import jwt, { JwtPayload } from "jsonwebtoken" import jwt, { JwtPayload } from "jsonwebtoken"
@@ -29,6 +29,13 @@ export class GoogleAuthService extends AbstractAuthModuleProvider {
this.logger_ = logger this.logger_ = logger
} }
async register(_): Promise<AuthenticationResponse> {
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"Google does not support registration. Use method `authenticate` instead."
)
}
async authenticate( async authenticate(
req: AuthenticationInput req: AuthenticationInput
): Promise<AuthenticationResponse> { ): Promise<AuthenticationResponse> {