@hostwebhook/platform-node 0.2.0 → 0.3.0
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.
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -0
- package/dist/presets-de-oauth/github-oauth-presets.d.ts +199 -0
- package/dist/presets-de-oauth/github-oauth-presets.js +142 -0
- package/dist/presets-de-oauth/linkedin-oauth-presets.d.ts +314 -0
- package/dist/presets-de-oauth/linkedin-oauth-presets.js +338 -0
- package/dist/presets-de-oauth/mailchimp-oauth-presets.d.ts +135 -0
- package/dist/presets-de-oauth/mailchimp-oauth-presets.js +96 -0
- package/dist/presets-de-oauth/shopify-oauth-presets.d.ts +209 -0
- package/dist/presets-de-oauth/shopify-oauth-presets.js +223 -0
- package/package.json +1 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shopify OAuth 2.0 configuration for HostWebhook.
|
|
3
|
+
*
|
|
4
|
+
* The app is registered in the **Dev Dashboard** (<https://dev.shopify.com/dashboard/>),
|
|
5
|
+
* not in the Partner Dashboard and not in a store's admin — both of those
|
|
6
|
+
* paths are dead: admin-created custom apps stopped being creatable on
|
|
7
|
+
* 2026-01-01. The redirect URL is
|
|
8
|
+
* `{API_URL}/api/credentials/shopify/oauth/callback`.
|
|
9
|
+
*
|
|
10
|
+
* See `docs/ADR-0007-shopify.md` for why this app uses **custom
|
|
11
|
+
* distribution**, which is the decision most of the notes below hang off.
|
|
12
|
+
*
|
|
13
|
+
* ## Three things that make Shopify unlike every other provider here
|
|
14
|
+
*
|
|
15
|
+
* **The store is part of the host.** Calls go to
|
|
16
|
+
* `https://{shop}.myshopify.com/admin/api/{version}/graphql.json`, and so does
|
|
17
|
+
* the authorization URL. That is worse than Mailchimp's `dc`, which at least
|
|
18
|
+
* arrives from Mailchimp: here the shop is **typed by the user before the flow
|
|
19
|
+
* starts**, so it is attacker-influenced input that we then put in a URL. It
|
|
20
|
+
* is validated twice — in the DTO and again here — against Shopify's own
|
|
21
|
+
* anchored pattern, and every call goes through `fetchSeguro`.
|
|
22
|
+
*
|
|
23
|
+
* **The callback is signed, and checking it is not optional.** Shopify appends
|
|
24
|
+
* an `hmac` over the rest of the query string. Skipping that check would let
|
|
25
|
+
* anyone hit our public callback with a `shop` of their choosing.
|
|
26
|
+
*
|
|
27
|
+
* **Tokens may or may not expire, and which one you get is a property of the
|
|
28
|
+
* app, not of the code.** Apps with *public* distribution created on or after
|
|
29
|
+
* 2026-04-01 must use expiring offline tokens (1 h access, 90-day refresh);
|
|
30
|
+
* apps with custom distribution are exempt and get a token that lives until
|
|
31
|
+
* the app is uninstalled. Ours is custom, so today no expiry arrives.
|
|
32
|
+
*
|
|
33
|
+
* We deliberately do **not** send `expiring: 1` in the token request — that is
|
|
34
|
+
* the flag that opts into the expiring kind, and asking for a token that dies
|
|
35
|
+
* every hour when we do not have to would buy the rotation problem for
|
|
36
|
+
* nothing. But the reader below stores `expires_in` / `refresh_token`
|
|
37
|
+
* **whenever Shopify sends them**, so the day this app goes public (which
|
|
38
|
+
* means a NEW app — distribution cannot be changed) the credential shape does
|
|
39
|
+
* not have to change with it.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* The Admin API version, in ONE place.
|
|
43
|
+
*
|
|
44
|
+
* Shopify ships quarterly and supports each version for at least 12 months, so
|
|
45
|
+
* this is a scheduled edit, not a fire. What is *not* optional is sending it:
|
|
46
|
+
* omit the version and Shopify "falls forward" to the oldest stable version it
|
|
47
|
+
* still serves, which means the API changes under us on a date nobody chose.
|
|
48
|
+
*/
|
|
49
|
+
export declare const SHOPIFY_API_VERSION = "2026-07";
|
|
50
|
+
export declare const SHOPIFY_OAUTH_CONFIG: {
|
|
51
|
+
/** Env var names holding HW's registered client credentials. */
|
|
52
|
+
readonly clientIdEnv: "SHOPIFY_OAUTH_CLIENT_ID";
|
|
53
|
+
readonly clientSecretEnv: "SHOPIFY_OAUTH_CLIENT_SECRET";
|
|
54
|
+
};
|
|
55
|
+
/** The credential `type` string. Must also exist in `@hostwebhook/node-types`
|
|
56
|
+
* `CREDENTIAL_TYPES` — the Mongoose enum and the DTO's `@IsIn` both come from
|
|
57
|
+
* there, and a type missing from that list fails only at runtime. */
|
|
58
|
+
export declare const SHOPIFY_CREDENTIAL_TYPE = "shopify_oauth2";
|
|
59
|
+
/**
|
|
60
|
+
* What the node needs, and nothing else.
|
|
61
|
+
*
|
|
62
|
+
* These must ALSO be declared on the app's released version in the Dev
|
|
63
|
+
* Dashboard — same two-places rule as Slack, not the one-place rule of
|
|
64
|
+
* Google. A scope requested here but not declared there is refused by
|
|
65
|
+
* Shopify at the authorize step.
|
|
66
|
+
*
|
|
67
|
+
* `read_all_orders` is deliberately absent: it needs a separate approval from
|
|
68
|
+
* Shopify and only widens the window past 60 days, which nothing in v1 uses.
|
|
69
|
+
*/
|
|
70
|
+
export declare const SHOPIFY_DEFAULT_SCOPES: readonly ["read_orders", "write_orders", "read_customers", "write_customers", "read_products", "write_products", "read_inventory", "write_inventory"];
|
|
71
|
+
/**
|
|
72
|
+
* Shopify's own pattern for a store domain, anchored at both ends.
|
|
73
|
+
*
|
|
74
|
+
* Taken from their authorization guide rather than written from scratch, and
|
|
75
|
+
* the anchors are the whole point: without them
|
|
76
|
+
* `evil.com/?x=shop.myshopify.com` passes.
|
|
77
|
+
*/
|
|
78
|
+
export declare const SHOP_DOMAIN_RE: RegExp;
|
|
79
|
+
/**
|
|
80
|
+
* Normalize and validate a store domain, or throw.
|
|
81
|
+
*
|
|
82
|
+
* Accepts what a user actually types — trailing slash, protocol, capitals, the
|
|
83
|
+
* bare handle — and returns the canonical `{shop}.myshopify.com`. Throwing
|
|
84
|
+
* rather than returning null is on purpose: every caller builds a URL with the
|
|
85
|
+
* result, so a soft failure would produce a request to a host we did not mean
|
|
86
|
+
* to call.
|
|
87
|
+
*/
|
|
88
|
+
export declare function normalizeShopDomain(input: string): string;
|
|
89
|
+
/**
|
|
90
|
+
* Base URL for every Admin API call made with this credential.
|
|
91
|
+
*
|
|
92
|
+
* Lives here rather than being interpolated at each call site so there is one
|
|
93
|
+
* place that knows the shape — and so a credential missing its shop fails
|
|
94
|
+
* loudly here instead of producing a request to `https://undefined…`. Same
|
|
95
|
+
* reasoning as `mailchimpApiBase(dc)`.
|
|
96
|
+
*/
|
|
97
|
+
export declare function shopifyGraphqlUrl(shop: string): string;
|
|
98
|
+
export declare function buildShopifyAuthorizeUrl(params: {
|
|
99
|
+
shop: string;
|
|
100
|
+
clientId: string;
|
|
101
|
+
redirectUri: string;
|
|
102
|
+
state: string;
|
|
103
|
+
scopes?: readonly string[];
|
|
104
|
+
}): string;
|
|
105
|
+
/**
|
|
106
|
+
* The body of the authorization-code exchange, including the one flag that
|
|
107
|
+
* decides whether the resulting token works at all.
|
|
108
|
+
*
|
|
109
|
+
* ## `expiring: 1` is not optional, and this file used to say the opposite
|
|
110
|
+
*
|
|
111
|
+
* Custom-distribution apps are exempt from expiring offline tokens, so the
|
|
112
|
+
* original reasoning was that asking for a token which dies every hour would
|
|
113
|
+
* buy the rotation problem for nothing. That is right — for a custom app. This
|
|
114
|
+
* app went **public**, and Shopify then refuses the non-expiring kind outright:
|
|
115
|
+
*
|
|
116
|
+
* ```
|
|
117
|
+
* [API] Non-expiring access tokens are no longer accepted for the Admin API.
|
|
118
|
+
* Start using expiring offline tokens.
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* HTTP 403 on every call, including ones touching nothing protected. Seen on
|
|
122
|
+
* 2026-08-26 the first time this was pointed at a real store: the credential
|
|
123
|
+
* connected, the shop name came back, and then not one webhook subscription
|
|
124
|
+
* could be registered — because the token TYPE is decided here and refused over
|
|
125
|
+
* there, which leaves the symptom nowhere near the cause.
|
|
126
|
+
*
|
|
127
|
+
* Sent unconditionally rather than only when public, because the failure is
|
|
128
|
+
* asymmetric: a custom app handed an expiring token still works, since
|
|
129
|
+
* `refreshAccessToken` keeps it alive, while a public app handed a
|
|
130
|
+
* non-expiring one is simply dead. And the distribution method lives in
|
|
131
|
+
* Shopify's dashboard, where this code cannot read it.
|
|
132
|
+
*
|
|
133
|
+
* Pure and exported for the same reason as `verifyShopifyOAuthHmac` below: a
|
|
134
|
+
* flag buried in a private method is a flag no spec can pin, and this one is
|
|
135
|
+
* the difference between a working integration and 403 on everything.
|
|
136
|
+
*
|
|
137
|
+
* ⚠️ No `redirect_uri`. Unlike every other provider here, Shopify's token
|
|
138
|
+
* endpoint does not take one.
|
|
139
|
+
*/
|
|
140
|
+
export declare function buildShopifyTokenExchangeBody(params: {
|
|
141
|
+
clientId: string;
|
|
142
|
+
clientSecret: string;
|
|
143
|
+
code: string;
|
|
144
|
+
}): URLSearchParams;
|
|
145
|
+
/**
|
|
146
|
+
* Verify the `hmac` Shopify appends to the callback.
|
|
147
|
+
*
|
|
148
|
+
* The recipe, from Shopify's authorization guide: drop `hmac`, sort what is
|
|
149
|
+
* left by key, join as `k=v&k=v` with the values **decoded**, HMAC-SHA256 with
|
|
150
|
+
* the client secret, compare the **hex** digest in constant time.
|
|
151
|
+
*
|
|
152
|
+
* Pure and exported so a spec can exercise it without a Nest module — the same
|
|
153
|
+
* reason `matchesGmailQuery` and friends live outside their services. A
|
|
154
|
+
* signature check that is awkward to test is a signature check nobody tests.
|
|
155
|
+
*
|
|
156
|
+
* ⚠️ Only `hmac` is removed. Older write-ups also strip `signature`; the
|
|
157
|
+
* current guide does not, and removing a parameter that Shopify *did* include
|
|
158
|
+
* in the message would make every valid callback fail to verify.
|
|
159
|
+
*/
|
|
160
|
+
export declare function verifyShopifyOAuthHmac(params: {
|
|
161
|
+
query: Record<string, string | undefined>;
|
|
162
|
+
clientSecret: string;
|
|
163
|
+
}): boolean;
|
|
164
|
+
/** What `POST /admin/oauth/access_token` gives back. */
|
|
165
|
+
export interface ShopifyTokenResponse {
|
|
166
|
+
access_token?: string;
|
|
167
|
+
scope?: string;
|
|
168
|
+
/** Only present for expiring tokens — i.e. never for a custom-distribution
|
|
169
|
+
* app today. Its absence is normal, not an error. */
|
|
170
|
+
expires_in?: number;
|
|
171
|
+
refresh_token?: string;
|
|
172
|
+
refresh_token_expires_in?: number;
|
|
173
|
+
error?: string;
|
|
174
|
+
error_description?: string;
|
|
175
|
+
}
|
|
176
|
+
export interface ShopifyOAuthStoredAuth {
|
|
177
|
+
accessToken: string;
|
|
178
|
+
/** Canonical `{shop}.myshopify.com`. Without this there is no host to call
|
|
179
|
+
* — see the note at the top. */
|
|
180
|
+
shop: string;
|
|
181
|
+
/** Scopes Shopify actually granted, which can be narrower than requested. */
|
|
182
|
+
scope: string;
|
|
183
|
+
/** Absolute ms timestamp, or null when the token does not expire. */
|
|
184
|
+
expiresAt: number | null;
|
|
185
|
+
refreshToken: string | null;
|
|
186
|
+
/** Absolute ms timestamp the refresh token itself dies, or null. */
|
|
187
|
+
refreshTokenExpiresAt: number | null;
|
|
188
|
+
/** Shop display name, for the credential list. */
|
|
189
|
+
shopName: string;
|
|
190
|
+
/** Whether a test query answered when the credential was connected. */
|
|
191
|
+
probeOk: boolean;
|
|
192
|
+
}
|
|
193
|
+
export interface PendingShopifyOAuthState {
|
|
194
|
+
orgId: string;
|
|
195
|
+
userId: string;
|
|
196
|
+
credentialName: string;
|
|
197
|
+
/** Set on reconnect to update an existing credential instead of creating one. */
|
|
198
|
+
credentialId?: string;
|
|
199
|
+
tags?: string[];
|
|
200
|
+
folderId?: string;
|
|
201
|
+
/** The store this flow was started for. The callback's `shop` must match it:
|
|
202
|
+
* the callback is a public route, and without this a valid-looking request
|
|
203
|
+
* could bind a different store to the credential. */
|
|
204
|
+
shop: string;
|
|
205
|
+
clientId: string;
|
|
206
|
+
clientSecret: string;
|
|
207
|
+
/** Absolute ms timestamp when this pending state expires. */
|
|
208
|
+
expiresAt: number;
|
|
209
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SHOP_DOMAIN_RE = exports.SHOPIFY_DEFAULT_SCOPES = exports.SHOPIFY_CREDENTIAL_TYPE = exports.SHOPIFY_OAUTH_CONFIG = exports.SHOPIFY_API_VERSION = void 0;
|
|
4
|
+
exports.normalizeShopDomain = normalizeShopDomain;
|
|
5
|
+
exports.shopifyGraphqlUrl = shopifyGraphqlUrl;
|
|
6
|
+
exports.buildShopifyAuthorizeUrl = buildShopifyAuthorizeUrl;
|
|
7
|
+
exports.buildShopifyTokenExchangeBody = buildShopifyTokenExchangeBody;
|
|
8
|
+
exports.verifyShopifyOAuthHmac = verifyShopifyOAuthHmac;
|
|
9
|
+
const crypto_1 = require("crypto");
|
|
10
|
+
/**
|
|
11
|
+
* Shopify OAuth 2.0 configuration for HostWebhook.
|
|
12
|
+
*
|
|
13
|
+
* The app is registered in the **Dev Dashboard** (<https://dev.shopify.com/dashboard/>),
|
|
14
|
+
* not in the Partner Dashboard and not in a store's admin — both of those
|
|
15
|
+
* paths are dead: admin-created custom apps stopped being creatable on
|
|
16
|
+
* 2026-01-01. The redirect URL is
|
|
17
|
+
* `{API_URL}/api/credentials/shopify/oauth/callback`.
|
|
18
|
+
*
|
|
19
|
+
* See `docs/ADR-0007-shopify.md` for why this app uses **custom
|
|
20
|
+
* distribution**, which is the decision most of the notes below hang off.
|
|
21
|
+
*
|
|
22
|
+
* ## Three things that make Shopify unlike every other provider here
|
|
23
|
+
*
|
|
24
|
+
* **The store is part of the host.** Calls go to
|
|
25
|
+
* `https://{shop}.myshopify.com/admin/api/{version}/graphql.json`, and so does
|
|
26
|
+
* the authorization URL. That is worse than Mailchimp's `dc`, which at least
|
|
27
|
+
* arrives from Mailchimp: here the shop is **typed by the user before the flow
|
|
28
|
+
* starts**, so it is attacker-influenced input that we then put in a URL. It
|
|
29
|
+
* is validated twice — in the DTO and again here — against Shopify's own
|
|
30
|
+
* anchored pattern, and every call goes through `fetchSeguro`.
|
|
31
|
+
*
|
|
32
|
+
* **The callback is signed, and checking it is not optional.** Shopify appends
|
|
33
|
+
* an `hmac` over the rest of the query string. Skipping that check would let
|
|
34
|
+
* anyone hit our public callback with a `shop` of their choosing.
|
|
35
|
+
*
|
|
36
|
+
* **Tokens may or may not expire, and which one you get is a property of the
|
|
37
|
+
* app, not of the code.** Apps with *public* distribution created on or after
|
|
38
|
+
* 2026-04-01 must use expiring offline tokens (1 h access, 90-day refresh);
|
|
39
|
+
* apps with custom distribution are exempt and get a token that lives until
|
|
40
|
+
* the app is uninstalled. Ours is custom, so today no expiry arrives.
|
|
41
|
+
*
|
|
42
|
+
* We deliberately do **not** send `expiring: 1` in the token request — that is
|
|
43
|
+
* the flag that opts into the expiring kind, and asking for a token that dies
|
|
44
|
+
* every hour when we do not have to would buy the rotation problem for
|
|
45
|
+
* nothing. But the reader below stores `expires_in` / `refresh_token`
|
|
46
|
+
* **whenever Shopify sends them**, so the day this app goes public (which
|
|
47
|
+
* means a NEW app — distribution cannot be changed) the credential shape does
|
|
48
|
+
* not have to change with it.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* The Admin API version, in ONE place.
|
|
52
|
+
*
|
|
53
|
+
* Shopify ships quarterly and supports each version for at least 12 months, so
|
|
54
|
+
* this is a scheduled edit, not a fire. What is *not* optional is sending it:
|
|
55
|
+
* omit the version and Shopify "falls forward" to the oldest stable version it
|
|
56
|
+
* still serves, which means the API changes under us on a date nobody chose.
|
|
57
|
+
*/
|
|
58
|
+
exports.SHOPIFY_API_VERSION = '2026-07';
|
|
59
|
+
exports.SHOPIFY_OAUTH_CONFIG = {
|
|
60
|
+
/** Env var names holding HW's registered client credentials. */
|
|
61
|
+
clientIdEnv: 'SHOPIFY_OAUTH_CLIENT_ID',
|
|
62
|
+
clientSecretEnv: 'SHOPIFY_OAUTH_CLIENT_SECRET',
|
|
63
|
+
};
|
|
64
|
+
/** The credential `type` string. Must also exist in `@hostwebhook/node-types`
|
|
65
|
+
* `CREDENTIAL_TYPES` — the Mongoose enum and the DTO's `@IsIn` both come from
|
|
66
|
+
* there, and a type missing from that list fails only at runtime. */
|
|
67
|
+
exports.SHOPIFY_CREDENTIAL_TYPE = 'shopify_oauth2';
|
|
68
|
+
/**
|
|
69
|
+
* What the node needs, and nothing else.
|
|
70
|
+
*
|
|
71
|
+
* These must ALSO be declared on the app's released version in the Dev
|
|
72
|
+
* Dashboard — same two-places rule as Slack, not the one-place rule of
|
|
73
|
+
* Google. A scope requested here but not declared there is refused by
|
|
74
|
+
* Shopify at the authorize step.
|
|
75
|
+
*
|
|
76
|
+
* `read_all_orders` is deliberately absent: it needs a separate approval from
|
|
77
|
+
* Shopify and only widens the window past 60 days, which nothing in v1 uses.
|
|
78
|
+
*/
|
|
79
|
+
exports.SHOPIFY_DEFAULT_SCOPES = [
|
|
80
|
+
'read_orders',
|
|
81
|
+
'write_orders',
|
|
82
|
+
'read_customers',
|
|
83
|
+
'write_customers',
|
|
84
|
+
'read_products',
|
|
85
|
+
'write_products',
|
|
86
|
+
'read_inventory',
|
|
87
|
+
'write_inventory',
|
|
88
|
+
];
|
|
89
|
+
/**
|
|
90
|
+
* Shopify's own pattern for a store domain, anchored at both ends.
|
|
91
|
+
*
|
|
92
|
+
* Taken from their authorization guide rather than written from scratch, and
|
|
93
|
+
* the anchors are the whole point: without them
|
|
94
|
+
* `evil.com/?x=shop.myshopify.com` passes.
|
|
95
|
+
*/
|
|
96
|
+
exports.SHOP_DOMAIN_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/;
|
|
97
|
+
/**
|
|
98
|
+
* Normalize and validate a store domain, or throw.
|
|
99
|
+
*
|
|
100
|
+
* Accepts what a user actually types — trailing slash, protocol, capitals, the
|
|
101
|
+
* bare handle — and returns the canonical `{shop}.myshopify.com`. Throwing
|
|
102
|
+
* rather than returning null is on purpose: every caller builds a URL with the
|
|
103
|
+
* result, so a soft failure would produce a request to a host we did not mean
|
|
104
|
+
* to call.
|
|
105
|
+
*/
|
|
106
|
+
function normalizeShopDomain(input) {
|
|
107
|
+
let shop = String(input ?? '')
|
|
108
|
+
.trim()
|
|
109
|
+
.toLowerCase();
|
|
110
|
+
// `https://tienda.myshopify.com/admin` → `tienda.myshopify.com`
|
|
111
|
+
shop = shop.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
112
|
+
// A bare handle is what people paste most often.
|
|
113
|
+
if (shop && !shop.includes('.'))
|
|
114
|
+
shop = `${shop}.myshopify.com`;
|
|
115
|
+
if (!exports.SHOP_DOMAIN_RE.test(shop)) {
|
|
116
|
+
throw new Error(`"${input}" is not a Shopify store domain. It should look like ` +
|
|
117
|
+
`your-store.myshopify.com — that is the permanent domain in Settings → ` +
|
|
118
|
+
`Domains, not a custom domain you may have pointed at the store.`);
|
|
119
|
+
}
|
|
120
|
+
return shop;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Base URL for every Admin API call made with this credential.
|
|
124
|
+
*
|
|
125
|
+
* Lives here rather than being interpolated at each call site so there is one
|
|
126
|
+
* place that knows the shape — and so a credential missing its shop fails
|
|
127
|
+
* loudly here instead of producing a request to `https://undefined…`. Same
|
|
128
|
+
* reasoning as `mailchimpApiBase(dc)`.
|
|
129
|
+
*/
|
|
130
|
+
function shopifyGraphqlUrl(shop) {
|
|
131
|
+
const domain = normalizeShopDomain(shop);
|
|
132
|
+
return `https://${domain}/admin/api/${exports.SHOPIFY_API_VERSION}/graphql.json`;
|
|
133
|
+
}
|
|
134
|
+
function buildShopifyAuthorizeUrl(params) {
|
|
135
|
+
const domain = normalizeShopDomain(params.shop);
|
|
136
|
+
const qs = new URLSearchParams({
|
|
137
|
+
client_id: params.clientId,
|
|
138
|
+
// Comma-separated, not space-separated. Shopify is the odd one out here.
|
|
139
|
+
scope: (params.scopes ?? exports.SHOPIFY_DEFAULT_SCOPES).join(','),
|
|
140
|
+
redirect_uri: params.redirectUri,
|
|
141
|
+
state: params.state,
|
|
142
|
+
});
|
|
143
|
+
return `https://${domain}/admin/oauth/authorize?${qs.toString()}`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The body of the authorization-code exchange, including the one flag that
|
|
147
|
+
* decides whether the resulting token works at all.
|
|
148
|
+
*
|
|
149
|
+
* ## `expiring: 1` is not optional, and this file used to say the opposite
|
|
150
|
+
*
|
|
151
|
+
* Custom-distribution apps are exempt from expiring offline tokens, so the
|
|
152
|
+
* original reasoning was that asking for a token which dies every hour would
|
|
153
|
+
* buy the rotation problem for nothing. That is right — for a custom app. This
|
|
154
|
+
* app went **public**, and Shopify then refuses the non-expiring kind outright:
|
|
155
|
+
*
|
|
156
|
+
* ```
|
|
157
|
+
* [API] Non-expiring access tokens are no longer accepted for the Admin API.
|
|
158
|
+
* Start using expiring offline tokens.
|
|
159
|
+
* ```
|
|
160
|
+
*
|
|
161
|
+
* HTTP 403 on every call, including ones touching nothing protected. Seen on
|
|
162
|
+
* 2026-08-26 the first time this was pointed at a real store: the credential
|
|
163
|
+
* connected, the shop name came back, and then not one webhook subscription
|
|
164
|
+
* could be registered — because the token TYPE is decided here and refused over
|
|
165
|
+
* there, which leaves the symptom nowhere near the cause.
|
|
166
|
+
*
|
|
167
|
+
* Sent unconditionally rather than only when public, because the failure is
|
|
168
|
+
* asymmetric: a custom app handed an expiring token still works, since
|
|
169
|
+
* `refreshAccessToken` keeps it alive, while a public app handed a
|
|
170
|
+
* non-expiring one is simply dead. And the distribution method lives in
|
|
171
|
+
* Shopify's dashboard, where this code cannot read it.
|
|
172
|
+
*
|
|
173
|
+
* Pure and exported for the same reason as `verifyShopifyOAuthHmac` below: a
|
|
174
|
+
* flag buried in a private method is a flag no spec can pin, and this one is
|
|
175
|
+
* the difference between a working integration and 403 on everything.
|
|
176
|
+
*
|
|
177
|
+
* ⚠️ No `redirect_uri`. Unlike every other provider here, Shopify's token
|
|
178
|
+
* endpoint does not take one.
|
|
179
|
+
*/
|
|
180
|
+
function buildShopifyTokenExchangeBody(params) {
|
|
181
|
+
return new URLSearchParams({
|
|
182
|
+
client_id: params.clientId,
|
|
183
|
+
client_secret: params.clientSecret,
|
|
184
|
+
code: params.code,
|
|
185
|
+
expiring: '1',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Verify the `hmac` Shopify appends to the callback.
|
|
190
|
+
*
|
|
191
|
+
* The recipe, from Shopify's authorization guide: drop `hmac`, sort what is
|
|
192
|
+
* left by key, join as `k=v&k=v` with the values **decoded**, HMAC-SHA256 with
|
|
193
|
+
* the client secret, compare the **hex** digest in constant time.
|
|
194
|
+
*
|
|
195
|
+
* Pure and exported so a spec can exercise it without a Nest module — the same
|
|
196
|
+
* reason `matchesGmailQuery` and friends live outside their services. A
|
|
197
|
+
* signature check that is awkward to test is a signature check nobody tests.
|
|
198
|
+
*
|
|
199
|
+
* ⚠️ Only `hmac` is removed. Older write-ups also strip `signature`; the
|
|
200
|
+
* current guide does not, and removing a parameter that Shopify *did* include
|
|
201
|
+
* in the message would make every valid callback fail to verify.
|
|
202
|
+
*/
|
|
203
|
+
function verifyShopifyOAuthHmac(params) {
|
|
204
|
+
const provided = params.query.hmac;
|
|
205
|
+
if (!provided)
|
|
206
|
+
return false;
|
|
207
|
+
const message = Object.keys(params.query)
|
|
208
|
+
.filter((k) => k !== 'hmac')
|
|
209
|
+
.filter((k) => params.query[k] !== undefined)
|
|
210
|
+
.sort()
|
|
211
|
+
.map((k) => `${k}=${params.query[k]}`)
|
|
212
|
+
.join('&');
|
|
213
|
+
const expected = (0, crypto_1.createHmac)('sha256', params.clientSecret)
|
|
214
|
+
.update(message, 'utf8')
|
|
215
|
+
.digest('hex');
|
|
216
|
+
const a = Buffer.from(expected, 'utf8');
|
|
217
|
+
const b = Buffer.from(provided, 'utf8');
|
|
218
|
+
// `timingSafeEqual` throws on a length mismatch, which is itself a leak of
|
|
219
|
+
// the comparison — check the length first and return the same `false`.
|
|
220
|
+
if (a.length !== b.length)
|
|
221
|
+
return false;
|
|
222
|
+
return (0, crypto_1.timingSafeEqual)(a, b);
|
|
223
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hostwebhook/platform-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Lo que los servicios de HostWebhook comparten del lado de NODE: cifrado y utilidades que no pueden estar escritas dos veces",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|