@shopify/shopify-app-remix 4.1.0 → 4.2.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.
Files changed (206) hide show
  1. package/dist/cjs/react/components/AppProvider/AppProvider.js.map +1 -1
  2. package/dist/cjs/react/components/AppProxyLink/AppProxyLink.js.map +1 -1
  3. package/dist/cjs/server/authenticate/admin/strategies/auth-code-flow.js.map +1 -1
  4. package/dist/cjs/server/authenticate/admin/strategies/merchant-custom-app.js.map +1 -1
  5. package/dist/cjs/server/authenticate/admin/strategies/token-exchange.js.map +1 -1
  6. package/dist/cjs/server/authenticate/webhooks/authenticate.js +34 -10
  7. package/dist/cjs/server/authenticate/webhooks/authenticate.js.map +1 -1
  8. package/dist/cjs/server/version.js +1 -1
  9. package/dist/cjs/server/version.js.map +1 -1
  10. package/dist/esm/react/components/AppProvider/AppProvider.mjs.map +1 -1
  11. package/dist/esm/react/components/AppProxyLink/AppProxyLink.mjs.map +1 -1
  12. package/dist/esm/server/authenticate/admin/strategies/auth-code-flow.mjs.map +1 -1
  13. package/dist/esm/server/authenticate/admin/strategies/merchant-custom-app.mjs.map +1 -1
  14. package/dist/esm/server/authenticate/admin/strategies/token-exchange.mjs.map +1 -1
  15. package/dist/esm/server/authenticate/webhooks/authenticate.mjs +35 -11
  16. package/dist/esm/server/authenticate/webhooks/authenticate.mjs.map +1 -1
  17. package/dist/esm/server/version.mjs +1 -1
  18. package/dist/esm/server/version.mjs.map +1 -1
  19. package/dist/ts/react/components/AppProvider/AppProvider.d.ts.map +1 -1
  20. package/dist/ts/react/components/AppProxyLink/AppProxyLink.d.ts.map +1 -1
  21. package/dist/ts/server/authenticate/admin/billing/types.d.ts.map +1 -1
  22. package/dist/ts/server/authenticate/admin/strategies/auth-code-flow.d.ts.map +1 -1
  23. package/dist/ts/server/authenticate/admin/strategies/merchant-custom-app.d.ts.map +1 -1
  24. package/dist/ts/server/authenticate/admin/strategies/token-exchange.d.ts.map +1 -1
  25. package/dist/ts/server/authenticate/admin/types.d.ts.map +1 -1
  26. package/dist/ts/server/authenticate/public/extension/types.d.ts.map +1 -1
  27. package/dist/ts/server/authenticate/webhooks/authenticate.d.ts.map +1 -1
  28. package/dist/ts/server/authenticate/webhooks/types.d.ts +34 -2
  29. package/dist/ts/server/authenticate/webhooks/types.d.ts.map +1 -1
  30. package/dist/ts/server/config-types.d.ts.map +1 -1
  31. package/dist/ts/server/version.d.ts +1 -1
  32. package/dist/tsconfig.build.tsbuildinfo +1 -1
  33. package/package.json +18 -14
  34. package/src/react/components/AppProvider/AppProvider.doc.ts +35 -0
  35. package/src/react/components/AppProvider/AppProvider.tsx +147 -0
  36. package/src/react/components/AppProvider/index.ts +1 -0
  37. package/src/react/components/AppProxyForm/AppProxyForm.doc.ts +28 -0
  38. package/src/react/components/AppProxyForm/AppProxyForm.tsx +82 -0
  39. package/src/react/components/AppProxyForm/index.ts +1 -0
  40. package/src/react/components/AppProxyLink/AppProxyLink.doc.ts +28 -0
  41. package/src/react/components/AppProxyLink/AppProxyLink.tsx +58 -0
  42. package/src/react/components/AppProxyLink/index.ts +1 -0
  43. package/src/react/components/AppProxyProvider/AppProxyProvider.doc.ts +40 -0
  44. package/src/react/components/AppProxyProvider/AppProxyProvider.tsx +100 -0
  45. package/src/react/components/AppProxyProvider/index.ts +5 -0
  46. package/src/react/components/RemixPolarisLink.tsx +14 -0
  47. package/src/react/components/index.ts +4 -0
  48. package/src/react/const.ts +2 -0
  49. package/src/react/index.ts +1 -0
  50. package/src/server/__test-helpers/const.ts +14 -0
  51. package/src/server/__test-helpers/expect-admin-api-client.ts +51 -0
  52. package/src/server/__test-helpers/expect-begin-auth-redirect.ts +22 -0
  53. package/src/server/__test-helpers/expect-document-request-headers.ts +25 -0
  54. package/src/server/__test-helpers/expect-exit-iframe.ts +37 -0
  55. package/src/server/__test-helpers/expect-login-redirect.ts +8 -0
  56. package/src/server/__test-helpers/expect-storefront-api-client.ts +49 -0
  57. package/src/server/__test-helpers/expect-token-refresh.ts +188 -0
  58. package/src/server/__test-helpers/get-hmac.ts +7 -0
  59. package/src/server/__test-helpers/get-jwt.ts +10 -0
  60. package/src/server/__test-helpers/get-thrown-error.ts +19 -0
  61. package/src/server/__test-helpers/get-thrown-response.ts +17 -0
  62. package/src/server/__test-helpers/index.ts +21 -0
  63. package/src/server/__test-helpers/mock-graphql-request.ts +60 -0
  64. package/src/server/__test-helpers/request-mock.ts +169 -0
  65. package/src/server/__test-helpers/setup-embedded-flow.ts +30 -0
  66. package/src/server/__test-helpers/setup-fetch-flow.ts +29 -0
  67. package/src/server/__test-helpers/setup-non-embedded-flow.ts +31 -0
  68. package/src/server/__test-helpers/setup-valid-request.ts +78 -0
  69. package/src/server/__test-helpers/setup-valid-session.ts +28 -0
  70. package/src/server/__test-helpers/sign-request-cookie.ts +21 -0
  71. package/src/server/__test-helpers/test-config.ts +56 -0
  72. package/src/server/adapters/node/index.ts +13 -0
  73. package/src/server/adapters/vercel/index.ts +13 -0
  74. package/src/server/authenticate/admin/authenticate.admin.doc.example.ts +34 -0
  75. package/src/server/authenticate/admin/authenticate.admin.doc.ts +51 -0
  76. package/src/server/authenticate/admin/authenticate.ts +232 -0
  77. package/src/server/authenticate/admin/billing/authenticate.admin.billing.doc.ts +29 -0
  78. package/src/server/authenticate/admin/billing/cancel.ts +38 -0
  79. package/src/server/authenticate/admin/billing/check.ts +41 -0
  80. package/src/server/authenticate/admin/billing/create-usage-record.ts +36 -0
  81. package/src/server/authenticate/admin/billing/helpers.ts +42 -0
  82. package/src/server/authenticate/admin/billing/index.ts +6 -0
  83. package/src/server/authenticate/admin/billing/request.ts +64 -0
  84. package/src/server/authenticate/admin/billing/require.ts +58 -0
  85. package/src/server/authenticate/admin/billing/types.ts +613 -0
  86. package/src/server/authenticate/admin/billing/update-usage-subscription-capped-amount.ts +55 -0
  87. package/src/server/authenticate/admin/helpers/begin-auth.ts +17 -0
  88. package/src/server/authenticate/admin/helpers/create-admin-api-context.ts +20 -0
  89. package/src/server/authenticate/admin/helpers/ensure-app-is-embedded-if-required.ts +18 -0
  90. package/src/server/authenticate/admin/helpers/ensure-session-token-search-param-if-required.ts +25 -0
  91. package/src/server/authenticate/admin/helpers/handle-client-error.ts +45 -0
  92. package/src/server/authenticate/admin/helpers/index.ts +14 -0
  93. package/src/server/authenticate/admin/helpers/redirect-to-auth-page.ts +28 -0
  94. package/src/server/authenticate/admin/helpers/redirect-to-bounce-page.ts +23 -0
  95. package/src/server/authenticate/admin/helpers/redirect-to-install-page.ts +53 -0
  96. package/src/server/authenticate/admin/helpers/redirect-to-shopify-or-app-root.ts +21 -0
  97. package/src/server/authenticate/admin/helpers/redirect-with-app-bridge-headers.ts +13 -0
  98. package/src/server/authenticate/admin/helpers/redirect-with-exitiframe.ts +28 -0
  99. package/src/server/authenticate/admin/helpers/redirect.ts +164 -0
  100. package/src/server/authenticate/admin/helpers/render-app-bridge.ts +46 -0
  101. package/src/server/authenticate/admin/helpers/trigger-after-auth-hook.ts +29 -0
  102. package/src/server/authenticate/admin/helpers/validate-redirect-url.ts +67 -0
  103. package/src/server/authenticate/admin/helpers/validate-shop-and-host-params.ts +46 -0
  104. package/src/server/authenticate/admin/scope/authenticate.admin.scopes.doc.ts +28 -0
  105. package/src/server/authenticate/admin/scope/client/fetch-scopes-details.ts +43 -0
  106. package/src/server/authenticate/admin/scope/client/revoke-scopes.ts +38 -0
  107. package/src/server/authenticate/admin/scope/factory.ts +21 -0
  108. package/src/server/authenticate/admin/scope/query.ts +51 -0
  109. package/src/server/authenticate/admin/scope/request.ts +32 -0
  110. package/src/server/authenticate/admin/scope/revoke.ts +48 -0
  111. package/src/server/authenticate/admin/scope/types.ts +126 -0
  112. package/src/server/authenticate/admin/strategies/auth-code-flow.ts +332 -0
  113. package/src/server/authenticate/admin/strategies/merchant-custom-app.ts +65 -0
  114. package/src/server/authenticate/admin/strategies/token-exchange.ts +188 -0
  115. package/src/server/authenticate/admin/strategies/types.ts +29 -0
  116. package/src/server/authenticate/admin/types.ts +216 -0
  117. package/src/server/authenticate/const.ts +15 -0
  118. package/src/server/authenticate/flow/authenticate.flow.doc.example.ts +39 -0
  119. package/src/server/authenticate/flow/authenticate.flow.doc.ts +47 -0
  120. package/src/server/authenticate/flow/authenticate.ts +68 -0
  121. package/src/server/authenticate/flow/types.ts +85 -0
  122. package/src/server/authenticate/fulfillment-service/authenticate.fulfillment-service.doc.example.ts +52 -0
  123. package/src/server/authenticate/fulfillment-service/authenticate.fulfillment-service.doc.ts +48 -0
  124. package/src/server/authenticate/fulfillment-service/authenticate.ts +82 -0
  125. package/src/server/authenticate/fulfillment-service/types.ts +82 -0
  126. package/src/server/authenticate/helpers/add-response-headers.ts +43 -0
  127. package/src/server/authenticate/helpers/app-bridge-url.ts +10 -0
  128. package/src/server/authenticate/helpers/ensure-cors-headers.ts +38 -0
  129. package/src/server/authenticate/helpers/get-session-token-header.ts +11 -0
  130. package/src/server/authenticate/helpers/get-shop-from-request.ts +4 -0
  131. package/src/server/authenticate/helpers/idempotent-promise-handler.ts +45 -0
  132. package/src/server/authenticate/helpers/index.ts +10 -0
  133. package/src/server/authenticate/helpers/invalidate-access-token.ts +17 -0
  134. package/src/server/authenticate/helpers/reject-bot-request.ts +27 -0
  135. package/src/server/authenticate/helpers/respond-to-invalid-session-token.ts +28 -0
  136. package/src/server/authenticate/helpers/respond-to-options-request.ts +26 -0
  137. package/src/server/authenticate/helpers/validate-session-token.ts +40 -0
  138. package/src/server/authenticate/login/login.ts +53 -0
  139. package/src/server/authenticate/public/appProxy/authenticate.public.app-proxy.doc.example.ts +27 -0
  140. package/src/server/authenticate/public/appProxy/authenticate.public.app-proxy.doc.ts +55 -0
  141. package/src/server/authenticate/public/appProxy/authenticate.ts +150 -0
  142. package/src/server/authenticate/public/appProxy/types.ts +215 -0
  143. package/src/server/authenticate/public/checkout/authenticate.public.checkout.doc.example.offers.ts +21 -0
  144. package/src/server/authenticate/public/checkout/authenticate.public.checkout.doc.example.ts +17 -0
  145. package/src/server/authenticate/public/checkout/authenticate.public.checkout.doc.ts +49 -0
  146. package/src/server/authenticate/public/checkout/authenticate.ts +13 -0
  147. package/src/server/authenticate/public/checkout/types.ts +65 -0
  148. package/src/server/authenticate/public/customer-account/authenticate.public.customerAccount.doc.example.offers.ts +36 -0
  149. package/src/server/authenticate/public/customer-account/authenticate.public.customerAccount.doc.example.ts +18 -0
  150. package/src/server/authenticate/public/customer-account/authenticate.public.customerAccount.doc.ts +49 -0
  151. package/src/server/authenticate/public/customer-account/authenticate.ts +13 -0
  152. package/src/server/authenticate/public/customer-account/types.ts +65 -0
  153. package/src/server/authenticate/public/extension/authenticate.ts +54 -0
  154. package/src/server/authenticate/public/extension/types.ts +11 -0
  155. package/src/server/authenticate/public/factory.ts +24 -0
  156. package/src/server/authenticate/public/index.ts +1 -0
  157. package/src/server/authenticate/public/pos/authenticate.ts +8 -0
  158. package/src/server/authenticate/public/pos/types.ts +65 -0
  159. package/src/server/authenticate/public/types.ts +96 -0
  160. package/src/server/authenticate/webhooks/authenticate.ts +105 -0
  161. package/src/server/authenticate/webhooks/authenticate.webhooks.doc.example.ts +43 -0
  162. package/src/server/authenticate/webhooks/authenticate.webhooks.doc.ts +39 -0
  163. package/src/server/authenticate/webhooks/index.ts +1 -0
  164. package/src/server/authenticate/webhooks/register.ts +48 -0
  165. package/src/server/authenticate/webhooks/types.ts +266 -0
  166. package/src/server/boundary/error.tsx +14 -0
  167. package/src/server/boundary/headers.ts +15 -0
  168. package/src/server/boundary/index.ts +39 -0
  169. package/src/server/boundary/types.ts +4 -0
  170. package/src/server/clients/admin/authenticate.admin.api.doc.ts +34 -0
  171. package/src/server/clients/admin/factory.ts +22 -0
  172. package/src/server/clients/admin/graphql.ts +39 -0
  173. package/src/server/clients/admin/index.ts +2 -0
  174. package/src/server/clients/admin/types.ts +120 -0
  175. package/src/server/clients/index.ts +2 -0
  176. package/src/server/clients/storefront/authenticate.storefront.api.doc.ts +34 -0
  177. package/src/server/clients/storefront/factory.ts +32 -0
  178. package/src/server/clients/storefront/index.ts +2 -0
  179. package/src/server/clients/storefront/types.ts +78 -0
  180. package/src/server/clients/types.ts +47 -0
  181. package/src/server/config-types.ts +309 -0
  182. package/src/server/errors.ts +3 -0
  183. package/src/server/future/flags.ts +62 -0
  184. package/src/server/helpers/create-or-load-offline-session.ts +19 -0
  185. package/src/server/helpers/ensure-offline-token-is-not-expired.ts +32 -0
  186. package/src/server/helpers/ensure-valid-offline-session.ts +15 -0
  187. package/src/server/helpers/index.ts +4 -0
  188. package/src/server/helpers/refresh-token.ts +35 -0
  189. package/src/server/index.ts +24 -0
  190. package/src/server/override-logger.ts +42 -0
  191. package/src/server/shopify-app.doc.ts +45 -0
  192. package/src/server/shopify-app.ts +215 -0
  193. package/src/server/test-helpers/index.ts +1 -0
  194. package/src/server/test-helpers/test-config.ts +79 -0
  195. package/src/server/types-contexts.ts +44 -0
  196. package/src/server/types.ts +507 -0
  197. package/src/server/unauthenticated/admin/factory.ts +23 -0
  198. package/src/server/unauthenticated/admin/index.ts +1 -0
  199. package/src/server/unauthenticated/admin/types.ts +79 -0
  200. package/src/server/unauthenticated/admin/unauthenticated.admin.doc.ts +40 -0
  201. package/src/server/unauthenticated/storefront/factory.ts +28 -0
  202. package/src/server/unauthenticated/storefront/index.ts +1 -0
  203. package/src/server/unauthenticated/storefront/types.ts +105 -0
  204. package/src/server/unauthenticated/storefront/unauthenticated.storefront.doc.ts +40 -0
  205. package/src/server/unauthenticated/types.ts +65 -0
  206. package/src/server/version.ts +1 -0
@@ -0,0 +1,43 @@
1
+ import {AdminApiContext} from '../../../../clients';
2
+
3
+ export interface FetchScopesDetailResponse {
4
+ app: {
5
+ requestedAccessScopes: {
6
+ handle: string;
7
+ }[];
8
+ optionalAccessScopes: {
9
+ handle: string;
10
+ }[];
11
+ installation: {
12
+ accessScopes: {
13
+ handle: string;
14
+ }[];
15
+ };
16
+ };
17
+ }
18
+
19
+ const FETCH_SCOPES_DETAIL_QUERY = `#graphql
20
+ query FetchAccessScopes{
21
+ app {
22
+ requestedAccessScopes {
23
+ handle
24
+ }
25
+ optionalAccessScopes {
26
+ handle
27
+ }
28
+ installation {
29
+ accessScopes {
30
+ handle
31
+ }
32
+ }
33
+ }
34
+ }`;
35
+
36
+ export async function fetchScopeDetail(
37
+ admin: AdminApiContext,
38
+ ): Promise<FetchScopesDetailResponse> {
39
+ const fetchScopeDetailResult = await admin.graphql(FETCH_SCOPES_DETAIL_QUERY);
40
+
41
+ const resultContent = await fetchScopeDetailResult.json();
42
+ return resultContent.data;
43
+ }
@@ -0,0 +1,38 @@
1
+ import {AdminApiContext} from '../../../../clients';
2
+
3
+ export interface RevokeScopesResponse {
4
+ revoked: {
5
+ handle: string;
6
+ }[];
7
+ userErrors: {
8
+ field: string;
9
+ message: string;
10
+ }[];
11
+ }
12
+
13
+ const REVOKE_SCOPE_MUTATION = `#graphql
14
+ mutation AppRevokeAccessScopes($scopes: [String!]!) {
15
+ appRevokeAccessScopes(scopes: $scopes){
16
+ revoked {
17
+ handle
18
+ }
19
+ userErrors {
20
+ field
21
+ message
22
+ }
23
+ }
24
+ }`;
25
+
26
+ export async function revokeScopes(
27
+ admin: AdminApiContext,
28
+ scopes: string[],
29
+ ): Promise<RevokeScopesResponse> {
30
+ const revokeScopesResult = await admin.graphql(REVOKE_SCOPE_MUTATION, {
31
+ variables: {
32
+ scopes,
33
+ },
34
+ });
35
+
36
+ const resultContent = await revokeScopesResult.json();
37
+ return resultContent.data.appRevokeAccessScopes;
38
+ }
@@ -0,0 +1,21 @@
1
+ import {Session} from '@shopify/shopify-api';
2
+
3
+ import {BasicParams} from '../../../types';
4
+ import {AdminApiContext} from '../../../clients';
5
+
6
+ import {ScopesApiContext} from './types';
7
+ import {requestScopesFactory} from './request';
8
+ import {queryScopesFactory} from './query';
9
+ import {revokeScopesFactory} from './revoke';
10
+
11
+ export function scopesApiFactory(
12
+ params: BasicParams,
13
+ session: Session,
14
+ admin: AdminApiContext,
15
+ ): ScopesApiContext {
16
+ return {
17
+ query: queryScopesFactory(params, session, admin),
18
+ request: requestScopesFactory(params, session, admin),
19
+ revoke: revokeScopesFactory(params, session, admin),
20
+ };
21
+ }
@@ -0,0 +1,51 @@
1
+ import {AuthScopes, Session} from '@shopify/shopify-api';
2
+
3
+ import {AdminApiContext} from '../../../clients';
4
+ import type {BasicParams} from '../../../types';
5
+
6
+ import {ScopesDetail} from './types';
7
+ import {
8
+ FetchScopesDetailResponse,
9
+ fetchScopeDetail,
10
+ } from './client/fetch-scopes-details';
11
+
12
+ export function queryScopesFactory(
13
+ params: BasicParams,
14
+ session: Session,
15
+ admin: AdminApiContext,
16
+ ) {
17
+ return async function queryScopes() {
18
+ const {logger} = params;
19
+
20
+ logger.debug('Querying scopes details: ', {
21
+ shop: session.shop,
22
+ });
23
+
24
+ const scopesDetail = await fetchScopeDetail(admin);
25
+ return mapFetchScopeDetail(scopesDetail);
26
+ };
27
+ }
28
+
29
+ export function mapFetchScopeDetail(
30
+ scopesDetailResponse: FetchScopesDetailResponse,
31
+ ): ScopesDetail {
32
+ const appInformation = scopesDetailResponse.app;
33
+
34
+ const granted = new AuthScopes(
35
+ appInformation.installation.accessScopes.map((scope) => scope.handle),
36
+ ).toArray(true);
37
+
38
+ const required = new AuthScopes(
39
+ appInformation.requestedAccessScopes.map((scope) => scope.handle),
40
+ ).toArray(true);
41
+
42
+ const optional = new AuthScopes(
43
+ appInformation.optionalAccessScopes.map((scope) => scope.handle),
44
+ ).toArray(true);
45
+
46
+ return {
47
+ granted,
48
+ required,
49
+ optional,
50
+ };
51
+ }
@@ -0,0 +1,32 @@
1
+ import {AuthScopes, Session} from '@shopify/shopify-api';
2
+
3
+ import type {BasicParams} from '../../../types';
4
+ import {redirectToInstallPage} from '../helpers/redirect-to-install-page';
5
+ import {AdminApiContext} from '../../../clients';
6
+
7
+ import {fetchScopeDetail} from './client/fetch-scopes-details';
8
+
9
+ export function requestScopesFactory(
10
+ params: BasicParams,
11
+ session: Session,
12
+ admin: AdminApiContext,
13
+ ) {
14
+ return async function requestScopes(scopes: string[]) {
15
+ const {logger} = params;
16
+
17
+ logger.debug('Requesting optional scopes: ', {shop: session.shop, scopes});
18
+
19
+ if (scopes.length === 0) return;
20
+ if (await alreadyGranted(scopes, admin)) return;
21
+
22
+ throw await redirectToInstallPage(params, session.shop, scopes);
23
+ };
24
+
25
+ async function alreadyGranted(scopes: string[], admin: AdminApiContext) {
26
+ const scopesDetail = await fetchScopeDetail(admin);
27
+ const grantedScopes = scopesDetail.app.installation.accessScopes.map(
28
+ (scope) => scope.handle,
29
+ );
30
+ return new AuthScopes(grantedScopes).has(scopes);
31
+ }
32
+ }
@@ -0,0 +1,48 @@
1
+ import {Session} from '@shopify/shopify-api';
2
+
3
+ import {AdminApiContext} from '../../../clients';
4
+ import type {BasicParams} from '../../../types';
5
+
6
+ import {revokeScopes} from './client/revoke-scopes';
7
+
8
+ export function revokeScopesFactory(
9
+ params: BasicParams,
10
+ session: Session,
11
+ admin: AdminApiContext,
12
+ ) {
13
+ return async function revoke(scopes: string[]) {
14
+ const {logger} = params;
15
+
16
+ await validateScopes(scopes);
17
+
18
+ logger.debug('Revoke scopes: ', {
19
+ shop: session.shop,
20
+ scopes,
21
+ });
22
+
23
+ const revokeScopesResult = await revokeScopes(admin, scopes);
24
+ if (revokeScopesResult.userErrors?.length > 0) {
25
+ logger.error('Failed to revoke scopes: ', {
26
+ shop: session.shop,
27
+ errors: revokeScopesResult.userErrors,
28
+ });
29
+
30
+ throw new Response(JSON.stringify(revokeScopesResult.userErrors), {
31
+ status: 422,
32
+ headers: {
33
+ 'Content-Type': 'application/json',
34
+ },
35
+ });
36
+ }
37
+
38
+ return {
39
+ revoked: revokeScopesResult.revoked.map((scope) => scope.handle),
40
+ };
41
+ };
42
+ }
43
+
44
+ async function validateScopes(scopes: string[]) {
45
+ if (!scopes || scopes.length === 0) {
46
+ throw new Response('No scopes provided', {status: 400});
47
+ }
48
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The Scopes API enables embedded apps and extensions to request merchant consent for access scopes.
3
+ */
4
+ export interface ScopesApiContext {
5
+ /**
6
+ * Queries Shopify to see what scopes have been granted
7
+ *
8
+ * @returns {ScopesDetail} The scope details.
9
+ *
10
+ * @example
11
+ * <caption>Query for granted scopes.</caption>
12
+ * <description>Call `scopes.query` to get scope details.</description>
13
+ * ```ts
14
+ * // /app._index.tsx
15
+ * import { authenticate } from "../shopify.server";
16
+ * import { json } from "@remix-run/node";
17
+ *
18
+ * export const loader = async ({ request }) => {
19
+ * const { scopes } = await authenticate.admin(request);
20
+ * const { required, optional, granted } = await scopes.query();
21
+ *
22
+ * if (granted.includes("write_products")) {
23
+ * // do something
24
+ * }
25
+ *
26
+ * return null
27
+ * };
28
+ *
29
+ * ```
30
+ */
31
+ query: () => Promise<ScopesDetail>;
32
+
33
+ /**
34
+ * Requests the merchant grant the provided scopes
35
+ *
36
+ * This method performs a redirect to the grant screen.
37
+ *
38
+ * @example
39
+ * <caption>Ask the merchant to grant the provided scopes.</caption>
40
+ * <description>Call `scopes.request` to request optional scopes.</description>
41
+ * ```ts
42
+ * // /app/routes/app.request.tsx
43
+ * import { authenticate } from "../shopify.server";
44
+ *
45
+ * // Example of an action to request optional scopes
46
+ * export const action = async ({ request }) => {
47
+ * const { scopes } = await authenticate.admin(request);
48
+ * const { granted } = await scopes.query();
49
+ *
50
+ * if (!granted.includes("write_products")) {
51
+ * await scopes.request(["write_products"]);
52
+ * }
53
+ *
54
+ * return null
55
+ * };
56
+ * ```
57
+ */
58
+ request: (scopes: Scope[]) => Promise<void>;
59
+
60
+ /**
61
+ * Revokes the provided scopes
62
+ *
63
+ * Warning: This method throws an [error](https://shopify.dev/docs/api/admin-graphql/unstable/objects/AppRevokeAccessScopesAppRevokeScopeError) if the provided optional scopes contains a required scope.
64
+ *
65
+ * @example
66
+ * <caption>Revoke optional scopes.</caption>
67
+ * <description>Call `scopes.revoke` to revoke optional scopes.</description>
68
+ * ```ts
69
+ * // /app._index.tsx
70
+ * import { authenticate } from "../shopify.server";
71
+ * import { json } from "@remix-run/node";
72
+ *
73
+ * // Example of an action to POST optional scopes to revoke
74
+ * export const action = async ({ request }) => {
75
+ * const { scopes } = await authenticate.admin(request);
76
+ * const { granted } = await scopes.query();
77
+ *
78
+ * if (granted.includes("write_products")) {
79
+ * const revokedResponse = await scopes.revoke(["write_products"]);
80
+ * return json(revokedResponse);
81
+ * }
82
+ *
83
+ * return null
84
+ * };
85
+ *
86
+ * ```
87
+ */
88
+ revoke: (scopes: Scope[]) => Promise<ScopesRevokeResponse>;
89
+ }
90
+
91
+ /**
92
+ * `Scope` represents an [access scope](https://shopify.dev/docs/api/usage/access-scopes) handle, like "`write_products`" or "`read_orders`"
93
+ */
94
+ export type Scope = string;
95
+
96
+ /**
97
+ * `UserResult` represents the results of a user responding to a scopes request, i.e. a merchant user’s action taken when presented with a grant modal.
98
+ */
99
+ export type UserResult = 'granted-all' | 'declined-all';
100
+
101
+ export interface ScopesDetail {
102
+ /**
103
+ * The scopes that have been granted on the shop for this app
104
+ */
105
+ granted: Scope[];
106
+ /**
107
+ * The required scopes that the app has declared in its configuration
108
+ */
109
+ required: Scope[];
110
+ /**
111
+ * The optional scopes that the app has declared in its configuration
112
+ */
113
+ optional: Scope[];
114
+ }
115
+
116
+ export interface ScopesRequestResponse {
117
+ result: UserResult;
118
+ detail: ScopesDetail;
119
+ }
120
+
121
+ export interface ScopesRevokeResponse {
122
+ /**
123
+ * The scopes that have been revoked on the shop for this app
124
+ */
125
+ revoked: Scope[];
126
+ }
@@ -0,0 +1,332 @@
1
+ import {
2
+ CookieNotFound,
3
+ GraphqlQueryError,
4
+ HttpResponseError,
5
+ InvalidHmacError,
6
+ InvalidOAuthError,
7
+ Session,
8
+ Shopify,
9
+ } from '@shopify/shopify-api';
10
+
11
+ import type {BasicParams} from '../../../types';
12
+ import {
13
+ beginAuth,
14
+ handleClientErrorFactory,
15
+ redirectToAuthPage,
16
+ redirectToShopifyOrAppRoot,
17
+ redirectWithExitIframe,
18
+ triggerAfterAuthHook,
19
+ validateShopAndHostParams,
20
+ } from '../helpers';
21
+ import {AppConfig, AppConfigArg} from '../../../config-types';
22
+ import {getSessionTokenHeader} from '../../helpers';
23
+ import {HandleAdminClientError} from '../../../clients';
24
+ import type {
25
+ ApiConfigWithFutureFlags,
26
+ ApiFutureFlags,
27
+ } from '../../../future/flags';
28
+
29
+ import {AuthorizationStrategy, SessionContext, OnErrorOptions} from './types';
30
+
31
+ export class AuthCodeFlowStrategy<
32
+ Config extends AppConfigArg,
33
+ > implements AuthorizationStrategy {
34
+ protected api: Shopify<
35
+ ApiConfigWithFutureFlags<Config['future']>,
36
+ ApiFutureFlags<Config['future']>
37
+ >;
38
+
39
+ protected config: AppConfig;
40
+ protected logger: Shopify['logger'];
41
+
42
+ public constructor({api, config, logger}: BasicParams) {
43
+ this.api = api;
44
+ this.config = config;
45
+ this.logger = logger;
46
+ }
47
+
48
+ public async respondToOAuthRequests(request: Request): Promise<void | never> {
49
+ const {api, config} = this;
50
+
51
+ const url = new URL(request.url);
52
+ const isAuthRequest = url.pathname === config.auth.path;
53
+ const isAuthCallbackRequest = url.pathname === config.auth.callbackPath;
54
+
55
+ if (isAuthRequest || isAuthCallbackRequest) {
56
+ const shop = api.utils.sanitizeShop(url.searchParams.get('shop')!);
57
+ if (!shop) throw new Response('Shop param is invalid', {status: 400});
58
+
59
+ if (isAuthRequest) {
60
+ throw await this.handleAuthBeginRequest(request, shop);
61
+ } else {
62
+ throw await this.handleAuthCallbackRequest(request, shop);
63
+ }
64
+ }
65
+
66
+ if (!getSessionTokenHeader(request)) {
67
+ // This is a document request that doesn't contain a session token. We check if the app is installed.
68
+ // If the app isn't installed, we initiate the OAuth auth code flow.
69
+ // Requests with a header can only happen after the app is installed.
70
+ await this.ensureInstalledOnShop(request);
71
+ }
72
+ }
73
+
74
+ public async authenticate(
75
+ request: Request,
76
+ sessionContext: SessionContext,
77
+ ): Promise<Session | never> {
78
+ const {api, config, logger} = this;
79
+
80
+ const {shop, session} = sessionContext;
81
+
82
+ if (!session) {
83
+ logger.debug('No session found, redirecting to OAuth', {shop});
84
+ await redirectToAuthPage({config, logger, api}, request, shop);
85
+ } else if (!session.isActive(config.scopes)) {
86
+ logger.debug(
87
+ 'Found a session, but it has expired, redirecting to OAuth',
88
+ {shop},
89
+ );
90
+ await redirectToAuthPage({config, logger, api}, request, shop);
91
+ }
92
+
93
+ logger.debug('Found a valid session', {shop});
94
+
95
+ return session!;
96
+ }
97
+
98
+ public handleClientError(request: Request): HandleAdminClientError {
99
+ const {api, config, logger} = this;
100
+ return handleClientErrorFactory({
101
+ request,
102
+ onError: async ({session, error}: OnErrorOptions) => {
103
+ if (error.response.code === 401) {
104
+ throw await redirectToAuthPage(
105
+ {api, config, logger},
106
+ request,
107
+ session.shop,
108
+ );
109
+ }
110
+ },
111
+ });
112
+ }
113
+
114
+ private async ensureInstalledOnShop(request: Request) {
115
+ const {api, config, logger} = this;
116
+
117
+ validateShopAndHostParams({api, config, logger}, request);
118
+
119
+ const url = new URL(request.url);
120
+ let shop = url.searchParams.get('shop');
121
+
122
+ // Ensure app is installed
123
+ logger.debug('Ensuring app is installed on shop', {shop});
124
+
125
+ if (!(await this.hasValidOfflineId(request))) {
126
+ logger.info("Could not find a shop, can't authenticate request");
127
+ throw new Response(undefined, {
128
+ status: 400,
129
+ statusText: 'Bad Request',
130
+ });
131
+ }
132
+
133
+ const offlineSession = await this.getOfflineSession(request);
134
+ const isEmbedded = url.searchParams.get('embedded') === '1';
135
+
136
+ if (!offlineSession) {
137
+ logger.info("Shop hasn't installed app yet, redirecting to OAuth", {
138
+ shop,
139
+ });
140
+ if (isEmbedded) {
141
+ redirectWithExitIframe({api, config, logger}, request, shop!);
142
+ } else {
143
+ throw await beginAuth({api, config, logger}, request, false, shop!);
144
+ }
145
+ }
146
+
147
+ shop = shop || offlineSession.shop;
148
+
149
+ if (config.isEmbeddedApp && !isEmbedded) {
150
+ try {
151
+ logger.debug('Ensuring offline session is valid before embedding', {
152
+ shop,
153
+ });
154
+ await this.testSession(offlineSession);
155
+
156
+ logger.debug('Offline session is still valid, embedding app', {shop});
157
+ } catch (error) {
158
+ await this.handleInvalidOfflineSession(error, request, shop);
159
+ }
160
+ }
161
+ }
162
+
163
+ private async handleAuthBeginRequest(
164
+ request: Request,
165
+ shop: string,
166
+ ): Promise<never> {
167
+ const {api, config, logger} = this;
168
+
169
+ logger.info('Handling OAuth begin request', {shop});
170
+
171
+ // If we're loading from an iframe, we need to break out of it
172
+ if (
173
+ config.isEmbeddedApp &&
174
+ request.headers.get('Sec-Fetch-Dest') === 'iframe'
175
+ ) {
176
+ logger.debug('Auth request in iframe detected, exiting iframe', {shop});
177
+ throw redirectWithExitIframe({api, config, logger}, request, shop);
178
+ } else {
179
+ throw await beginAuth({api, config, logger}, request, false, shop);
180
+ }
181
+ }
182
+
183
+ private async handleAuthCallbackRequest(
184
+ request: Request,
185
+ shop: string,
186
+ ): Promise<never> {
187
+ const {api, config, logger} = this;
188
+
189
+ logger.info('Handling OAuth callback request', {shop});
190
+
191
+ try {
192
+ const {session, headers: responseHeaders} = await api.auth.callback({
193
+ rawRequest: request,
194
+ expiring: config.future.expiringOfflineAccessTokens,
195
+ });
196
+
197
+ await config.sessionStorage!.storeSession(session);
198
+
199
+ if (config.useOnlineTokens && !session.isOnline) {
200
+ logger.info('Requesting online access token for offline session', {
201
+ shop,
202
+ });
203
+ await beginAuth({api, config, logger}, request, true, shop);
204
+ }
205
+
206
+ logger.debug('Request is valid, loaded session from OAuth callback', {
207
+ shop: session.shop,
208
+ isOnline: session.isOnline,
209
+ });
210
+
211
+ await triggerAfterAuthHook({api, config, logger}, session, request, this);
212
+
213
+ throw await redirectToShopifyOrAppRoot(
214
+ request,
215
+ {api, config, logger},
216
+ responseHeaders,
217
+ );
218
+ } catch (error) {
219
+ if (error instanceof Response) throw error;
220
+
221
+ throw await this.oauthCallbackError(error, request, shop);
222
+ }
223
+ }
224
+
225
+ private async getOfflineSession(
226
+ request: Request,
227
+ ): Promise<Session | undefined> {
228
+ const offlineId = await this.getOfflineSessionId(request);
229
+ return this.config.sessionStorage!.loadSession(offlineId!);
230
+ }
231
+
232
+ private async hasValidOfflineId(request: Request) {
233
+ return Boolean(await this.getOfflineSessionId(request));
234
+ }
235
+
236
+ private async getOfflineSessionId(
237
+ request: Request,
238
+ ): Promise<string | undefined> {
239
+ const {api} = this;
240
+ const url = new URL(request.url);
241
+ const shop = url.searchParams.get('shop');
242
+
243
+ return shop
244
+ ? api.session.getOfflineId(shop)
245
+ : api.session.getCurrentId({isOnline: false, rawRequest: request});
246
+ }
247
+
248
+ private async testSession(session: Session): Promise<void> {
249
+ const {api} = this;
250
+
251
+ const client = new api.clients.Graphql({
252
+ session,
253
+ });
254
+
255
+ await client.request(`#graphql
256
+ query shopifyAppShopName {
257
+ shop {
258
+ name
259
+ }
260
+ }
261
+ `);
262
+ }
263
+
264
+ private async oauthCallbackError(
265
+ error: Error,
266
+ request: Request,
267
+ shop: string,
268
+ ) {
269
+ const {logger} = this;
270
+ logger.error('Error during OAuth callback', {shop, error: error.message});
271
+
272
+ if (error instanceof CookieNotFound) {
273
+ return this.handleAuthBeginRequest(request, shop);
274
+ }
275
+
276
+ if (
277
+ error instanceof InvalidHmacError ||
278
+ error instanceof InvalidOAuthError
279
+ ) {
280
+ return new Response(undefined, {
281
+ status: 400,
282
+ statusText: 'Invalid OAuth Request',
283
+ });
284
+ }
285
+
286
+ return new Response(undefined, {
287
+ status: 500,
288
+ statusText: 'Internal Server Error',
289
+ });
290
+ }
291
+
292
+ private async handleInvalidOfflineSession(
293
+ error: Error,
294
+ request: Request,
295
+ shop: string,
296
+ ) {
297
+ const {api, logger, config} = this;
298
+ if (error instanceof HttpResponseError) {
299
+ if (error.response.code === 401) {
300
+ logger.info('Shop session is no longer valid, redirecting to OAuth', {
301
+ shop,
302
+ });
303
+ throw await beginAuth({api, config, logger}, request, false, shop);
304
+ } else {
305
+ const message = JSON.stringify(error.response.body, null, 2);
306
+ logger.error(`Unexpected error during session validation: ${message}`, {
307
+ shop,
308
+ });
309
+
310
+ throw new Response(undefined, {
311
+ status: error.response.code,
312
+ statusText: error.response.statusText,
313
+ });
314
+ }
315
+ } else if (error instanceof GraphqlQueryError) {
316
+ const context: Record<string, string> = {shop};
317
+ if (error.response) {
318
+ context.response = JSON.stringify(error.body);
319
+ }
320
+
321
+ logger.error(
322
+ `Unexpected error during session validation: ${error.message}`,
323
+ context,
324
+ );
325
+
326
+ throw new Response(undefined, {
327
+ status: 500,
328
+ statusText: 'Internal Server Error',
329
+ });
330
+ }
331
+ }
332
+ }