@reactionary/magento 0.12.4 → 0.12.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,20 +9,21 @@ var __decorateClass = (decorators, target, key, kind) => {
9
9
  return result;
10
10
  };
11
11
  import {
12
- IdentityCapability,
13
12
  Reactionary,
14
13
  IdentityQuerySelfSchema,
15
14
  IdentitySchema,
16
15
  IdentityMutationRegisterSchema,
17
16
  IdentityMutationLoginSchema,
18
17
  IdentityMutationLogoutSchema,
18
+ error,
19
19
  success
20
20
  } from "@reactionary/core";
21
+ import { OidcIdentityCapability } from "@reactionary/oidc";
21
22
  import createDebug from "debug";
22
23
  const debug = createDebug("reactionary:magento:identity");
23
- class MagentoIdentityCapability extends IdentityCapability {
24
+ class MagentoIdentityCapability extends OidcIdentityCapability {
24
25
  constructor(config, cache, context, magentoApi) {
25
- super(cache, context);
26
+ super(cache, context, config.externalIdentityProviders);
26
27
  this.magentoApi = magentoApi;
27
28
  this.config = config;
28
29
  }
@@ -92,6 +93,21 @@ class MagentoIdentityCapability extends IdentityCapability {
92
93
  password: payload.password
93
94
  });
94
95
  }
96
+ /**
97
+ * Stock Magento has no external login. Once the shared OpenID Connect client has verified the identity, this
98
+ * finds or creates the customer by verified email (the pattern in Adobe's social login guide) and issues a
99
+ * customer token. An existing account with the same email is signed in, which is only appropriate for identity
100
+ * providers the shop owner trusts: that is what the configured provider list is.
101
+ */
102
+ async exchangeExternalIdentity(input) {
103
+ const email = input.claims["email"];
104
+ if (typeof email !== "string" || email.length === 0) {
105
+ return error({ type: "InvalidInput", error: "The identity provider did not return an email address" });
106
+ }
107
+ await this.magentoApi.loginWithExternalIdentity({ email, ...namesFromClaims(input.claims) });
108
+ const me = await this.magentoApi.getMe();
109
+ return success({ type: "Registered", id: { userId: String(me.id) } });
110
+ }
95
111
  }
