diff --git a/.changeset/early-chefs-chew.md b/.changeset/early-chefs-chew.md new file mode 100644 index 0000000000..946db6fa89 --- /dev/null +++ b/.changeset/early-chefs-chew.md @@ -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 diff --git a/integration-tests/helpers/create-admin-user.ts b/integration-tests/helpers/create-admin-user.ts index 76cc0926a0..969aa98e44 100644 --- a/integration-tests/helpers/create-admin-user.ts +++ b/integration-tests/helpers/create-admin-user.ts @@ -7,6 +7,7 @@ import { } from "@medusajs/framework/types" import { ApiKeyType, + ContainerRegistrationKeys, Modules, PUBLISHABLE_KEY_HEADER, } 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( { actor_id: user.id, actor_type: "user", auth_identity_id: authIdentity.id, }, - "test", + jwtSecret, { expiresIn: "1d", + ...jwtOptions, } ) diff --git a/integration-tests/http/__fixtures__/auth/medusa-config.js b/integration-tests/http/__fixtures__/auth/medusa-config.js new file mode 100644 index 0000000000..c29d67b597 --- /dev/null +++ b/integration-tests/http/__fixtures__/auth/medusa-config.js @@ -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, + }, + }, + ], +}) diff --git a/integration-tests/http/__tests__/auth/admin/auth-asymetric.spec.ts b/integration-tests/http/__tests__/auth/admin/auth-asymetric.spec.ts new file mode 100644 index 0000000000..5686d328fd --- /dev/null +++ b/integration-tests/http/__tests__/auth/admin/auth-asymetric.spec.ts @@ -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) + }) + }, +}) diff --git a/packages/core/core-flows/src/auth/workflows/generate-reset-password-token.ts b/packages/core/core-flows/src/auth/workflows/generate-reset-password-token.ts index 8c7bd193c2..10b922a007 100644 --- a/packages/core/core-flows/src/auth/workflows/generate-reset-password-token.ts +++ b/packages/core/core-flows/src/auth/workflows/generate-reset-password-token.ts @@ -9,6 +9,7 @@ import { WorkflowResponse, } from "@medusajs/framework/workflows-sdk" 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 @@ -45,7 +46,8 @@ export const generateResetPasswordTokenWorkflow = createWorkflow( entityId: string actorType: string provider: string - secret: string + secret: ProjectConfigOptions["http"]["jwtSecret"] + jwtOptions?: ProjectConfigOptions["http"]["jwtOptions"] }) => { const providerIdentities = useRemoteQueryStep({ entry_point: "provider_identity", @@ -79,6 +81,7 @@ export const generateResetPasswordTokenWorkflow = createWorkflow( { secret: input.secret, expiresIn: "15m", + jwtOptions: input.jwtOptions, } ) diff --git a/packages/core/framework/src/config/config.ts b/packages/core/framework/src/config/config.ts index 02f0ccf0f9..f4f70807d7 100644 --- a/packages/core/framework/src/config/config.ts +++ b/packages/core/framework/src/config/config.ts @@ -80,7 +80,21 @@ export class ConfigManager { http.storeCors = http.storeCors ?? "" http.adminCors = http.adminCors ?? "" + http.jwtOptions ??= {} + http.jwtOptions.expiresIn = http.jwtExpiresIn + 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) { this.rejectErrors( diff --git a/packages/core/framework/src/http/middlewares/authenticate-middleware.ts b/packages/core/framework/src/http/middlewares/authenticate-middleware.ts index 2a27024a02..5cab158a2d 100644 --- a/packages/core/framework/src/http/middlewares/authenticate-middleware.ts +++ b/packages/core/framework/src/http/middlewares/authenticate-middleware.ts @@ -1,7 +1,13 @@ import { ApiKeyDTO, IApiKeyModuleService } from "@medusajs/types" import { ContainerRegistrationKeys, Modules } from "@medusajs/utils" 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 { AuthContext, @@ -76,7 +82,9 @@ export const authenticate = ( req.headers.authorization, http.jwtSecret!, authTypes, - actorTypes + actorTypes, + http.jwtPublicKey, + http.jwtVerifyOptions ?? http.jwtOptions ) } @@ -172,9 +180,11 @@ const getAuthContextFromSession = ( export const getAuthContextFromJwtToken = ( authHeader: string | undefined, - jwtSecret: string, + jwtSecret: Secret, authTypes: AuthType[], - actorTypes: string[] + actorTypes: string[], + jwtPublicKey?: Secret, + jwtOptions?: VerifyOptions | SignOptions ): AuthContext | null => { if (!authTypes.includes(BEARER_AUTH)) { return null @@ -195,7 +205,18 @@ export const getAuthContextFromJwtToken = ( // get config jwt secret // verify token and set authUser 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)) { return verified as AuthContext } diff --git a/packages/core/types/package.json b/packages/core/types/package.json index 49ad1167b6..12ee2c853b 100644 --- a/packages/core/types/package.json +++ b/packages/core/types/package.json @@ -35,6 +35,7 @@ "bignumber.js": "^9.1.2" }, "devDependencies": { + "@types/jsonwebtoken": "^8.5.9", "awilix": "^8.0.1", "expect-type": "^0.20.0", "ioredis": "^5.4.1", diff --git a/packages/core/types/src/common/config-module.ts b/packages/core/types/src/common/config-module.ts index 3405129610..baee934a07 100644 --- a/packages/core/types/src/common/config-module.ts +++ b/packages/core/types/src/common/config-module.ts @@ -2,6 +2,7 @@ import { ExternalModuleDeclaration, InternalModuleDeclaration, } from "../modules-sdk" +import type { SignOptions, Secret, VerifyOptions } from "jsonwebtoken" import type { RedisOptions } from "ioredis" 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). * diff --git a/packages/core/utils/src/auth/token.ts b/packages/core/utils/src/auth/token.ts index 5014619b59..29a53ad75f 100644 --- a/packages/core/utils/src/auth/token.ts +++ b/packages/core/utils/src/auth/token.ts @@ -1,21 +1,27 @@ -import jwt from "jsonwebtoken" +import jwt, { type Secret, type SignOptions } from "jsonwebtoken" import { MedusaError } from "../common" export const generateJwtToken = ( tokenPayload: Record, jwtConfig: { - secret: string | undefined - expiresIn: string | undefined + secret?: Secret + expiresIn?: number | string + jwtOptions?: SignOptions } ) => { - if (!jwtConfig.secret || !jwtConfig.expiresIn) { + if ( + !jwtConfig.secret || + (!jwtConfig.expiresIn && !jwtConfig.jwtOptions?.expiresIn) + ) { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, "JWT secret and expiresIn must be provided when generating a token" ) } + const expiresIn = jwtConfig.expiresIn ?? jwtConfig.jwtOptions?.expiresIn return jwt.sign(tokenPayload, jwtConfig.secret, { - expiresIn: jwtConfig.expiresIn, + ...jwtConfig.jwtOptions, + expiresIn, }) } diff --git a/packages/core/utils/src/common/__tests__/define-config.spec.ts b/packages/core/utils/src/common/__tests__/define-config.spec.ts index 02dc06f42e..a1e75fc58d 100644 --- a/packages/core/utils/src/common/__tests__/define-config.spec.ts +++ b/packages/core/utils/src/common/__tests__/define-config.spec.ts @@ -117,7 +117,10 @@ describe("defineConfig", function () { }, "user": { "options": { + "jwt_options": undefined, + "jwt_public_key": undefined, "jwt_secret": "supersecret", + "jwt_verify_options": undefined, }, "resolve": "@medusajs/medusa/user", }, @@ -132,6 +135,7 @@ describe("defineConfig", function () { "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "cookieSecret": "supersecret", + "jwtPublicKey": undefined, "jwtSecret": "supersecret", "restrictedFields": { "store": [ @@ -277,7 +281,10 @@ describe("defineConfig", function () { }, "user": { "options": { + "jwt_options": undefined, + "jwt_public_key": undefined, "jwt_secret": "supersecret", + "jwt_verify_options": undefined, }, "resolve": "@medusajs/medusa/user", }, @@ -292,6 +299,7 @@ describe("defineConfig", function () { "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "cookieSecret": "supersecret", + "jwtPublicKey": undefined, "jwtSecret": "supersecret", "restrictedFields": { "store": [ @@ -445,7 +453,10 @@ describe("defineConfig", function () { }, "user": { "options": { + "jwt_options": undefined, + "jwt_public_key": undefined, "jwt_secret": "supersecret", + "jwt_verify_options": undefined, }, "resolve": "@medusajs/medusa/user", }, @@ -460,6 +471,7 @@ describe("defineConfig", function () { "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "cookieSecret": "supersecret", + "jwtPublicKey": undefined, "jwtSecret": "supersecret", "restrictedFields": { "store": [ @@ -614,7 +626,10 @@ describe("defineConfig", function () { }, "user": { "options": { + "jwt_options": undefined, + "jwt_public_key": undefined, "jwt_secret": "supersecret", + "jwt_verify_options": undefined, }, "resolve": "@medusajs/medusa/user", }, @@ -629,6 +644,7 @@ describe("defineConfig", function () { "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "cookieSecret": "supersecret", + "jwtPublicKey": undefined, "jwtSecret": "supersecret", "restrictedFields": { "store": [ @@ -771,7 +787,10 @@ describe("defineConfig", function () { }, "user": { "options": { + "jwt_options": undefined, + "jwt_public_key": undefined, "jwt_secret": "supersecret", + "jwt_verify_options": undefined, }, "resolve": "@medusajs/medusa/user", }, @@ -786,6 +805,7 @@ describe("defineConfig", function () { "adminCors": "http://localhost:3000", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "cookieSecret": "supersecret", + "jwtPublicKey": undefined, "jwtSecret": "supersecret", "restrictedFields": { "store": [ @@ -931,7 +951,10 @@ describe("defineConfig", function () { }, "user": { "options": { + "jwt_options": undefined, + "jwt_public_key": undefined, "jwt_secret": "supersecret", + "jwt_verify_options": undefined, }, "resolve": "@medusajs/medusa/user", }, @@ -946,6 +969,7 @@ describe("defineConfig", function () { "adminCors": "http://localhost:3000", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "cookieSecret": "supersecret", + "jwtPublicKey": undefined, "jwtSecret": "supersecret", "restrictedFields": { "store": [ @@ -1119,7 +1143,10 @@ describe("defineConfig", function () { }, "user": { "options": { + "jwt_options": undefined, + "jwt_public_key": undefined, "jwt_secret": "supersecret", + "jwt_verify_options": undefined, }, "resolve": "@medusajs/medusa/user", }, @@ -1139,6 +1166,7 @@ describe("defineConfig", function () { "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "cookieSecret": "supersecret", + "jwtPublicKey": undefined, "jwtSecret": "supersecret", "restrictedFields": { "store": [ @@ -1314,7 +1342,10 @@ describe("defineConfig", function () { }, "user": { "options": { + "jwt_options": undefined, + "jwt_public_key": undefined, "jwt_secret": "supersecret", + "jwt_verify_options": undefined, }, "resolve": "@medusajs/medusa/user", }, @@ -1334,6 +1365,7 @@ describe("defineConfig", function () { "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "cookieSecret": "supersecret", + "jwtPublicKey": undefined, "jwtSecret": "supersecret", "restrictedFields": { "store": [ @@ -1525,7 +1557,10 @@ describe("defineConfig", function () { }, "user": { "options": { + "jwt_options": undefined, + "jwt_public_key": undefined, "jwt_secret": "supersecret", + "jwt_verify_options": undefined, }, "resolve": "@medusajs/medusa/user", }, @@ -1545,6 +1580,7 @@ describe("defineConfig", function () { "adminCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "cookieSecret": "supersecret", + "jwtPublicKey": undefined, "jwtSecret": "supersecret", "restrictedFields": { "store": [ @@ -1708,7 +1744,10 @@ describe("defineConfig", function () { }, "user": { "options": { + "jwt_options": undefined, + "jwt_public_key": undefined, "jwt_secret": "supersecret", + "jwt_verify_options": undefined, }, "resolve": "@medusajs/medusa/user", }, @@ -1723,6 +1762,7 @@ describe("defineConfig", function () { "adminCors": "http://localhost:3000", "authCors": "http://localhost:7000,http://localhost:7001,http://localhost:5173", "cookieSecret": "supersecret", + "jwtPublicKey": undefined, "jwtSecret": "supersecret", "restrictedFields": { "store": [ diff --git a/packages/core/utils/src/common/define-config.ts b/packages/core/utils/src/common/define-config.ts index 998883afc0..9322afd964 100644 --- a/packages/core/utils/src/common/define-config.ts +++ b/packages/core/utils/src/common/define-config.ts @@ -48,7 +48,7 @@ export function defineConfig(config: InputConfig = {}): ConfigModule { const projectConfig = normalizeProjectConfig(config.projectConfig, options) const adminConfig = normalizeAdminConfig(config.admin) - const modules = resolveModules(config.modules, options) + const modules = resolveModules(config.modules, options, config.projectConfig) return { projectConfig, @@ -132,7 +132,8 @@ export function transformModules( */ function resolveModules( configModules: InputConfig["modules"], - { isCloud }: { isCloud: boolean } + { isCloud }: { isCloud: boolean }, + projectConfig: InputConfig["projectConfig"] ): Exclude { const sharedModules = [ { resolve: MODULE_PACKAGE_NAMES[Modules.STOCK_LOCATION] }, @@ -166,7 +167,10 @@ function resolveModules( { resolve: MODULE_PACKAGE_NAMES[Modules.USER], 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 * with the user defined config. */ - return { + const config = { ...(isCloud ? { redisUrl: process.env.REDIS_URL } : {}), databaseUrl: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, http: { @@ -326,6 +330,7 @@ function normalizeProjectConfig( adminCors: process.env.ADMIN_CORS || DEFAULT_ADMIN_CORS, authCors: process.env.AUTH_CORS || DEFAULT_ADMIN_CORS, jwtSecret: process.env.JWT_SECRET || DEFAULT_SECRET, + jwtPublicKey: process.env.JWT_PUBLIC_KEY, cookieSecret: process.env.COOKIE_SECRET || DEFAULT_SECRET, restrictedFields: { store: DEFAULT_STORE_RESTRICTED_FIELDS, @@ -375,6 +380,8 @@ function normalizeProjectConfig( }, ...restOfProjectConfig, } satisfies ConfigModule["projectConfig"] + + return config } function normalizeAdminConfig( diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts index 2d2fd69b0a..a42f923b98 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts @@ -38,8 +38,9 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { const token = generateJwtTokenForAuthIdentity( { authIdentity, actorType: actor_type }, { - secret: http.jwtSecret, + secret: http.jwtSecret!, expiresIn: http.jwtExpiresIn, + options: http.jwtOptions, } ) diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts index ac226bacac..1e790ab97c 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts @@ -41,8 +41,9 @@ export const POST = async (req: MedusaRequest, res: MedusaResponse) => { actorType: actor_type, }, { - secret: http.jwtSecret, + secret: http.jwtSecret!, expiresIn: http.jwtExpiresIn, + options: http.jwtOptions, } ) diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/reset-password/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/reset-password/route.ts index 93e9e28c6f..a59eb05d86 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/reset-password/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/reset-password/route.ts @@ -22,7 +22,8 @@ export const POST = async ( entityId: identifier, actorType: actor_type, 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 }) diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts index 627c71aed3..ca1cf6eeeb 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts @@ -45,8 +45,9 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { actorType: actor_type, }, { - secret: http.jwtSecret, + secret: http.jwtSecret!, expiresIn: http.jwtExpiresIn, + options: http.jwtOptions, } ) diff --git a/packages/medusa/src/api/auth/token/refresh/route.ts b/packages/medusa/src/api/auth/token/refresh/route.ts index 222c5486a8..20c09e7fa2 100644 --- a/packages/medusa/src/api/auth/token/refresh/route.ts +++ b/packages/medusa/src/api/auth/token/refresh/route.ts @@ -26,8 +26,9 @@ export const POST = async ( const token = generateJwtTokenForAuthIdentity( { authIdentity, actorType: req.auth_context.actor_type }, { - secret: http.jwtSecret, + secret: http.jwtSecret!, expiresIn: http.jwtExpiresIn, + options: http.jwtOptions, } ) diff --git a/packages/medusa/src/api/auth/utils/generate-jwt-token.ts b/packages/medusa/src/api/auth/utils/generate-jwt-token.ts index 46a2dc14af..0f38c3f90f 100644 --- a/packages/medusa/src/api/auth/utils/generate-jwt-token.ts +++ b/packages/medusa/src/api/auth/utils/generate-jwt-token.ts @@ -1,5 +1,9 @@ -import { AuthIdentityDTO } from "@medusajs/framework/types" +import { + AuthIdentityDTO, + ProjectConfigOptions, +} from "@medusajs/framework/types" import { generateJwtToken } from "@medusajs/framework/utils" +import { type Secret } from "jsonwebtoken" export function generateJwtTokenForAuthIdentity( { @@ -9,8 +13,14 @@ export function generateJwtTokenForAuthIdentity( { secret, 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 entityId = authIdentity?.app_metadata?.[entityIdKey] as | string @@ -27,7 +37,8 @@ export function generateJwtTokenForAuthIdentity( }, { secret, - expiresIn, + expiresIn: expiresIn_, + jwtOptions: options, } ) } diff --git a/packages/medusa/src/api/auth/utils/validate-token.ts b/packages/medusa/src/api/auth/utils/validate-token.ts index 78fbddfee4..477d4d81cb 100644 --- a/packages/medusa/src/api/auth/utils/validate-token.ts +++ b/packages/medusa/src/api/auth/utils/validate-token.ts @@ -30,16 +30,17 @@ export const validateToken = () => { const req_ = req as AuthenticatedMedusaRequest - // @ts-ignore const { http } = req_.scope.resolve( ContainerRegistrationKeys.CONFIG_MODULE ).projectConfig const token = getAuthContextFromJwtToken( req.headers.authorization, - http.jwtSecret as string, + http.jwtSecret!, ["bearer"], - [actor_type] + [actor_type], + http.jwtPublicKey, + http.jwtVerifyOptions ?? http.jwtOptions ) as UpdateProviderJwtPayload | null const errorObject = new MedusaError( diff --git a/packages/modules/providers/auth-google/src/services/google.ts b/packages/modules/providers/auth-google/src/services/google.ts index 9b35337cb5..0f1e6aa44b 100644 --- a/packages/modules/providers/auth-google/src/services/google.ts +++ b/packages/modules/providers/auth-google/src/services/google.ts @@ -10,7 +10,7 @@ import { AbstractAuthModuleProvider, MedusaError, } from "@medusajs/framework/utils" -import jwt, { JwtPayload } from "jsonwebtoken" +import jwt, { type JwtPayload } from "jsonwebtoken" type InjectedDependencies = { logger: Logger diff --git a/packages/modules/user/src/services/user-module.ts b/packages/modules/user/src/services/user-module.ts index 61d6b91836..2f07ef5e9d 100644 --- a/packages/modules/user/src/services/user-module.ts +++ b/packages/modules/user/src/services/user-module.ts @@ -4,6 +4,7 @@ import { InferEntityType, InternalModuleDeclaration, ModulesSdkTypes, + ProjectConfigOptions, UserTypes, } from "@medusajs/framework/types" import { @@ -11,6 +12,7 @@ import { CommonEvents, EmitEvents, generateEntityId, + generateJwtToken, InjectManager, InjectTransactionManager, MedusaContext, @@ -20,10 +22,11 @@ import { Modules, UserEvents, } from "@medusajs/framework/utils" -import jwt, { JwtPayload } from "jsonwebtoken" +import jwt, { JwtPayload, SignOptions, VerifyOptions } from "jsonwebtoken" import crypto from "node:crypto" import { Invite, User } from "@models" +import { getExpiresAt } from "../utils/utils" type InjectedDependencies = { baseRepository: DAL.RepositoryService @@ -51,11 +54,18 @@ export default class UserModuleService protected readonly inviteService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > - 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( { userService, inviteService, baseRepository }: InjectedDependencies, - protected readonly moduleDeclaration: InternalModuleDeclaration + moduleDeclaration: InternalModuleDeclaration ) { // @ts-ignore super(...arguments) @@ -63,11 +73,18 @@ export default class UserModuleService this.baseRepository_ = baseRepository this.userService_ = userService this.inviteService_ = inviteService + this.config = { jwtSecret: moduleDeclaration["jwt_secret"], - expiresIn: - parseInt(moduleDeclaration["valid_duration"]) || - DEFAULT_VALID_INVITE_DURATION_SECONDS, + jwtPublicKey: moduleDeclaration["jwt_public_key"], + jwt_verify_options: moduleDeclaration["jwt_verify_options"], + jwtOptions: { + ...moduleDeclaration["jwt_options"], + expiresIn: + moduleDeclaration["valid_duration"] ?? + moduleDeclaration["jwt_options"]?.expiresIn ?? + DEFAULT_VALID_INVITE_DURATION_SECONDS, + }, } if (!this.config.jwtSecret) { @@ -83,8 +100,21 @@ export default class UserModuleService token: string, @MedusaContext() sharedContext: Context = {} ): Promise { - const jwtSecret = this.moduleDeclaration["jwt_secret"] - const decoded: JwtPayload = jwt.verify(token, jwtSecret, { complete: true }) + const options = { + ...(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( decoded.payload.id, @@ -155,10 +185,11 @@ export default class UserModuleService } } + const expiresAt = getExpiresAt(this.config.jwtOptions.expiresIn) const updates = invites.map((invite) => { return { 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 }), } }) @@ -317,12 +348,14 @@ export default class UserModuleService ) } + const expiresAt = getExpiresAt(this.config.jwtOptions.expiresIn) + const toCreate = data.map((invite) => { const id = generateEntityId((invite as { id?: string }).id, "invite") return { ...invite, id, - expires_at: new Date(Date.now() + this.config.expiresIn * 1000), + expires_at: expiresAt, token: this.generateToken({ id, email: invite.email }), } }) @@ -375,10 +408,15 @@ export default class UserModuleService } private generateToken(data: any): string { - const jwtSecret: string = this.moduleDeclaration["jwt_secret"] - return jwt.sign(data, jwtSecret, { - jwtid: crypto.randomUUID(), - expiresIn: this.config.expiresIn, + const jwtId = this.config.jwtOptions.jwtid ?? crypto.randomUUID() + const token = generateJwtToken(data, { + secret: this.config.jwtSecret, + jwtOptions: { + ...this.config.jwtOptions, + jwtid: jwtId, + }, }) + + return token } } diff --git a/packages/modules/user/src/utils/utils.ts b/packages/modules/user/src/utils/utils.ts new file mode 100644 index 0000000000..7e88a99fbe --- /dev/null +++ b/packages/modules/user/src/utils/utils.ts @@ -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 +} diff --git a/yarn.lock b/yarn.lock index 7c805999ac..77c429c387 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7223,6 +7223,7 @@ __metadata: version: 0.0.0-use.local resolution: "@medusajs/types@workspace:packages/core/types" dependencies: + "@types/jsonwebtoken": ^8.5.9 awilix: ^8.0.1 bignumber.js: ^9.1.2 expect-type: ^0.20.0