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
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export default class Dev extends AppLinkedCommand {
'graphiql-key': Flags.string({
hidden: true,
description:
'Key used to authenticate GraphiQL requests. Should be specified if exposing GraphiQL on a publicly accessible URL. By default, no key is required.',
'Key used to authenticate GraphiQL requests. By default, a key is automatically derived from the app secret. Use this flag to override with a custom key.',
env: 'SHOPIFY_FLAG_GRAPHIQL_KEY',
}),
}
Expand Down
49 changes: 49 additions & 0 deletions packages/app/src/cli/services/dev/graphiql/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {deriveGraphiQLKey, resolveGraphiQLKey} from './server.js'
import {describe, expect, test} from 'vitest'

describe('deriveGraphiQLKey', () => {
test('returns a 64-character hex string', () => {
const key = deriveGraphiQLKey('secret', 'store.myshopify.com')
expect(key).toMatch(/^[0-9a-f]{64}$/)
})

test('is deterministic — same inputs produce the same key', () => {
const key1 = deriveGraphiQLKey('secret', 'store.myshopify.com')
const key2 = deriveGraphiQLKey('secret', 'store.myshopify.com')
expect(key1).toBe(key2)
})

test('different secrets produce different keys', () => {
const key1 = deriveGraphiQLKey('secret-1', 'store.myshopify.com')
const key2 = deriveGraphiQLKey('secret-2', 'store.myshopify.com')
expect(key1).not.toBe(key2)
})

test('different stores produce different keys', () => {
const key1 = deriveGraphiQLKey('secret', 'store-a.myshopify.com')
const key2 = deriveGraphiQLKey('secret', 'store-b.myshopify.com')
expect(key1).not.toBe(key2)
})
})

describe('resolveGraphiQLKey', () => {
test('uses provided key when non-empty', () => {
const key = resolveGraphiQLKey('my-custom-key', 'secret', 'store.myshopify.com')
expect(key).toBe('my-custom-key')
})

test('derives key when provided key is undefined', () => {
const key = resolveGraphiQLKey(undefined, 'secret', 'store.myshopify.com')
expect(key).toBe(deriveGraphiQLKey('secret', 'store.myshopify.com'))
})

test('derives key when provided key is empty string', () => {
const key = resolveGraphiQLKey('', 'secret', 'store.myshopify.com')
expect(key).toBe(deriveGraphiQLKey('secret', 'store.myshopify.com'))
})

test('derives key when provided key is whitespace-only', () => {
const key = resolveGraphiQLKey(' ', 'secret', 'store.myshopify.com')
expect(key).toBe(deriveGraphiQLKey('secret', 'store.myshopify.com'))
})
})
33 changes: 29 additions & 4 deletions packages/app/src/cli/services/dev/graphiql/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,28 @@ import {adminUrl, supportedApiVersions} from '@shopify/cli-kit/node/api/admin'
import {fetch} from '@shopify/cli-kit/node/http'
import {renderLiquidTemplate} from '@shopify/cli-kit/node/liquid'
import {outputDebug} from '@shopify/cli-kit/node/output'
import {createHmac, timingSafeEqual} from 'crypto'
import {Server} from 'http'
import {Writable} from 'stream'
import {createRequire} from 'module'

/**
* Derives a deterministic GraphiQL authentication key from the app's API secret and store FQDN.
* The key is stable across dev server restarts (so browser tabs survive restarts)
* but is not guessable without the app secret.
*/
export function deriveGraphiQLKey(apiSecret: string, storeFqdn: string): string {
return createHmac('sha256', apiSecret).update(`graphiql:${storeFqdn}`).digest('hex')
}

/**
* Resolves the GraphiQL authentication key. Uses the explicitly provided key
* if non-empty, otherwise derives one deterministically from the app secret.
*/
export function resolveGraphiQLKey(providedKey: string | undefined, apiSecret: string, storeFqdn: string): string {
return providedKey?.trim() || deriveGraphiQLKey(apiSecret, storeFqdn)
}

const require = createRequire(import.meta.url)

class TokenRefreshError extends AbortError {
Expand Down Expand Up @@ -50,15 +68,21 @@ export function setupGraphiQLServer({
appUrl,
apiKey,
apiSecret,
key,
key: providedKey,
storeFqdn,
}: SetupGraphiQLServerOptions): Server {
// Always require an authentication key. If not explicitly provided, derive one
// deterministically from apiSecret + storeFqdn so the key is stable across restarts
// (browser tabs survive dev server restarts) but not guessable without the app secret.
const key = resolveGraphiQLKey(providedKey, apiSecret, storeFqdn)
outputDebug(`Setting up GraphiQL HTTP server on port ${port}...`, stdout)
const app = express()

function failIfUnmatchedKey(str: string, res: express.Response): boolean {
if (!key || str === key) return false
res.status(404).send(`Invalid path ${res.req.originalUrl}`)
const strBuffer = Buffer.from(str ?? '')
const keyBuffer = Buffer.from(key)
if (strBuffer.length === keyBuffer.length && timingSafeEqual(strBuffer, keyBuffer)) return false
res.status(404).type('text/plain').send(`Invalid path ${res.req.originalUrl}`)
return true
}

Expand Down Expand Up @@ -116,7 +140,8 @@ export function setupGraphiQLServer({
)
}

app.get('/graphiql/status', (_req, res) => {
app.get('/graphiql/status', (req, res) => {
if (failIfUnmatchedKey(req.query.key as string, res)) return
fetchApiVersionsWithTokenRefresh()
.then(() => res.send({status: 'OK', storeFqdn, appName, appUrl}))
.catch(() => res.send({status: 'UNAUTHENTICATED'}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ interface GraphiQLTemplateOptions {
apiVersions: string[]
appName: string
appUrl: string
key?: string
key: string
storeFqdn: string
}

Expand Down Expand Up @@ -248,7 +248,7 @@ export function graphiqlTemplate({
ReactDOM.render(
React.createElement(GraphiQL, {
fetcher: GraphiQL.createFetcher({
url: '{{url}}/graphiql/graphql.json?key=${key ?? ''}&api_version=' + apiVersion,
url: '{{url}}/graphiql/graphql.json?key=${encodeURIComponent(key)}&api_version=' + apiVersion,
}),
defaultEditorToolsVisibility: true,
{% if query %}
Expand Down Expand Up @@ -320,7 +320,7 @@ export function graphiqlTemplate({

// Verify the current store/app connection
setInterval(function() {
fetch('{{ url }}/graphiql/status')
fetch('{{ url }}/graphiql/status?key=${encodeURIComponent(key)}')
.then(async function(response) {
const {status, storeFqdn, appName, appUrl} = await response.json()
appIsInstalled = status === 'OK'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {pushUpdatesForDraftableExtensions} from './draftable-extension.js'
import {pushUpdatesForDevSession} from './dev-session/dev-session-process.js'
import {runThemeAppExtensionsServer} from './theme-app-extension.js'
import {launchAppWatcher} from './app-watcher-process.js'
import {resolveGraphiQLKey} from '../graphiql/server.js'
import {
testAppAccessConfigExtension,
testAppConfigExtensions,
Expand Down Expand Up @@ -312,6 +313,71 @@ describe('setup-dev-processes', () => {
})
})

test('auto-derives a graphiql key when none is provided', async () => {
const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient()
const storeFqdn = 'store.myshopify.io'
const storeId = '123456789'
const remoteAppUpdated = true
const graphiqlPort = 1234
const commandOptions: DevConfig['commandOptions'] = {
...appContextResult,
directory: '',
update: false,
commandConfig: new Config({root: ''}),
skipDependenciesInstallation: false,
subscriptionProductUrl: '/products/999999',
checkoutCartUrl: '/cart/999999:1',
tunnel: {mode: 'auto'},
}
const network: DevConfig['network'] = {
proxyUrl: 'https://example.com/proxy',
proxyPort: 444,
backendPort: 111,
frontendPort: 222,
currentUrls: {
applicationUrl: 'https://example.com/application',
redirectUrlWhitelist: ['https://example.com/redirect'],
},
}
const localApp = testAppWithConfig({config: {}})
vi.spyOn(loader, 'reloadApp').mockResolvedValue(localApp)

const remoteApp: DevConfig['remoteApp'] = {
apiKey: 'api-key',
apiSecretKeys: [{secret: 'api-secret'}],
id: '1234',
title: 'App',
organizationId: '5678',
grantedScopes: [],
flags: [],
developerPlatformClient,
}

// No graphiqlKey provided — should auto-derive one
const res = await setupDevProcesses({
localApp,
commandOptions,
network,
remoteApp,
remoteAppUpdated,
storeFqdn,
storeId,
developerPlatformClient,
partnerUrlsUpdated: true,
graphiqlPort,
})

const expectedKey = resolveGraphiQLKey(undefined, 'api-secret', storeFqdn)

// The graphiql process should use the resolved key
const graphiqlProcess = res.processes.find((process) => process.type === 'graphiql')
expect(graphiqlProcess).toBeDefined()
expect((graphiqlProcess!.options as {key: string}).key).toBe(expectedKey)

// The graphiql URL should include the resolved key
expect(res.graphiqlUrl).toBe(`http://localhost:${graphiqlPort}/graphiql?key=${encodeURIComponent(expectedKey)}`)
})

test('process list includes dev-session when useDevSession is true', async () => {
const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({supportsDevSessions: true})
const storeFqdn = 'store.myshopify.io'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {DevSessionProcess, setupDevSessionProcess} from './dev-session/dev-sessi
import {AppLogsSubscribeProcess, setupAppLogsPollingProcess} from './app-logs-polling.js'
import {AppWatcherProcess, setupAppWatcherProcess} from './app-watcher-process.js'
import {DevSessionStatusManager} from './dev-session/dev-session-status-manager.js'
import {resolveGraphiQLKey} from '../graphiql/server.js'
import {environmentVariableNames} from '../../../constants.js'
import {AppLinkedInterface, getAppScopes, WebType} from '../../../models/app/app.js'

Expand Down Expand Up @@ -119,8 +120,9 @@ export async function setupDevProcesses({
const useDevConsole = is1PDev && anyPreviewableExtensions
const previewURL = useDevConsole ? devConsoleURL : appPreviewUrl

const resolvedGraphiqlKey = resolveGraphiQLKey(graphiqlKey, apiSecret, storeFqdn)
const graphiqlURL = shouldRenderGraphiQL
? `http://localhost:${graphiqlPort}/graphiql${graphiqlKey ? `?key=${graphiqlKey}` : ''}`
? `http://localhost:${graphiqlPort}/graphiql?key=${encodeURIComponent(resolvedGraphiqlKey)}`
: undefined

const devSessionStatusManager = new DevSessionStatusManager({isReady: false, previewURL, graphiqlURL})
Expand All @@ -142,7 +144,7 @@ export async function setupDevProcesses({
port: graphiqlPort,
apiKey,
apiSecret,
key: graphiqlKey,
key: resolvedGraphiqlKey,
storeFqdn,
})
: undefined,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -848,7 +848,7 @@
"type": "option"
},
"graphiql-key": {
"description": "Key used to authenticate GraphiQL requests. Should be specified if exposing GraphiQL on a publicly accessible URL. By default, no key is required.",
"description": "Key used to authenticate GraphiQL requests. By default, a key is automatically derived from the app secret. Use this flag to override with a custom key.",
"env": "SHOPIFY_FLAG_GRAPHIQL_KEY",
"hasDynamicHelp": false,
"hidden": true,
Expand Down
Loading