Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof fetch>>)
}

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',
}),
}),
)
})
})
Original file line number Diff line number Diff line change
@@ -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<string> => {
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()
},
}
}
40 changes: 2 additions & 38 deletions packages/app/src/cli/services/dev/processes/graphiql.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -52,39 +52,3 @@ export const launchGraphiQLServer: DevProcessFunction<GraphiQLServerProcessOptio
httpServer.close()
})
}

/**
* In-memory token provider that mints Admin API tokens via OAuth `client_credentials`
* using the Partners app's `apiKey` + `apiSecret`. Refreshes lazily and re-mints on demand.
*/
function createClientCredentialsTokenProvider(options: {
apiKey: string
apiSecret: string
storeFqdn: string
}): TokenProvider {
let cachedToken: string | undefined

const mint = async (): Promise<string> => {
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()
},
}
}
Loading