Skip to content

Commit

Permalink
Merge pull request #1085 from Shopify/liz/merchant-custom-apps-template
Browse files Browse the repository at this point in the history
Skip OAuth for Merchant Custom Apps
  • Loading branch information
lizkenyon authored Jul 9, 2024
2 parents 06793fb + 1a29090 commit 79395bd
Show file tree
Hide file tree
Showing 11 changed files with 322 additions and 12 deletions.
35 changes: 35 additions & 0 deletions .changeset/hot-items-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@shopify/shopify-app-remix': minor
---

# Add support for merchant custom apps

Merchant custom apps or apps that are distributed by the Shopify Admin are now supported.

These apps do not Authorize by OAuth, and instead use a access token that has been generated by the Shopify Admin.

Apps of this type are standalone apps and are not initiated from the Shopify Admin. Therefore it is **up to the developer of the app to add login and authentication functionality**.


To use this library with Merchant Custom Apps set the following configuration in the `shopify.server` file:

```ts
const shopify = shopifyApp({
apiKey: "your-api-key",
apiSecretKey: "your-api-secret-key",
adminApiAccessToken:"shpat_1234567890",
distribution: AppDistribution.ShopifyAdmin,
appUrl: "https://localhost:3000",
isEmbeddedApp: false,
```
Session storage is *not* required for merchant custom apps. A session is created from the provided access token.
At this time merchant custom apps are not supported by the Shopify CLI. Developers will need to start the development server directly.
```sh
npm exec remix vite:dev
```
You can then access the the app at `http://localhost:3000/app?shop=my-shop.myshopify.com`

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {ShopifyError} from '@shopify/shopify-api';

