Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sweet-islands-behave.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -60,6 +60,14 @@ describe('CartsService', () => {
update: ReturnType<typeof vi.fn>;
addShippingMethod: ReturnType<typeof vi.fn>;
};
customer: {
retrieve: ReturnType<typeof vi.fn>;
};
};
admin: {
customer: {
retrieve: ReturnType<typeof vi.fn>;
};
};
client: { fetch: ReturnType<typeof vi.fn> };
};
Expand All @@ -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() },
};
Expand All @@ -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,
);
});
Expand All @@ -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');
Expand All @@ -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 () => {
Expand Down
73 changes: 28 additions & 45 deletions packages/integrations/medusajs/src/modules/carts/carts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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();
Expand All @@ -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));
Expand Down Expand Up @@ -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)),
);
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,7 +33,10 @@ const guestOrder = { ...minimalOrder, id: 'order_guest', customer_id: null };
describe('OrdersService', () => {
let service: OrdersService;
let mockSdk: {
store: { order: { retrieve: ReturnType<typeof vi.fn>; list: ReturnType<typeof vi.fn> } };
store: {
order: { retrieve: ReturnType<typeof vi.fn>; list: ReturnType<typeof vi.fn> };
customer: { retrieve: ReturnType<typeof vi.fn> };
};
admin: { customer: { retrieve: ReturnType<typeof vi.fn> } };
};
let mockMedusaJsService: {
Expand All @@ -55,6 +56,9 @@ describe('OrdersService', () => {
retrieve: vi.fn(),
list: vi.fn(),
},
customer: {
retrieve: vi.fn().mockResolvedValue({ customer: { id: 'cust_1' } }),
},
},
admin: {
customer: {
Expand All @@ -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,
);
});

Expand All @@ -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');
});
Expand Down Expand Up @@ -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'));

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -28,15 +28,14 @@ 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.*';

constructor(
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();
Expand Down Expand Up @@ -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));
Expand Down
50 changes: 29 additions & 21 deletions packages/integrations/medusajs/src/utils/customer-access.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
storeHeaders: Record<string, string>,
customerId: string | undefined,
authorization: string | undefined,
): Observable<void> => {
// 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 authorizationcheck if this is a guest customer via Admin API
// No tokenonly 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
}),
);
};
Loading