-
Notifications
You must be signed in to change notification settings - Fork 113
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1085 from Shopify/liz/merchant-custom-apps-template
Skip OAuth for Merchant Custom Apps
- Loading branch information
Showing
11 changed files
with
322 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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` |
10 changes: 7 additions & 3 deletions
10
packages/apps/shopify-app-remix/docs/generated/generated_docs_data.json
Large diffs are not rendered by default.
Oops, something went wrong.
19 changes: 19 additions & 0 deletions
19
packages/apps/shopify-app-remix/src/server/__test-helpers/get-thrown-error.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
134 changes: 134 additions & 0 deletions
134
...x/src/server/authenticate/admin/strategies/__tests__/merchant-custom/admin-client.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; | ||
} |
30 changes: 30 additions & 0 deletions
30
...x/src/server/authenticate/admin/strategies/__tests__/merchant-custom/authenticate.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
70 changes: 70 additions & 0 deletions
70
...es/apps/shopify-app-remix/src/server/authenticate/admin/strategies/merchant-custom-app.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.', | ||
); | ||
} | ||
}, | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters