From 0c3e522f6f931d30073eaa4e0f762e1c480f7bc2 Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Wed, 25 Mar 2026 14:34:51 +0100 Subject: [PATCH 1/3] refactor: remove Auth service dependency from Carts and Orders services --- .../src/modules/carts/carts.service.spec.ts | 4 +- .../src/modules/carts/carts.service.ts | 58 ++++--------------- .../src/modules/orders/orders.service.spec.ts | 4 -- .../src/modules/orders/orders.service.ts | 6 +- .../medusajs/src/utils/customer-access.ts | 30 ++-------- 5 files changed, 20 insertions(+), 82 deletions(-) diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts index f9f3276ec..c6b4d9d14 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts @@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config'; import { firstValueFrom } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { Auth, Carts } from '@o2s/framework/modules'; +import { Carts } from '@o2s/framework/modules'; import { CartsService } from './carts.service'; @@ -109,7 +109,6 @@ describe('CartsService', () => { mockConfig as unknown as ConfigService, mockLogger as unknown as import('@o2s/utils.logger').LoggerService, mockMedusaJsService as unknown as import('@/modules/medusajs').Service, - mockAuthService as unknown as Auth.Service, mockCustomersService as unknown as import('@o2s/framework/modules').Customers.Service, ); }); @@ -124,7 +123,6 @@ describe('CartsService', () => { mockConfig as unknown as ConfigService, mockLogger as unknown as import('@o2s/utils.logger').LoggerService, mockMedusaJsService as unknown as import('@/modules/medusajs').Service, - mockAuthService as unknown as Auth.Service, mockCustomersService as unknown as import('@o2s/framework/modules').Customers.Service, ), ).toThrow('DEFAULT_CURRENCY is not defined'); diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index 8f0e1b1fd..f4e2fb196 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -7,14 +7,13 @@ import { InternalServerErrorException, NotFoundException, NotImplementedException, - UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Observable, catchError, forkJoin, from, map, of, switchMap, throwError } from 'rxjs'; import { LoggerService } from '@o2s/utils.logger'; -import { Auth, Carts, Customers } from '@o2s/framework/modules'; +import { Carts, Customers } from '@o2s/framework/modules'; import { Service as MedusaJsService } from '@/modules/medusajs'; @@ -36,7 +35,6 @@ export class CartsService extends Carts.Service { private readonly config: ConfigService, @Inject(LoggerService) protected readonly logger: LoggerService, private readonly medusaJsService: MedusaJsService, - private readonly authService: Auth.Service, private readonly customersService: Customers.Service, ) { super(); @@ -62,7 +60,6 @@ export class CartsService extends Carts.Service { return verifyResourceAccess( this.sdk, - this.authService, this.medusaJsService.getMedusaAdminApiHeaders(), cart.customerId, authorization, @@ -149,46 +146,21 @@ export class CartsService extends Carts.Service { throw new BadRequestException('variantId is required for Medusa carts'); } - const customerId = authorization ? this.authService.getCustomerId(authorization) : undefined; - - // If cartId provided, use it (after verifying access) + // If cartId provided, add item directly — Medusa validates cart access server-side. if (data.cartId) { - const cartId = data.cartId; return from( - this.sdk.store.cart.retrieve( - cartId, + this.sdk.store.cart.createLineItem( + data.cartId, + { + variant_id: data.variantId!, + quantity: data.quantity, + metadata: data.metadata, + }, { fields: this.cartItemsFields }, this.medusaJsService.getStoreApiHeaders(authorization), ), ).pipe( - switchMap((response: HttpTypes.StoreCartResponse) => { - const cart = mapCart(response.cart, this.defaultCurrency); - - if (cart.customerId) { - if (!authorization) { - return throwError( - () => new UnauthorizedException('Authentication required to access this cart'), - ); - } - if (cart.customerId !== customerId) { - return throwError(() => new UnauthorizedException('Unauthorized to access this cart')); - } - } - - return from( - this.sdk.store.cart.createLineItem( - cartId, - { - variant_id: data.variantId!, - quantity: data.quantity, - metadata: data.metadata, - }, - { fields: this.cartItemsFields }, - this.medusaJsService.getStoreApiHeaders(authorization), - ), - ); - }), - map((addResponse: HttpTypes.StoreCartResponse) => mapCart(addResponse.cart, this.defaultCurrency)), + map((response: HttpTypes.StoreCartResponse) => mapCart(response.cart, this.defaultCurrency)), catchError((error) => handleHttpError(error)), ); } @@ -306,15 +278,7 @@ export class CartsService extends Carts.Service { return throwError(() => new NotFoundException(`Cart with ID ${params.cartId} not found`)); } - // Verify ownership for customer carts - if (cart.customerId && authorization) { - const customerId = this.authService.getCustomerId(authorization); - if (cart.customerId !== customerId) { - return throwError( - () => new UnauthorizedException('Unauthorized to prepare checkout for this cart'), - ); - } - } + // Ownership is already verified by Medusa when getCart retrieves the cart. // Validate cart has items if (!cart.items || cart.items.data.length === 0) { diff --git a/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts b/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts index f2de404fd..af80160d5 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts @@ -4,8 +4,6 @@ import { ConfigService } from '@nestjs/config'; import { firstValueFrom } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { Auth } from '@o2s/framework/modules'; - import { OrdersService } from './orders.service'; const DEFAULT_CURRENCY = 'EUR'; @@ -77,7 +75,6 @@ describe('OrdersService', () => { mockConfig as unknown as ConfigService, mockLogger as unknown as import('@o2s/utils.logger').LoggerService, mockMedusaJsService as unknown as import('@/modules/medusajs').Service, - mockAuthService as unknown as Auth.Service, ); }); @@ -91,7 +88,6 @@ describe('OrdersService', () => { mockConfig as unknown as ConfigService, mockLogger as unknown as import('@o2s/utils.logger').LoggerService, mockMedusaJsService as unknown as import('@/modules/medusajs').Service, - mockAuthService as unknown as Auth.Service, ), ).toThrow('DEFAULT_CURRENCY is not defined'); }); diff --git a/packages/integrations/medusajs/src/modules/orders/orders.service.ts b/packages/integrations/medusajs/src/modules/orders/orders.service.ts index f205e6fe5..8bb6c9781 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.service.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.ts @@ -6,7 +6,7 @@ import { Observable, catchError, from, map, switchMap } from 'rxjs'; import { LoggerService } from '@o2s/utils.logger'; -import { Auth, Orders } from '@o2s/framework/modules'; +import { Orders } from '@o2s/framework/modules'; import { Service as MedusaJsService } from '@/modules/medusajs'; @@ -28,7 +28,7 @@ export class OrdersService extends Orders.Service { private readonly defaultCurrency: string; private readonly additionalOrderListFields = - '+total,+subtotal,+tax_total,+discount_total,+shipping_total,+shipping_subtotal,+tax_total,+items.product.*'; + '+total,+subtotal,+item_subtotal,+tax_total,+discount_total,+shipping_total,+shipping_subtotal,+items.product.*'; // customer_id required for authorization check (guest vs customer order access) private readonly additionalOrderDetailsFields = '+customer_id,items.product.*'; @@ -36,7 +36,6 @@ export class OrdersService extends Orders.Service { private readonly config: ConfigService, @Inject(LoggerService) protected readonly logger: LoggerService, private readonly medusaJsService: MedusaJsService, - private readonly authService: Auth.Service, ) { super(); this.sdk = this.medusaJsService.getSdk(); @@ -70,7 +69,6 @@ export class OrdersService extends Orders.Service { return verifyResourceAccess( this.sdk, - this.authService, this.medusaJsService.getMedusaAdminApiHeaders(), order.customerId, authorization, diff --git a/packages/integrations/medusajs/src/utils/customer-access.ts b/packages/integrations/medusajs/src/utils/customer-access.ts index 896fe7867..4cbef8b69 100644 --- a/packages/integrations/medusajs/src/utils/customer-access.ts +++ b/packages/integrations/medusajs/src/utils/customer-access.ts @@ -2,48 +2,30 @@ import Medusa from '@medusajs/js-sdk'; import { UnauthorizedException } from '@nestjs/common'; import { Observable, from, map } from 'rxjs'; -import { Auth } from '@o2s/framework/modules'; - /** - * Checks if a resource (cart/order) with a given customerId can be accessed. - * - * Medusa assigns a customer_id to both registered and guest customers. - * Guest customers (has_account=false) are created automatically when an email is provided. - * This helper uses Admin API to check has_account and enforce ownership: + * Prevents unauthenticated access to resources owned by registered customers. * - * - No customerId on resource → accessible to anyone - * - No authorization token → check if customer is guest (has_account=false), if so allow access - * - Authorization provided → customerId must match the authenticated user + * Guest resources (no customerId, or guest customer with has_account=false) are + * accessible without a token. Authenticated requests are always allowed — Medusa + * validates ownership server-side when the token is forwarded. */ export const verifyResourceAccess = ( sdk: Medusa, - authService: Auth.Service, adminHeaders: Record, customerId: string | undefined, authorization: string | undefined, ): Observable => { - // No customer on resource — public access - if (!customerId) { - return from(Promise.resolve()); - } - - // Authorized user — check ownership directly (no Admin API call needed) - if (authorization) { - const tokenCustomerId = authService.getCustomerId(authorization); - if (customerId !== tokenCustomerId) { - throw new UnauthorizedException('Unauthorized'); - } + if (!customerId || authorization) { return from(Promise.resolve()); } - // No authorization — check if this is a guest customer via Admin API + // No token + resource has a customer — only allow if it's a guest customer return from(sdk.admin.customer.retrieve(customerId, {}, adminHeaders)).pipe( map((response) => { const customer = response.customer as { has_account?: boolean }; if (customer.has_account !== false) { throw new UnauthorizedException('Authentication required to access this resource'); } - // Guest customer (has_account=false) — allow access }), ); }; From b0e9e471a390aab9d4516b7222ae014c4d083d1a Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Wed, 25 Mar 2026 17:45:18 +0100 Subject: [PATCH 2/3] feat: updated access verification logic --- .../src/modules/carts/carts.service.spec.ts | 20 ++++++++- .../src/modules/carts/carts.service.ts | 45 +++++++++++++------ .../src/modules/orders/orders.service.spec.ts | 13 ++++-- .../src/modules/orders/orders.service.ts | 1 + .../medusajs/src/utils/customer-access.ts | 40 ++++++++++++++--- 5 files changed, 94 insertions(+), 25 deletions(-) diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts index c6b4d9d14..773a24991 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.spec.ts @@ -60,6 +60,14 @@ describe('CartsService', () => { update: ReturnType; addShippingMethod: ReturnType; }; + customer: { + retrieve: ReturnType; + }; + }; + admin: { + customer: { + retrieve: ReturnType; + }; }; client: { fetch: ReturnType }; }; @@ -86,6 +94,14 @@ describe('CartsService', () => { update: vi.fn(), addShippingMethod: vi.fn(), }, + customer: { + retrieve: vi.fn().mockResolvedValue({ customer: { id: 'cust_1' } }), + }, + }, + admin: { + customer: { + retrieve: vi.fn().mockResolvedValue({ customer: { has_account: false } }), + }, }, client: { fetch: vi.fn() }, }; @@ -147,12 +163,12 @@ describe('CartsService', () => { it('should throw UnauthorizedException when cart.customerId !== auth customerId', async () => { mockSdk.store.cart.retrieve.mockResolvedValue({ cart: { ...minimalCart, customer_id: 'cust_1' } }); - mockAuthService.getCustomerId.mockReturnValue('cust_other'); + mockSdk.store.customer.retrieve.mockResolvedValue({ customer: { id: 'cust_other' } }); + mockSdk.admin.customer.retrieve.mockResolvedValue({ customer: { has_account: true } }); await expect(firstValueFrom(service.getCart({ id: 'cart_1' }, 'Bearer token'))).rejects.toThrow( UnauthorizedException, ); - expect(mockAuthService.getCustomerId).toHaveBeenCalledWith('Bearer token'); }); it('should throw NotFoundException on 404', async () => { diff --git a/packages/integrations/medusajs/src/modules/carts/carts.service.ts b/packages/integrations/medusajs/src/modules/carts/carts.service.ts index f4e2fb196..8fef09d1c 100644 --- a/packages/integrations/medusajs/src/modules/carts/carts.service.ts +++ b/packages/integrations/medusajs/src/modules/carts/carts.service.ts @@ -61,6 +61,7 @@ export class CartsService extends Carts.Service { return verifyResourceAccess( this.sdk, this.medusaJsService.getMedusaAdminApiHeaders(), + this.medusaJsService.getStoreApiHeaders(authorization), cart.customerId, authorization, ).pipe(map(() => cart)); @@ -146,20 +147,38 @@ export class CartsService extends Carts.Service { throw new BadRequestException('variantId is required for Medusa carts'); } - // If cartId provided, add item directly — Medusa validates cart access server-side. + // If cartId provided, verify access then add item if (data.cartId) { - return from( - this.sdk.store.cart.createLineItem( - data.cartId, - { - variant_id: data.variantId!, - quantity: data.quantity, - metadata: data.metadata, - }, - { fields: this.cartItemsFields }, - this.medusaJsService.getStoreApiHeaders(authorization), - ), - ).pipe( + const cartId = data.cartId; + const storeHeaders = this.medusaJsService.getStoreApiHeaders(authorization); + + return from(this.sdk.store.cart.retrieve(cartId, { fields: this.cartItemsFields }, storeHeaders)).pipe( + switchMap((response: HttpTypes.StoreCartResponse) => { + const cart = mapCart(response.cart, this.defaultCurrency); + + return verifyResourceAccess( + this.sdk, + this.medusaJsService.getMedusaAdminApiHeaders(), + storeHeaders, + cart.customerId, + authorization, + ).pipe( + switchMap(() => + from( + this.sdk.store.cart.createLineItem( + cartId, + { + variant_id: data.variantId!, + quantity: data.quantity, + metadata: data.metadata, + }, + { fields: this.cartItemsFields }, + storeHeaders, + ), + ), + ), + ); + }), map((response: HttpTypes.StoreCartResponse) => mapCart(response.cart, this.defaultCurrency)), catchError((error) => handleHttpError(error)), ); diff --git a/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts b/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts index af80160d5..96d4f1008 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.spec.ts @@ -33,7 +33,10 @@ const guestOrder = { ...minimalOrder, id: 'order_guest', customer_id: null }; describe('OrdersService', () => { let service: OrdersService; let mockSdk: { - store: { order: { retrieve: ReturnType; list: ReturnType } }; + store: { + order: { retrieve: ReturnType; list: ReturnType }; + customer: { retrieve: ReturnType }; + }; admin: { customer: { retrieve: ReturnType } }; }; let mockMedusaJsService: { @@ -53,6 +56,9 @@ describe('OrdersService', () => { retrieve: vi.fn(), list: vi.fn(), }, + customer: { + retrieve: vi.fn().mockResolvedValue({ customer: { id: 'cust_1' } }), + }, }, admin: { customer: { @@ -129,7 +135,7 @@ describe('OrdersService', () => { it('should return customer order when authenticated user matches customerId', async () => { mockSdk.store.order.retrieve.mockResolvedValue({ order: minimalOrder }); - mockAuthService.getCustomerId.mockReturnValue('cust_1'); + mockSdk.store.customer.retrieve.mockResolvedValue({ customer: { id: 'cust_1' } }); const result = await firstValueFrom(service.getOrder({ id: 'order_1' }, 'Bearer token')); @@ -141,7 +147,8 @@ describe('OrdersService', () => { it('should throw UnauthorizedException when authenticated user tries to get another customer order', async () => { mockSdk.store.order.retrieve.mockResolvedValue({ order: minimalOrder }); - mockAuthService.getCustomerId.mockReturnValue('cust_other'); + mockSdk.store.customer.retrieve.mockResolvedValue({ customer: { id: 'cust_other' } }); + mockSdk.admin.customer.retrieve.mockResolvedValue({ customer: { has_account: true } }); await expect(firstValueFrom(service.getOrder({ id: 'order_1' }, 'Bearer token'))).rejects.toThrow( UnauthorizedException, diff --git a/packages/integrations/medusajs/src/modules/orders/orders.service.ts b/packages/integrations/medusajs/src/modules/orders/orders.service.ts index 8bb6c9781..6dfe639cb 100644 --- a/packages/integrations/medusajs/src/modules/orders/orders.service.ts +++ b/packages/integrations/medusajs/src/modules/orders/orders.service.ts @@ -70,6 +70,7 @@ export class OrdersService extends Orders.Service { return verifyResourceAccess( this.sdk, this.medusaJsService.getMedusaAdminApiHeaders(), + this.medusaJsService.getStoreApiHeaders(authorization), order.customerId, authorization, ).pipe(map(() => order)); diff --git a/packages/integrations/medusajs/src/utils/customer-access.ts b/packages/integrations/medusajs/src/utils/customer-access.ts index 4cbef8b69..479bf17c6 100644 --- a/packages/integrations/medusajs/src/utils/customer-access.ts +++ b/packages/integrations/medusajs/src/utils/customer-access.ts @@ -1,25 +1,51 @@ import Medusa from '@medusajs/js-sdk'; +import { HttpTypes } from '@medusajs/types'; import { UnauthorizedException } from '@nestjs/common'; -import { Observable, from, map } from 'rxjs'; +import { Observable, from, map, of, switchMap } from 'rxjs'; /** - * Prevents unauthenticated access to resources owned by registered customers. + * Verifies that the caller has access to a resource owned by a given customer. * - * Guest resources (no customerId, or guest customer with has_account=false) are - * accessible without a token. Authenticated requests are always allowed — Medusa - * validates ownership server-side when the token is forwarded. + * - No customerId on resource → allow (public/unassigned resource) + * - Authenticated + resource is mine → allow + * - Authenticated + resource belongs to a guest → allow (e.g. cart created before login) + * - Authenticated + resource belongs to another registered user → deny + * - Unauthenticated + resource belongs to a guest → allow (guest checkout flow) + * - Unauthenticated + resource belongs to a registered user → deny */ export const verifyResourceAccess = ( sdk: Medusa, adminHeaders: Record, + storeHeaders: Record, customerId: string | undefined, authorization: string | undefined, ): Observable => { - if (!customerId || authorization) { + if (!customerId) { return from(Promise.resolve()); } - // No token + resource has a customer — only allow if it's a guest customer + if (authorization) { + // Resolve the caller's Medusa customer ID via /store/customers/me + return from(sdk.store.customer.retrieve({}, storeHeaders)).pipe( + switchMap((response: HttpTypes.StoreCustomerResponse) => { + if (response.customer?.id === customerId) { + return of(undefined); + } + + // Not the owner — allow if it's a guest cart, deny if registered + return from(sdk.admin.customer.retrieve(customerId, {}, adminHeaders)).pipe( + map((adminResponse) => { + const customer = adminResponse.customer as { has_account?: boolean }; + if (customer.has_account !== false) { + throw new UnauthorizedException('Unauthorized to access this resource'); + } + }), + ); + }), + ); + } + + // No token — only allow if this is a guest customer (has_account=false) return from(sdk.admin.customer.retrieve(customerId, {}, adminHeaders)).pipe( map((response) => { const customer = response.customer as { has_account?: boolean }; From 2a66bd3d44bc9787bc770a72e9dca82cce0ffa9e Mon Sep 17 00:00:00 2001 From: "lukasz.bielecki" Date: Wed, 25 Mar 2026 17:48:21 +0100 Subject: [PATCH 3/3] chore: added changeset --- .changeset/sweet-islands-behave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sweet-islands-behave.md diff --git a/.changeset/sweet-islands-behave.md b/.changeset/sweet-islands-behave.md new file mode 100644 index 000000000..f91fb824b --- /dev/null +++ b/.changeset/sweet-islands-behave.md @@ -0,0 +1,5 @@ +--- +'@o2s/integrations.medusajs': minor +--- + +Simplify cart/order ownership checks — resolve Medusa customer ID via /store/customers/me instead of relying on auth token claims. Fix missing +item_subtotal field in order list query.