From f675f20fb4753712f9fc6adf6a5c4d92ddb73f86 Mon Sep 17 00:00:00 2001 From: Michael van Tellingen Date: Thu, 8 Oct 2026 09:59:25 +0200 Subject: [PATCH] fix(oauth): sign a global customer in to any store The in-store password flow only accepted a customer assigned to the store, so a global customer, assigned to none, was refused. A customer assigned to the store still wins when an email exists twice; one assigned only to other stores is still refused. Co-Authored-By: Claude Opus 5.5 --- .changeset/in-store-token-global-customer.md | 5 ++ src/oauth/server.test.ts | 49 ++++++++++++++++++++ src/oauth/server.ts | 12 +++-- 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 .changeset/in-store-token-global-customer.md diff --git a/.changeset/in-store-token-global-customer.md b/.changeset/in-store-token-global-customer.md new file mode 100644 index 00000000..d3e3e82b --- /dev/null +++ b/.changeset/in-store-token-global-customer.md @@ -0,0 +1,5 @@ +--- +"@labdigital/commercetools-mock": patch +--- + +Issue an in-store password token to a global customer. A customer assigned to no store can sign in to any store again; a customer assigned only to other stores is still refused. diff --git a/src/oauth/server.test.ts b/src/oauth/server.test.ts index ce845b32..a2268d7a 100644 --- a/src/oauth/server.test.ts +++ b/src/oauth/server.test.ts @@ -222,5 +222,54 @@ describe("OAuth2Server", () => { token_type: "Bearer", }); }); + + const addCustomer = (projectKey: string, stores: string[]) => + storage.add(projectKey, "customer", { + ...getBaseResourceProperties(), + email: "j.doe@example.org", + password: hashPassword("password"), + addresses: [], + authenticationMode: "password", + isEmailVerified: true, + stores: stores.map((key) => ({ typeId: "store" as const, key })), + shippingAddressIds: [], + billingAddressIds: [], + customerGroupAssignments: [], + }); + + const requestToken = (projectKey: string, storeKey: string) => + app.inject({ + method: "POST", + url: `/${projectKey}/in-store/key=${storeKey}/customers/token?${new URLSearchParams( + { + grant_type: "password", + username: "j.doe@example.org", + password: "password", + scope: `${projectKey}:manage_my_profile`, + }, + )}`, + headers: { + Authorization: `Basic ${Buffer.from("validClientId:validClientSecret").toString("base64")}`, + }, + }); + + it("should return a token for a global customer", async () => { + addCustomer("test-project", []); + + const response = await requestToken("test-project", "test-store"); + + expect(response.statusCode).toBe(200); + }); + + it("should refuse a customer assigned to another store", async () => { + addCustomer("test-project", ["other-store"]); + + const response = await requestToken("test-project", "test-store"); + + expect(response.statusCode).toBe(400); + expect(response.json().errors[0].code).toBe( + "invalid_customer_account_credentials", + ); + }); }); }); diff --git a/src/oauth/server.ts b/src/oauth/server.ts index b7824daa..46e67c43 100644 --- a/src/oauth/server.ts +++ b/src/oauth/server.ts @@ -313,11 +313,13 @@ export class OAuth2Server { }, ); - // Emails are only unique per store, so make sure we only sign in the - // customer that is actually assigned to this store. - const customer = result.results.find((c) => - c.stores?.some((store) => store.key === storeKey), - ); + // Emails are only unique per store, so the customer assigned to this + // store wins. A global customer, assigned to no store, may sign in to + // any store; one assigned to other stores only may not. + const customer = + result.results.find((c) => + c.stores?.some((store) => store.key === storeKey), + ) ?? result.results.find((c) => !c.stores?.length); if (!customer) { throw new CommercetoolsError(