Skip to content
Merged
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,113 @@
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({
ok: true,
status: 200,
json: async () => ({access_token: token}),
} as unknown as Awaited<ReturnType<typeof fetch>>)
}

function mockFailedTokenResponse(status: number, body: object = {}) {
mockedFetch.mockResolvedValueOnce({
ok: false,
status,
json: async () => body,
} 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',
}),
}),
)
})

test('throws when the token response is not successful', async () => {
mockFailedTokenResponse(401)

const provider = createClientCredentialsTokenProvider({
apiKey: 'api-key',
apiSecret: 'api-secret',
storeFqdn: 'store.myshopify.com',
})

await expect(provider.getToken()).rejects.toThrow('Token request failed with status 401')
expect(mockedFetch).toHaveBeenCalledTimes(1)
})

test('throws when a successful token response does not include an access token', async () => {
mockedFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({}),
} as unknown as Awaited<ReturnType<typeof fetch>>)

const provider = createClientCredentialsTokenProvider({
apiKey: 'api-key',
apiSecret: 'api-secret',
storeFqdn: 'store.myshopify.com',
})

await expect(provider.getToken()).rejects.toThrow('Token request failed with status 200')
expect(mockedFetch).toHaveBeenCalledTimes(1)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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}
if (!tokenResponse.ok || !tokenJson.access_token) {
throw new Error(`Token request failed with status ${tokenResponse.status}`)
}

cachedToken = tokenJson.access_token
return cachedToken
}

return {
getToken: async () => cachedToken ?? mint(),
refreshToken: async () => {
cachedToken = undefined
return mint()
},
}
}
19 changes: 18 additions & 1 deletion packages/app/src/cli/services/dev/processes/graphiql.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {BaseProcess, DevProcessFunction} from './types.js'
import {createClientCredentialsTokenProvider} from './graphiql-token-provider.js'
import {setupGraphiQLServer} from '@shopify/cli-kit/node/graphiql/server'

interface GraphiQLServerProcessOptions {
Expand Down Expand Up @@ -30,7 +31,23 @@ export const launchGraphiQLServer: DevProcessFunction<GraphiQLServerProcessOptio
{stdout, abortSignal},
options: GraphiQLServerProcessOptions,
) => {
const httpServer = setupGraphiQLServer({...options, stdout})
const tokenProvider = createClientCredentialsTokenProvider({
apiKey: options.apiKey,
apiSecret: options.apiSecret,
storeFqdn: options.storeFqdn,
})
const httpServer = setupGraphiQLServer({
stdout,
port: options.port,
storeFqdn: options.storeFqdn,
key: options.key,
tokenProvider,
appContext: {
appName: options.appName,
appUrl: options.appUrl,
apiSecret: options.apiSecret,
},
})
abortSignal.addEventListener('abort', async () => {
httpServer.close()
})
Expand Down
174 changes: 172 additions & 2 deletions packages/cli-kit/src/public/node/graphiql/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import {deriveGraphiQLKey, resolveGraphiQLKey} from './server.js'
import {describe, expect, test} from 'vitest'
import {deriveGraphiQLKey, resolveGraphiQLKey, setupGraphiQLServer, TokenProvider} from './server.js'
import {getAvailableTCPPort} from '../tcp.js'
import {afterEach, describe, expect, test, vi} from 'vitest'
import {Server} from 'http'
import {Writable} from 'stream'

describe('deriveGraphiQLKey', () => {
test('returns a 64-character hex string', () => {
Expand Down Expand Up @@ -47,3 +50,170 @@ describe('resolveGraphiQLKey', () => {
expect(key).toBe(deriveGraphiQLKey('secret', 'store.myshopify.com'))
})
})

describe('setupGraphiQLServer', () => {
const servers: Server[] = []

afterEach(() => {
for (const server of servers) server.close()
servers.length = 0
})

/**
* Starts the GraphiQL server with the given options on an available port and
* returns its base URL. Server is auto-closed by the afterEach hook.
*/
async function startServer(options: {
tokenProvider: TokenProvider
storeFqdn?: string
key?: string
protectMutations?: boolean
appContext?: {appName: string; appUrl: string; apiSecret: string}
}) {
const port = await getAvailableTCPPort()
const noopStdout = new Writable({write: (_chunk, _enc, cb) => cb()})
const server = setupGraphiQLServer({
stdout: noopStdout,
port,
storeFqdn: options.storeFqdn ?? 'store.myshopify.com',
tokenProvider: options.tokenProvider,
key: options.key,
protectMutations: options.protectMutations,
appContext: options.appContext,
})
servers.push(server)
await new Promise<void>((resolve) => server.on('listening', () => resolve()))
return {url: `http://localhost:${port}`}
}

test('rejects mutations with HTTP 400 when protectMutations is true', async () => {
const tokenProvider: TokenProvider = {getToken: vi.fn(async () => 'access-token')}
const {url} = await startServer({tokenProvider, key: 'k', protectMutations: true})

const response = await fetch(`${url}/graphiql/graphql.json?key=k&api_version=2024-10`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: 'mutation M { shopUpdate(input: {}) { id } }'}),
})

expect(response.status).toBe(400)
const body = (await response.json()) as {errors: {message: string}[]}
expect(body.errors[0]?.message).toMatch(/mutations are disabled/i)
expect(tokenProvider.getToken).not.toHaveBeenCalled()
})

test('does not invoke the token provider for blocked mutations', async () => {
const tokenProvider: TokenProvider = {
getToken: vi.fn(async () => 'access-token'),
refreshToken: vi.fn(async () => 'refreshed-token'),
}
const {url} = await startServer({tokenProvider, key: 'k', protectMutations: true})

await fetch(`${url}/graphiql/graphql.json?key=k&api_version=2024-10`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: 'mutation M { shopUpdate(input: {}) { id } }'}),
})

expect(tokenProvider.getToken).not.toHaveBeenCalled()
expect(tokenProvider.refreshToken).not.toHaveBeenCalled()
})

