diff --git a/packages/app/src/cli/services/dev/processes/graphiql-token-provider.test.ts b/packages/app/src/cli/services/dev/processes/graphiql-token-provider.test.ts new file mode 100644 index 0000000000..1d786b924e --- /dev/null +++ b/packages/app/src/cli/services/dev/processes/graphiql-token-provider.test.ts @@ -0,0 +1,73 @@ +import {createClientCredentialsTokenProvider} from './graphiql-token-provider.js' +import {fetch} from '@shopify/cli-kit/node/http' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/http') + +const mockedFetch = vi.mocked(fetch) + +function mockTokenResponse(token: string) { + mockedFetch.mockResolvedValueOnce({ + json: async () => ({access_token: token}), + } as unknown as Awaited>) +} + +describe('createClientCredentialsTokenProvider', () => { + beforeEach(() => { + mockedFetch.mockReset() + }) + + test('mints a token on first getToken call and caches it', async () => { + mockTokenResponse('first-token') + + const provider = createClientCredentialsTokenProvider({ + apiKey: 'api-key', + apiSecret: 'api-secret', + storeFqdn: 'store.myshopify.com', + }) + + await expect(provider.getToken()).resolves.toBe('first-token') + await expect(provider.getToken()).resolves.toBe('first-token') + expect(mockedFetch).toHaveBeenCalledTimes(1) + }) + + test('refreshToken always re-mints, even when a cached token exists', async () => { + mockTokenResponse('first-token') + mockTokenResponse('second-token') + + const provider = createClientCredentialsTokenProvider({ + apiKey: 'api-key', + apiSecret: 'api-secret', + storeFqdn: 'store.myshopify.com', + }) + + await expect(provider.getToken()).resolves.toBe('first-token') + await expect(provider.refreshToken!()).resolves.toBe('second-token') + await expect(provider.getToken()).resolves.toBe('second-token') + expect(mockedFetch).toHaveBeenCalledTimes(2) + }) + + test('posts the OAuth client_credentials body to the store admin endpoint', async () => { + mockTokenResponse('token') + + const provider = createClientCredentialsTokenProvider({ + apiKey: 'api-key', + apiSecret: 'api-secret', + storeFqdn: 'store.myshopify.com', + }) + await provider.getToken() + + expect(mockedFetch).toHaveBeenCalledWith( + 'https://store.myshopify.com/admin/oauth/access_token', + expect.objectContaining({ + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: 'api-key', + client_secret: 'api-secret', + grant_type: 'client_credentials', + }), + }), + ) + }) +}) diff --git a/packages/app/src/cli/services/dev/processes/graphiql-token-provider.ts b/packages/app/src/cli/services/dev/processes/graphiql-token-provider.ts new file mode 100644 index 0000000000..c5ce6d7b37 --- /dev/null +++ b/packages/app/src/cli/services/dev/processes/graphiql-token-provider.ts @@ -0,0 +1,48 @@ +import {TokenProvider} from '@shopify/cli-kit/node/graphiql/server' +import {fetch} from '@shopify/cli-kit/node/http' + +interface ClientCredentialsTokenProviderOptions { + apiKey: string + apiSecret: string + storeFqdn: string +} + +/** + * Returns a `TokenProvider` that mints Admin API tokens via OAuth `client_credentials` + * using a Partners app's `apiKey` + `apiSecret`. Tokens are cached in-memory and + * re-minted on demand when `refreshToken` is called (e.g. on a 401 from upstream). + * + * This is the strategy used by `shopify app dev`'s GraphiQL server. It assumes the app + * is installed on the target store and that the app secret can mint a fresh token at any time. + */ +export function createClientCredentialsTokenProvider({ + apiKey, + apiSecret, + storeFqdn, +}: ClientCredentialsTokenProviderOptions): TokenProvider { + let cachedToken: string | undefined + + const mint = async (): Promise => { + const tokenResponse = await fetch(`https://${storeFqdn}/admin/oauth/access_token`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: apiKey, + client_secret: apiSecret, + grant_type: 'client_credentials', + }), + }) + + const tokenJson = (await tokenResponse.json()) as {access_token: string} + cachedToken = tokenJson.access_token + return cachedToken + } + + return { + getToken: async () => cachedToken ?? mint(), + refreshToken: async () => { + cachedToken = undefined + return mint() + }, + } +} diff --git a/packages/app/src/cli/services/dev/processes/graphiql.ts b/packages/app/src/cli/services/dev/processes/graphiql.ts index 95d1b44054..049741bce0 100644 --- a/packages/app/src/cli/services/dev/processes/graphiql.ts +++ b/packages/app/src/cli/services/dev/processes/graphiql.ts @@ -1,6 +1,6 @@ import {BaseProcess, DevProcessFunction} from './types.js' -import {setupGraphiQLServer, TokenProvider} from '@shopify/cli-kit/node/graphiql/server' -import {fetch} from '@shopify/cli-kit/node/http' +import {createClientCredentialsTokenProvider} from './graphiql-token-provider.js' +import {setupGraphiQLServer} from '@shopify/cli-kit/node/graphiql/server' interface GraphiQLServerProcessOptions { appName: string @@ -52,39 +52,3 @@ export const launchGraphiQLServer: DevProcessFunction => { - const tokenResponse = await fetch(`https://${options.storeFqdn}/admin/oauth/access_token`, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - client_id: options.apiKey, - client_secret: options.apiSecret, - grant_type: 'client_credentials', - }), - }) - - const tokenJson = (await tokenResponse.json()) as {access_token: string} - cachedToken = tokenJson.access_token - return cachedToken - } - - return { - getToken: async () => cachedToken ?? mint(), - refreshToken: async () => { - cachedToken = undefined - return mint() - }, - } -}