export async function getThrownError(
callback: (request: Request) => Promise<any>,
request: Request,
): Promise<ShopifyError> {
try {
await callback(request);
} catch (error) {
if (!(error instanceof ShopifyError)) {
throw new Error(
`${request.method} request to ${request.url} did not throw a ShopifyError.`,
);
}
return error;
}

throw new Error(`${request.method} request to ${request.url} did not throw`);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from './setup-valid-session';
export * from './setup-valid-request';
export * from './sign-request-cookie';
export * from './test-config';
export * from './get-thrown-error';
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,12 @@ export async function setUpValidSession(

return session;
}

export function setupValidCustomAppSession(shop: string): Session {
return new Session({
id: '',
shop,
state: '',
isOnline: false,
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,6 @@ export function authStrategyFactory<
shop,
});

logger.debug('Request is valid, loaded session from session token', {
shop: session.shop,
isOnline: session.isOnline,
});

return createContext(request, session, strategy, payload);
} catch (errorOrResponse) {
if (errorOrResponse instanceof Response) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import {restResources} from '@shopify/shopify-api/rest/admin/2023-04';
import {
ApiVersion,
LATEST_API_VERSION,
Session,
ShopifyError,
} from '@shopify/shopify-api';

import {AppDistribution} from '../../../../../types';
import {AdminApiContext} from '../../../../../clients';
import {shopifyApp} from '../../../../..';
import {
APP_URL,
TEST_SHOP,
expectAdminApiClient,
mockExternalRequest,
setupValidCustomAppSession,
testConfig,
getThrownError,
} from '../../../../../__test-helpers';

describe('admin.authenticate context', () => {
expectAdminApiClient(async () => {
const {
admin,
expectedSession,
session: actualSession,
} = await setUpMerchantCustomFlow();

return {admin, expectedSession, actualSession};
});
describe.each([
{
testGroup: 'REST client',
mockRequest: mockRestRequest,
action: async (admin: AdminApiContext, _session: Session) =>
admin.rest.get({path: '/customers.json'}),
},
{
testGroup: 'REST resources',
mockRequest: mockRestRequest,
action: async (admin: AdminApiContext, session: Session) =>
admin.rest.resources.Customer.all({session}),
},
{
testGroup: 'GraphQL client',
mockRequest: mockGraphqlRequest(),
action: async (admin: AdminApiContext, _session: Session) =>
admin.graphql('{ shop { name } }'),
},
{
testGroup: 'GraphQL client with options',
mockRequest: mockGraphqlRequest('2021-01' as ApiVersion),
action: async (admin: AdminApiContext, _session: Session) =>
admin.graphql(
'mutation myMutation($ID: String!) { shop(ID: $ID) { name } }',
{
variables: {ID: '123'},
apiVersion: '2021-01' as ApiVersion,
headers: {custom: 'header'},
tries: 2,
},
),
},
])(
'$testGroup re-authentication',
({testGroup: _testGroup, mockRequest, action}) => {
it('throws a Shopify Error when receives a 401 response on fetch requests', async () => {
// GIVEN
const {admin, session} = await setUpMerchantCustomFlow();
const requestMock = await mockRequest();

// WHEN
const error = await getThrownError(
async () => action(admin, session),
requestMock,
);

// THEN
expect(error).toBeInstanceOf(ShopifyError);
});
},
);
});

async function setUpMerchantCustomFlow() {
const shopify = shopifyApp(
testConfig({
restResources,
isEmbeddedApp: false,
distribution: AppDistribution.ShopifyAdmin,
adminApiAccessToken: 'test-token',
}),
);

const expectedSession = setupValidCustomAppSession(TEST_SHOP);

const request = new Request(`${APP_URL}?shop=${TEST_SHOP}`);

return {
shopify,
expectedSession,
...(await shopify.authenticate.admin(request)),
};
}

async function mockRestRequest(status = 401) {
const requestMock = new Request(
`https://${TEST_SHOP}/admin/api/${LATEST_API_VERSION}/customers.json`,
);

await mockExternalRequest({
request: requestMock,
response: new Response('{}', {status}),
});

return requestMock;
}

function mockGraphqlRequest(apiVersion = LATEST_API_VERSION) {
return async function (status = 401) {
const requestMock = new Request(
`https://${TEST_SHOP}/admin/api/${apiVersion}/graphql.json`,
{method: 'POST'},
);

await mockExternalRequest({
request: requestMock,
response: new Response(undefined, {status}),
});

return requestMock;
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {AppDistribution} from '../../../../../types';
import {
APP_URL,
TEST_SHOP,
setupValidCustomAppSession,
testConfig,
} from '../../../../../__test-helpers';
import {shopifyApp} from '../../../../..';

describe('authenticate', () => {
it('creates a valid session from the configured access token', async () => {
// GIVEN
const config = testConfig({
isEmbeddedApp: false,
distribution: AppDistribution.ShopifyAdmin,
adminApiAccessToken: 'test-token',
});
const shopify = shopifyApp(config);

const expectedSession = setupValidCustomAppSession(TEST_SHOP);

// WHEN
const {session} = await shopify.authenticate.admin(
new Request(`${APP_URL}?shop=${TEST_SHOP}`),
);

// THEN
expect(session).toEqual(expectedSession);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {AppConfig, AppConfigArg} from 'src/server/config-types';
import {
Session,
Shopify,
ShopifyError,
ShopifyRestResources,
} from '@shopify/shopify-api';
import {BasicParams} from 'src/server/types';
import {
ApiConfigWithFutureFlags,
ApiFutureFlags,
} from 'src/server/future/flags';
import {HandleAdminClientError} from 'src/server/clients';

import {handleClientErrorFactory} from '../helpers';

import {AuthorizationStrategy, OnErrorOptions, SessionContext} from './types';

export class MerchantCustomAuth<Config extends AppConfigArg>
implements AuthorizationStrategy
{
protected api: Shopify<
ApiConfigWithFutureFlags<Config['future']>,
ShopifyRestResources,
ApiFutureFlags<Config['future']>
>;

protected config: AppConfig;
protected logger: Shopify['logger'];

public constructor({api, config, logger}: BasicParams<Config['future']>) {
this.api = api;
this.config = config;
this.logger = logger;
}

public async respondToOAuthRequests(_request: Request): Promise<void> {
this.logger.debug('Skipping OAuth request for merchant custom app');
}

public async authenticate(
_request: Request,
sessionContext: SessionContext,
): Promise<Session | never> {
const {shop} = sessionContext;

this.logger.debug(
'Building session from configured access token for merchant custom app',
);
const session = this.api.session.customAppSession(shop);

return session;
}

public handleClientError(request: Request): HandleAdminClientError {
return handleClientErrorFactory({
request,
onError: async ({error}: OnErrorOptions) => {
if (error.response.code === 401) {
this.logger.info(
'Request failed with 401. Review your API credentials or generate new tokens. https://shopify.dev/docs/apps/build/authentication-authorization/access-token-types/generate-app-access-tokens-admin#rotating-api-credentials-for-admin-created-apps ',
);
throw new ShopifyError(
'Unauthorized: Access token has been revoked.',
);
}
},
});
}
}
1 change: 1 addition & 0 deletions packages/apps/shopify-app-remix/src/server/config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ export interface AppConfig<Storage extends SessionStorage = SessionStorage>
hooks: HooksConfig;
future: FutureFlags;
idempotentPromiseHandler: IdempotentPromiseHandler;
distribution: AppDistribution;
}

export interface AuthConfig {
Expand Down
20 changes: 16 additions & 4 deletions packages/apps/shopify-app-remix/src/server/shopify-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {authenticatePublicFactory} from './authenticate/public';
import {unauthenticatedStorefrontContextFactory} from './unauthenticated/storefront';
import {AuthCodeFlowStrategy} from './authenticate/admin/strategies/auth-code-flow';
import {TokenExchangeStrategy} from './authenticate/admin/strategies/token-exchange';
import {MerchantCustomAuth} from './authenticate/admin/strategies/merchant-custom-app';
import {IdempotentPromiseHandler} from './authenticate/helpers/idempotent-promise-handler';
import {authenticateFlowFactory} from './authenticate/flow/authenticate';
import {authenticateFulfillmentServiceFactory} from './authenticate/fulfillment-service/authenticate';
Expand Down Expand Up @@ -71,12 +72,22 @@ export function shopifyApp<
}

const params: BasicParams = {api, config, logger};

let strategy;
if (config.distribution === AppDistribution.ShopifyAdmin) {
strategy = new MerchantCustomAuth(params);
} else if (
config.future.unstable_newEmbeddedAuthStrategy &&
config.isEmbeddedApp
) {
strategy = new TokenExchangeStrategy(params);
} else {
strategy = new AuthCodeFlowStrategy(params);
}

const authStrategy = authStrategyFactory<Config, Resources>({
...params,
strategy:
config.future.unstable_newEmbeddedAuthStrategy && config.isEmbeddedApp
? new TokenExchangeStrategy(params)
: new AuthCodeFlowStrategy(params),
strategy,
});

const shopify:
Expand Down Expand Up @@ -197,5 +208,6 @@ function deriveConfig<Storage extends SessionStorage>(
exitIframePath: `${authPathPrefix}/exit-iframe`,
loginPath: `${authPathPrefix}/login`,
},
distribution: appConfig.distribution,
};
}

0 comments on commit 79395bd

Please sign in to comment.