Skip to content
This repository has been archived by the owner on Apr 11, 2024. It is now read-only.

Commit

Permalink
Merge pull request #710 from Shopify/kos/change_private_app_to_custom…
Browse files Browse the repository at this point in the history
…_store_app

Change isPrivateApp to isCustomStoreApp, modify behaviour when isCustomStoreApp is true
  • Loading branch information
mkevinosullivan authored Feb 3, 2023
2 parents d3286d4 + 6371359 commit feba483
Show file tree
Hide file tree
Showing 21 changed files with 204 additions and 49 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
- [Patch] Improve logger call on different API versions [#664](https://github.com/Shopify/shopify-api-js/pull/664)
- [Patch] Prevent leakage of session object with REST resources [#690](https://github.com/Shopify/shopify-api-js/pull/690)
- [Patch] Improve typing of `PREV_PAGE_INFO` and `NEXT_PAGE_INFO` for REST resources [#701](https://github.com/Shopify/shopify-api-js/pull/701)
- [Minor] Change `isPrivateApp` configuration item to `isCustomStoreApp`, keep `isPrivateApp` but with a deprecation notice, add `shopify.session.customAppSession` method to create a session suitable for use with clients, REST resources in a store-specific custom app [#710](https://github.com/Shopify/shopify-api-js/pull/710)

## [6.1.0] - 2023-01-05

Expand Down
2 changes: 1 addition & 1 deletion adapters/__e2etests__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const config: ConfigInterface = {
hostScheme: 'https',
apiVersion: LATEST_API_VERSION,
isEmbeddedApp: true,
isPrivateApp: false,
isCustomStoreApp: false,
logger: {
log: () => Promise.resolve(),
level: LogSeverity.Debug,
Expand Down
21 changes: 8 additions & 13 deletions docs/guides/custom-store-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ A custom app is an app that you or a developer builds exclusively for your Shopi

A store-specific custom app does not use the OAuth process to authenticate - it uses the secrets established during the app creation and install process to access the API. As a result, there are no sessions to be retrieved from incoming requests and stored in a database, etc.

When initializing `shopifyApi` in a custom app, set the `isPrivateApp` configuration property to `true`, and set the `apiSecretKey` to the **Admin API access token** obtained during the installation process (step 2 in the [prerequisites](#prerequisites)).

The `scopes` configuration property must be set to something for initialization to work. However, the value is ignored as the scopes of the app are set during the create and install process (step 2 in the [prerequisites](#prerequisites)) and are linked to the Admin API access token.
When initializing `shopifyApi` in a custom app, set the `isCustomStoreApp` configuration property to `true`, and set the `apiSecretKey` to the **Admin API access token** obtained during the installation process (step 2 in the [prerequisites](#prerequisites)).

## Example

Expand All @@ -27,32 +25,29 @@ import { restResources } from "@shopify/shopify-api/rest/admin/2023-01";

const shopify = shopifyApi({
apiKey: "App_API_key",
apiSecretKey: "Admin_API_Access_Token", // Note: this is the API Access Token, NOT the API Secret Key
apiSecretKey: "Admin_API_Access_Token", // Note: this is the API access token, NOT the API Secret Key
apiVersion: LATEST_API_VERSION,
isPrivateApp: true, // this MUST be set to true (default is false)
scopes: ["read_products"], // this must have a value but it will be ignored by the library
isCustomStoreApp: true, // this MUST be set to true (default is false)
scopes: [],
isEmbeddedApp: false,
hostName: "my-shop.myshopify.com",
// Mount REST resources.
restResources,
});
```

> **Note** The `apiSecretKey` is **NOT** set to the API secret key but to the **Admin API Access Token**.
> **Note** The `apiSecretKey` is **NOT** set to the API secret key but to the **Admin API access token**.
### Making requests

API requests, either using `shopify.clients.Rest` or `shopify.clients.Graphql`, or using the REST resources, require a `session` parameter. Since there are no sessions in a store-specific custom app, a `session` parameter needs to be created.

To create the session object, the `id`, `state` and `isOnline` properties must be populated but will be ignored. Only the `shop` parameter is required when making the API requests from a store specifc custom app and the value must match the stop on which the custom app is installed.

The library provides a utility method to create such a session, `shopify.session.customAppSession`.

```js
const session = new Session({
id: 'not-a-real-session-id', // must have a string value, will be ignored
shop: "my-shop.myshopify.com", // MUST match shop on which custom app is installed
state: "state", // must have a string value, will be ignored
isOnline: false, // must have a boolean value, will be ignored
});
const session = shopify.session.customAppSession("my-shop.myshopify.com");

// Use REST resources to make calls.
const { count: productCount } = await shopify.rest.Product.count({ session });
Expand Down
11 changes: 6 additions & 5 deletions docs/reference/session/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

This object contains functions used to authenticate apps, and redirect users to Shopify.

| Property | Description |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| [getCurrentId](./getCurrentId.md) | Extracts a Shopify session id for the current request when there is user interaction with the app. |
| [getOfflineId](./getOfflineId.md) | Builds the session id for the given shop, for background tasks that don't involve user interaction. |
| [decodeSessionToken](./decodeSessionToken.md) | Extracts and validates the session token JWT from App Bridge requests. |
| Property | Description |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| [customAppSession](./customAppSession.md) | Creates a Session object suitable for use with `shopify.clients` and REST resources in a [custom store app](../../guides/custom-store-app.md). |
| [getCurrentId](./getCurrentId.md) | Extracts a Shopify session id for the current request when there is user interaction with the app. |
| [getOfflineId](./getOfflineId.md) | Builds the session id for the given shop, for background tasks that don't involve user interaction. |
| [decodeSessionToken](./decodeSessionToken.md) | Extracts and validates the session token JWT from App Bridge requests. |

[Back to shopifyApi](../shopifyApi.md)
18 changes: 18 additions & 0 deletions docs/reference/session/customAppSession.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# shopify.session.customAppSession

Builds a session instance that can be used in a [store-specific custom app](../../guides/custom-store-app.md).

It is the equivalent of calling the following:

```ts
const session = new Session({
id: '',
shop: `${sanitizeShop(config)(shop, true)}`,
state: '',
isOnline: false,
});
```

> **Note** This method performs validation on the `shop` parameter and will throw an error if the `shop` is not valid.
[Back to shopify.session](./README.md)
4 changes: 2 additions & 2 deletions docs/reference/shopifyApi.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const shopify = shopifyApi({
apiVersion: ApiVersion.July22,
isEmbeddedApp: true,
sessionStorage: new MemorySessionStorage(),
isPrivateApp: false,
isCustomStoreApp: false,
userAgentPrefix: 'Custom prefix',
privateAppStorefrontAccessToken: 'PrivateAccessToken',
customShopDomains: ['*.my-custom-domain.io'],
Expand Down Expand Up @@ -83,7 +83,7 @@ API version your app will be querying. E.g. `ApiVersion.October22`.

Whether your app will run within the Shopify Admin. Learn more about embedded apps with [`App Bridge`](https://shopify.dev/apps/tools/app-bridge/getting-started/app-setup).

### isPrivateApp
### isCustomStoreApp

`boolean` | Defaults to `false`

Expand Down
59 changes: 58 additions & 1 deletion lib/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe('Config object', () => {
hostName: 'host_name',
apiVersion: ApiVersion.Unstable,
isEmbeddedApp: true,
isPrivateApp: false,
isCustomStoreApp: false,
logger: {
log: jest.fn(),
level: LogSeverity.Debug,
Expand Down Expand Up @@ -86,6 +86,15 @@ describe('Config object', () => {
expect(() => validateConfig(empty)).toThrow(ShopifyErrors.ShopifyError);
});

it("ignores an empty 'scopes' when isCustomStoreApp is true", () => {
validParams.isCustomStoreApp = true;
delete (validParams as any).scopes;

expect(() => validateConfig(validParams)).not.toThrow(
ShopifyErrors.ShopifyError,
);
});

it('can partially override logger settings', () => {
const configWithLogger = {...validParams};
configWithLogger.logger = {
Expand All @@ -111,4 +120,52 @@ describe('Config object', () => {

expect(config.hostName).toEqual('my-host-name');
});

[true, false].forEach((isPrivateApp) => {
describe(`isPrivateApp (TO BE DEPRECATED IN 7.0.0) is ${isPrivateApp}`, () => {
it(`logs deprecation`, () => {
const {isCustomStoreApp, ...params} = validParams;
Object.assign(params, {isPrivateApp});

const config = validateConfig(params);

expect(config.logger.log).toHaveBeenCalledWith(
LogSeverity.Warning,
expect.stringContaining('[Deprecated | 7.0.0]'),
);
});

it(`sets isCustomStoreApp to value of isPrivateApp if isCustomStoreApp not explicitly set`, () => {
const {isCustomStoreApp, ...params} = validParams;
Object.assign(params, {isPrivateApp});

const config = validateConfig(params);

expect(config.isCustomStoreApp).toBe(isPrivateApp);
expect('isPrivateApp' in config).toBe(false);
});

it(`ignores value of isPrivateApp if isCustomStoreApp explicitly set`, () => {
validParams.isCustomStoreApp = !isPrivateApp;
const params = {...validParams};
Object.assign(params, {isPrivateApp});

const config = validateConfig(params);

expect(config.isCustomStoreApp).toBe(!isPrivateApp);
expect('isPrivateApp' in config).toBe(false);
});

if (isPrivateApp) {
it("ignores an empty 'scopes' when isPrivateApp is true", () => {
const {isCustomStoreApp, scopes, ...params} = validParams;
Object.assign(params, {isPrivateApp: true});

expect(() => validateConfig(validParams)).not.toThrow(
ShopifyErrors.ShopifyError,
);
});
}
});
});
});
2 changes: 1 addition & 1 deletion lib/__tests__/test-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function getNewTestConfig(): ConfigParams {
hostScheme: 'https',
apiVersion: LATEST_API_VERSION,
isEmbeddedApp: false,
isPrivateApp: false,
isCustomStoreApp: false,
customShopDomains: undefined,
billing: undefined,
logger: {
Expand Down
4 changes: 2 additions & 2 deletions lib/auth/oauth/__tests__/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ describe('beginAuth', () => {
});

test('fails to start if the app is private', () => {
shopify.config.isPrivateApp = true;
shopify.config.isCustomStoreApp = true;

expect(
shopify.auth.begin({
Expand All @@ -167,7 +167,7 @@ describe('callback', () => {
});

test('fails to run if the app is private', () => {
shopify.config.isPrivateApp = true;
shopify.config.isCustomStoreApp = true;

expect(
shopify.auth.callback({
Expand Down
15 changes: 9 additions & 6 deletions lib/auth/oauth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export function begin(config: ConfigInterface) {
isOnline,
...adapterArgs
}: BeginParams): Promise<AdapterResponse> => {
throwIfPrivateApp(
config.isPrivateApp,
throwIfCustomStoreApp(
config.isCustomStoreApp,
'Cannot perform OAuth for private apps',
);

Expand Down Expand Up @@ -102,8 +102,8 @@ export function callback(config: ConfigInterface) {
isOnline: isOnlineParam,
...adapterArgs
}: CallbackParams): Promise<CallbackResponse<T>> {
throwIfPrivateApp(
config.isPrivateApp,
throwIfCustomStoreApp(
config.isCustomStoreApp,
'Cannot perform OAuth for private apps',
);

Expand Down Expand Up @@ -265,8 +265,11 @@ function createSession({
}
}

function throwIfPrivateApp(isPrivateApp: boolean, message: string): void {
if (isPrivateApp) {
function throwIfCustomStoreApp(
isCustomStoreApp: boolean,
message: string,
): void {
if (isCustomStoreApp) {
throw new ShopifyErrors.PrivateAppError(message);
}
}
4 changes: 2 additions & 2 deletions lib/base-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface ConfigParams<T extends ShopifyRestResources = any> {
hostScheme?: 'http' | 'https';
apiVersion: ApiVersion;
isEmbeddedApp: boolean;
isPrivateApp?: boolean;
isCustomStoreApp?: boolean;
userAgentPrefix?: string;
privateAppStorefrontAccessToken?: string;
customShopDomains?: (RegExp | string)[];
Expand All @@ -31,7 +31,7 @@ export interface ConfigParams<T extends ShopifyRestResources = any> {
export interface ConfigInterface extends Omit<ConfigParams, 'restResources'> {
hostScheme: 'http' | 'https';
scopes: AuthScopes;
isPrivateApp: boolean;
isCustomStoreApp: boolean;
logger: {
log: LogFunction;
level: LogSeverity;
Expand Down
4 changes: 2 additions & 2 deletions lib/clients/graphql/__tests__/graphql_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('GraphQL client', () => {
});

it('adapts to private app requests', async () => {
shopify.config.isPrivateApp = true;
shopify.config.isCustomStoreApp = true;

const client = new shopify.clients.Graphql({session});
queueMockResponse(JSON.stringify(successResponse));
Expand All @@ -111,7 +111,7 @@ describe('GraphQL client', () => {
headers: customHeaders,
}).toMatchMadeHttpRequest();

shopify.config.isPrivateApp = false;
shopify.config.isCustomStoreApp = false;
});

it('fails to instantiate without access token', () => {
Expand Down
2 changes: 1 addition & 1 deletion lib/clients/graphql/__tests__/storefront_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('Storefront GraphQL client', () => {
});

it('can return response from config private app setting', async () => {
shopify.config.isPrivateApp = true;
shopify.config.isCustomStoreApp = true;
shopify.config.privateAppStorefrontAccessToken = 'private_token';

const client = new shopify.clients.Storefront({
Expand Down
4 changes: 2 additions & 2 deletions lib/clients/graphql/graphql_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class GraphqlClient {
constructor(params: GraphqlClientParams) {
const config = this.graphqlClass().config;

if (!config.isPrivateApp && !params.session.accessToken) {
if (!config.isCustomStoreApp && !params.session.accessToken) {
throw new ShopifyErrors.MissingRequiredArgument(
'Missing access token when creating GraphQL client',
);
Expand Down Expand Up @@ -89,7 +89,7 @@ export class GraphqlClient {

protected getApiHeaders(): HeaderParams {
return {
[ShopifyHeader.AccessToken]: this.graphqlClass().config.isPrivateApp
[ShopifyHeader.AccessToken]: this.graphqlClass().config.isCustomStoreApp
? this.graphqlClass().config.apiSecretKey
: (this.session.accessToken as string),
};
Expand Down
2 changes: 1 addition & 1 deletion lib/clients/graphql/storefront_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class StorefrontClient extends GraphqlClient {

return {
[ShopifyHeader.StorefrontAccessToken]: this.storefrontClass().config
.isPrivateApp
.isCustomStoreApp
? this.storefrontClass().config.privateAppStorefrontAccessToken ||
this.storefrontAccessToken
: this.storefrontAccessToken,
Expand Down
4 changes: 2 additions & 2 deletions lib/clients/rest/__tests__/rest_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ describe('REST client', () => {
});

it('adapts to private app requests', async () => {
shopify.config.isPrivateApp = true;
shopify.config.isCustomStoreApp = true;

const client = new shopify.clients.Rest({session});

Expand All @@ -367,7 +367,7 @@ describe('REST client', () => {
headers: customHeaders,
}).toMatchMadeHttpRequest();

shopify.config.isPrivateApp = false;
shopify.config.isCustomStoreApp = false;
});

it('fails to instantiate without access token', () => {
Expand Down
4 changes: 2 additions & 2 deletions lib/clients/rest/rest_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class RestClient extends HttpClient {

const config = this.restClass().config;

if (!config.isPrivateApp && !params.session.accessToken) {
if (!config.isCustomStoreApp && !params.session.accessToken) {
throw new ShopifyErrors.MissingRequiredArgument(
'Missing access token when creating REST client',
);
Expand All @@ -55,7 +55,7 @@ export class RestClient extends HttpClient {
params: RequestParams,
): Promise<RestRequestReturn<T>> {
params.extraHeaders = {
[ShopifyHeader.AccessToken]: this.restClass().config.isPrivateApp
[ShopifyHeader.AccessToken]: this.restClass().config.isCustomStoreApp
? this.restClass().config.apiSecretKey
: (this.session.accessToken as string),
...params.extraHeaders,
Expand Down
Loading

0 comments on commit feba483

Please sign in to comment.