Skip to content

Commit 0237573

Browse files
Merge pull request #7380 from Shopify/graphiql-store/auth-agnostic-server
Make GraphiQL server auth-agnostic
2 parents 73dd3da + 573f91b commit 0237573

9 files changed

Lines changed: 688 additions & 110 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
ok: true,
12+
status: 200,
13+
json: async () => ({access_token: token}),
14+
} as unknown as Awaited<ReturnType<typeof fetch>>)
15+
}
16+
17+
function mockFailedTokenResponse(status: number, body: object = {}) {
18+
mockedFetch.mockResolvedValueOnce({
19+
ok: false,
20+
status,
21+
json: async () => body,
22+
} as unknown as Awaited<ReturnType<typeof fetch>>)
23+
}
24+
25+
describe('createClientCredentialsTokenProvider', () => {
26+
beforeEach(() => {
27+
mockedFetch.mockReset()
28+
})
29+
30+
test('mints a token on first getToken call and caches it', async () => {
31+
mockTokenResponse('first-token')
32+
33+
const provider = createClientCredentialsTokenProvider({
34+
apiKey: 'api-key',
35+
apiSecret: 'api-secret',
36+
storeFqdn: 'store.myshopify.com',
37+
})
38+
39+
await expect(provider.getToken()).resolves.toBe('first-token')
40+
await expect(provider.getToken()).resolves.toBe('first-token')
41+
expect(mockedFetch).toHaveBeenCalledTimes(1)
42+
})
43+
44+
test('refreshToken always re-mints, even when a cached token exists', async () => {
45+
mockTokenResponse('first-token')
46+
mockTokenResponse('second-token')
47+
48+
const provider = createClientCredentialsTokenProvider({
49+
apiKey: 'api-key',
50+
apiSecret: 'api-secret',
51+
storeFqdn: 'store.myshopify.com',
52+
})
53+
54+
await expect(provider.getToken()).resolves.toBe('first-token')
55+
await expect(provider.refreshToken!()).resolves.toBe('second-token')
56+
await expect(provider.getToken()).resolves.toBe('second-token')
57+
expect(mockedFetch).toHaveBeenCalledTimes(2)
58+
})
59+
60+
test('posts the OAuth client_credentials body to the store admin endpoint', async () => {
61+
mockTokenResponse('token')
62+
63+
const provider = createClientCredentialsTokenProvider({
64+
apiKey: 'api-key',
65+
apiSecret: 'api-secret',
66+
storeFqdn: 'store.myshopify.com',
67+
})
68+
await provider.getToken()
69+
70+
expect(mockedFetch).toHaveBeenCalledWith(
71+
'https://store.myshopify.com/admin/oauth/access_token',
72+
expect.objectContaining({
73+
method: 'POST',
74+
headers: {'Content-Type': 'application/json'},
75+
body: JSON.stringify({
76+
client_id: 'api-key',
77+
client_secret: 'api-secret',
78+
grant_type: 'client_credentials',
79+
}),
80+
}),
81+
)
82+
})
83+
84+
test('throws when the token response is not successful', async () => {
85+
mockFailedTokenResponse(401)
86+
87+
const provider = createClientCredentialsTokenProvider({
88+
apiKey: 'api-key',
89+
apiSecret: 'api-secret',
90+
storeFqdn: 'store.myshopify.com',
91+
})
92+
93+
await expect(provider.getToken()).rejects.toThrow('Token request failed with status 401')
94+
expect(mockedFetch).toHaveBeenCalledTimes(1)
95+
})
96+
97+
test('throws when a successful token response does not include an access token', async () => {
98+
mockedFetch.mockResolvedValueOnce({
99+
ok: true,
100+
status: 200,
101+
json: async () => ({}),
102+
} as unknown as Awaited<ReturnType<typeof fetch>>)
103+
104+
const provider = createClientCredentialsTokenProvider({
105+
apiKey: 'api-key',
106+
apiSecret: 'api-secret',
107+
storeFqdn: 'store.myshopify.com',
108+
})
109+
110+
await expect(provider.getToken()).rejects.toThrow('Token request failed with status 200')
111+
expect(mockedFetch).toHaveBeenCalledTimes(1)
112+
})
113+
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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+
if (!tokenResponse.ok || !tokenJson.access_token) {
38+
throw new Error(`Token request failed with status ${tokenResponse.status}`)
39+
}
40+
41+
cachedToken = tokenJson.access_token
42+
return cachedToken
43+
}
44+
45+
return {
46+
getToken: async () => cachedToken ?? mint(),
47+
refreshToken: async () => {
48+
cachedToken = undefined
49+
return mint()
50+
},
51+
}
52+
}

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {BaseProcess, DevProcessFunction} from './types.js'
2+
import {createClientCredentialsTokenProvider} from './graphiql-token-provider.js'
23
import {setupGraphiQLServer} from '@shopify/cli-kit/node/graphiql/server'
34

