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
This commit is contained in:
Oli Juhl authored and GitHub committed 2024-09-06 12:28:29 +02:00
1 parent 2c5e72d141
commit e27056b3c3
29 files changed
+633 -194

No files matched your search

@@ -1,3 +1,4 @@
import { generateResetPasswordTokenWorkflow } from "@medusajs/core-flows"
import { medusaIntegrationTestRunner } from "medusa-test-utils" import { medusaIntegrationTestRunner } from "medusa-test-utils"
import { import {
adminHeaders, adminHeaders,
@@ -8,120 +9,250 @@ jest.setTimeout(30000)
medusaIntegrationTestRunner({ medusaIntegrationTestRunner({
testSuite: ({ dbConnection, getContainer, api }) => { testSuite: ({ dbConnection, getContainer, api }) => {
let container
beforeEach(async () => { beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, getContainer()) container = getContainer()
await createAdminUser(dbConnection, adminHeaders, container)
}) })
it("Invite + registration + authentication flow", async () => { afterEach(() => {
// Create invite jest.useRealTimers()
const { token: inviteToken } = ( })
await api.post(
"/admin/invites",
{ email: "newadmin@medusa.js" },
adminHeaders
)
).data.invite
// Register identity describe("Full authentication lifecycle", () => {
const signup = await api.post("/auth/user/emailpass/register", { it("Invite + registration + authentication flow", async () => {
email: "newadmin@medusa.js", // Create invite
password: "secret_password", const { token: inviteToken } = (
}) await api.post(
"/admin/invites",
{ email: "newadmin@medusa.js" },
adminHeaders
)
).data.invite
expect(signup.status).toEqual(200) // Register identity
expect(signup.data).toEqual({ token: expect.any(String) }) const signup = await api.post("/auth/user/emailpass/register", {
// Accept invite
const response = await api.post(
`/admin/invites/accept?token=${inviteToken}`,
{
email: "newadmin@medusa.js", email: "newadmin@medusa.js",
first_name: "John", password: "secret_password",
last_name: "Doe", })
},
{ expect(signup.status).toEqual(200)
headers: { expect(signup.data).toEqual({ token: expect.any(String) })
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(response.status).toEqual(200) expect(response.status).toEqual(200)
expect(response.data).toEqual({ expect(response.data).toEqual({
user: expect.objectContaining({ 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", email: "newadmin@medusa.js",
first_name: "John", password: "secret_password",
last_name: "Doe", })
}), 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 it("should respond with 401 on register, if email already exists", async () => {
const login = await api.post("/auth/user/emailpass", { const signup = await api
email: "newadmin@medusa.js", .post("/auth/user/emailpass/register", {
password: "secret_password", 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 it("should respond with 401 on sign in, if email does not exist", async () => {
const createSession = await api.post( const signup = await api
"/auth/session", .post("/auth/user/emailpass", {
{}, email: "john@doe.com",
{ headers: { authorization: `Bearer ${login.data.token}` } } password: "secret_password",
) })
expect(createSession.status).toEqual(200) .catch((e) => e)
// Extract cookie expect(signup.response.status).toEqual(401)
const [cookie] = createSession.headers["set-cookie"][0].split(";") expect(signup.response.data.message).toEqual(
expect(cookie).toEqual(expect.stringContaining("connect.sid")) "Invalid email or password"
)
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)
}) })
it("should respond with 401 on register, if email already exists", async () => { describe("Reset password flows", () => {
const signup = await api it("should generate a reset password token", async () => {
.post("/auth/user/emailpass/register", { const response = await api.post("/auth/user/emailpass/reset-password", {
email: "admin@medusa.js", 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", password: "secret_password",
}) })
.catch((e) => e)
expect(signup.response.status).toEqual(401) // The token won't be part of the Rest API response, so we need to generate it manually
expect(signup.response.data.message).toEqual( const { result } = await generateResetPasswordTokenWorkflow(
"Identity with email already exists" 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 response = await api.post(
const signup = await api `/auth/user/emailpass/update?token=${result}`,
.post("/auth/user/emailpass", { {
email: "john@doe.com", 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", password: "secret_password",
}) })
.catch((e) => e)
expect(signup.response.status).toEqual(401) // The token won't be part of the Rest API response, so we need to generate it manually
expect(signup.response.data.message).toEqual("Invalid email or password") 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")
})
}) })
}, },
}) })
@@ -1 +1,3 @@
export * from "./steps" export * from "./steps"
export * from "./workflows"
@@ -30,7 +30,7 @@ export const setAuthAppMetadataStep = createStep(
appMetadata[key] = data.value appMetadata[key] = data.value
await service.updateAuthIdentites({ await service.updateAuthIdentities({
id: authIdentity.id, id: authIdentity.id,
app_metadata: appMetadata, app_metadata: appMetadata,
}) })
@@ -58,7 +58,7 @@ export const setAuthAppMetadataStep = createStep(
delete appMetadata[key] delete appMetadata[key]
} }
await service.updateAuthIdentites({ await service.updateAuthIdentities({
id: authIdentity.id, id: authIdentity.id,
app_metadata: appMetadata, app_metadata: appMetadata,
}) })
@@ -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)
}
)
@@ -0,0 +1 @@
export * from "./generate-reset-password-token"
@@ -83,6 +83,11 @@ export type ProviderIdentityDTO = {
*/ */
entity_id: string 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. * The auth identity linked to the provider identity.
*/ */
@@ -205,6 +210,11 @@ export interface FilterableProviderIdentityProps
*/ */
entity_id?: string 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. * The provider handle to filter the provider identity by.
*/ */
+4
View File
@@ -41,4 +41,8 @@ export interface IAuthProvider {
data: AuthenticationInput, data: AuthenticationInput,
authIdentityProviderService: AuthIdentityProviderService authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse> ): Promise<AuthenticationResponse>
update: (
data: Record<string, unknown>,
authIdentityProviderService: AuthIdentityProviderService
) => Promise<AuthenticationResponse>
} }
+12 -7
View File
@@ -54,6 +54,11 @@ export interface IAuthModuleService extends IModuleService {
providerData: AuthenticationInput providerData: AuthenticationInput
): Promise<AuthenticationResponse> ): Promise<AuthenticationResponse>
updateProvider(
provider: string,
providerData: Record<string, unknown>
): Promise<AuthenticationResponse>
/** /**
* When authenticating users with a third-party provider, such as Google, the user performs an * 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 * 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<AuthIdentityDTO[]>} The updated auths. * @returns {Promise<AuthIdentityDTO[]>} The updated auths.
* *
* @example * @example
* const authIdentities = await authModuleService.updateAuthIdentites([ * const authIdentities = await authModuleService.updateAuthIdentities([
* { * {
* id: "authusr_123", * id: "authusr_123",
* }, * },
* ]) * ])
*/ */
updateAuthIdentites( updateAuthIdentities(
data: UpdateAuthIdentityDTO[], data: UpdateAuthIdentityDTO[],
sharedContext?: Context sharedContext?: Context
): Promise<AuthIdentityDTO[]> ): Promise<AuthIdentityDTO[]>
@@ -263,11 +268,11 @@ export interface IAuthModuleService extends IModuleService {
* @returns {Promise<AuthIdentityDTO>} The updated auth. * @returns {Promise<AuthIdentityDTO>} The updated auth.
* *
* @example * @example
* const authIdentity = await authModuleService.updateAuthIdentites({ * const authIdentity = await authModuleService.updateAuthIdentities({
* id: "authusr_123", * id: "authusr_123",
* }) * })
*/ */
updateAuthIdentites( updateAuthIdentities(
data: UpdateAuthIdentityDTO, data: UpdateAuthIdentityDTO,
sharedContext?: Context sharedContext?: Context
): Promise<AuthIdentityDTO> ): Promise<AuthIdentityDTO>
@@ -399,7 +404,7 @@ export interface IAuthModuleService extends IModuleService {
* }, * },
* ]) * ])
*/ */
updateProviderIdentites( updateProviderIdentities(
data: UpdateProviderIdentityDTO[], data: UpdateProviderIdentityDTO[],
sharedContext?: Context sharedContext?: Context
): Promise<ProviderIdentityDTO[]> ): Promise<ProviderIdentityDTO[]>
@@ -412,11 +417,11 @@ export interface IAuthModuleService extends IModuleService {
* @returns {Promise<ProviderIdentityDTO>} The updated provider identity. * @returns {Promise<ProviderIdentityDTO>} The updated provider identity.
* *
* @example * @example
* const providerIdentity = await authModuleService.updateProviderIdentites({ * const providerIdentity = await authModuleService.updateProviderIdentities({
* id: "provider_123", * id: "provider_123",
* }) * })
*/ */
updateProviderIdentites( updateProviderIdentities(
data: UpdateProviderIdentityDTO, data: UpdateProviderIdentityDTO,
sharedContext?: Context sharedContext?: Context
): Promise<ProviderIdentityDTO> ): Promise<ProviderIdentityDTO>
@@ -2,7 +2,7 @@ import {
AuthenticationInput, AuthenticationInput,
AuthenticationResponse, AuthenticationResponse,
AuthIdentityProviderService, AuthIdentityProviderService,
IAuthProvider, IAuthProvider
} from "@medusajs/types" } from "@medusajs/types"
/** /**
@@ -238,6 +238,15 @@ export abstract class AbstractAuthModuleProvider implements IAuthProvider {
) )
} }
update(
data: Record<string, unknown>,
authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse> {
throw new Error(
`Method 'update' not implemented for provider ${this.provider}`
)
}
/** /**
* This method validates the callback of an authentication request. * This method validates the callback of an authentication request.
* *
@@ -10,6 +10,16 @@ export const OrderWorkflowEvents = {
COMPLETED: "order.completed", 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 = { export const SalesChannelWorkflowEvents = {
CREATED: "sales-channel.created", CREATED: "sales-channel.created",
UPDATED: "sales-channel.updated", UPDATED: "sales-channel.updated",
-6
View File
@@ -7,9 +7,3 @@ export const UserEvents = {
...buildEventNamesFromEntityName(eventBaseNames, Modules.USER), ...buildEventNamesFromEntityName(eventBaseNames, Modules.USER),
INVITE_TOKEN_GENERATED: `${Modules.USER}.user.invite.token_generated`, INVITE_TOKEN_GENERATED: `${Modules.USER}.user.invite.token_generated`,
} }
export const UserWorkflowEvents = {
CREATED: "user.created",
UPDATED: "user.updated",
DELETED: "user.deleted",
}
@@ -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 * as QueryConfig from "./query-config"
import { import {
AdminGetUserParams, AdminGetUserParams,
AdminGetUsersParams, AdminGetUsersParams,
AdminUpdateUser, AdminUpdateUser
} from "./validators" } 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. // 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. // See https://medusacorp.slack.com/archives/C025KMS13SA/p1716455350491879 for details.
export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [
@@ -31,8 +29,8 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [
middlewares: [ middlewares: [
authenticate("user", ["bearer", "session"]), authenticate("user", ["bearer", "session"]),
validateAndTransformQuery( validateAndTransformQuery(
AdminGetUserParams, AdminGetUserParams,
QueryConfig.retrieveTransformQueryConfig QueryConfig.retrieveTransformQueryConfig
), ),
], ],
}, },
@@ -42,8 +40,8 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [
middlewares: [ middlewares: [
authenticate("user", ["bearer", "session"]), authenticate("user", ["bearer", "session"]),
validateAndTransformQuery( validateAndTransformQuery(
AdminGetUserParams, AdminGetUserParams,
QueryConfig.retrieveTransformQueryConfig QueryConfig.retrieveTransformQueryConfig
), ),
], ],
}, },
@@ -54,8 +52,8 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [
authenticate("user", ["bearer", "session"]), authenticate("user", ["bearer", "session"]),
validateAndTransformBody(AdminUpdateUser), validateAndTransformBody(AdminUpdateUser),
validateAndTransformQuery( validateAndTransformQuery(
AdminGetUserParams, AdminGetUserParams,
QueryConfig.retrieveTransformQueryConfig QueryConfig.retrieveTransformQueryConfig
), ),
], ],
}, },
@@ -38,3 +38,4 @@ export const AdminUpdateUser = z.object({
last_name: z.string().nullish(), last_name: z.string().nullish(),
avatar_url: z.string().nullish(), avatar_url: z.string().nullish(),
}) })
@@ -1,8 +1,4 @@
import { import { AuthenticationInput, IAuthModuleService } from "@medusajs/types"
AuthenticationInput,
ConfigModule,
IAuthModuleService,
} from "@medusajs/types"
import { import {
ContainerRegistrationKeys, ContainerRegistrationKeys,
MedusaError, MedusaError,
@@ -13,21 +9,6 @@ import { MedusaRequest, MedusaResponse } from "../../../../../types/routing"
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
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( const service: IAuthModuleService = req.scope.resolve(
ModuleRegistrationName.AUTH ModuleRegistrationName.AUTH
@@ -17,18 +17,6 @@ export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
ContainerRegistrationKeys.CONFIG_MODULE 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( const service: IAuthModuleService = req.scope.resolve(
ModuleRegistrationName.AUTH ModuleRegistrationName.AUTH
) )
@@ -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
@@ -17,18 +17,6 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
ContainerRegistrationKeys.CONFIG_MODULE 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( const service: IAuthModuleService = req.scope.resolve(
ModuleRegistrationName.AUTH ModuleRegistrationName.AUTH
) )
@@ -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<IAuthModuleService>(
ModuleRegistrationName.AUTH
)
const { authIdentity, success, error } = await authService.updateProvider(
auth_provider,
req.body as Record<string, unknown>
)
if (success && authIdentity) {
return res.status(200).json({ success: true })
}
throw new MedusaError(MedusaError.Types.UNAUTHORIZED, error || "Unauthorized")
}
+16 -4
View File
@@ -1,5 +1,7 @@
import { MiddlewareRoute } from "@medusajs/framework" import { MiddlewareRoute } from "@medusajs/framework"
import { authenticate } from "../../utils/middlewares/authenticate-middleware" 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[] = [ export const authRoutesMiddlewares: MiddlewareRoute[] = [
{ {
@@ -15,21 +17,31 @@ export const authRoutesMiddlewares: MiddlewareRoute[] = [
{ {
method: ["POST"], method: ["POST"],
matcher: "/auth/:actor_type/:auth_provider/callback", matcher: "/auth/:actor_type/:auth_provider/callback",
middlewares: [], middlewares: [validateScopeProviderAssociation()],
}, },
{ {
method: ["POST"], method: ["POST"],
matcher: "/auth/:actor_type/:auth_provider/register", matcher: "/auth/:actor_type/:auth_provider/register",
middlewares: [], middlewares: [validateScopeProviderAssociation()],
}, },
{ {
method: ["POST"], method: ["POST"],
matcher: "/auth/:actor_type/:auth_provider", matcher: "/auth/:actor_type/:auth_provider",
middlewares: [], middlewares: [validateScopeProviderAssociation()],
}, },
{ {
method: ["GET"], method: ["GET"],
matcher: "/auth/:actor_type/:auth_provider", 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()],
}, },
] ]
@@ -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()
}
}
@@ -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<ConfigModule>(
ContainerRegistrationKeys.CONFIG_MODULE
).projectConfig
const authModule = req.scope.resolve<IAuthModuleService>(
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()
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { MiddlewareFunction } from "@medusajs/framework"
import { NextFunction } from "express" import { NextFunction } from "express"
import { MedusaRequest, MedusaResponse } from "../../types/routing" 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. // 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. // This function allows us to skip middlewares for particular routes, so we can temporarily solve this without completely breaking the route loader for everyone.
+1 -1
View File
@@ -54,7 +54,7 @@ export default async function ({
} }
// We know the authIdentity is not undefined // We know the authIdentity is not undefined
await authService.updateAuthIdentites({ await authService.updateAuthIdentities({
id: authIdentity!.id, id: authIdentity!.id,
app_metadata: { app_metadata: {
user_id: user.id, user_id: user.id,
@@ -215,7 +215,7 @@ moduleIntegrationTestRunner<IAuthModuleService>({
let error let error
try { try {
await service.updateAuthIdentites([ await service.updateAuthIdentities([
{ {
id: "does-not-exist", id: "does-not-exist",
}, },
@@ -230,7 +230,7 @@ moduleIntegrationTestRunner<IAuthModuleService>({
}) })
it("should update authIdentity", async () => { it("should update authIdentity", async () => {
await service.updateAuthIdentites([ await service.updateAuthIdentities([
{ {
id, id,
app_metadata: { email: "test@email.com" }, app_metadata: { email: "test@email.com" },
@@ -364,7 +364,7 @@ moduleIntegrationTestRunner<IAuthModuleService>({
let error let error
try { try {
await service.updateProviderIdentites([ await service.updateProviderIdentities([
{ {
id: "does-not-exist", id: "does-not-exist",
}, },
@@ -382,18 +382,18 @@ moduleIntegrationTestRunner<IAuthModuleService>({
let [providerIdentity] = await service.listProviderIdentities({ let [providerIdentity] = await service.listProviderIdentities({
entity_id, entity_id,
}) })
await service.updateProviderIdentites([ await service.updateProviderIdentities([
{ {
id: providerIdentity.id, id: providerIdentity.id,
provider_metadata: { email: "test@email.com" }, provider_metadata: { email: "test@email.com" },
}, },
]) ])
const providerIdentites = await service.listProviderIdentities({ const providerIdentities = await service.listProviderIdentities({
id: [providerIdentity.id], id: [providerIdentity.id],
}) })
expect(providerIdentites[0]).toEqual( expect(providerIdentities[0]).toEqual(
expect.objectContaining({ expect.objectContaining({
provider_metadata: expect.objectContaining({ provider_metadata: expect.objectContaining({
email: "test@email.com", email: "test@email.com",
@@ -1,8 +1,8 @@
import { IAuthModuleService } from "@medusajs/types" import { IAuthModuleService } from "@medusajs/types"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { resolve } from "path"
import { Module, Modules } from "@medusajs/utils" import { Module, Modules } from "@medusajs/utils"
import { AuthModuleService } from "@services" import { AuthModuleService } from "@services"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
import { resolve } from "path"
let moduleOptions = { let moduleOptions = {
providers: [ providers: [
@@ -42,7 +42,10 @@ moduleIntegrationTestRunner({
service: AuthModuleService, service: AuthModuleService,
}).linkable }).linkable
expect(Object.keys(linkable)).toEqual(["authIdentity"]) expect(Object.keys(linkable)).toEqual([
"authIdentity",
"providerIdentity",
])
linkable.authIdentity.toJSON = undefined linkable.authIdentity.toJSON = undefined
@@ -54,6 +57,17 @@ moduleIntegrationTestRunner({
field: "authIdentity", 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 () => { it("it fails if the provider does not exist", async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
import { AuthIdentity } from "@models"
import { defineJoinerConfig, Modules } from "@medusajs/utils" import { defineJoinerConfig, Modules } from "@medusajs/utils"
import { AuthIdentity, ProviderIdentity } from "@models"
export const joinerConfig = defineJoinerConfig(Modules.AUTH, { export const joinerConfig = defineJoinerConfig(Modules.AUTH, {
models: [AuthIdentity], models: [AuthIdentity, ProviderIdentity],
}) })
@@ -9,17 +9,14 @@ import {
ModuleJoinerConfig, ModuleJoinerConfig,
ModulesSdkTypes, ModulesSdkTypes,
} from "@medusajs/types" } from "@medusajs/types"
import { AuthIdentity, ProviderIdentity } from "@models"
import { joinerConfig } from "../joiner-config"
import { import {
InjectManager, InjectManager,
MedusaContext, MedusaContext,
MedusaError, MedusaError,
MedusaService, MedusaService,
} from "@medusajs/utils" } from "@medusajs/utils"
import { AuthIdentity, ProviderIdentity } from "@models"
import { joinerConfig } from "../joiner-config"
import AuthProviderService from "./auth-provider" import AuthProviderService from "./auth-provider"
type InjectedDependencies = { type InjectedDependencies = {
@@ -92,18 +89,19 @@ export default class AuthModuleService
} }
// TODO: Update to follow convention // TODO: Update to follow convention
updateAuthIdentites( // @ts-expect-error
updateAuthIdentities(
data: AuthTypes.UpdateAuthIdentityDTO[], data: AuthTypes.UpdateAuthIdentityDTO[],
sharedContext?: Context sharedContext?: Context
): Promise<AuthTypes.AuthIdentityDTO[]> ): Promise<AuthTypes.AuthIdentityDTO[]>
updateAuthIdentites( updateAuthIdentities(
data: AuthTypes.UpdateAuthIdentityDTO, data: AuthTypes.UpdateAuthIdentityDTO,
sharedContext?: Context sharedContext?: Context
): Promise<AuthTypes.AuthIdentityDTO> ): Promise<AuthTypes.AuthIdentityDTO>
@InjectManager("baseRepository_") @InjectManager("baseRepository_")
async updateAuthIdentites( async updateAuthIdentities(
data: AuthTypes.UpdateAuthIdentityDTO | AuthTypes.UpdateAuthIdentityDTO[], data: AuthTypes.UpdateAuthIdentityDTO | AuthTypes.UpdateAuthIdentityDTO[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<AuthTypes.AuthIdentityDTO | AuthTypes.AuthIdentityDTO[]> { ): Promise<AuthTypes.AuthIdentityDTO | AuthTypes.AuthIdentityDTO[]> {
@@ -135,6 +133,7 @@ export default class AuthModuleService
return { success: false, error: error.message } return { success: false, error: error.message }
} }
} }
// @ts-expect-error // @ts-expect-error
createProviderIdentities( createProviderIdentities(
data: AuthTypes.CreateProviderIdentityDTO[], data: AuthTypes.CreateProviderIdentityDTO[],
@@ -163,18 +162,19 @@ export default class AuthModuleService
>(providerIdentities) >(providerIdentities)
} }
updateProviderIdentites( // @ts-expect-error
updateProviderIdentities(
data: AuthTypes.UpdateProviderIdentityDTO[], data: AuthTypes.UpdateProviderIdentityDTO[],
sharedContext?: Context sharedContext?: Context
): Promise<AuthTypes.ProviderIdentityDTO[]> ): Promise<AuthTypes.ProviderIdentityDTO[]>
updateProviderIdentites( updateProviderIdentities(
data: AuthTypes.UpdateProviderIdentityDTO, data: AuthTypes.UpdateProviderIdentityDTO,
sharedContext?: Context sharedContext?: Context
): Promise<AuthTypes.ProviderIdentityDTO> ): Promise<AuthTypes.ProviderIdentityDTO>
@InjectManager("baseRepository_") @InjectManager("baseRepository_")
async updateProviderIdentites( async updateProviderIdentities(
data: data:
| AuthTypes.UpdateProviderIdentityDTO | AuthTypes.UpdateProviderIdentityDTO
| AuthTypes.UpdateProviderIdentityDTO[], | AuthTypes.UpdateProviderIdentityDTO[],
@@ -192,6 +192,21 @@ export default class AuthModuleService
return Array.isArray(data) ? serializedProviders : serializedProviders[0] return Array.isArray(data) ? serializedProviders : serializedProviders[0]
} }
async updateProvider(
provider: string,
data: Record<string, unknown>
): Promise<AuthenticationResponse> {
try {
return await this.authProviderService_.update(
provider,
data,
this.getAuthIdentityProviderService(provider)
)
} catch (error) {
return { success: false, error: error.message }
}
}
async authenticate( async authenticate(
provider: string, provider: string,
authenticationData: AuthenticationInput authenticationData: AuthenticationInput
@@ -2,7 +2,7 @@ import {
AuthIdentityProviderService, AuthIdentityProviderService,
AuthTypes, AuthTypes,
AuthenticationInput, AuthenticationInput,
AuthenticationResponse, AuthenticationResponse
} from "@medusajs/types" } from "@medusajs/types"
import { MedusaError } from "@medusajs/utils" import { MedusaError } from "@medusajs/utils"
import { AuthProviderRegistrationPrefix } from "@types" import { AuthProviderRegistrationPrefix } from "@types"
@@ -51,6 +51,15 @@ export default class AuthProviderService {
return await providerHandler.register(auth, authIdentityProviderService) return await providerHandler.register(auth, authIdentityProviderService)
} }
async update(
provider: string,
data: Record<string, unknown>,
authIdentityProviderService: AuthIdentityProviderService
): Promise<AuthenticationResponse> {
const providerHandler = this.retrieveProviderRegistration(provider)
return await providerHandler.update(data, authIdentityProviderService)
}
async validateCallback( async validateCallback(
provider: string, provider: string,
auth: AuthenticationInput, auth: AuthenticationInput,
@@ -35,14 +35,56 @@ export class EmailPassAuthService extends AbstractAuthModuleProvider {
this.logger_ = logger 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 hashConfig = this.config_.hashConfig ?? { logN: 15, r: 8, p: 1 }
const passwordHash = await Scrypt.kdf(password, hashConfig) 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({ const createdAuthIdentity = await authIdentityService.create({
entity_id: email, entity_id: email,
provider_metadata: { provider_metadata: {
password: passwordHash.toString("base64"), password: passwordHash,
}, },
}) })