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. 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..773a24991 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'; @@ -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() }, }; @@ -109,7 +125,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 +139,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'); @@ -149,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 8f0e1b1fd..8fef09d1c 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,8 +60,8 @@ export class CartsService extends Carts.Service { return verifyResourceAccess( this.sdk, - this.authService, this.medusaJsService.getMedusaAdminApiHeaders(), + this.medusaJsService.getStoreApiHeaders(authorization), cart.customerId, authorization, ).pipe(map(() => cart)); @@ -149,46 +147,39 @@ 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, verify access then add item if (data.cartId) { const cartId = data.cartId; - return from( - this.sdk.store.cart.retrieve( - cartId, - { fields: this.cartItemsFields }, - this.medusaJsService.getStoreApiHeaders(authorization), - ), - ).pipe( + 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); - 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), + 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((addResponse: HttpTypes.StoreCartResponse) => mapCart(addResponse.cart, this.defaultCurrency)), + map((response: HttpTypes.StoreCartResponse) => mapCart(response.cart, this.defaultCurrency)), catchError((error) => handleHttpError(error)), ); } @@ -306,15 +297,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..96d4f1008 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'; @@ -35,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: { @@ -55,6 +56,9 @@ describe('OrdersService', () => { retrieve: vi.fn(), list: vi.fn(), }, + customer: { + retrieve: vi.fn().mockResolvedValue({ customer: { id: 'cust_1' } }), + }, }, admin: { customer: { @@ -77,7 +81,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 +94,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'); }); @@ -133,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')); @@ -145,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 f205e6fe5..6dfe639cb 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,8 +69,8 @@ export class OrdersService extends Orders.Service { return verifyResourceAccess( this.sdk, - this.authService, 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 896fe7867..479bf17c6 100644 --- a/packages/integrations/medusajs/src/utils/customer-access.ts +++ b/packages/integrations/medusajs/src/utils/customer-access.ts @@ -1,49 +1,57 @@ import Medusa from '@medusajs/js-sdk'; +import { HttpTypes } from '@medusajs/types'; import { UnauthorizedException } from '@nestjs/common'; -import { Observable, from, map } from 'rxjs'; - -import { Auth } from '@o2s/framework/modules'; +import { Observable, from, map, of, switchMap } from 'rxjs'; /** - * 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: + * Verifies that the caller has access to a resource owned by a given customer. * - * - 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 + * - 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, - authService: Auth.Service, adminHeaders: Record, + storeHeaders: 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'); - } - return from(Promise.resolve()); + // 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 authorization — check if this is a guest customer via Admin API + // 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 }; if (customer.has_account !== false) { throw new UnauthorizedException('Authentication required to access this resource'); } - // Guest customer (has_account=false) — allow access }), ); };