96
112
  __decorateClass([
97
113
  Reactionary({
@@ -117,6 +133,16 @@ __decorateClass([
117
133
  outputSchema: IdentitySchema
118
134
  })
119
135
  ], MagentoIdentityCapability.prototype, "register", 1);
136
+ function namesFromClaims(claims) {
137
+ const text = (value) => typeof value === "string" && value.trim() ? value.trim() : void 0;
138
+ const given = text(claims["given_name"]);
139
+ const family = text(claims["family_name"]);
140
+ if (given || family) {
141
+ return { firstName: given ?? "User", lastName: family ?? "Account" };
142
+ }
143
+ const [first, ...rest] = (text(claims["name"]) ?? "").split(" ").filter(Boolean);
144
+ return { firstName: first ?? "User", lastName: rest.join(" ") || "Account" };
145
+ }
120
146
  export {
121
147
  MagentoIdentityCapability
122
148
  };
package/core/client.js CHANGED
@@ -15,6 +15,11 @@ function ensureMagentoPasswordPolicy(password) {
15
15
  if (!/[^A-Za-z0-9]/.test(password)) suffix += "!";
16
16
  return `${password}${suffix}`;
17
17
  }
18
+ function randomMagentoPassword() {
19
+ const bytes = new Uint8Array(24);
20
+ crypto.getRandomValues(bytes);
21
+ return ensureMagentoPasswordPolicy(btoa(String.fromCharCode(...bytes)));
22
+ }
18
23
  class RequestContextTokenStore {
19
24
  constructor(context, keyPrefix = "__x") {
20
25
  this.context = context;
@@ -160,6 +165,34 @@ class Magento {
160
165
  await this.tokenStore.setItem("customerToken", normalized);
161
166
  return normalized;
162
167
  },
168
+ /**
169
+ * Establishes a customer session for a verified external identity, the way Adobe's social login guide does:
170
+ * find the customer by email, create it if there is none, then issue a customer token with the admin key.
171
+ * Needs the Storefront Compatibility Package (4.7.6+) for POST /V1/customers/{id}/token.
172
+ */
173
+ loginWithExternalIdentity: async (identity) => {
174
+ const params = new URLSearchParams();
175
+ params.set("searchCriteria[filterGroups][0][filters][0][field]", "email");
176
+ params.set("searchCriteria[filterGroups][0][filters][0][value]", identity.email);
177
+ params.set("searchCriteria[filterGroups][0][filters][0][condition_type]", "eq");
178
+ params.set("searchCriteria[pageSize]", "1");
179
+ const found = await this.adminRest.request(
180
+ "GET",
181
+ `/V1/customers/search?${params.toString()}`
182
+ );
183
+ let customerId = found.items?.[0]?.id;
184
+ if (customerId === void 0) {
185
+ const created = await this.adminRest.request("POST", "/V1/customers", {
186
+ customer: { email: identity.email, firstname: identity.firstName, lastname: identity.lastName },
187
+ password: randomMagentoPassword()
188
+ });
189
+ customerId = created.id;
190
+ }
191
+ const token = await this.adminRest.request("POST", `/V1/customers/${customerId}/token`);
192
+ const normalized = typeof token === "string" ? token.replace(/^"|"$/g, "") : String(token);
193
+ await this.tokenStore.setItem("customerToken", normalized);
194
+ return normalized;
195
+ },
163
196
  logout: async () => {
164
197
  await this.tokenStore.removeItem("customerToken");
165
198
  }
@@ -297,6 +330,16 @@ class Magento {
297
330
  }
298
331
  },
299
332
  cart: {
333
+ /**
334
+ * Moves a guest cart's items into the signed-in customer's cart (Adobe's recommended step after login).
335
+ * Must run with the customer token already stored.
336
+ */
337
+ mergeGuestCart: async (guestCartId) => {
338
+ await this.gql.request(
339
+ "mutation MergeGuestCart($source: String!) { mergeCarts(source_cart_id: $source) { id } }",
340
+ { source: guestCartId }
341
+ );
342
+ },
300
343
  create: async (customerToken) => {
301
344
  if (customerToken) {
302
345
  const res2 = await this.rest.request("POST", "/V1/carts/mine");
@@ -455,6 +498,25 @@ class MagentoClient {
455
498
  const client = await this.getClient();
456
499
  return client.auth.login(email, password);
457
500
  }
501
+ /**
502
+ * Signs in a verified external identity (see Magento.auth.loginWithExternalIdentity) and carries a guest cart over.
503
+ * The guest cart id is cleared either way: after login the cart endpoints use /carts/mine, and a stale id would
504
+ * make getSelf report a guest after the next logout.
505
+ */
506
+ async loginWithExternalIdentity(identity) {
507
+ const client = await this.getClient();
508
+ const guestCartId = await this.getActiveCartId();
509
+ const token = await client.auth.loginWithExternalIdentity(identity);
510
+ if (guestCartId) {
511
+ try {
512
+ await client.store.cart.mergeGuestCart(guestCartId);
513
+ } catch (err) {
514
+ debug("Could not merge the guest cart after external login:", err);
515
+ }
516
+ await this.clearActiveCartId();
517
+ }
518
+ return token;
519
+ }
458
520
  async logout() {
459
521
  const client = await this.getClient();
460
522
  return client.auth.logout();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reactionary/magento",
3
- "version": "0.12.4",
3
+ "version": "0.12.7",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "types": "./src/index.d.ts",
@@ -9,9 +9,10 @@
9
9
  "url": "git+https://github.com/Solteq/reactionary.git"
10
10
  },
11
11
  "dependencies": {
12
- "@reactionary/core": "0.12.4",
12
+ "@reactionary/core": "0.12.7",
13
13
  "zod": "4.1.9",
14
- "debug": "^4.4.3"
14
+ "debug": "^4.4.3",
15
+ "@reactionary/oidc": "0.12.7"
15
16
  },
16
17
  "devDependencies": {
17
18
  "vitest": "^4.0.9"
@@ -1,4 +1,5 @@
1
1
  import * as z from "zod";
2
+ import { ExternalIdentityProviderSchema } from "@reactionary/oidc";
2
3
  const MagentoConfigurationSchema = z.looseObject({
3
4
  adminApiKey: z.string().meta({ description: "The API key for Magento admin operations. Needed for the few tasks that require admin access." }),
4
5
  baseUrl: z.string().meta({ description: "The base URL for the Magento installation." }),
@@ -11,6 +12,7 @@ const MagentoConfigurationSchema = z.looseObject({
11
12
  reviewRatingCode: z.string().optional().meta({ description: 'The name of the Magento review rating (as listed by `productReviewRatingsMetadata`) that carries the overall star rating. Defaults to "Rating"; the first available rating is used as a fallback.' }),
12
13
  storeCode: z.string().default(""),
13
14
  checkoutAgreementIds: z.array(z.string()).optional().meta({ description: "Ids of the Magento terms and conditions (Magento_CheckoutAgreements) the shopper accepts when placing an order. Required by stores with \"Enable Terms and Conditions\" on, which otherwise reject order placement. Sent as the payment method's `extension_attributes.agreement_ids`. The storefront must collect the shopper's consent before finalizing checkout." }),
15
+ externalIdentityProviders: z.array(ExternalIdentityProviderSchema).optional().meta({ description: "OpenID Connect identity providers shoppers can sign in with. Stock Magento has no social login, so reactionary runs the OpenID Connect flow and then creates or finds the customer by verified email and issues a customer token (Adobe's documented pattern). The admin API key needs the Magento_Customer::retrieve_tokens permission and the Storefront Compatibility Package (4.7.6 or newer) must be installed." }),
14
16
  authStoreCode: z.string().default("default").meta({ description: "The store/website scope used for customer authentication and account operations (token, customers/me). Customers typically live on the default website, which may differ from the catalog storeCode." })
15
17
  });
16
18
  export {
@@ -1,7 +1,8 @@
1
- import { type Identity, type IdentityMutationLogin, type IdentityMutationLogout, type IdentityMutationRegister, type IdentityQuerySelf, type RequestContext, type Cache, IdentityCapability, type AnonymousIdentity, type Result } from '@reactionary/core';
1
+ import { type Identity, type IdentityConflictError, type IdentityMutationLogin, type IdentityMutationLogout, type IdentityMutationRegister, type IdentityQuerySelf, type RequestContext, type Cache, type AnonymousIdentity, type Result } from '@reactionary/core';
2
+ import { OidcIdentityCapability, type ExchangeExternalIdentityInput } from '@reactionary/oidc';
2
3
  import type { MagentoClient } from '../core/client.js';
3
4
  import type { MagentoConfiguration } from '../schema/configuration.schema.js';
4
- export declare class MagentoIdentityCapability extends IdentityCapability {
5
+ export declare class MagentoIdentityCapability extends OidcIdentityCapability {
5
6
  magentoApi: MagentoClient;
6
7
  protected config: MagentoConfiguration;
7
8
  constructor(config: MagentoConfiguration, cache: Cache, context: RequestContext, magentoApi: MagentoClient);
@@ -10,4 +11,11 @@ export declare class MagentoIdentityCapability extends IdentityCapability {
10
11
  login(payload: IdentityMutationLogin): Promise<Result<Identity>>;
11
12
  logout(_payload: IdentityMutationLogout): Promise<Result<Identity>>;
12
13
  register(payload: IdentityMutationRegister): Promise<Result<Identity>>;
14
+ /**
15
+ * Stock Magento has no external login. Once the shared OpenID Connect client has verified the identity, this
16
+ * finds or creates the customer by verified email (the pattern in Adobe's social login guide) and issues a
17
+ * customer token. An existing account with the same email is signed in, which is only appropriate for identity
18
+ * providers the shop owner trusts: that is what the configured provider list is.
19
+ */
20
+ protected exchangeExternalIdentity(input: ExchangeExternalIdentityInput): Promise<Result<Identity, IdentityConflictError>>;
13
21
  }
@@ -55,6 +55,16 @@ export declare class Magento {
55
55
  private get gql();
56
56
  auth: {
57
57
  login: (email: string, password: string) => Promise<string>;
58
+ /**
59
+ * Establishes a customer session for a verified external identity, the way Adobe's social login guide does:
60
+ * find the customer by email, create it if there is none, then issue a customer token with the admin key.
61
+ * Needs the Storefront Compatibility Package (4.7.6+) for POST /V1/customers/{id}/token.
62
+ */
63
+ loginWithExternalIdentity: (identity: {
64
+ email: string;
65
+ firstName: string;
66
+ lastName: string;
67
+ }) => Promise<string>;
58
68
  logout: () => Promise<void>;
59
69
  };
60
70
  store: {
@@ -95,6 +105,11 @@ export declare class Magento {
95
105
  placeOrder: (cartId: string | null, payload: MagentoPlaceOrderPayload, customerToken?: string | null) => Promise<number>;
96
106
  };
97
107
  cart: {
108
+ /**
109
+ * Moves a guest cart's items into the signed-in customer's cart (Adobe's recommended step after login).
110
+ * Must run with the customer token already stored.
111
+ */
112
+ mergeGuestCart: (guestCartId: string) => Promise<void>;
98
113
  create: (customerToken?: string | null) => Promise<string>;
99
114
  get: (cartId?: string | null, customerToken?: string | null) => Promise<any>;
100
115
  getTotals: (cartId?: string | null, customerToken?: string | null) => Promise<any>;
@@ -128,6 +143,16 @@ export declare class MagentoClient {
128
143
  constructor(config: MagentoConfiguration, context: RequestContext);
129
144
  getClient(): Promise<Magento>;
130
145
  login(email: string, password: string): Promise<string>;
146
+ /**
147
+ * Signs in a verified external identity (see Magento.auth.loginWithExternalIdentity) and carries a guest cart over.
148
+ * The guest cart id is cleared either way: after login the cart endpoints use /carts/mine, and a stale id would
149
+ * make getSelf report a guest after the next logout.
150
+ */
151
+ loginWithExternalIdentity(identity: {
152
+ email: string;
153
+ firstName: string;
154
+ lastName: string;
155
+ }): Promise<string>;
131
156
  logout(): Promise<void>;
132
157
  getMe(): Promise<any>;
133
158
  getCustomerToken(): Promise<string | null>;
@@ -14,6 +14,20 @@ export declare const MagentoConfigurationSchema: z.ZodObject<{
14
14
  reviewRatingCode: z.ZodOptional<z.ZodString>;
15
15
  storeCode: z.ZodDefault<z.ZodString>;
16
16
  checkoutAgreementIds: z.ZodOptional<z.ZodArray<z.ZodString>>;
17
+ externalIdentityProviders: z.ZodOptional<z.ZodArray<z.ZodObject<{
18
+ key: z.ZodString;
19
+ displayName: z.ZodString;
20
+ brand: z.ZodOptional<z.ZodString>;
21
+ issuer: z.ZodURL;
22
+ clientId: z.ZodString;
23
+ clientSecret: z.ZodOptional<z.ZodString>;
24
+ scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
25
+ authorizationParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
26
+ requireVerifiedEmail: z.ZodOptional<z.ZodBoolean>;
27
+ authorizationEndpoint: z.ZodOptional<z.ZodURL>;
28
+ tokenEndpoint: z.ZodOptional<z.ZodURL>;
29
+ jwksUri: z.ZodOptional<z.ZodURL>;
30
+ }, z.core.$loose>>>;
17
31
  authStoreCode: z.ZodDefault<z.ZodString>;
18
32
  }, z.core.$loose>;
19
33
  export type MagentoConfiguration = z.infer<typeof MagentoConfigurationSchema>;