test('lets queries through to the upstream call when protectMutations is true', async () => {
const tokenProvider: TokenProvider = {getToken: vi.fn(async () => 'access-token')}
const {url} = await startServer({tokenProvider, key: 'k', protectMutations: true})

const response = await fetch(`${url}/graphiql/graphql.json?key=k&api_version=2024-10`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: 'query Q { shop { name } }'}),
})

expect(response.status).not.toBe(400)
expect(tokenProvider.getToken).toHaveBeenCalled()
})

test('returns 404 when the request key does not match', async () => {
const tokenProvider: TokenProvider = {getToken: async () => 'access-token'}
const {url} = await startServer({tokenProvider, key: 'expected-key'})

const response = await fetch(`${url}/graphiql/graphql.json?key=wrong-key&api_version=2024-10`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: '{ shop { name } }'}),
})

expect(response.status).toBe(404)
})

test('uses the deterministic derived key when appContext is provided and no key is set', async () => {
const tokenProvider: TokenProvider = {getToken: async () => 'access-token'}
const derived = deriveGraphiQLKey('app-secret', 'store.myshopify.com')
const {url} = await startServer({
tokenProvider,
protectMutations: true,
appContext: {appName: 'My App', appUrl: 'https://example.com', apiSecret: 'app-secret'},
})

const valid = await fetch(`${url}/graphiql/graphql.json?key=${derived}&api_version=2024-10`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: 'mutation M { x { id } }'}),
})
expect(valid.status).toBe(400)
const validBody = (await valid.json()) as {errors: {message: string}[]}
expect(validBody.errors[0]?.message).toMatch(/mutations are disabled/i)

const invalid = await fetch(`${url}/graphiql/graphql.json?key=wrong&api_version=2024-10`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: 'mutation M { x { id } }'}),
})
expect(invalid.status).toBe(404)
})

test('generates a random per-process key when no appContext and no key are provided', async () => {
const tokenProvider: TokenProvider = {getToken: async () => 'access-token'}
const {url} = await startServer({tokenProvider})

const response = await fetch(`${url}/graphiql/graphql.json?key=anything&api_version=2024-10`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: '{ shop { name } }'}),
})

// We don't know the key; hitting the endpoint with an arbitrary key should 404.
expect(response.status).toBe(404)
})

test('renders app install guidance for unauthorized app GraphiQL sessions', async () => {
const tokenProvider: TokenProvider = {getToken: async () => Promise.reject(new Error('No token'))}
const {url} = await startServer({
tokenProvider,
appContext: {appName: 'My App', appUrl: 'https://example.com', apiSecret: 'app-secret'},
})
const derived = deriveGraphiQLKey('app-secret', 'store.myshopify.com')

const response = await fetch(`${url}/graphiql?key=${derived}`)
const body = await response.text()

expect(body).toContain('Install your app to access GraphiQL')
expect(body).toContain('Install your app')
expect(body).not.toContain('shopify store auth --store store.myshopify.com')
})

test('renders store auth guidance for unauthorized app-less GraphiQL sessions', async () => {
const tokenProvider: TokenProvider = {getToken: async () => Promise.reject(new Error('No token'))}
const {url} = await startServer({tokenProvider, key: 'k'})

const response = await fetch(`${url}/graphiql?key=k`)
const body = await response.text()

expect(body).toContain('Reconnect store authentication to access GraphiQL')
expect(body).toContain('The GraphiQL Explorer couldn&#x27;t access this store with the stored authentication.')
expect(body).toContain('shopify store auth --store store.myshopify.com')
expect(body).toContain('GraphiQL Explorer - Authentication Required')
expect(body).not.toContain('Install your app to access GraphiQL')
expect(body).not.toContain('id="app-install-button"')
})
})
Loading
Loading