Skip to content

Commit 6a55501

Browse files
refactor(cli-kit): make GraphiQL server auth-agnostic
Decouple the GraphiQL HTTP proxy from any specific token strategy or app context so it can be reused outside of `shopify app dev`. - New `TokenProvider` interface with `getToken` and optional `refreshToken`. The proxy delegates auth to the provider, removing the hard-coded `client_credentials` flow. - App-specific concerns (app name/url, app secret) move behind an optional `appContext` option. Without it, the template hides the App pill, swaps the unauthorized badge label, and shows a stored-auth scopes note. - New `protectMutations` option rejects mutation operations server-side with HTTP 400 before forwarding. This lets interactive sessions mirror the safe-by-default semantics of `shopify store execute` without --allow-mutations. - Key resolution: explicit key wins; otherwise derive deterministically from `appContext.apiSecret` + `storeFqdn` (preserving today's behavior for app dev) or fall back to a random per-process key. - Extract `containsMutation(query, operationName?)` to `@shopify/cli-kit/node/graphql` for use by the proxy. - Update the app dev wrapper to provide an in-memory client_credentials `TokenProvider`. Behavior unchanged for `app dev`. Tests cover protectMutations behavior, key handling, token-provider plumbing, and containsMutation across queries, mutations, fragments, named operations, and invalid input.
1 parent 9fd0733 commit 6a55501

6 files changed

Lines changed: 458 additions & 72 deletions

File tree

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

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

45
interface GraphiQLServerProcessOptions {
56
appName: string
@@ -30,8 +31,60 @@ 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
})
3754
}
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+
}

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

Lines changed: 138 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,136 @@ 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+
appContext: {appName: 'My App', appUrl: 'https://example.com', apiSecret: 'app-secret'},
155+
})
156+
157+
const valid = await fetch(`${url}/graphiql/graphql.json?key=${derived}&api_version=2024-10`, {
158+
method: 'POST',
159+
headers: {'Content-Type': 'application/json'},
160+
body: JSON.stringify({query: 'mutation M { x { id } }'}),
161+
})
162+
expect(valid.status).not.toBe(404)
163+
164+
const invalid = await fetch(`${url}/graphiql/graphql.json?key=wrong&api_version=2024-10`, {
165+
method: 'POST',
166+
headers: {'Content-Type': 'application/json'},
167+
body: JSON.stringify({query: 'mutation M { x { id } }'}),
168+
})
169+
expect(invalid.status).toBe(404)
170+
})
171+
172+
test('generates a random per-process key when no appContext and no key are provided', async () => {
173+
const tokenProvider: TokenProvider = {getToken: async () => 'access-token'}
174+
const {url} = await startServer({tokenProvider})
175+
176+
const response = await fetch(`${url}/graphiql/graphql.json?key=anything&api_version=2024-10`, {
177+
method: 'POST',
178+
headers: {'Content-Type': 'application/json'},
179+
body: JSON.stringify({query: '{ shop { name } }'}),
180+
})
181+
182+
// We don't know the key; hitting the endpoint with an arbitrary key should 404.
183+
expect(response.status).toBe(404)
184+
})
185+
})

0 commit comments

Comments
 (0)