45
interface GraphiQLServerProcessOptions {
@@ -30,7 +31,23 @@ export const launchGraphiQLServer: DevProcessFunction<GraphiQLServerProcessOptio
3031
{stdout, abortSignal},
3132
options: GraphiQLServerProcessOptions,
3233
) => {
33-
const httpServer = setupGraphiQLServer({...options, stdout})
34+
const tokenProvider = createClientCredentialsTokenProvider({
35+
apiKey: options.apiKey,
36+
apiSecret: options.apiSecret,
37+
storeFqdn: options.storeFqdn,
38+
})
39+
const httpServer = setupGraphiQLServer({
40+
stdout,
41+
port: options.port,
42+
storeFqdn: options.storeFqdn,
43+
key: options.key,
44+
tokenProvider,
45+
appContext: {
46+
appName: options.appName,
47+
appUrl: options.appUrl,
48+
apiSecret: options.apiSecret,
49+
},
50+
})
3451
abortSignal.addEventListener('abort', async () => {
3552
httpServer.close()
3653
})

packages/cli-kit/src/public/node/graphiql/server.test.ts

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import {deriveGraphiQLKey, resolveGraphiQLKey} from './server.js'
2-
import {describe, expect, test} from 'vitest'
1+
import {deriveGraphiQLKey, resolveGraphiQLKey, setupGraphiQLServer, TokenProvider} from './server.js'
2+
import {getAvailableTCPPort} from '../tcp.js'
3+
import {afterEach, describe, expect, test, vi} from 'vitest'
4+
import {Server} from 'http'
5+
import {Writable} from 'stream'
36

47
describe('deriveGraphiQLKey', () => {
58
test('returns a 64-character hex string', () => {
@@ -47,3 +50,170 @@ describe('resolveGraphiQLKey', () => {
4750
expect(key).toBe(deriveGraphiQLKey('secret', 'store.myshopify.com'))
4851
})
4952
})
53+
54+
describe('setupGraphiQLServer', () => {
55+
const servers: Server[] = []
56+
57+
afterEach(() => {
58+
for (const server of servers) server.close()
59+
servers.length = 0
60+
})
61+
62+
/**
63+
* Starts the GraphiQL server with the given options on an available port and
64+
* returns its base URL. Server is auto-closed by the afterEach hook.
65+
*/
66+
async function startServer(options: {
67+
tokenProvider: TokenProvider
68+
storeFqdn?: string
69+
key?: string
70+
protectMutations?: boolean
71+
appContext?: {appName: string; appUrl: string; apiSecret: string}
72+
}) {
73+
const port = await getAvailableTCPPort()
74+
const noopStdout = new Writable({write: (_chunk, _enc, cb) => cb()})
75+
const server = setupGraphiQLServer({
76+
stdout: noopStdout,
77+
port,
78+
storeFqdn: options.storeFqdn ?? 'store.myshopify.com',
79+
tokenProvider: options.tokenProvider,
80+
key: options.key,
81+
protectMutations: options.protectMutations,
82+
appContext: options.appContext,
83+
})
84+
servers.push(server)
85+
await new Promise<void>((resolve) => server.on('listening', () => resolve()))
86+
return {url: `http://localhost:${port}`}
87+
}
88+
89+
test('rejects mutations with HTTP 400 when protectMutations is true', async () => {
90+
const tokenProvider: TokenProvider = {getToken: vi.fn(async () => 'access-token')}
91+
const {url} = await startServer({tokenProvider, key: 'k', protectMutations: true})
92+
93+
const response = await fetch(`${url}/graphiql/graphql.json?key=k&api_version=2024-10`, {
94+
method: 'POST',
95+
headers: {'Content-Type': 'application/json'},
96+
body: JSON.stringify({query: 'mutation M { shopUpdate(input: {}) { id } }'}),
97+
})
98+
99+
expect(response.status).toBe(400)
100+
const body = (await response.json()) as {errors: {message: string}[]}
101+
expect(body.errors[0]?.message).toMatch(/mutations are disabled/i)
102+
expect(tokenProvider.getToken).not.toHaveBeenCalled()
103+
})
104+
105+
test('does not invoke the token provider for blocked mutations', async () => {
106+
const tokenProvider: TokenProvider = {
107+
getToken: vi.fn(async () => 'access-token'),
108+
refreshToken: vi.fn(async () => 'refreshed-token'),
109+
}
110+
const {url} = await startServer({tokenProvider, key: 'k', protectMutations: true})
111+
112+
await fetch(`${url}/graphiql/graphql.json?key=k&api_version=2024-10`, {
113+
method: 'POST',
114+
headers: {'Content-Type': 'application/json'},
115+
body: JSON.stringify({query: 'mutation M { shopUpdate(input: {}) { id } }'}),
116+
})
117+
118+
expect(tokenProvider.getToken).not.toHaveBeenCalled()
119+
expect(tokenProvider.refreshToken).not.toHaveBeenCalled()
120+
})
121+
122+
test('lets queries through to the upstream call when protectMutations is true', async () => {
123+
const tokenProvider: TokenProvider = {getToken: vi.fn(async () => 'access-token')}
124+
const {url} = await startServer({tokenProvider, key: 'k', protectMutations: true})
125+
126+
const response = await fetch(`${url}/graphiql/graphql.json?key=k&api_version=2024-10`, {
127+
method: 'POST',
128+
headers: {'Content-Type': 'application/json'},
129+
body: JSON.stringify({query: 'query Q { shop { name } }'}),
130+
})
131+
132+
expect(response.status).not.toBe(400)
133+
expect(tokenProvider.getToken).toHaveBeenCalled()
134+
})
135+
136+
test('returns 404 when the request key does not match', async () => {
137+
const tokenProvider: TokenProvider = {getToken: async () => 'access-token'}
138+
const {url} = await startServer({tokenProvider, key: 'expected-key'})
139+
140+
const response = await fetch(`${url}/graphiql/graphql.json?key=wrong-key&api_version=2024-10`, {
141+
method: 'POST',
142+
headers: {'Content-Type': 'application/json'},
143+
body: JSON.stringify({query: '{ shop { name } }'}),
144+
})
145+
146+
expect(response.status).toBe(404)
147+
})
148+
149+
test('uses the deterministic derived key when appContext is provided and no key is set', async () => {
150+
const tokenProvider: TokenProvider = {getToken: async () => 'access-token'}
151+
const derived = deriveGraphiQLKey('app-secret', 'store.myshopify.com')
152+
const {url} = await startServer({
153+
tokenProvider,
154+
protectMutations: true,
155+
appContext: {appName: 'My App', appUrl: 'https://example.com', apiSecret: 'app-secret'},
156+
})
157+
158+
const valid = await fetch(`${url}/graphiql/graphql.json?key=${derived}&api_version=2024-10`, {
159+
method: 'POST',
160+
headers: {'Content-Type': 'application/json'},
161+
body: JSON.stringify({query: 'mutation M { x { id } }'}),
162+
})
163+
expect(valid.status).toBe(400)
164+
const validBody = (await valid.json()) as {errors: {message: string}[]}
165+
expect(validBody.errors[0]?.message).toMatch(/mutations are disabled/i)
166+
167+
const invalid = await fetch(`${url}/graphiql/graphql.json?key=wrong&api_version=2024-10`, {
168+
method: 'POST',
169+
headers: {'Content-Type': 'application/json'},
170+
body: JSON.stringify({query: 'mutation M { x { id } }'}),
171+
})
172+
expect(invalid.status).toBe(404)
173+
})
174+
175+
test('generates a random per-process key when no appContext and no key are provided', async () => {
176+
const tokenProvider: TokenProvider = {getToken: async () => 'access-token'}
177+
const {url} = await startServer({tokenProvider})
178+
179+
const response = await fetch(`${url}/graphiql/graphql.json?key=anything&api_version=2024-10`, {
180+
method: 'POST',
181+
headers: {'Content-Type': 'application/json'},
182+
body: JSON.stringify({query: '{ shop { name } }'}),
183+
})
184+
185+
// We don't know the key; hitting the endpoint with an arbitrary key should 404.
186+
expect(response.status).toBe(404)
187+
})
188+
189+
test('renders app install guidance for unauthorized app GraphiQL sessions', async () => {
190+
const tokenProvider: TokenProvider = {getToken: async () => Promise.reject(new Error('No token'))}
191+
const {url} = await startServer({
192+
tokenProvider,
193+
appContext: {appName: 'My App', appUrl: 'https://example.com', apiSecret: 'app-secret'},
194+
})
195+
const derived = deriveGraphiQLKey('app-secret', 'store.myshopify.com')
196+
197+
const response = await fetch(`${url}/graphiql?key=${derived}`)
198+
const body = await response.text()
199+
200+
expect(body).toContain('Install your app to access GraphiQL')
201+
expect(body).toContain('Install your app')
202+
expect(body).not.toContain('shopify store auth --store store.myshopify.com')
203+
})
204+
205+
test('renders store auth guidance for unauthorized app-less GraphiQL sessions', async () => {
206+
const tokenProvider: TokenProvider = {getToken: async () => Promise.reject(new Error('No token'))}
207+
const {url} = await startServer({tokenProvider, key: 'k'})
208+
209+
const response = await fetch(`${url}/graphiql?key=k`)
210+
const body = await response.text()
211+
212+
expect(body).toContain('Reconnect store authentication to access GraphiQL')
213+
expect(body).toContain('The GraphiQL Explorer couldn&#x27;t access this store with the stored authentication.')
214+
expect(body).toContain('shopify store auth --store store.myshopify.com')
215+
expect(body).toContain('GraphiQL Explorer - Authentication Required')
216+
expect(body).not.toContain('Install your app to access GraphiQL')
217+
expect(body).not.toContain('id="app-install-button"')
218+
})
219+
})

0 commit comments

Comments
 (0)