Skip to content

Commit 5297f10

Browse files
chore(app): extract GraphiQL client-credentials token provider
Move the in-memory OAuth client_credentials token strategy out of processes/graphiql.ts into its own graphiql-token-provider.ts so it has a clear seam and is testable in isolation. Adds unit tests for createClientCredentialsTokenProvider covering caching semantics, forced refresh, and the OAuth request shape. Made-with: Cursor
1 parent 6a55501 commit 5297f10

3 files changed

Lines changed: 123 additions & 38 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {createClientCredentialsTokenProvider} from './graphiql-token-provider.js'
2+
import {fetch} from '@shopify/cli-kit/node/http'
3+
import {beforeEach, describe, expect, test, vi} from 'vitest'
4+
5+
vi.mock('@shopify/cli-kit/node/http')
6+
7+
const mockedFetch = vi.mocked(fetch)
8+
9+
function mockTokenResponse(token: string) {
10+
mockedFetch.mockResolvedValueOnce({
11+
json: async () => ({access_token: token}),
12+
} as unknown as Awaited<ReturnType<typeof fetch>>)
13+
}
14+
15+
describe('createClientCredentialsTokenProvider', () => {
16+
beforeEach(() => {
17+
mockedFetch.mockReset()
18+
})
19+
20+
test('mints a token on first getToken call and caches it', async () => {
21+
mockTokenResponse('first-token')
22+
23+
const provider = createClientCredentialsTokenProvider({
24+
apiKey: 'api-key',
25+
apiSecret: 'api-secret',
26+
storeFqdn: 'store.myshopify.com',
27+
})
28+
29+
await expect(provider.getToken()).resolves.toBe('first-token')
30+
await expect(provider.getToken()).resolves.toBe('first-token')
31+
expect(mockedFetch).toHaveBeenCalledTimes(1)
32+
})
33+
34+
test('refreshToken always re-mints, even when a cached token exists', async () => {
35+
mockTokenResponse('first-token')
36+
mockTokenResponse('second-token')
37+
38+
const provider = createClientCredentialsTokenProvider({
39+
apiKey: 'api-key',
40+
apiSecret: 'api-secret',
41+
storeFqdn: 'store.myshopify.com',
42+
})
43+
44+
await expect(provider.getToken()).resolves.toBe('first-token')
45+
await expect(provider.refreshToken!()).resolves.toBe('second-token')
46+
await expect(provider.getToken()).resolves.toBe('second-token')
47+
expect(mockedFetch).toHaveBeenCalledTimes(2)
48+
})
49+
50+
test('posts the OAuth client_credentials body to the store admin endpoint', async () => {
51+
mockTokenResponse('token')
52+
53+
const provider = createClientCredentialsTokenProvider({
54+
apiKey: 'api-key',
55+
apiSecret: 'api-secret',
56+
storeFqdn: 'store.myshopify.com',
57+
})
58+
await provider.getToken()
59+
60+
expect(mockedFetch).toHaveBeenCalledWith(
61+
'https://store.myshopify.com/admin/oauth/access_token',
62+
expect.objectContaining({
63+
method: 'POST',
64+
headers: {'Content-Type': 'application/json'},
65+
body: JSON.stringify({
66+
client_id: 'api-key',
67+
client_secret: 'api-secret',
68+
grant_type: 'client_credentials',
69+
}),
70+
}),
71+
)
72+
})
73+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {TokenProvider} from '@shopify/cli-kit/node/graphiql/server'
2+
import {fetch} from '@shopify/cli-kit/node/http'
3+
4+
interface ClientCredentialsTokenProviderOptions {
5+
apiKey: string
6+
apiSecret: string
7+
storeFqdn: string
8+
}
9+
10+
/**
11+
* Returns a `TokenProvider` that mints Admin API tokens via OAuth `client_credentials`
12+
* using a Partners app's `apiKey` + `apiSecret`. Tokens are cached in-memory and
13+
* re-minted on demand when `refreshToken` is called (e.g. on a 401 from upstream).
14+
*
15+
* This is the strategy used by `shopify app dev`'s GraphiQL server. It assumes the app
16+
* is installed on the target store and that the app secret can mint a fresh token at any time.
17+
*/
18+
export function createClientCredentialsTokenProvider({
19+
apiKey,
20+
apiSecret,
21+
storeFqdn,
22+
}: ClientCredentialsTokenProviderOptions): TokenProvider {
23+
let cachedToken: string | undefined
24+
25+
const mint = async (): Promise<string> => {
26+
const tokenResponse = await fetch(`https://${storeFqdn}/admin/oauth/access_token`, {
27+
method: 'POST',
28+
headers: {'Content-Type': 'application/json'},
29+
body: JSON.stringify({
30+
client_id: apiKey,
31+
client_secret: apiSecret,
32+
grant_type: 'client_credentials',
33+
}),
34+
})
35+
36+
const tokenJson = (await tokenResponse.json()) as {access_token: string}
37+
cachedToken = tokenJson.access_token
38+
return cachedToken
39+
}
40+
41+
return {
42+
getToken: async () => cachedToken ?? mint(),
43+
refreshToken: async () => {
44+
cachedToken = undefined
45+
return mint()
46+
},
47+
}
48+
}

packages/app/src/cli/services/dev/processes/graphiql.ts

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {BaseProcess, DevProcessFunction} from './types.js'
2-
import {setupGraphiQLServer, TokenProvider} from '@shopify/cli-kit/node/graphiql/server'
3-
import {fetch} from '@shopify/cli-kit/node/http'
2+
import {createClientCredentialsTokenProvider} from './graphiql-token-provider.js'
3+
import {setupGraphiQLServer} from '@shopify/cli-kit/node/graphiql/server'
44

55
interface GraphiQLServerProcessOptions {
66
appName: string
@@ -52,39 +52,3 @@ export const launchGraphiQLServer: DevProcessFunction<GraphiQLServerProcessOptio
5252
httpServer.close()
5353
})
5454
}
55-
56-
/**
57-
* In-memory token provider that mints Admin API tokens via OAuth `client_credentials`
58-
* using the Partners app's `apiKey` + `apiSecret`. Refreshes lazily and re-mints on demand.
59-
*/
60-
function createClientCredentialsTokenProvider(options: {
61-
apiKey: string
62-
apiSecret: string
63-
storeFqdn: string
64-
}): TokenProvider {
65-
let cachedToken: string | undefined
66-
67-
const mint = async (): Promise<string> => {
68-
const tokenResponse = await fetch(`https://${options.storeFqdn}/admin/oauth/access_token`, {
69-
method: 'POST',
70-
headers: {'Content-Type': 'application/json'},
71-
body: JSON.stringify({
72-
client_id: options.apiKey,
73-
client_secret: options.apiSecret,
74-
grant_type: 'client_credentials',
75-
}),
76-
})
77-
78-
const tokenJson = (await tokenResponse.json()) as {access_token: string}
79-
cachedToken = tokenJson.access_token
80-
return cachedToken
81-
}
82-
83-
return {
84-
getToken: async () => cachedToken ?? mint(),
85-
refreshToken: async () => {
86-
cachedToken = undefined
87-
return mint()
88-
},
89-
}
90-
}

0 commit comments

Comments
 (0)