feat(): Add support for jwt asymetric keys (#12813)

* feat(): Add support for jwt asymetric keys

* Create early-chefs-chew.md

* fix unit tests

* Add verify options support

* feedback

* fix unit tests
This commit is contained in:
Adrien de Peretti authored and GitHub committed 2025-06-25 10:29:32 +02:00
1 parent a833c3c98c
commit d517dbd66a
23 files changed
+813 -43

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
---
"@medusajs/medusa": patch
"@medusajs/user": patch
"@medusajs/auth-google": patch
"@medusajs/core-flows": patch
"@medusajs/framework": patch
"@medusajs/types": patch
"@medusajs/utils": patch
---
feat(): Add support for jwt asymetric keys
@@ -7,6 +7,7 @@ import {
} from "@medusajs/framework/types" } from "@medusajs/framework/types"
import { import {
ApiKeyType, ApiKeyType,
ContainerRegistrationKeys,
Modules, Modules,
PUBLISHABLE_KEY_HEADER, PUBLISHABLE_KEY_HEADER,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
@@ -51,15 +52,19 @@ export const createAdminUser = async (
}, },
}) })
const config = container.resolve(ContainerRegistrationKeys.CONFIG_MODULE)
const { projectConfig } = config
const { jwtSecret, jwtOptions } = projectConfig.http
const token = jwt.sign( const token = jwt.sign(
{ {
actor_id: user.id, actor_id: user.id,
actor_type: "user", actor_type: "user",
auth_identity_id: authIdentity.id, auth_identity_id: authIdentity.id,
}, },
"test", jwtSecret,
{ {
expiresIn: "1d", expiresIn: "1d",
...jwtOptions,
} }
) )
@@ -0,0 +1,62 @@
const { defineConfig, Modules } = require("@medusajs/utils")
const { generateKeyPairSync } = require("crypto")
const os = require("os")
const passphrase = "secret"
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
modulusLength: 4096,
publicKeyEncoding: {
type: "spki",
format: "pem",
},
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
cipher: "aes-256-cbc",
passphrase,
},
})
const DB_HOST = process.env.DB_HOST
const DB_USERNAME = process.env.DB_USERNAME
const DB_PASSWORD = process.env.DB_PASSWORD
const DB_NAME = process.env.DB_TEMP_NAME
const DB_URL = `postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/${DB_NAME}`
process.env.DATABASE_URL = DB_URL
process.env.LOG_LEVEL = "error"
const jwtOptions = {
algorithm: "RS256",
expiresIn: "1h",
issuer: "medusa",
keyid: "medusa",
}
module.exports = defineConfig({
admin: {
disable: true,
},
projectConfig: {
http: {
jwtSecret: {
key: privateKey,
passphrase,
},
jwtPublicKey: publicKey,
jwtOptions: jwtOptions,
},
},
modules: [
{
key: Modules.USER,
options: {
jwt_secret: {
key: privateKey,
passphrase,
},
jwt_public_key: publicKey,
jwt_options: jwtOptions,
},
},
],
})
@@ -0,0 +1,470 @@
import { generateResetPasswordTokenWorkflow } from "@medusajs/core-flows"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import jwt from "jsonwebtoken"
import {
adminHeaders,
createAdminUser,
} from "../../../../helpers/create-admin-user"
import path from "path"
import { ContainerRegistrationKeys } from "@medusajs/utils"
jest.setTimeout(300000)
medusaIntegrationTestRunner({
medusaConfigFile: path.join(__dirname, "../../../__fixtures__/auth"),
testSuite: ({ dbConnection, getContainer, api }) => {
let container
beforeEach(async () => {
container = getContainer()
await createAdminUser(dbConnection, adminHeaders, container)
})
afterEach(() => {
jest.useRealTimers()
})
describe("Full authentication lifecycle", () => {
it("Invite + registration + authentication flow", async () => {
// Create invite
const { token: inviteToken } = (
await api.post(
"/admin/invites",
{ email: "newadmin@medusa.js" },
adminHeaders
)
).data.invite
// Register identity
const signup = await api.post("/auth/user/emailpass/register", {
email: "newadmin@medusa.js",
password: "secret_password",
})
expect(signup.status).toEqual(200)
expect(signup.data).toEqual({ token: expect.any(String) })
// Accept invite
const response = await api.post(
`/admin/invites/accept?token=${inviteToken}`,
{
email: "newadmin@medusa.js",
first_name: "John",
last_name: "Doe",
},
{
headers: {
authorization: `Bearer ${signup.data.token}`,
},
}
)
expect(response.status).toEqual(200)
expect(response.data).toEqual({
user: expect.objectContaining({
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",
})
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)
})
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"
)
})
})
describe("Reset password flows", () => {
it("should generate a reset password token", async () => {
const response = await api.post("/auth/user/emailpass/reset-password", {
identifier: "admin@medusa.js",
})
expect(response.status).toEqual(201)
})
it("should fail if identifier is not provided", async () => {
const errResponse = await api
.post("/auth/user/emailpass/reset-password", {})
.catch((e) => e)
expect(errResponse.response.data.message).toEqual(
"Invalid request: Field 'identifier' is required"
)
expect(errResponse.response.status).toEqual(400)
})
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", {
identifier: "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",
{ identifier: "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",
})
// The token won't be part of the Rest API response, so we need to generate it manually
const { http } = container.resolve(
ContainerRegistrationKeys.CONFIG_MODULE
).projectConfig
const { result } = await generateResetPasswordTokenWorkflow(
container
).run({
input: {
entityId: "test@medusa-commerce.com",
actorType: "user",
provider: "emailpass",
secret: http.jwtSecret!,
jwtOptions: http.jwtOptions,
},
})
const response = await api.post(
`/auth/user/emailpass/update`,
{
password: "new_password",
},
{
headers: {
authorization: `Bearer ${result}`,
},
}
)
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 ensure you can only update password", async () => {
// Register user
await api.post("/auth/user/emailpass/register", {
email: "test@medusa-commerce.com",
password: "secret_password",
})
// The token won't be part of the Rest API response, so we need to generate it manually
const { http } = container.resolve(
ContainerRegistrationKeys.CONFIG_MODULE
).projectConfig
const { result } = await generateResetPasswordTokenWorkflow(
container
).run({
input: {
entityId: "test@medusa-commerce.com",
actorType: "user",
provider: "emailpass",
secret: http.jwtSecret!,
jwtOptions: http.jwtOptions,
},
})
const response = await api.post(
`/auth/user/emailpass/update`,
{
email: "test+new@medusa-commerce.com",
password: "new_password",
},
{
headers: {
authorization: `Bearer ${result}`,
},
}
)
expect(response.status).toEqual(200)
expect(response.data).toEqual({ success: true })
const failedLogin = await api
.post("/auth/user/emailpass", {
email: "test+new@medusa-commerce.com",
password: "new_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",
})
// The token won't be part of the Rest API response, so we need to generate it manually
const { result } = await generateResetPasswordTokenWorkflow(
container
).run({
input: {
entityId: "test@medusa-commerce.com",
actorType: "user",
provider: "emailpass",
secret: "test",
},
})
// Advance time by 15 minutes
jest.advanceTimersByTime(15 * 60 * 1000)
const response = await api
.post(
`/auth/user/emailpass/update`,
{
password: "new_password",
},
{
headers: {
authorization: `Bearer ${result}`,
},
}
)
.catch((e) => e)
expect(response.response.status).toEqual(401)
expect(response.response.data.message).toEqual("Invalid token")
})
it("should fail if no token is passed", async () => {
jest.useFakeTimers()
// Register user
await api.post("/auth/user/emailpass/register", {
email: "test@medusa-commerce.com",
password: "secret_password",
})
// Advance time by 15 minutes
jest.advanceTimersByTime(15 * 60 * 1000)
const response = await api
.post(`/auth/user/emailpass/update`, {
email: "test@medusa-commerce.com",
})
.catch((e) => e)
expect(response.response.status).toEqual(401)
expect(response.response.data.message).toEqual("Invalid token")
})
it("should fail if update is attempted on different actor type", async () => {
jest.useFakeTimers()
// Register user
await api.post("/auth/user/emailpass/register", {
email: "test@medusa-commerce.com",
password: "secret_password",
})
// The token won't be part of the Rest API response, so we need to generate it manually
const { result } = await generateResetPasswordTokenWorkflow(
container
).run({
input: {
entityId: "test@medusa-commerce.com",
actorType: "user",
provider: "emailpass",
secret: "test",
},
})
// Advance time by 15 minutes
jest.advanceTimersByTime(15 * 60 * 1000)
const response = await api
.post(
`/auth/customer/emailpass/update`,
{
password: "new_password",
},
{
headers: {
authorization: `Bearer ${result}`,
},
}
)
.catch((e) => e)
expect(response.response.status).toEqual(401)
expect(response.response.data.message).toEqual("Invalid token")
})
it("should fail if token secret is incorrect", async () => {
jest.useFakeTimers()
// Register user
await api.post("/auth/user/emailpass/register", {
email: "test@medusa-commerce.com",
password: "secret_password",
})
// The token won't be part of the Rest API response, so we need to generate it manually
const { result } = await generateResetPasswordTokenWorkflow(
container
).run({
input: {
entityId: "test@medusa-commerce.com",
actorType: "user",
provider: "emailpass",
secret: "incorrect_secret",
},
})
// Advance time by 15 minutes
jest.advanceTimersByTime(15 * 60 * 1000)
const response = await api
.post(
`/auth/user/emailpass/update`,
{
password: "new_password",
},
{
headers: {
authorization: `Bearer ${result}`,
},
}
)
.catch((e) => e)
expect(response.response.status).toEqual(401)
expect(response.response.data.message).toEqual("Invalid token")
})
})
it("should refresh the token successfully", async () => {
// Make sure issue date is later than the admin one
jest.useFakeTimers()
jest.advanceTimersByTime(2000)
const resp = await api.post("/auth/token/refresh", {}, adminHeaders)
const decodedOriginalToken = jwt.decode(
adminHeaders.headers["authorization"].split(" ")[1]
) as any
const decodedRefreshedToken = jwt.decode(resp.data.token) as any
expect(decodedOriginalToken).toEqual(
expect.objectContaining({
actor_id: decodedRefreshedToken.actor_id,
actor_type: decodedRefreshedToken.actor_type,
auth_identity_id: decodedRefreshedToken.auth_identity_id,
})
)
expect(decodedOriginalToken.iat).toBeLessThan(decodedRefreshedToken.iat)
expect(decodedOriginalToken.exp).toBeLessThan(decodedRefreshedToken.exp)
})
},
})
@@ -9,6 +9,7 @@ import {
WorkflowResponse, WorkflowResponse,
} from "@medusajs/framework/workflows-sdk" } from "@medusajs/framework/workflows-sdk"
import { emitEventStep, useRemoteQueryStep } from "../../common" import { emitEventStep, useRemoteQueryStep } from "../../common"
import { ProjectConfigOptions } from "@medusajs/framework/types"
/** /**
* This workflow generates a reset password token for a user. It's used by the * This workflow generates a reset password token for a user. It's used by the
@@ -45,7 +46,8 @@ export const generateResetPasswordTokenWorkflow = createWorkflow(
entityId: string entityId: string
actorType: string actorType: string
provider: string provider: string
secret: string secret: ProjectConfigOptions["http"]["jwtSecret"]
jwtOptions?: ProjectConfigOptions["http"]["jwtOptions"]
}) => { }) => {
const providerIdentities = useRemoteQueryStep({ const providerIdentities = useRemoteQueryStep({
entry_point: "provider_identity", entry_point: "provider_identity",
@@ -79,6 +81,7 @@ export const generateResetPasswordTokenWorkflow = createWorkflow(
{ {
secret: input.secret, secret: input.secret,
expiresIn: "15m", expiresIn: "15m",
jwtOptions: input.jwtOptions,
} }
) )
@@ -80,7 +80,21 @@ export class ConfigManager {
http.storeCors = http.storeCors ?? "" http.storeCors = http.storeCors ?? ""
http.adminCors = http.adminCors ?? "" http.adminCors = http.adminCors ?? ""
http.jwtOptions ??= {}
http.jwtOptions.expiresIn = http.jwtExpiresIn
http.jwtSecret = http?.jwtSecret ?? process.env.JWT_SECRET http.jwtSecret = http?.jwtSecret ?? process.env.JWT_SECRET
http.jwtPublicKey = http?.jwtPublicKey ?? process.env.JWT_PUBLIC_KEY
if (
http?.jwtPublicKey &&
((http.jwtVerifyOptions && !http.jwtVerifyOptions.algorithms?.length) ||
(http.jwtOptions && !http.jwtOptions.algorithm))
) {
this.rejectErrors(
`JWT public key is provided but no algorithm is set in the 'jwtVerifyOptions' or 'jwtOptions' if 'jwtVerifyOptions' is not provided. It means that the algorithm will be inferred from the public key which can lead to missmatch and invalid algorithm errors.`
)
}
if (!http.jwtSecret) { if (!http.jwtSecret) {
this.rejectErrors( this.rejectErrors(
@@ -1,7 +1,13 @@
import { ApiKeyDTO, IApiKeyModuleService } from "@medusajs/types" import { ApiKeyDTO, IApiKeyModuleService } from "@medusajs/types"
import { ContainerRegistrationKeys, Modules } from "@medusajs/utils" import { ContainerRegistrationKeys, Modules } from "@medusajs/utils"
import { NextFunction, RequestHandler } from "express" import { NextFunction, RequestHandler } from "express"
import { JwtPayload, verify } from "jsonwebtoken" import type {
JwtPayload,
Secret,
SignOptions,
VerifyOptions,
} from "jsonwebtoken"
import { verify } from "jsonwebtoken"
import { ConfigModule } from "../../config" import { ConfigModule } from "../../config"
import { import {
AuthContext, AuthContext,
@@ -76,7 +82,9 @@ export const authenticate = (
req.headers.authorization, req.headers.authorization,
http.jwtSecret!, http.jwtSecret!,
authTypes, authTypes,
actorTypes actorTypes,
http.jwtPublicKey,
http.jwtVerifyOptions ?? http.jwtOptions
) )
} }
@@ -172,9 +180,11 @@ const getAuthContextFromSession = (
export const getAuthContextFromJwtToken = ( export const getAuthContextFromJwtToken = (
authHeader: string | undefined, authHeader: string | undefined,
jwtSecret: string, jwtSecret: Secret,
authTypes: AuthType[], authTypes: AuthType[],
actorTypes: string[] actorTypes: string[],
jwtPublicKey?: Secret,
jwtOptions?: VerifyOptions | SignOptions
): AuthContext | null => { ): AuthContext | null => {
if (!authTypes.includes(BEARER_AUTH)) { if (!authTypes.includes(BEARER_AUTH)) {
return null return null
@@ -195,7 +205,18 @@ export const getAuthContextFromJwtToken = (
// get config jwt secret // get config jwt secret
// verify token and set authUser // verify token and set authUser
try { try {
const verified = verify(token, jwtSecret) as JwtPayload const options = { ...jwtOptions } as VerifyOptions & SignOptions
if (!options.algorithms && options.algorithm) {
options.algorithms = [options.algorithm]
delete options.algorithm
}
const verified = verify(
token,
jwtPublicKey ?? jwtSecret!,
options
) as JwtPayload
if (isActorTypePermitted(actorTypes, verified.actor_type)) { if (isActorTypePermitted(actorTypes, verified.actor_type)) {
return verified as AuthContext return verified as AuthContext
} }
+1
View File
@@ -35,6 +35,7 @@
"bignumber.js": "^9.1.2" "bignumber.js": "^9.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/jsonwebtoken": "^8.5.9",
"awilix": "^8.0.1", "awilix": "^8.0.1",
"expect-type": "^0.20.0", "expect-type": "^0.20.0",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
@@ -2,6 +2,7 @@ import {
ExternalModuleDeclaration, ExternalModuleDeclaration,
InternalModuleDeclaration, InternalModuleDeclaration,
} from "../modules-sdk" } from "../modules-sdk"
import type { SignOptions, Secret, VerifyOptions } from "jsonwebtoken"
import type { RedisOptions } from "ioredis" import type { RedisOptions } from "ioredis"
import { ConnectionOptions } from "node:tls" import { ConnectionOptions } from "node:tls"
@@ -505,7 +506,70 @@ export type ProjectConfigOptions = {
* }) * })
* ``` * ```
*/ */
jwtSecret?: string jwtSecret?: Secret
/**
* The public key used to verify the JWT token in combination with the JWT secret and the JWT options.
* Only used when the JWT secret is a secret key for asymetric validation.
*
* @example
* ```js title="medusa-config.ts"
* module.exports = defineConfig({
* projectConfig: {
* http: {
* jwtPublicKey: "public-key"
* }
* // ...
* },
* // ...
* })
* ```
*/
jwtPublicKey?: Secret
/**
* Options for the JWT token when using asymetric signing private/public key. Will be used for validation if `jwtVerifyOptions` is not provided.
*
* @example
* ```js title="medusa-config.ts"
* module.exports = defineConfig({
* projectConfig: {
* http: {
* jwtOptions: {
* algorithm: "RS256",
* expiresIn: "1h",
* issuer: "medusa",
* keyid: "medusa",
* }
* }
* // ...
* },
* // ...
* })
* ```
*/
jwtOptions?: SignOptions
/**
* Options for the JWT token when using asymetric validation private/public key.
*
* @example
* ```js title="medusa-config.ts"
* module.exports = defineConfig({
* projectConfig: {
* http: {
* jwtVerifyOptions: {
* // ...
* }
* }
* // ...
* },
* // ...
* })
* ```
*/
jwtVerifyOptions?: VerifyOptions
/** /**
* The expiration time for the JWT token. Its format is based off the [ms package](https://github.com/vercel/ms). * The expiration time for the JWT token. Its format is based off the [ms package](https://github.com/vercel/ms).
* *
+11 -5
View File
@@ -1,21 +1,27 @@
import jwt from "jsonwebtoken" import jwt, { type Secret, type SignOptions } from "jsonwebtoken"
import { MedusaError } from "../common" import { MedusaError } from "../common"
export const generateJwtToken = ( export const generateJwtToken = (
tokenPayload: Record<string, unknown>, tokenPayload: Record<string, unknown>,
jwtConfig: { jwtConfig: {
secret: string | undefined secret?: Secret
expiresIn: string | undefined expiresIn?: number | string
jwtOptions?: SignOptions
} }
) => { ) => {
if (!jwtConfig.secret || !jwtConfig.expiresIn) { if (
!jwtConfig.secret ||
(!jwtConfig.expiresIn && !jwtConfig.jwtOptions?.expiresIn)
) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT, MedusaError.Types.INVALID_ARGUMENT,
"JWT secret and expiresIn must be provided when generating a token" "JWT secret and expiresIn must be provided when generating a token"
) )
} }
const expiresIn = jwtConfig.expiresIn ?? jwtConfig.jwtOptions?.expiresIn
return jwt.sign(tokenPayload, jwtConfig.secret, { return jwt.sign(tokenPayload, jwtConfig.secret, {
expiresIn: jwtConfig.expiresIn, ...jwtConfig.jwtOptions,
expiresIn,
}) })
} }
@@ -117,7 +117,10 @@ describe("defineConfig", function () {
}, },
"user": { "user": {
"options": { "options": {
"jwt_options": undefined,
"jwt_public_key": undefined,
"jwt_secret": "supersecret", "jwt_secret": "supersecret",
"jwt_verify_options": undefined,
}, },
"resolve": "@medusajs/medusa/user", "resolve": "@medusajs/medusa/user",
}, },
@@ -132,6 +135,7 @@ describe("defineConfig", function () {
"adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"cookieSecret": "supersecret", "cookieSecret": "supersecret",
"jwtPublicKey": undefined,
"jwtSecret": "supersecret", "jwtSecret": "supersecret",
"restrictedFields": { "restrictedFields": {
"store": [ "store": [
@@ -277,7 +281,10 @@ describe("defineConfig", function () {
}, },
"user": { "user": {
"options": { "options": {
"jwt_options": undefined,
"jwt_public_key": undefined,
"jwt_secret": "supersecret", "jwt_secret": "supersecret",
"jwt_verify_options": undefined,
}, },
"resolve": "@medusajs/medusa/user", "resolve": "@medusajs/medusa/user",
}, },
@@ -292,6 +299,7 @@ describe("defineConfig", function () {
"adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"cookieSecret": "supersecret", "cookieSecret": "supersecret",
"jwtPublicKey": undefined,
"jwtSecret": "supersecret", "jwtSecret": "supersecret",
"restrictedFields": { "restrictedFields": {
"store": [ "store": [
@@ -445,7 +453,10 @@ describe("defineConfig", function () {
}, },
"user": { "user": {
"options": { "options": {
"jwt_options": undefined,
"jwt_public_key": undefined,
"jwt_secret": "supersecret", "jwt_secret": "supersecret",
"jwt_verify_options": undefined,
}, },
"resolve": "@medusajs/medusa/user", "resolve": "@medusajs/medusa/user",
}, },
@@ -460,6 +471,7 @@ describe("defineConfig", function () {
"adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"cookieSecret": "supersecret", "cookieSecret": "supersecret",
"jwtPublicKey": undefined,
"jwtSecret": "supersecret", "jwtSecret": "supersecret",
"restrictedFields": { "restrictedFields": {
"store": [ "store": [
@@ -614,7 +626,10 @@ describe("defineConfig", function () {
}, },
"user": { "user": {
"options": { "options": {
"jwt_options": undefined,
"jwt_public_key": undefined,
"jwt_secret": "supersecret", "jwt_secret": "supersecret",
"jwt_verify_options": undefined,
}, },
"resolve": "@medusajs/medusa/user", "resolve": "@medusajs/medusa/user",
}, },
@@ -629,6 +644,7 @@ describe("defineConfig", function () {
"adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"cookieSecret": "supersecret", "cookieSecret": "supersecret",
"jwtPublicKey": undefined,
"jwtSecret": "supersecret", "jwtSecret": "supersecret",
"restrictedFields": { "restrictedFields": {
"store": [ "store": [
@@ -771,7 +787,10 @@ describe("defineConfig", function () {
}, },
"user": { "user": {
"options": { "options": {
"jwt_options": undefined,
"jwt_public_key": undefined,
"jwt_secret": "supersecret", "jwt_secret": "supersecret",
"jwt_verify_options": undefined,
}, },
"resolve": "@medusajs/medusa/user", "resolve": "@medusajs/medusa/user",
}, },
@@ -786,6 +805,7 @@ describe("defineConfig", function () {
"adminCors": "http://localhost:3000", "adminCors": "http://localhost:3000",
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"cookieSecret": "supersecret", "cookieSecret": "supersecret",
"jwtPublicKey": undefined,
"jwtSecret": "supersecret", "jwtSecret": "supersecret",
"restrictedFields": { "restrictedFields": {
"store": [ "store": [
@@ -931,7 +951,10 @@ describe("defineConfig", function () {
}, },
"user": { "user": {
"options": { "options": {
"jwt_options": undefined,
"jwt_public_key": undefined,
"jwt_secret": "supersecret", "jwt_secret": "supersecret",
"jwt_verify_options": undefined,
}, },
"resolve": "@medusajs/medusa/user", "resolve": "@medusajs/medusa/user",
}, },
@@ -946,6 +969,7 @@ describe("defineConfig", function () {
"adminCors": "http://localhost:3000", "adminCors": "http://localhost:3000",
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"cookieSecret": "supersecret", "cookieSecret": "supersecret",
"jwtPublicKey": undefined,
"jwtSecret": "supersecret", "jwtSecret": "supersecret",
"restrictedFields": { "restrictedFields": {
"store": [ "store": [
@@ -1119,7 +1143,10 @@ describe("defineConfig", function () {
}, },
"user": { "user": {
"options": { "options": {
"jwt_options": undefined,
"jwt_public_key": undefined,
"jwt_secret": "supersecret", "jwt_secret": "supersecret",
"jwt_verify_options": undefined,
}, },
"resolve": "@medusajs/medusa/user", "resolve": "@medusajs/medusa/user",
}, },
@@ -1139,6 +1166,7 @@ describe("defineConfig", function () {
"adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"cookieSecret": "supersecret", "cookieSecret": "supersecret",
"jwtPublicKey": undefined,
"jwtSecret": "supersecret", "jwtSecret": "supersecret",
"restrictedFields": { "restrictedFields": {
"store": [ "store": [
@@ -1314,7 +1342,10 @@ describe("defineConfig", function () {
}, },
"user": { "user": {
"options": { "options": {
"jwt_options": undefined,
"jwt_public_key": undefined,
"jwt_secret": "supersecret", "jwt_secret": "supersecret",
"jwt_verify_options": undefined,
}, },
"resolve": "@medusajs/medusa/user", "resolve": "@medusajs/medusa/user",
}, },
@@ -1334,6 +1365,7 @@ describe("defineConfig", function () {
"adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"cookieSecret": "supersecret", "cookieSecret": "supersecret",
"jwtPublicKey": undefined,
"jwtSecret": "supersecret", "jwtSecret": "supersecret",
"restrictedFields": { "restrictedFields": {
"store": [ "store": [
@@ -1525,7 +1557,10 @@ describe("defineConfig", function () {
}, },
"user": { "user": {
"options": { "options": {
"jwt_options": undefined,
"jwt_public_key": undefined,
"jwt_secret": "supersecret", "jwt_secret": "supersecret",
"jwt_verify_options": undefined,
}, },
"resolve": "@medusajs/medusa/user", "resolve": "@medusajs/medusa/user",
}, },
@@ -1545,6 +1580,7 @@ describe("defineConfig", function () {
"adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"cookieSecret": "supersecret", "cookieSecret": "supersecret",
"jwtPublicKey": undefined,
"jwtSecret": "supersecret", "jwtSecret": "supersecret",
"restrictedFields": { "restrictedFields": {
"store": [ "store": [
@@ -1708,7 +1744,10 @@ describe("defineConfig", function () {
}, },
"user": { "user": {
"options": { "options": {
"jwt_options": undefined,
"jwt_public_key": undefined,
"jwt_secret": "supersecret", "jwt_secret": "supersecret",
"jwt_verify_options": undefined,
}, },
"resolve": "@medusajs/medusa/user", "resolve": "@medusajs/medusa/user",
}, },
@@ -1723,6 +1762,7 @@ describe("defineConfig", function () {
"adminCors": "http://localhost:3000", "adminCors": "http://localhost:3000",
"authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173",
"cookieSecret": "supersecret", "cookieSecret": "supersecret",
"jwtPublicKey": undefined,
"jwtSecret": "supersecret", "jwtSecret": "supersecret",
"restrictedFields": { "restrictedFields": {
"store": [ "store": [
@@ -48,7 +48,7 @@ export function defineConfig(config: InputConfig = {}): ConfigModule {
const projectConfig = normalizeProjectConfig(config.projectConfig, options) const projectConfig = normalizeProjectConfig(config.projectConfig, options)
const adminConfig = normalizeAdminConfig(config.admin) const adminConfig = normalizeAdminConfig(config.admin)
const modules = resolveModules(config.modules, options) const modules = resolveModules(config.modules, options, config.projectConfig)
return { return {
projectConfig, projectConfig,
@@ -132,7 +132,8 @@ export function transformModules(
*/ */
function resolveModules( function resolveModules(
configModules: InputConfig["modules"], configModules: InputConfig["modules"],
{ isCloud }: { isCloud: boolean } { isCloud }: { isCloud: boolean },
projectConfig: InputConfig["projectConfig"]
): Exclude<ConfigModule["modules"], undefined> { ): Exclude<ConfigModule["modules"], undefined> {
const sharedModules = [ const sharedModules = [
{ resolve: MODULE_PACKAGE_NAMES[Modules.STOCK_LOCATION] }, { resolve: MODULE_PACKAGE_NAMES[Modules.STOCK_LOCATION] },
@@ -166,7 +167,10 @@ function resolveModules(
{ {
resolve: MODULE_PACKAGE_NAMES[Modules.USER], resolve: MODULE_PACKAGE_NAMES[Modules.USER],
options: { options: {
jwt_secret: process.env.JWT_SECRET ?? DEFAULT_SECRET, jwt_secret: projectConfig?.http?.jwtSecret ?? DEFAULT_SECRET,
jwt_options: projectConfig?.http?.jwtOptions,
jwt_verify_options: projectConfig?.http?.jwtVerifyOptions,
jwt_public_key: projectConfig?.http?.jwtPublicKey,
}, },
}, },
{ {
@@ -318,7 +322,7 @@ function normalizeProjectConfig(
* The defaults to use for the project config. They are shallow merged * The defaults to use for the project config. They are shallow merged
* with the user defined config. * with the user defined config.
*/ */
return { const config = {
...(isCloud ? { redisUrl: process.env.REDIS_URL } : {}), ...(isCloud ? { redisUrl: process.env.REDIS_URL } : {}),
databaseUrl: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, databaseUrl: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
http: { http: {
@@ -326,6 +330,7 @@ function normalizeProjectConfig(
adminCors: process.env.ADMIN_CORS || DEFAULT_ADMIN_CORS, adminCors: process.env.ADMIN_CORS || DEFAULT_ADMIN_CORS,
authCors: process.env.AUTH_CORS || DEFAULT_ADMIN_CORS, authCors: process.env.AUTH_CORS || DEFAULT_ADMIN_CORS,
jwtSecret: process.env.JWT_SECRET || DEFAULT_SECRET, jwtSecret: process.env.JWT_SECRET || DEFAULT_SECRET,
jwtPublicKey: process.env.JWT_PUBLIC_KEY,
cookieSecret: process.env.COOKIE_SECRET || DEFAULT_SECRET, cookieSecret: process.env.COOKIE_SECRET || DEFAULT_SECRET,
restrictedFields: { restrictedFields: {
store: DEFAULT_STORE_RESTRICTED_FIELDS, store: DEFAULT_STORE_RESTRICTED_FIELDS,
@@ -375,6 +380,8 @@ function normalizeProjectConfig(
}, },
...restOfProjectConfig, ...restOfProjectConfig,
} satisfies ConfigModule["projectConfig"] } satisfies ConfigModule["projectConfig"]
return config
} }
function normalizeAdminConfig( function normalizeAdminConfig(
@@ -38,8 +38,9 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const token = generateJwtTokenForAuthIdentity( const token = generateJwtTokenForAuthIdentity(
{ authIdentity, actorType: actor_type }, { authIdentity, actorType: actor_type },
{ {
secret: http.jwtSecret, secret: http.jwtSecret!,
expiresIn: http.jwtExpiresIn, expiresIn: http.jwtExpiresIn,
options: http.jwtOptions,
} }
) )
@@ -41,8 +41,9 @@ export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
actorType: actor_type, actorType: actor_type,
}, },
{ {
secret: http.jwtSecret, secret: http.jwtSecret!,
expiresIn: http.jwtExpiresIn, expiresIn: http.jwtExpiresIn,
options: http.jwtOptions,
} }
) )
@@ -22,7 +22,8 @@ export const POST = async (
entityId: identifier, entityId: identifier,
actorType: actor_type, actorType: actor_type,
provider: auth_provider, provider: auth_provider,
secret: http.jwtSecret as string, secret: http.jwtSecret!,
jwtOptions: http.jwtOptions,
}, },
throwOnError: false, // we don't want to throw on error to avoid leaking information about non-existing identities throwOnError: false, // we don't want to throw on error to avoid leaking information about non-existing identities
}) })
@@ -45,8 +45,9 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
actorType: actor_type, actorType: actor_type,
}, },
{ {
secret: http.jwtSecret, secret: http.jwtSecret!,
expiresIn: http.jwtExpiresIn, expiresIn: http.jwtExpiresIn,
options: http.jwtOptions,
} }
) )
@@ -26,8 +26,9 @@ export const POST = async (
const token = generateJwtTokenForAuthIdentity( const token = generateJwtTokenForAuthIdentity(
{ authIdentity, actorType: req.auth_context.actor_type }, { authIdentity, actorType: req.auth_context.actor_type },
{ {
secret: http.jwtSecret, secret: http.jwtSecret!,
expiresIn: http.jwtExpiresIn, expiresIn: http.jwtExpiresIn,
options: http.jwtOptions,
} }
) )
@@ -1,5 +1,9 @@
import { AuthIdentityDTO } from "@medusajs/framework/types" import {
AuthIdentityDTO,
ProjectConfigOptions,
} from "@medusajs/framework/types"
import { generateJwtToken } from "@medusajs/framework/utils" import { generateJwtToken } from "@medusajs/framework/utils"
import { type Secret } from "jsonwebtoken"
export function generateJwtTokenForAuthIdentity( export function generateJwtTokenForAuthIdentity(
{ {
@@ -9,8 +13,14 @@ export function generateJwtTokenForAuthIdentity(
{ {
secret, secret,
expiresIn, expiresIn,
}: { secret: string | undefined; expiresIn: string | undefined } options,
}: {
secret: Secret
expiresIn: string | undefined
options?: ProjectConfigOptions["http"]["jwtOptions"]
}
) { ) {
const expiresIn_ = expiresIn ?? options?.expiresIn
const entityIdKey = `${actorType}_id` const entityIdKey = `${actorType}_id`
const entityId = authIdentity?.app_metadata?.[entityIdKey] as const entityId = authIdentity?.app_metadata?.[entityIdKey] as
| string | string
@@ -27,7 +37,8 @@ export function generateJwtTokenForAuthIdentity(
}, },
{ {
secret, secret,
expiresIn, expiresIn: expiresIn_,
jwtOptions: options,
} }
) )
} }
@@ -30,16 +30,17 @@ export const validateToken = () => {
const req_ = req as AuthenticatedMedusaRequest const req_ = req as AuthenticatedMedusaRequest
// @ts-ignore
const { http } = req_.scope.resolve<ConfigModule>( const { http } = req_.scope.resolve<ConfigModule>(
ContainerRegistrationKeys.CONFIG_MODULE ContainerRegistrationKeys.CONFIG_MODULE
).projectConfig ).projectConfig
const token = getAuthContextFromJwtToken( const token = getAuthContextFromJwtToken(
req.headers.authorization, req.headers.authorization,
http.jwtSecret as string, http.jwtSecret!,
["bearer"], ["bearer"],
[actor_type] [actor_type],
http.jwtPublicKey,
http.jwtVerifyOptions ?? http.jwtOptions
) as UpdateProviderJwtPayload | null ) as UpdateProviderJwtPayload | null
const errorObject = new MedusaError( const errorObject = new MedusaError(
@@ -10,7 +10,7 @@ import {
AbstractAuthModuleProvider, AbstractAuthModuleProvider,
MedusaError, MedusaError,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import jwt, { JwtPayload } from "jsonwebtoken" import jwt, { type JwtPayload } from "jsonwebtoken"
type InjectedDependencies = { type InjectedDependencies = {
logger: Logger logger: Logger
@@ -4,6 +4,7 @@ import {
InferEntityType, InferEntityType,
InternalModuleDeclaration, InternalModuleDeclaration,
ModulesSdkTypes, ModulesSdkTypes,
ProjectConfigOptions,
UserTypes, UserTypes,
} from "@medusajs/framework/types" } from "@medusajs/framework/types"
import { import {
@@ -11,6 +12,7 @@ import {
CommonEvents, CommonEvents,
EmitEvents, EmitEvents,
generateEntityId, generateEntityId,
generateJwtToken,
InjectManager, InjectManager,
InjectTransactionManager, InjectTransactionManager,
MedusaContext, MedusaContext,
@@ -20,10 +22,11 @@ import {
Modules, Modules,
UserEvents, UserEvents,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import jwt, { JwtPayload } from "jsonwebtoken" import jwt, { JwtPayload, SignOptions, VerifyOptions } from "jsonwebtoken"
import crypto from "node:crypto" import crypto from "node:crypto"
import { Invite, User } from "@models" import { Invite, User } from "@models"
import { getExpiresAt } from "../utils/utils"
type InjectedDependencies = { type InjectedDependencies = {
baseRepository: DAL.RepositoryService baseRepository: DAL.RepositoryService
@@ -51,11 +54,18 @@ export default class UserModuleService
protected readonly inviteService_: ModulesSdkTypes.IMedusaInternalService< protected readonly inviteService_: ModulesSdkTypes.IMedusaInternalService<
InferEntityType<typeof Invite> InferEntityType<typeof Invite>
> >
protected readonly config: { jwtSecret: string; expiresIn: number } protected readonly config: {
jwtSecret: string
jwtPublicKey?: string
jwt_verify_options: ProjectConfigOptions["http"]["jwtVerifyOptions"]
jwtOptions: ProjectConfigOptions["http"]["jwtOptions"] & {
expiresIn: number
}
}
constructor( constructor(
{ userService, inviteService, baseRepository }: InjectedDependencies, { userService, inviteService, baseRepository }: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration moduleDeclaration: InternalModuleDeclaration
) { ) {
// @ts-ignore // @ts-ignore
super(...arguments) super(...arguments)
@@ -63,11 +73,18 @@ export default class UserModuleService
this.baseRepository_ = baseRepository this.baseRepository_ = baseRepository
this.userService_ = userService this.userService_ = userService
this.inviteService_ = inviteService this.inviteService_ = inviteService
this.config = { this.config = {
jwtSecret: moduleDeclaration["jwt_secret"], jwtSecret: moduleDeclaration["jwt_secret"],
expiresIn: jwtPublicKey: moduleDeclaration["jwt_public_key"],
parseInt(moduleDeclaration["valid_duration"]) || jwt_verify_options: moduleDeclaration["jwt_verify_options"],
DEFAULT_VALID_INVITE_DURATION_SECONDS, jwtOptions: {
...moduleDeclaration["jwt_options"],
expiresIn:
moduleDeclaration["valid_duration"] ??
moduleDeclaration["jwt_options"]?.expiresIn ??
DEFAULT_VALID_INVITE_DURATION_SECONDS,
},
} }
if (!this.config.jwtSecret) { if (!this.config.jwtSecret) {
@@ -83,8 +100,21 @@ export default class UserModuleService
token: string, token: string,
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<UserTypes.InviteDTO> { ): Promise<UserTypes.InviteDTO> {
const jwtSecret = this.moduleDeclaration["jwt_secret"] const options = {
const decoded: JwtPayload = jwt.verify(token, jwtSecret, { complete: true }) ...(this.config.jwt_verify_options ?? this.config.jwtOptions),
complete: true,
} as VerifyOptions & SignOptions
if (!options.algorithms && options.algorithm) {
options.algorithms = [options.algorithm]
delete options.algorithm
}
const decoded = jwt.verify(
token,
this.config.jwtPublicKey ?? this.config.jwtSecret,
options
) as JwtPayload
const invite = await this.inviteService_.retrieve( const invite = await this.inviteService_.retrieve(
decoded.payload.id, decoded.payload.id,
@@ -155,10 +185,11 @@ export default class UserModuleService
} }
} }
const expiresAt = getExpiresAt(this.config.jwtOptions.expiresIn)
const updates = invites.map((invite) => { const updates = invites.map((invite) => {
return { return {
id: invite.id, id: invite.id,
expires_at: new Date(Date.now() + this.config.expiresIn * 1000), expires_at: expiresAt,
token: this.generateToken({ id: invite.id, email: invite.email }), token: this.generateToken({ id: invite.id, email: invite.email }),
} }
}) })
@@ -317,12 +348,14 @@ export default class UserModuleService
) )
} }
const expiresAt = getExpiresAt(this.config.jwtOptions.expiresIn)
const toCreate = data.map((invite) => { const toCreate = data.map((invite) => {
const id = generateEntityId((invite as { id?: string }).id, "invite") const id = generateEntityId((invite as { id?: string }).id, "invite")
return { return {
...invite, ...invite,
id, id,
expires_at: new Date(Date.now() + this.config.expiresIn * 1000), expires_at: expiresAt,
token: this.generateToken({ id, email: invite.email }), token: this.generateToken({ id, email: invite.email }),
} }
}) })
@@ -375,10 +408,15 @@ export default class UserModuleService
} }
private generateToken(data: any): string { private generateToken(data: any): string {
const jwtSecret: string = this.moduleDeclaration["jwt_secret"] const jwtId = this.config.jwtOptions.jwtid ?? crypto.randomUUID()
return jwt.sign(data, jwtSecret, { const token = generateJwtToken(data, {
jwtid: crypto.randomUUID(), secret: this.config.jwtSecret,
expiresIn: this.config.expiresIn, jwtOptions: {
...this.config.jwtOptions,
jwtid: jwtId,
},
}) })
return token
} }
} }
+10
View File
@@ -0,0 +1,10 @@
import timespan from "jsonwebtoken/lib/timespan"
export function getExpiresAt(expiresIn: string | number) {
const expiresAt =
typeof expiresIn === "number"
? new Date(Date.now() + expiresIn * 1000)
: new Date(Math.floor(timespan(expiresIn)) * 1000)
return expiresAt
}
+1
View File
@@ -7223,6 +7223,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@medusajs/types@workspace:packages/core/types" resolution: "@medusajs/types@workspace:packages/core/types"
dependencies: dependencies:
"@types/jsonwebtoken": ^8.5.9
awilix: ^8.0.1 awilix: ^8.0.1
bignumber.js: ^9.1.2 bignumber.js: ^9.1.2
expect-type: ^0.20.0 expect-type: ^0.20.0