feat: Add publishable key scopes middleware (#7301)
**What** Add pub key + sales channel middlewares to the store carts API - Assign sales channel associated with pub key, if sales channel is not passed in request - Throw if pub key has multiple associated sales channels - Throw if sales channel ID in payload is not associated with publishable API key in header
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@medusajs/modules-sdk"
|
} from "@medusajs/modules-sdk"
|
||||||
import PaymentModuleService from "@medusajs/payment/dist/services/payment-module"
|
import PaymentModuleService from "@medusajs/payment/dist/services/payment-module"
|
||||||
import {
|
import {
|
||||||
|
IApiKeyModuleService,
|
||||||
ICartModuleService,
|
ICartModuleService,
|
||||||
ICustomerModuleService,
|
ICustomerModuleService,
|
||||||
IFulfillmentModuleService,
|
IFulfillmentModuleService,
|
||||||
@@ -47,6 +48,7 @@ medusaIntegrationTestRunner({
|
|||||||
let pricingModule: IPricingModuleService
|
let pricingModule: IPricingModuleService
|
||||||
let remoteLink: RemoteLink
|
let remoteLink: RemoteLink
|
||||||
let promotionModule: IPromotionModuleService
|
let promotionModule: IPromotionModuleService
|
||||||
|
let apiKeyModule: IApiKeyModuleService
|
||||||
let taxModule: ITaxModuleService
|
let taxModule: ITaxModuleService
|
||||||
let fulfillmentModule: IFulfillmentModuleService
|
let fulfillmentModule: IFulfillmentModuleService
|
||||||
let remoteLinkService
|
let remoteLinkService
|
||||||
@@ -64,6 +66,7 @@ medusaIntegrationTestRunner({
|
|||||||
customerModule = appContainer.resolve(ModuleRegistrationName.CUSTOMER)
|
customerModule = appContainer.resolve(ModuleRegistrationName.CUSTOMER)
|
||||||
productModule = appContainer.resolve(ModuleRegistrationName.PRODUCT)
|
productModule = appContainer.resolve(ModuleRegistrationName.PRODUCT)
|
||||||
pricingModule = appContainer.resolve(ModuleRegistrationName.PRICING)
|
pricingModule = appContainer.resolve(ModuleRegistrationName.PRICING)
|
||||||
|
apiKeyModule = appContainer.resolve(ModuleRegistrationName.API_KEY)
|
||||||
remoteLink = appContainer.resolve(LinkModuleUtils.REMOTE_LINK)
|
remoteLink = appContainer.resolve(LinkModuleUtils.REMOTE_LINK)
|
||||||
promotionModule = appContainer.resolve(ModuleRegistrationName.PROMOTION)
|
promotionModule = appContainer.resolve(ModuleRegistrationName.PROMOTION)
|
||||||
taxModule = appContainer.resolve(ModuleRegistrationName.TAX)
|
taxModule = appContainer.resolve(ModuleRegistrationName.TAX)
|
||||||
@@ -346,6 +349,158 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("throws if publishable key is not associated with sales channel", async () => {
|
||||||
|
const salesChannel = await scModule.create({
|
||||||
|
name: "Retail Store",
|
||||||
|
})
|
||||||
|
|
||||||
|
const salesChannel2 = await scModule.create({
|
||||||
|
name: "Webshop",
|
||||||
|
})
|
||||||
|
|
||||||
|
const pubKey = await apiKeyModule.create({
|
||||||
|
title: "Test key",
|
||||||
|
type: "publishable",
|
||||||
|
created_by: "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
await api.post(
|
||||||
|
`/admin/api-keys/${pubKey.id}/sales-channels`,
|
||||||
|
{
|
||||||
|
add: [salesChannel2.id],
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
|
const errorRes = await api
|
||||||
|
.post(
|
||||||
|
"/store/carts",
|
||||||
|
{
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: { "x-publishable-api-key": pubKey.token },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.catch((e) => e)
|
||||||
|
|
||||||
|
expect(errorRes.response.status).toEqual(400)
|
||||||
|
expect(errorRes.response.data).toEqual({
|
||||||
|
errors: expect.arrayContaining([
|
||||||
|
`Sales channel ID in payload ${salesChannel.id} is not associated with the Publishable API Key in the header.`,
|
||||||
|
]),
|
||||||
|
message:
|
||||||
|
"Provided request body contains errors. Please check the data and retry the request",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws if publishable key has multiple associated sales channels", async () => {
|
||||||
|
const salesChannel = await scModule.create({
|
||||||
|
name: "Retail Store",
|
||||||
|
})
|
||||||
|
|
||||||
|
const salesChannel2 = await scModule.create({
|
||||||
|
name: "Webshop",
|
||||||
|
})
|
||||||
|
|
||||||
|
const pubKey = await apiKeyModule.create({
|
||||||
|
title: "Test key",
|
||||||
|
type: "publishable",
|
||||||
|
created_by: "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
await api.post(
|
||||||
|
`/admin/api-keys/${pubKey.id}/sales-channels`,
|
||||||
|
{
|
||||||
|
add: [salesChannel.id, salesChannel2.id],
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
|
const errorRes = await api
|
||||||
|
.post(
|
||||||
|
"/store/carts",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: { "x-publishable-api-key": pubKey.token },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.catch((e) => e)
|
||||||
|
|
||||||
|
expect(errorRes.response.status).toEqual(400)
|
||||||
|
expect(errorRes.response.data).toEqual({
|
||||||
|
errors: expect.arrayContaining([
|
||||||
|
`Cannot assign sales channel to cart. The Publishable API Key in the header has multiple associated sales channels. Please provide a sales channel ID in the request body.`,
|
||||||
|
]),
|
||||||
|
message:
|
||||||
|
"Provided request body contains errors. Please check the data and retry the request",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create cart with sales channel if pub key does not have any scopes defined", async () => {
|
||||||
|
const salesChannel = await scModule.create({
|
||||||
|
name: "Retail Store",
|
||||||
|
})
|
||||||
|
|
||||||
|
const pubKey = await apiKeyModule.create({
|
||||||
|
title: "Test key",
|
||||||
|
type: "publishable",
|
||||||
|
created_by: "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
const successRes = await api.post(
|
||||||
|
"/store/carts",
|
||||||
|
{
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: { "x-publishable-api-key": pubKey.token },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(successRes.status).toEqual(200)
|
||||||
|
expect(successRes.data.cart).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should create cart with sales channel associated with pub key", async () => {
|
||||||
|
const salesChannel = await scModule.create({
|
||||||
|
name: "Retail Store",
|
||||||
|
})
|
||||||
|
|
||||||
|
const pubKey = await apiKeyModule.create({
|
||||||
|
title: "Test key",
|
||||||
|
type: "publishable",
|
||||||
|
created_by: "test",
|
||||||
|
})
|
||||||
|
|
||||||
|
await api.post(
|
||||||
|
`/admin/api-keys/${pubKey.id}/sales-channels`,
|
||||||
|
{
|
||||||
|
add: [salesChannel.id],
|
||||||
|
},
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
|
const successRes = await api.post(
|
||||||
|
"/store/carts",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: { "x-publishable-api-key": pubKey.token },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(successRes.status).toEqual(200)
|
||||||
|
expect(successRes.data.cart).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
sales_channel_id: salesChannel.id,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it("should respond 400 bad request on unknown props", async () => {
|
it("should respond 400 bad request on unknown props", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
api.post(`/store/carts`, {
|
api.post(`/store/carts`, {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from "./link-sales-channels-to-publishable-key"
|
|
||||||
export * from "./create-api-keys"
|
export * from "./create-api-keys"
|
||||||
export * from "./delete-api-keys"
|
export * from "./delete-api-keys"
|
||||||
|
export * from "./link-sales-channels-to-publishable-key"
|
||||||
export * from "./revoke-api-keys"
|
export * from "./revoke-api-keys"
|
||||||
export * from "./update-api-keys"
|
export * from "./update-api-keys"
|
||||||
export * from "./validate-sales-channel-exists"
|
export * from "./validate-sales-channel-exists"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./create-api-keys"
|
export * from "./create-api-keys"
|
||||||
export * from "./delete-api-keys"
|
export * from "./delete-api-keys"
|
||||||
export * from "./update-api-keys"
|
|
||||||
export * from "./revoke-api-keys"
|
|
||||||
export * from "./link-sales-channels-to-publishable-key"
|
export * from "./link-sales-channels-to-publishable-key"
|
||||||
|
export * from "./revoke-api-keys"
|
||||||
|
export * from "./update-api-keys"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
|
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
|
||||||
import { authenticate } from "../../../utils/middlewares/authenticate-middleware"
|
import { authenticate } from "../../../utils/middlewares/authenticate-middleware"
|
||||||
|
import { ensurePublishableKeyAndSalesChannelMatch } from "../../utils/middlewares/common/ensure-pub-key-sales-channel-match"
|
||||||
|
import { maybeAttachPublishableKeyScopes } from "../../utils/middlewares/common/maybe-attach-pub-key-scopes"
|
||||||
import { validateAndTransformBody } from "../../utils/validate-body"
|
import { validateAndTransformBody } from "../../utils/validate-body"
|
||||||
import { validateAndTransformQuery } from "../../utils/validate-query"
|
import { validateAndTransformQuery } from "../../utils/validate-query"
|
||||||
import * as OrderQueryConfig from "../orders/query-config"
|
import * as OrderQueryConfig from "../orders/query-config"
|
||||||
@@ -47,6 +49,8 @@ export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [
|
|||||||
StoreGetCartsCart,
|
StoreGetCartsCart,
|
||||||
QueryConfig.retrieveTransformQueryConfig
|
QueryConfig.retrieveTransformQueryConfig
|
||||||
),
|
),
|
||||||
|
maybeAttachPublishableKeyScopes,
|
||||||
|
ensurePublishableKeyAndSalesChannelMatch,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextFunction } from "express"
|
||||||
|
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
|
||||||
|
import { StoreCreateCartType } from "../../../store/carts/validators"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a publishable key (PK) is passed in the header of the request AND
|
||||||
|
* the request carries a sales channel id param in the url or body,
|
||||||
|
* we check if the sales channel is valid for the key.
|
||||||
|
*
|
||||||
|
* If the request does not carry a sales channel id, we attempt to assign
|
||||||
|
* a sales channel associated with the PK.
|
||||||
|
*
|
||||||
|
* @throws If sales channel id is passed as a url or body param
|
||||||
|
* but that id is not in the scope defined by the PK from the header.
|
||||||
|
* If the PK is associated with multiple sales channels but no
|
||||||
|
* sales channel id is passed in the request.
|
||||||
|
*/
|
||||||
|
export async function ensurePublishableKeyAndSalesChannelMatch(
|
||||||
|
req: MedusaRequest<StoreCreateCartType> & { publishableApiKeyScopes },
|
||||||
|
res: MedusaResponse,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const pubKey = req.get("x-publishable-api-key")
|
||||||
|
|
||||||
|
if (pubKey) {
|
||||||
|
const pubKeySalesChannels =
|
||||||
|
req.publishableApiKeyScopes?.sales_channel_ids ?? []
|
||||||
|
const channelId = req.validatedBody?.sales_channel_id
|
||||||
|
|
||||||
|
req.errors = req.errors ?? []
|
||||||
|
|
||||||
|
if (pubKeySalesChannels.length) {
|
||||||
|
if (channelId && !pubKeySalesChannels.includes(channelId)) {
|
||||||
|
req.errors.push(
|
||||||
|
`Sales channel ID in payload ${channelId} is not associated with the Publishable API Key in the header.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelId) {
|
||||||
|
if (pubKeySalesChannels.length > 1) {
|
||||||
|
req.errors.push(
|
||||||
|
`Cannot assign sales channel to cart. The Publishable API Key in the header has multiple associated sales channels. Please provide a sales channel ID in the request body.`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
req.validatedBody.sales_channel_id = pubKeySalesChannels[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { RemoteQueryFunction } from "@medusajs/types"
|
||||||
|
import {
|
||||||
|
ContainerRegistrationKeys,
|
||||||
|
remoteQueryObjectFromString,
|
||||||
|
} from "@medusajs/utils"
|
||||||
|
import { NextFunction } from "express"
|
||||||
|
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a publishable key (PK) is passed in the header of the request, we attach
|
||||||
|
* the IDs of resources within the scope of the key.
|
||||||
|
*
|
||||||
|
* @param req - request object
|
||||||
|
* @param res - response object
|
||||||
|
* @param next - next middleware call
|
||||||
|
*
|
||||||
|
* @throws if sales channel id is passed as a url or body param
|
||||||
|
* but that id is not in the scope defined by the PK from the header
|
||||||
|
*/
|
||||||
|
export async function maybeAttachPublishableKeyScopes(
|
||||||
|
req: MedusaRequest & { publishableApiKeyScopes: any },
|
||||||
|
res: MedusaResponse,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const pubKey = req.get("x-publishable-api-key")
|
||||||
|
|
||||||
|
if (pubKey) {
|
||||||
|
const remoteQuery = req.scope.resolve<RemoteQueryFunction>(
|
||||||
|
ContainerRegistrationKeys.REMOTE_QUERY
|
||||||
|
)
|
||||||
|
|
||||||
|
const queryObject = remoteQueryObjectFromString({
|
||||||
|
entryPoint: "api_key",
|
||||||
|
fields: ["sales_channels.id"],
|
||||||
|
variables: {
|
||||||
|
filters: { token: pubKey },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [apiKey] = await remoteQuery(queryObject)
|
||||||
|
|
||||||
|
req.publishableApiKeyScopes = {
|
||||||
|
sales_channel_ids: apiKey?.sales_channels.map((sc) => sc.id) ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user