From d29c33aa0b92062d7e923b7817d0501feb58dc7d Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 2 Apr 2026 18:00:17 -0400 Subject: [PATCH 1/5] Make store auth scopes additive --- .../services/store/admin-graphql-context.ts | 106 +----- .../cli/src/cli/services/store/auth.test.ts | 330 +++++++++++++++++- packages/cli/src/cli/services/store/auth.ts | 139 +++++++- .../src/cli/services/store/stored-session.ts | 104 ++++++ 4 files changed, 564 insertions(+), 115 deletions(-) create mode 100644 packages/cli/src/cli/services/store/stored-session.ts diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.ts b/packages/cli/src/cli/services/store/admin-graphql-context.ts index f014e5b9e9e..a0c5f6a6fe6 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-context.ts @@ -1,17 +1,11 @@ import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {AdminSession} from '@shopify/cli-kit/node/session' -import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' -import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './auth-recovery.js' -import { - clearStoredStoreAppSession, - getStoredStoreAppSession, - isSessionExpired, - setStoredStoreAppSession, - StoredStoreAppSession, -} from './session.js' +import {reauthenticateStoreAuthError} from './auth-recovery.js' +import {clearStoredStoreAppSession} from './session.js' +import type {StoredStoreAppSession} from './session.js' +import {loadStoredStoreSession} from './stored-session.js' export interface AdminStoreGraphQLContext { adminSession: AdminSession @@ -19,98 +13,6 @@ export interface AdminStoreGraphQLContext { sessionUserId: string } -async function refreshStoreToken(session: StoredStoreAppSession): Promise { - if (!session.refreshToken) { - throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) - } - - const endpoint = `https://${session.store}/admin/oauth/access_token` - - outputDebug( - outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, - ) - - const response = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - client_id: STORE_AUTH_APP_CLIENT_ID, - grant_type: 'refresh_token', - refresh_token: session.refreshToken, - }), - }) - - const body = await response.text() - - if (!response.ok) { - outputDebug( - outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, - ) - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Token refresh failed for ${session.store} (HTTP ${response.status}).`, - session.store, - session.scopes.join(','), - ) - } - - let data: {access_token?: string; refresh_token?: string; expires_in?: number; refresh_token_expires_in?: number} - try { - data = JSON.parse(body) - } catch { - clearStoredStoreAppSession(session.store, session.userId) - throw new AbortError('Received an invalid refresh response from Shopify.') - } - - if (!data.access_token) { - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Token refresh returned an invalid response for ${session.store}.`, - session.store, - session.scopes.join(','), - ) - } - - const now = Date.now() - const expiresAt = data.expires_in ? new Date(now + data.expires_in * 1000).toISOString() : session.expiresAt - - const refreshedSession: StoredStoreAppSession = { - ...session, - accessToken: data.access_token, - refreshToken: data.refresh_token ?? session.refreshToken, - expiresAt, - refreshTokenExpiresAt: data.refresh_token_expires_in - ? new Date(now + data.refresh_token_expires_in * 1000).toISOString() - : session.refreshTokenExpiresAt, - acquiredAt: new Date(now).toISOString(), - } - - outputDebug( - outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(session.accessToken))} → ${outputToken.raw(maskToken(refreshedSession.accessToken))}, new expiry ${outputToken.raw(expiresAt ?? 'unknown')}`, - ) - - setStoredStoreAppSession(refreshedSession) - return refreshedSession -} - -async function loadStoredStoreSession(store: string): Promise { - let session = getStoredStoreAppSession(store) - - if (!session) { - throw createStoredStoreAuthError(store) - } - - outputDebug( - outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, - ) - - if (isSessionExpired(session)) { - session = await refreshStoreToken(session) - } - - return session -} - async function resolveApiVersion(options: { session: StoredStoreAppSession adminSession: AdminSession diff --git a/packages/cli/src/cli/services/store/auth.test.ts b/packages/cli/src/cli/services/store/auth.test.ts index 0ae4fe7c94e..7dba9f6d9ca 100644 --- a/packages/cli/src/cli/services/store/auth.test.ts +++ b/packages/cli/src/cli/services/store/auth.test.ts @@ -7,14 +7,28 @@ import { generateCodeVerifier, computeCodeChallenge, exchangeStoreAuthCodeForToken, + resolveExistingStoreAuthScopes, waitForStoreAuthCode, } from './auth.js' -import {setStoredStoreAppSession} from './session.js' +import {loadStoredStoreSession} from './stored-session.js' +import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {fetch} from '@shopify/cli-kit/node/http' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('./session.js') +vi.mock('./stored-session.js', () => ({loadStoredStoreSession: vi.fn()})) vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/api/graphql') +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + adminUrl: vi.fn(), + } +}) vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) @@ -62,10 +76,12 @@ function callbackParams(options?: { describe('store auth service', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json') }) afterEach(() => { vi.restoreAllMocks() + mockAndCaptureOutput().clear() }) test('generateCodeVerifier produces a base64url string of 43 chars', () => { @@ -115,6 +131,139 @@ describe('store auth service', () => { expect(url.searchParams.get('grant_options[]')).toBeNull() }) + test('resolveExistingStoreAuthScopes returns no scopes when no stored auth exists', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({scopes: [], authoritative: true}) + expect(loadStoredStoreSession).not.toHaveBeenCalled() + expect(graphqlRequest).not.toHaveBeenCalled() + }) + + test('resolveExistingStoreAuthScopes prefers current remote scopes over stale local scopes', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: {accessScopes: [{handle: 'read_products'}, {handle: 'read_customers'}]}, + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_products', 'read_customers'], + authoritative: true, + }) + expect(adminUrl).toHaveBeenCalledWith('shop.myshopify.com', 'unstable') + expect(graphqlRequest).toHaveBeenCalledWith({ + query: expect.stringContaining('currentAppInstallation'), + api: 'Admin', + url: 'https://shop.myshopify.com/admin/api/unstable/graphql.json', + token: 'fresh-token', + responseOptions: {handleErrors: false}, + }) + }) + + test('resolveExistingStoreAuthScopes falls back to locally stored scopes when remote lookup fails', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockRejectedValue(new Error('boom')) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) + + test('resolveExistingStoreAuthScopes falls back to locally stored scopes when access scopes request fails', async () => { + const output = mockAndCaptureOutput() + + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + const scopeLookupError = new Error('GraphQL Error (Code: 401)') + Object.assign(scopeLookupError, { + response: { + status: 401, + errors: '[API] Invalid API key or access token (unrecognized login or wrong password)', + }, + request: { + query: '#graphql query CurrentAppInstallationAccessScopes { currentAppInstallation { accessScopes { handle } } }', + }, + }) + vi.mocked(graphqlRequest).mockRejectedValue(scopeLookupError) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + expect(output.debug()).toContain('after remote scope lookup failed: HTTP 401: [API] Invalid API key or access token') + expect(output.debug()).not.toContain('CurrentAppInstallationAccessScopes') + }) + + test('resolveExistingStoreAuthScopes falls back to locally stored scopes when access scopes response is invalid', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: undefined, + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) + test('waitForStoreAuthCode resolves after a valid callback', async () => { const port = await getAvailablePort() const params = callbackParams() @@ -354,6 +503,185 @@ describe('store auth service', () => { }) }) + test('authenticateStoreWithApp uses remote scopes by default when available', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: {accessScopes: [{handle: 'read_customers'}]}, + } as any) + + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_customers,read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_customers,read_products') + }) + + test('authenticateStoreWithApp reuses resolved existing scopes when requesting additional access', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_orders,read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: true}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_orders', 'read_products'], + }), + ) + }) + + test('authenticateStoreWithApp does not require non-authoritative cached scopes to still be granted', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: false}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_products'], + }), + ) + }) + + test('authenticateStoreWithApp avoids requesting redundant read scopes already implied by existing write scopes', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'write_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['write_products'], authoritative: true}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('write_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['write_products'], + }), + ) + }) + test('authenticateStoreWithApp shows a manual auth URL when the browser does not open automatically', async () => { const openURL = vi.fn().mockResolvedValue(false) const presenter = { diff --git a/packages/cli/src/cli/services/store/auth.ts b/packages/cli/src/cli/services/store/auth.ts index 0b38fc842d3..27a6eeda04f 100644 --- a/packages/cli/src/cli/services/store/auth.ts +++ b/packages/cli/src/cli/services/store/auth.ts @@ -1,6 +1,10 @@ import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, STORE_AUTH_CALLBACK_PATH, maskToken, storeAuthRedirectUri} from './auth-config.js' import {retryStoreAuthWithPermanentDomainError} from './auth-recovery.js' -import {setStoredStoreAppSession} from './session.js' +import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' +import type {StoredStoreAppSession} from './session.js' +import {loadStoredStoreSession} from './stored-session.js' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {randomUUID} from '@shopify/cli-kit/node/crypto' import {AbortError} from '@shopify/cli-kit/node/error' @@ -93,6 +97,106 @@ function expandImpliedStoreScopes(scopes: string[]): Set { return expandedScopes } +function mergeRequestedAndStoredScopes(requestedScopes: string[], storedScopes: string[]): string[] { + const mergedScopes = [...storedScopes] + const expandedScopes = expandImpliedStoreScopes(storedScopes) + + for (const scope of requestedScopes) { + if (expandedScopes.has(scope)) continue + + mergedScopes.push(scope) + for (const expandedScope of expandImpliedStoreScopes([scope])) { + expandedScopes.add(expandedScope) + } + } + + return mergedScopes +} + +interface StoreAccessScopesResponse { + currentAppInstallation?: { + accessScopes?: {handle?: string}[] + } +} + +interface ResolvedStoreAuthScopes { + scopes: string[] + authoritative: boolean +} + +function truncateDebugMessage(message: string, length = 300): string { + return message.slice(0, length) +} + +function formatStoreScopeLookupError(error: unknown): string { + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as {response?: {status?: number; errors?: unknown}}).response + const status = response?.status + const details = response?.errors + + if (typeof status === 'number') { + const summary = typeof details === 'string' ? details : JSON.stringify(details) + return truncateDebugMessage(summary ? `HTTP ${status}: ${summary}` : `HTTP ${status}`) + } + } + + return truncateDebugMessage(error instanceof Error ? error.message : String(error)) +} + +const CurrentAppInstallationAccessScopesQuery = `#graphql + query CurrentAppInstallationAccessScopes { + currentAppInstallation { + accessScopes { + handle + } + } + } +` + +async function fetchCurrentStoreAuthScopes(session: StoredStoreAppSession): Promise { + outputDebug( + outputContent`Fetching current app installation scopes for ${outputToken.raw(session.store)} using token ${outputToken.raw(maskToken(session.accessToken))}`, + ) + + const data = await graphqlRequest({ + query: CurrentAppInstallationAccessScopesQuery, + api: 'Admin', + url: adminUrl(session.store, 'unstable'), + token: session.accessToken, + responseOptions: {handleErrors: false}, + }) + + if (!Array.isArray(data.currentAppInstallation?.accessScopes)) { + throw new Error('Shopify did not return currentAppInstallation.accessScopes.') + } + + return data.currentAppInstallation.accessScopes.flatMap((scope) => + typeof scope.handle === 'string' ? [scope.handle] : [], + ) +} + +export async function resolveExistingStoreAuthScopes(store: string): Promise { + const normalizedStore = normalizeStoreFqdn(store) + const storedSession = getStoredStoreAppSession(normalizedStore) + if (!storedSession) return {scopes: [], authoritative: true} + + try { + const usableSession = await loadStoredStoreSession(normalizedStore) + const remoteScopes = await fetchCurrentStoreAuthScopes(usableSession) + + outputDebug( + outputContent`Resolved current remote scopes for ${outputToken.raw(normalizedStore)}: ${outputToken.raw(remoteScopes.join(',') || 'none')}`, + ) + + return {scopes: remoteScopes, authoritative: true} + } catch (error) { + outputDebug( + outputContent`Falling back to locally stored scopes for ${outputToken.raw(normalizedStore)} after remote scope lookup failed: ${outputToken.raw(formatStoreScopeLookupError(error))}`, + ) + return {scopes: storedSession.scopes, authoritative: false} + } +} + function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes: string[]): string[] { if (!tokenResponse.scope) { outputDebug(outputContent`Token response did not include scope; falling back to requested scopes`) @@ -363,6 +467,7 @@ interface StoreAuthDependencies { openURL: typeof openURL waitForStoreAuthCode: typeof waitForStoreAuthCode exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken + resolveExistingScopes?: (store: string) => Promise presenter: StoreAuthPresenter } @@ -394,12 +499,12 @@ const defaultStoreAuthDependencies: StoreAuthDependencies = { presenter: defaultStoreAuthPresenter, } -function createPkceBootstrap( - input: StoreAuthInput, - exchangeCodeForToken: typeof exchangeStoreAuthCodeForToken, -): StoreAuthBootstrap { - const store = normalizeStoreFqdn(input.store) - const scopes = parseStoreAuthScopes(input.scopes) +function createPkceBootstrap(options: { + store: string + scopes: string[] + exchangeCodeForToken: typeof exchangeStoreAuthCodeForToken +}): StoreAuthBootstrap { + const {store, scopes, exchangeCodeForToken} = options const port = DEFAULT_STORE_AUTH_PORT const state = randomUUID() const redirectUri = storeAuthRedirectUri(port) @@ -434,10 +539,20 @@ export async function authenticateStoreWithApp( input: StoreAuthInput, dependencies: StoreAuthDependencies = defaultStoreAuthDependencies, ): Promise { - const bootstrap = createPkceBootstrap(input, dependencies.exchangeStoreAuthCodeForToken) - const { - authorization: {store, scopes, redirectUri, authorizationUrl}, - } = bootstrap + const store = normalizeStoreFqdn(input.store) + const requestedScopes = parseStoreAuthScopes(input.scopes) + const existingScopeResolution = await (dependencies.resolveExistingScopes ?? resolveExistingStoreAuthScopes)(store) + const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) + const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes + + if (existingScopeResolution.scopes.length > 0) { + outputDebug( + outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`, + ) + } + + const bootstrap = createPkceBootstrap({store, scopes, exchangeCodeForToken: dependencies.exchangeStoreAuthCodeForToken}) + const {authorization: {authorizationUrl}} = bootstrap dependencies.presenter.openingBrowser() @@ -467,7 +582,7 @@ export async function authenticateStoreWithApp( // Store the raw scopes returned by Shopify. Validation may treat implied // write_* -> read_* permissions as satisfied, so callers should not assume // session.scopes is an expanded/effective permission set. - scopes: resolveGrantedScopes(tokenResponse, scopes), + scopes: resolveGrantedScopes(tokenResponse, validationScopes), acquiredAt: new Date(now).toISOString(), expiresAt, refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in diff --git a/packages/cli/src/cli/services/store/stored-session.ts b/packages/cli/src/cli/services/store/stored-session.ts new file mode 100644 index 00000000000..f41329b6b0b --- /dev/null +++ b/packages/cli/src/cli/services/store/stored-session.ts @@ -0,0 +1,104 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {fetch} from '@shopify/cli-kit/node/http' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' +import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './auth-recovery.js' +import { + clearStoredStoreAppSession, + getStoredStoreAppSession, + isSessionExpired, + setStoredStoreAppSession, +} from './session.js' +import type {StoredStoreAppSession} from './session.js' + +async function refreshStoreToken(session: StoredStoreAppSession): Promise { + if (!session.refreshToken) { + throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) + } + + const endpoint = `https://${session.store}/admin/oauth/access_token` + + outputDebug( + outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, + ) + + const response = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: session.refreshToken, + }), + }) + + const body = await response.text() + + if (!response.ok) { + outputDebug( + outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, + ) + clearStoredStoreAppSession(session.store, session.userId) + throw reauthenticateStoreAuthError( + `Token refresh failed for ${session.store} (HTTP ${response.status}).`, + session.store, + session.scopes.join(','), + ) + } + + let data: {access_token?: string; refresh_token?: string; expires_in?: number; refresh_token_expires_in?: number} + try { + data = JSON.parse(body) + } catch { + clearStoredStoreAppSession(session.store, session.userId) + throw new AbortError('Received an invalid refresh response from Shopify.') + } + + if (!data.access_token) { + clearStoredStoreAppSession(session.store, session.userId) + throw reauthenticateStoreAuthError( + `Token refresh returned an invalid response for ${session.store}.`, + session.store, + session.scopes.join(','), + ) + } + + const now = Date.now() + const expiresAt = data.expires_in ? new Date(now + data.expires_in * 1000).toISOString() : session.expiresAt + + const refreshedSession: StoredStoreAppSession = { + ...session, + accessToken: data.access_token, + refreshToken: data.refresh_token ?? session.refreshToken, + expiresAt, + refreshTokenExpiresAt: data.refresh_token_expires_in + ? new Date(now + data.refresh_token_expires_in * 1000).toISOString() + : session.refreshTokenExpiresAt, + acquiredAt: new Date(now).toISOString(), + } + + outputDebug( + outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(session.accessToken))} → ${outputToken.raw(maskToken(refreshedSession.accessToken))}, new expiry ${outputToken.raw(expiresAt ?? 'unknown')}`, + ) + + setStoredStoreAppSession(refreshedSession) + return refreshedSession +} + +export async function loadStoredStoreSession(store: string): Promise { + let session = getStoredStoreAppSession(store) + + if (!session) { + throw createStoredStoreAuthError(store) + } + + outputDebug( + outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, + ) + + if (isSessionExpired(session)) { + session = await refreshStoreToken(session) + } + + return session +} From e7e2f9148dad2f2455d354c389bb8d2776024a7e Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Fri, 3 Apr 2026 11:27:32 -0400 Subject: [PATCH 2/5] Store auth restructure --- .../cli/src/cli/commands/store/auth.test.ts | 4 +- packages/cli/src/cli/commands/store/auth.ts | 2 +- .../store/admin-graphql-context.test.ts | 30 +- .../services/store/admin-graphql-context.ts | 8 +- .../store/admin-graphql-transport.test.ts | 4 +- .../services/store/admin-graphql-transport.ts | 4 +- .../cli/src/cli/services/store/auth.test.ts | 902 ------------------ packages/cli/src/cli/services/store/auth.ts | 607 ------------ .../cli/services/store/auth/callback.test.ts | 189 ++++ .../src/cli/services/store/auth/callback.ts | 185 ++++ .../store/{auth-config.ts => auth/config.ts} | 0 .../store/auth/existing-scopes.test.ts | 164 ++++ .../services/store/auth/existing-scopes.ts | 54 ++ .../src/cli/services/store/auth/index.test.ts | 447 +++++++++ .../cli/src/cli/services/store/auth/index.ts | 134 +++ .../src/cli/services/store/auth/pkce.test.ts | 45 + .../cli/src/cli/services/store/auth/pkce.ts | 90 ++ .../{auth-recovery.ts => auth/recovery.ts} | 0 .../cli/services/store/auth/scopes.test.ts | 54 ++ .../cli/src/cli/services/store/auth/scopes.ts | 70 ++ .../store/auth/session-lifecycle.test.ts | 179 ++++ .../services/store/auth/session-lifecycle.ts | 105 ++ .../session-store.test.ts} | 95 +- .../cli/services/store/auth/session-store.ts | 192 ++++ .../services/store/auth/token-client.test.ts | 167 ++++ .../cli/services/store/auth/token-client.ts | 171 ++++ .../src/cli/services/store/execute.test.ts | 14 +- .../cli/src/cli/services/store/session.ts | 125 --- .../src/cli/services/store/stored-session.ts | 104 -- 29 files changed, 2344 insertions(+), 1801 deletions(-) delete mode 100644 packages/cli/src/cli/services/store/auth.test.ts delete mode 100644 packages/cli/src/cli/services/store/auth.ts create mode 100644 packages/cli/src/cli/services/store/auth/callback.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/callback.ts rename packages/cli/src/cli/services/store/{auth-config.ts => auth/config.ts} (100%) create mode 100644 packages/cli/src/cli/services/store/auth/existing-scopes.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/existing-scopes.ts create mode 100644 packages/cli/src/cli/services/store/auth/index.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/index.ts create mode 100644 packages/cli/src/cli/services/store/auth/pkce.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/pkce.ts rename packages/cli/src/cli/services/store/{auth-recovery.ts => auth/recovery.ts} (100%) create mode 100644 packages/cli/src/cli/services/store/auth/scopes.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/scopes.ts create mode 100644 packages/cli/src/cli/services/store/auth/session-lifecycle.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/session-lifecycle.ts rename packages/cli/src/cli/services/store/{session.test.ts => auth/session-store.test.ts} (50%) create mode 100644 packages/cli/src/cli/services/store/auth/session-store.ts create mode 100644 packages/cli/src/cli/services/store/auth/token-client.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/token-client.ts delete mode 100644 packages/cli/src/cli/services/store/session.ts delete mode 100644 packages/cli/src/cli/services/store/stored-session.ts diff --git a/packages/cli/src/cli/commands/store/auth.test.ts b/packages/cli/src/cli/commands/store/auth.test.ts index d2bc2c9a33d..21701dff48b 100644 --- a/packages/cli/src/cli/commands/store/auth.test.ts +++ b/packages/cli/src/cli/commands/store/auth.test.ts @@ -1,8 +1,8 @@ import {describe, test, expect, vi, beforeEach} from 'vitest' import StoreAuth from './auth.js' -import {authenticateStoreWithApp} from '../../services/store/auth.js' +import {authenticateStoreWithApp} from '../../services/store/auth/index.js' -vi.mock('../../services/store/auth.js') +vi.mock('../../services/store/auth/index.js') describe('store auth command', () => { beforeEach(() => { diff --git a/packages/cli/src/cli/commands/store/auth.ts b/packages/cli/src/cli/commands/store/auth.ts index 1fe48e3f3dc..6b46ece1257 100644 --- a/packages/cli/src/cli/commands/store/auth.ts +++ b/packages/cli/src/cli/commands/store/auth.ts @@ -2,7 +2,7 @@ import Command from '@shopify/cli-kit/node/base-command' import {globalFlags} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' -import {authenticateStoreWithApp} from '../../services/store/auth.js' +import {authenticateStoreWithApp} from '../../services/store/auth/index.js' export default class StoreAuth extends Command { static summary = 'Authenticate an app against a store for store commands.' diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts index 137c3f2761f..83371130436 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts @@ -4,14 +4,13 @@ import {AbortError} from '@shopify/cli-kit/node/error' import {fetch} from '@shopify/cli-kit/node/http' import { clearStoredStoreAppSession, - getStoredStoreAppSession, - isSessionExpired, + getCurrentStoredStoreAppSession, setStoredStoreAppSession, -} from './session.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' +} from './auth/session-store.js' +import {STORE_AUTH_APP_CLIENT_ID} from './auth/config.js' import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' -vi.mock('./session.js') +vi.mock('./auth/session-store.js') vi.mock('@shopify/cli-kit/node/http') vi.mock('@shopify/cli-kit/node/api/admin', async () => { const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') @@ -23,6 +22,9 @@ vi.mock('@shopify/cli-kit/node/api/admin', async () => { describe('prepareAdminStoreGraphQLContext', () => { const store = 'shop.myshopify.com' + const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString() + const expiredAt = new Date(Date.now() - 60 * 1000).toISOString() + const storedSession = { store, clientId: STORE_AUTH_APP_CLIENT_ID, @@ -31,13 +33,12 @@ describe('prepareAdminStoreGraphQLContext', () => { refreshToken: 'refresh-token', scopes: ['read_products'], acquiredAt: '2026-03-27T00:00:00.000Z', - expiresAt: '2026-03-27T01:00:00.000Z', + expiresAt: futureExpiry, } beforeEach(() => { vi.clearAllMocks() - vi.mocked(getStoredStoreAppSession).mockReturnValue(storedSession) - vi.mocked(isSessionExpired).mockReturnValue(false) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedSession) vi.mocked(fetchApiVersions).mockResolvedValue([ {handle: '2025-10', supported: true}, {handle: '2025-07', supported: true}, @@ -59,7 +60,7 @@ describe('prepareAdminStoreGraphQLContext', () => { }) test('refreshes expired sessions before resolving the API version', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) vi.mocked(fetch).mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue( @@ -98,7 +99,7 @@ describe('prepareAdminStoreGraphQLContext', () => { }) test('throws when no stored auth exists', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ message: `No stored app authentication found for ${store}.`, @@ -108,7 +109,7 @@ describe('prepareAdminStoreGraphQLContext', () => { }) test('clears stored auth when token refresh fails', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) vi.mocked(fetch).mockResolvedValue({ ok: false, status: 401, @@ -124,8 +125,7 @@ describe('prepareAdminStoreGraphQLContext', () => { }) test('throws when an expired session cannot be refreshed because no refresh token is stored', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) - vi.mocked(getStoredStoreAppSession).mockReturnValue({...storedSession, refreshToken: undefined}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, refreshToken: undefined, expiresAt: expiredAt}) await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ message: `No refresh token stored for ${store}.`, @@ -136,7 +136,7 @@ describe('prepareAdminStoreGraphQLContext', () => { }) test('clears only the current stored auth when token refresh returns an invalid response body', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) vi.mocked(fetch).mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})), @@ -151,7 +151,7 @@ describe('prepareAdminStoreGraphQLContext', () => { }) test('clears only the current stored auth when token refresh returns malformed JSON', async () => { - vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) vi.mocked(fetch).mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue('not-json'), diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.ts b/packages/cli/src/cli/services/store/admin-graphql-context.ts index a0c5f6a6fe6..e0a493fbc09 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-context.ts @@ -2,10 +2,10 @@ import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {AdminSession} from '@shopify/cli-kit/node/session' -import {reauthenticateStoreAuthError} from './auth-recovery.js' -import {clearStoredStoreAppSession} from './session.js' -import type {StoredStoreAppSession} from './session.js' -import {loadStoredStoreSession} from './stored-session.js' +import {reauthenticateStoreAuthError} from './auth/recovery.js' +import {clearStoredStoreAppSession} from './auth/session-store.js' +import type {StoredStoreAppSession} from './auth/session-store.js' +import {loadStoredStoreSession} from './auth/session-lifecycle.js' export interface AdminStoreGraphQLContext { adminSession: AdminSession diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts b/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts index a0d03fc1051..3a994321fce 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts @@ -2,11 +2,11 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {clearStoredStoreAppSession} from './session.js' +import {clearStoredStoreAppSession} from './auth/session-store.js' import {prepareStoreExecuteRequest} from './execute-request.js' import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' -vi.mock('./session.js') +vi.mock('./auth/session-store.js') vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/api/admin', async () => { diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.ts b/packages/cli/src/cli/services/store/admin-graphql-transport.ts index 1a104ee160e..6c1a3f8a5db 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-transport.ts @@ -4,9 +4,9 @@ import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent} from '@shopify/cli-kit/node/output' import {AdminSession} from '@shopify/cli-kit/node/session' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {reauthenticateStoreAuthError} from './auth-recovery.js' +import {reauthenticateStoreAuthError} from './auth/recovery.js' import {PreparedStoreExecuteRequest} from './execute-request.js' -import {clearStoredStoreAppSession} from './session.js' +import {clearStoredStoreAppSession} from './auth/session-store.js' function isGraphQLClientError(error: unknown): error is {response: {errors?: unknown; status?: number}} { if (!error || typeof error !== 'object' || !('response' in error)) return false diff --git a/packages/cli/src/cli/services/store/auth.test.ts b/packages/cli/src/cli/services/store/auth.test.ts deleted file mode 100644 index 7dba9f6d9ca..00000000000 --- a/packages/cli/src/cli/services/store/auth.test.ts +++ /dev/null @@ -1,902 +0,0 @@ -import {createServer} from 'http' -import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' -import { - authenticateStoreWithApp, - buildStoreAuthUrl, - parseStoreAuthScopes, - generateCodeVerifier, - computeCodeChallenge, - exchangeStoreAuthCodeForToken, - resolveExistingStoreAuthScopes, - waitForStoreAuthCode, -} from './auth.js' -import {loadStoredStoreSession} from './stored-session.js' -import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' -import {adminUrl} from '@shopify/cli-kit/node/api/admin' -import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' -import {fetch} from '@shopify/cli-kit/node/http' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' - -vi.mock('./session.js') -vi.mock('./stored-session.js', () => ({loadStoredStoreSession: vi.fn()})) -vi.mock('@shopify/cli-kit/node/http') -vi.mock('@shopify/cli-kit/node/api/graphql') -vi.mock('@shopify/cli-kit/node/api/admin', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') - return { - ...actual, - adminUrl: vi.fn(), - } -}) -vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) -vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) - -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = createServer() - - server.on('error', reject) - server.listen(0, '127.0.0.1', () => { - const address = server.address() - if (!address || typeof address === 'string') { - reject(new Error('Expected an ephemeral port.')) - return - } - - server.close((error) => { - if (error) { - reject(error) - return - } - - resolve(address.port) - }) - }) - }) -} - -function callbackParams(options?: { - code?: string - shop?: string - state?: string - error?: string -}): URLSearchParams { - const params = new URLSearchParams() - params.set('shop', options?.shop ?? 'shop.myshopify.com') - params.set('state', options?.state ?? 'state-123') - - if (options?.code) params.set('code', options.code) - if (options?.error) params.set('error', options.error) - if (!options?.code && !options?.error) params.set('code', 'abc123') - - return params -} - -describe('store auth service', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json') - }) - - afterEach(() => { - vi.restoreAllMocks() - mockAndCaptureOutput().clear() - }) - - test('generateCodeVerifier produces a base64url string of 43 chars', () => { - const verifier = generateCodeVerifier() - expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/) - }) - - test('generateCodeVerifier produces unique values', () => { - const a = generateCodeVerifier() - const b = generateCodeVerifier() - expect(a).not.toBe(b) - }) - - test('computeCodeChallenge produces a deterministic S256 hash', () => { - const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' - const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' - expect(computeCodeChallenge(verifier)).toBe(expected) - }) - - test('parseStoreAuthScopes splits and deduplicates scopes', () => { - expect(parseStoreAuthScopes('read_products, write_products,read_products')).toEqual([ - 'read_products', - 'write_products', - ]) - }) - - test('buildStoreAuthUrl includes PKCE params and response_type=code', () => { - const url = new URL( - buildStoreAuthUrl({ - store: 'shop.myshopify.com', - scopes: ['read_products', 'write_products'], - state: 'state-123', - redirectUri: 'http://127.0.0.1:13387/auth/callback', - codeChallenge: 'test-challenge-value', - }), - ) - - expect(url.hostname).toBe('shop.myshopify.com') - expect(url.pathname).toBe('/admin/oauth/authorize') - expect(url.searchParams.get('client_id')).toBe(STORE_AUTH_APP_CLIENT_ID) - expect(url.searchParams.get('scope')).toBe('read_products,write_products') - expect(url.searchParams.get('state')).toBe('state-123') - expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:13387/auth/callback') - expect(url.searchParams.get('response_type')).toBe('code') - expect(url.searchParams.get('code_challenge')).toBe('test-challenge-value') - expect(url.searchParams.get('code_challenge_method')).toBe('S256') - expect(url.searchParams.get('grant_options[]')).toBeNull() - }) - - test('resolveExistingStoreAuthScopes returns no scopes when no stored auth exists', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) - - await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({scopes: [], authoritative: true}) - expect(loadStoredStoreSession).not.toHaveBeenCalled() - expect(graphqlRequest).not.toHaveBeenCalled() - }) - - test('resolveExistingStoreAuthScopes prefers current remote scopes over stale local scopes', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - }) - vi.mocked(loadStoredStoreSession).mockResolvedValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'fresh-token', - refreshToken: 'fresh-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - } as any) - vi.mocked(graphqlRequest).mockResolvedValue({ - currentAppInstallation: {accessScopes: [{handle: 'read_products'}, {handle: 'read_customers'}]}, - } as any) - - await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ - scopes: ['read_products', 'read_customers'], - authoritative: true, - }) - expect(adminUrl).toHaveBeenCalledWith('shop.myshopify.com', 'unstable') - expect(graphqlRequest).toHaveBeenCalledWith({ - query: expect.stringContaining('currentAppInstallation'), - api: 'Admin', - url: 'https://shop.myshopify.com/admin/api/unstable/graphql.json', - token: 'fresh-token', - responseOptions: {handleErrors: false}, - }) - }) - - test('resolveExistingStoreAuthScopes falls back to locally stored scopes when remote lookup fails', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - }) - vi.mocked(loadStoredStoreSession).mockRejectedValue(new Error('boom')) - - await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ - scopes: ['read_orders'], - authoritative: false, - }) - }) - - test('resolveExistingStoreAuthScopes falls back to locally stored scopes when access scopes request fails', async () => { - const output = mockAndCaptureOutput() - - vi.mocked(getStoredStoreAppSession).mockReturnValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - }) - vi.mocked(loadStoredStoreSession).mockResolvedValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'fresh-token', - refreshToken: 'fresh-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - } as any) - const scopeLookupError = new Error('GraphQL Error (Code: 401)') - Object.assign(scopeLookupError, { - response: { - status: 401, - errors: '[API] Invalid API key or access token (unrecognized login or wrong password)', - }, - request: { - query: '#graphql query CurrentAppInstallationAccessScopes { currentAppInstallation { accessScopes { handle } } }', - }, - }) - vi.mocked(graphqlRequest).mockRejectedValue(scopeLookupError) - - await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ - scopes: ['read_orders'], - authoritative: false, - }) - expect(output.debug()).toContain('after remote scope lookup failed: HTTP 401: [API] Invalid API key or access token') - expect(output.debug()).not.toContain('CurrentAppInstallationAccessScopes') - }) - - test('resolveExistingStoreAuthScopes falls back to locally stored scopes when access scopes response is invalid', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - }) - vi.mocked(loadStoredStoreSession).mockResolvedValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'fresh-token', - refreshToken: 'fresh-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - } as any) - vi.mocked(graphqlRequest).mockResolvedValue({ - currentAppInstallation: undefined, - } as any) - - await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ - scopes: ['read_orders'], - authoritative: false, - }) - }) - - test('waitForStoreAuthCode resolves after a valid callback', async () => { - const port = await getAvailablePort() - const params = callbackParams() - const onListening = vi.fn(async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(200) - await response.text() - }) - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening, - }), - ).resolves.toBe('abc123') - - expect(onListening).toHaveBeenCalledOnce() - }) - - test('waitForStoreAuthCode rejects when callback state does not match', async () => { - const port = await getAvailablePort() - const params = callbackParams({state: 'wrong-state'}) - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening: async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(400) - await response.text() - }, - }), - ).rejects.toThrow('OAuth callback state does not match the original request.') - }) - - test('waitForStoreAuthCode rejects when callback store does not match and suggests the returned permanent domain', async () => { - const port = await getAvailablePort() - const params = callbackParams({shop: 'other-shop.myshopify.com'}) - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening: async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(400) - await response.text() - }, - }), - ).rejects.toMatchObject({ - message: 'OAuth callback store does not match the requested store.', - tryMessage: 'Shopify returned other-shop.myshopify.com during authentication. Re-run using the permanent store domain:', - nextSteps: [[{command: 'shopify store auth --store other-shop.myshopify.com --scopes '}]], - }) - }) - - test('waitForStoreAuthCode rejects when Shopify returns an OAuth error', async () => { - const port = await getAvailablePort() - const params = callbackParams({error: 'access_denied'}) - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening: async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(400) - await response.text() - }, - }), - ).rejects.toThrow('Shopify returned an OAuth error: access_denied') - }) - - test('waitForStoreAuthCode rejects when callback does not include an authorization code', async () => { - const port = await getAvailablePort() - const params = callbackParams() - params.delete('code') - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - onListening: async () => { - const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) - expect(response.status).toBe(400) - await response.text() - }, - }), - ).rejects.toThrow('OAuth callback did not include an authorization code.') - }) - - test('waitForStoreAuthCode rejects when the port is already in use', async () => { - const port = await getAvailablePort() - const server = createServer() - await new Promise((resolve, reject) => { - server.on('error', reject) - server.listen(port, '127.0.0.1', () => resolve()) - }) - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 1000, - }), - ).rejects.toThrow(`Port ${port} is already in use.`) - - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error) - return - } - - resolve() - }) - }) - }) - - test('waitForStoreAuthCode rejects on timeout', async () => { - const port = await getAvailablePort() - - await expect( - waitForStoreAuthCode({ - store: 'shop.myshopify.com', - state: 'state-123', - port, - timeoutMs: 25, - }), - ).rejects.toThrow('Timed out waiting for OAuth callback.') - }) - - test('exchangeStoreAuthCodeForToken sends PKCE params and returns token response', async () => { - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue( - JSON.stringify({ - access_token: 'token', - scope: 'read_products', - expires_in: 86400, - refresh_token: 'refresh-token', - associated_user: {id: 42, email: 'test@example.com'}, - }), - ), - } as any) - - const response = await exchangeStoreAuthCodeForToken({ - store: 'shop.myshopify.com', - code: 'abc123', - codeVerifier: 'test-verifier', - redirectUri: 'http://127.0.0.1:13387/auth/callback', - }) - - expect(response.access_token).toBe('token') - expect(response.refresh_token).toBe('refresh-token') - expect(response.expires_in).toBe(86400) - - expect(fetch).toHaveBeenCalledWith( - 'https://shop.myshopify.com/admin/oauth/access_token', - expect.objectContaining({ - method: 'POST', - body: expect.stringContaining('"code_verifier":"test-verifier"'), - }), - ) - - const sentBody = JSON.parse((fetch as any).mock.calls[0][1].body) - expect(sentBody.client_id).toBe(STORE_AUTH_APP_CLIENT_ID) - expect(sentBody.code).toBe('abc123') - expect(sentBody.code_verifier).toBe('test-verifier') - expect(sentBody.redirect_uri).toBe('http://127.0.0.1:13387/auth/callback') - expect(sentBody.client_secret).toBeUndefined() - }) - - test('authenticateStoreWithApp opens the browser and stores the session with refresh token', async () => { - const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'read_products', - expires_in: 86400, - refresh_token: 'refresh-token', - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter, - }, - ) - - expect(presenter.openingBrowser).toHaveBeenCalledOnce() - expect(openURL).toHaveBeenCalledWith(expect.stringContaining('/admin/oauth/authorize?')) - expect(presenter.manualAuthUrl).not.toHaveBeenCalled() - expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') - - const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] - expect(storedSession.store).toBe('shop.myshopify.com') - expect(storedSession.clientId).toBe(STORE_AUTH_APP_CLIENT_ID) - expect(storedSession.userId).toBe('42') - expect(storedSession.accessToken).toBe('token') - expect(storedSession.refreshToken).toBe('refresh-token') - expect(storedSession.scopes).toEqual(['read_products']) - expect(storedSession.expiresAt).toBeDefined() - expect(storedSession.associatedUser).toEqual({ - id: 42, - email: 'test@example.com', - firstName: undefined, - lastName: undefined, - accountOwner: undefined, - }) - }) - - test('authenticateStoreWithApp uses remote scopes by default when available', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'existing-token', - refreshToken: 'existing-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - }) - vi.mocked(loadStoredStoreSession).mockResolvedValue({ - store: 'shop.myshopify.com', - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'fresh-token', - refreshToken: 'fresh-refresh-token', - scopes: ['read_orders'], - acquiredAt: '2026-04-02T00:00:00.000Z', - } as any) - vi.mocked(graphqlRequest).mockResolvedValue({ - currentAppInstallation: {accessScopes: [{handle: 'read_customers'}]}, - } as any) - - const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'read_customers,read_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter, - }, - ) - - const authorizationUrl = new URL(openURL.mock.calls[0]![0]) - expect(authorizationUrl.searchParams.get('scope')).toBe('read_customers,read_products') - }) - - test('authenticateStoreWithApp reuses resolved existing scopes when requesting additional access', async () => { - const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'read_orders,read_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: true}), - presenter, - }, - ) - - const authorizationUrl = new URL(openURL.mock.calls[0]![0]) - expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - scopes: ['read_orders', 'read_products'], - }), - ) - }) - - test('authenticateStoreWithApp does not require non-authoritative cached scopes to still be granted', async () => { - const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'read_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: false}), - presenter, - }, - ) - - const authorizationUrl = new URL(openURL.mock.calls[0]![0]) - expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - scopes: ['read_products'], - }), - ) - }) - - test('authenticateStoreWithApp avoids requesting redundant read scopes already implied by existing write scopes', async () => { - const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'write_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['write_products'], authoritative: true}), - presenter, - }, - ) - - const authorizationUrl = new URL(openURL.mock.calls[0]![0]) - expect(authorizationUrl.searchParams.get('scope')).toBe('write_products') - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - scopes: ['write_products'], - }), - ) - }) - - test('authenticateStoreWithApp shows a manual auth URL when the browser does not open automatically', async () => { - const openURL = vi.fn().mockResolvedValue(false) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'read_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter, - }, - ) - - expect(presenter.openingBrowser).toHaveBeenCalledOnce() - expect(presenter.manualAuthUrl).toHaveBeenCalledWith( - expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'), - ) - expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') - }) - - test('authenticateStoreWithApp rejects when Shopify grants fewer scopes than requested', async () => { - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await expect( - authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products,write_products', - }, - { - openURL: vi.fn().mockResolvedValue(true), - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'read_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, - }, - ), - ).rejects.toMatchObject({ - message: 'Shopify granted fewer scopes than were requested.', - tryMessage: 'Missing scopes: write_products.', - nextSteps: [ - 'Update the app or store installation scopes.', - 'See https://shopify.dev/app/scopes', - 'Re-run shopify store auth.', - ], - }) - - expect(setStoredStoreAppSession).not.toHaveBeenCalled() - }) - - test('authenticateStoreWithApp accepts compressed write scopes that imply requested read scopes', async () => { - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products,write_products', - }, - { - openURL: vi.fn().mockResolvedValue(true), - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'write_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, - }, - ) - - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - scopes: ['write_products'], - }), - ) - }) - - test('authenticateStoreWithApp still rejects when other requested scopes are missing', async () => { - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await expect( - authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products,write_products,read_orders', - }, - { - openURL: vi.fn().mockResolvedValue(true), - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'write_products', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, - }, - ), - ).rejects.toThrow('Shopify granted fewer scopes than were requested.') - - expect(setStoredStoreAppSession).not.toHaveBeenCalled() - }) - - test('authenticateStoreWithApp falls back to requested scopes when Shopify omits granted scopes', async () => { - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - }, - { - openURL: vi.fn().mockResolvedValue(true), - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, - }, - ) - - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - scopes: ['read_products'], - }), - ) - }) - - test('authenticateStoreWithApp accepts compressed unauthenticated write scopes that imply requested unauthenticated read scopes', async () => { - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'unauthenticated_read_product_listings,unauthenticated_write_product_listings', - }, - { - openURL: vi.fn().mockResolvedValue(true), - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ - access_token: 'token', - scope: 'unauthenticated_write_product_listings', - expires_in: 86400, - associated_user: {id: 42, email: 'test@example.com'}, - }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, - }, - ) - - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store: 'shop.myshopify.com', - scopes: ['unauthenticated_write_product_listings'], - }), - ) - }) -}) diff --git a/packages/cli/src/cli/services/store/auth.ts b/packages/cli/src/cli/services/store/auth.ts deleted file mode 100644 index 27a6eeda04f..00000000000 --- a/packages/cli/src/cli/services/store/auth.ts +++ /dev/null @@ -1,607 +0,0 @@ -import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, STORE_AUTH_CALLBACK_PATH, maskToken, storeAuthRedirectUri} from './auth-config.js' -import {retryStoreAuthWithPermanentDomainError} from './auth-recovery.js' -import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' -import type {StoredStoreAppSession} from './session.js' -import {loadStoredStoreSession} from './stored-session.js' -import {adminUrl} from '@shopify/cli-kit/node/api/admin' -import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' -import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {randomUUID} from '@shopify/cli-kit/node/crypto' -import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' -import {outputCompleted, outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' -import {openURL} from '@shopify/cli-kit/node/system' -import {createHash, randomBytes, timingSafeEqual} from 'crypto' -import {createServer} from 'http' - -interface StoreAuthInput { - store: string - scopes: string -} - -interface StoreTokenResponse { - access_token: string - token_type?: string - scope?: string - expires_in?: number - refresh_token?: string - refresh_token_expires_in?: number - associated_user_scope?: string - associated_user?: { - id: number - first_name?: string - last_name?: string - email?: string - account_owner?: boolean - locale?: string - collaborator?: boolean - email_verified?: boolean - } -} - -interface StoreAuthorizationContext { - store: string - scopes: string[] - state: string - port: number - redirectUri: string - authorizationUrl: string - codeVerifier: string -} - -interface StoreAuthBootstrap { - authorization: StoreAuthorizationContext - waitForAuthCodeOptions: WaitForAuthCodeOptions - exchangeCodeForToken: (code: string) => Promise -} - -interface WaitForAuthCodeOptions { - store: string - state: string - port: number - timeoutMs?: number - onListening?: () => void | Promise -} - -export function generateCodeVerifier(): string { - return randomBytes(32).toString('base64url') -} - -export function computeCodeChallenge(verifier: string): string { - return createHash('sha256').update(verifier).digest('base64url') -} - -export function parseStoreAuthScopes(input: string): string[] { - const scopes = input - .split(',') - .map((scope) => scope.trim()) - .filter(Boolean) - - if (scopes.length === 0) { - throw new AbortError('At least one scope is required.', 'Pass --scopes as a comma-separated list.') - } - - return [...new Set(scopes)] -} - -function expandImpliedStoreScopes(scopes: string[]): Set { - const expandedScopes = new Set(scopes) - - for (const scope of scopes) { - const matches = scope.match(/^(unauthenticated_)?write_(.*)$/) - if (matches) { - expandedScopes.add(`${matches[1] ?? ''}read_${matches[2]}`) - } - } - - return expandedScopes -} - -function mergeRequestedAndStoredScopes(requestedScopes: string[], storedScopes: string[]): string[] { - const mergedScopes = [...storedScopes] - const expandedScopes = expandImpliedStoreScopes(storedScopes) - - for (const scope of requestedScopes) { - if (expandedScopes.has(scope)) continue - - mergedScopes.push(scope) - for (const expandedScope of expandImpliedStoreScopes([scope])) { - expandedScopes.add(expandedScope) - } - } - - return mergedScopes -} - -interface StoreAccessScopesResponse { - currentAppInstallation?: { - accessScopes?: {handle?: string}[] - } -} - -interface ResolvedStoreAuthScopes { - scopes: string[] - authoritative: boolean -} - -function truncateDebugMessage(message: string, length = 300): string { - return message.slice(0, length) -} - -function formatStoreScopeLookupError(error: unknown): string { - if (error && typeof error === 'object' && 'response' in error) { - const response = (error as {response?: {status?: number; errors?: unknown}}).response - const status = response?.status - const details = response?.errors - - if (typeof status === 'number') { - const summary = typeof details === 'string' ? details : JSON.stringify(details) - return truncateDebugMessage(summary ? `HTTP ${status}: ${summary}` : `HTTP ${status}`) - } - } - - return truncateDebugMessage(error instanceof Error ? error.message : String(error)) -} - -const CurrentAppInstallationAccessScopesQuery = `#graphql - query CurrentAppInstallationAccessScopes { - currentAppInstallation { - accessScopes { - handle - } - } - } -` - -async function fetchCurrentStoreAuthScopes(session: StoredStoreAppSession): Promise { - outputDebug( - outputContent`Fetching current app installation scopes for ${outputToken.raw(session.store)} using token ${outputToken.raw(maskToken(session.accessToken))}`, - ) - - const data = await graphqlRequest({ - query: CurrentAppInstallationAccessScopesQuery, - api: 'Admin', - url: adminUrl(session.store, 'unstable'), - token: session.accessToken, - responseOptions: {handleErrors: false}, - }) - - if (!Array.isArray(data.currentAppInstallation?.accessScopes)) { - throw new Error('Shopify did not return currentAppInstallation.accessScopes.') - } - - return data.currentAppInstallation.accessScopes.flatMap((scope) => - typeof scope.handle === 'string' ? [scope.handle] : [], - ) -} - -export async function resolveExistingStoreAuthScopes(store: string): Promise { - const normalizedStore = normalizeStoreFqdn(store) - const storedSession = getStoredStoreAppSession(normalizedStore) - if (!storedSession) return {scopes: [], authoritative: true} - - try { - const usableSession = await loadStoredStoreSession(normalizedStore) - const remoteScopes = await fetchCurrentStoreAuthScopes(usableSession) - - outputDebug( - outputContent`Resolved current remote scopes for ${outputToken.raw(normalizedStore)}: ${outputToken.raw(remoteScopes.join(',') || 'none')}`, - ) - - return {scopes: remoteScopes, authoritative: true} - } catch (error) { - outputDebug( - outputContent`Falling back to locally stored scopes for ${outputToken.raw(normalizedStore)} after remote scope lookup failed: ${outputToken.raw(formatStoreScopeLookupError(error))}`, - ) - return {scopes: storedSession.scopes, authoritative: false} - } -} - -function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes: string[]): string[] { - if (!tokenResponse.scope) { - outputDebug(outputContent`Token response did not include scope; falling back to requested scopes`) - return requestedScopes - } - - const grantedScopes = parseStoreAuthScopes(tokenResponse.scope) - const expandedGrantedScopes = expandImpliedStoreScopes(grantedScopes) - const missingScopes = requestedScopes.filter((scope) => !expandedGrantedScopes.has(scope)) - - if (missingScopes.length > 0) { - throw new AbortError( - 'Shopify granted fewer scopes than were requested.', - `Missing scopes: ${missingScopes.join(', ')}.`, - [ - 'Update the app or store installation scopes.', - 'See https://shopify.dev/app/scopes', - 'Re-run shopify store auth.', - ], - ) - } - - return grantedScopes -} - -export function buildStoreAuthUrl(options: { - store: string - scopes: string[] - state: string - redirectUri: string - codeChallenge: string -}): string { - const params = new URLSearchParams() - params.set('client_id', STORE_AUTH_APP_CLIENT_ID) - params.set('scope', options.scopes.join(',')) - params.set('redirect_uri', options.redirectUri) - params.set('state', options.state) - params.set('response_type', 'code') - params.set('code_challenge', options.codeChallenge) - params.set('code_challenge_method', 'S256') - - return `https://${options.store}/admin/oauth/authorize?${params.toString()}` -} - -function renderAuthCallbackPage(title: string, message: string): string { - const safeTitle = title - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - const safeMessage = message - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - - return ` - - - - - ${safeTitle} - - -
-
-

${safeTitle}

-

${safeMessage}

-
-
- -` -} - -export async function waitForStoreAuthCode({ - store, - state, - port, - timeoutMs = 5 * 60 * 1000, - onListening, -}: WaitForAuthCodeOptions): Promise { - const normalizedStore = normalizeStoreFqdn(store) - - return new Promise((resolve, reject) => { - let settled = false - let isListening = false - - const timeout = setTimeout(() => { - settleWithError(new AbortError('Timed out waiting for OAuth callback.')) - }, timeoutMs) - - const server = createServer((req, res) => { - const requestUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`) - - if (requestUrl.pathname !== STORE_AUTH_CALLBACK_PATH) { - res.statusCode = 404 - res.end('Not found') - return - } - - const {searchParams} = requestUrl - - const fail = (error: AbortError | string, tryMessage?: string) => { - const abortError = typeof error === 'string' ? new AbortError(error, tryMessage) : error - - res.statusCode = 400 - res.setHeader('Content-Type', 'text/html') - res.setHeader('Connection', 'close') - res.once('finish', () => settleWithError(abortError)) - res.end(renderAuthCallbackPage('Authentication failed', abortError.message)) - } - - const returnedStore = searchParams.get('shop') - outputDebug(outputContent`Received OAuth callback for shop ${outputToken.raw(returnedStore ?? 'unknown')}`) - - if (!returnedStore) { - fail('OAuth callback store does not match the requested store.') - return - } - - const normalizedReturnedStore = normalizeStoreFqdn(returnedStore) - if (normalizedReturnedStore !== normalizedStore) { - fail(retryStoreAuthWithPermanentDomainError(normalizedReturnedStore)) - return - } - - const returnedState = searchParams.get('state') - if (!returnedState || !constantTimeEqual(returnedState, state)) { - fail('OAuth callback state does not match the original request.') - return - } - - const error = searchParams.get('error') - if (error) { - fail(`Shopify returned an OAuth error: ${error}`) - return - } - - const code = searchParams.get('code') - if (!code) { - fail('OAuth callback did not include an authorization code.') - return - } - - outputDebug(outputContent`Received authorization code ${outputToken.raw(maskToken(code))}`) - - res.statusCode = 200 - res.setHeader('Content-Type', 'text/html') - res.setHeader('Connection', 'close') - res.once('finish', () => settle(() => resolve(code))) - res.end(renderAuthCallbackPage('Authentication succeeded', 'You can close this window and return to the terminal.')) - }) - - const settle = (callback: () => void) => { - if (settled) return - settled = true - clearTimeout(timeout) - - const finalize = () => { - callback() - } - - if (!isListening) { - finalize() - return - } - - server.close(() => { - isListening = false - finalize() - }) - server.closeIdleConnections?.() - } - - const settleWithError = (error: Error) => { - settle(() => reject(error)) - } - - server.on('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'EADDRINUSE') { - settleWithError( - new AbortError( - `Port ${port} is already in use.`, - `Free port ${port} and re-run ${outputToken.genericShellCommand(`shopify store auth --store ${store} --scopes `).value}. Ensure that redirect URI is allowed in the app configuration.`, - ), - ) - return - } - - settleWithError(error) - }) - - server.listen(port, '127.0.0.1', async () => { - isListening = true - outputDebug( - outputContent`PKCE callback server listening on http://127.0.0.1:${outputToken.raw(String(port))}${outputToken.raw(STORE_AUTH_CALLBACK_PATH)}`, - ) - - if (!onListening) return - - try { - await onListening() - } catch (error) { - settleWithError(error instanceof Error ? error : new Error(String(error))) - } - }) - }) -} - -function constantTimeEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false - return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')) -} - -export async function exchangeStoreAuthCodeForToken(options: { - store: string - code: string - codeVerifier: string - redirectUri: string -}): Promise { - const endpoint = `https://${options.store}/admin/oauth/access_token` - - outputDebug(outputContent`Exchanging authorization code for token at ${outputToken.raw(endpoint)}`) - - const response = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - client_id: STORE_AUTH_APP_CLIENT_ID, - code: options.code, - code_verifier: options.codeVerifier, - redirect_uri: options.redirectUri, - }), - }) - - const body = await response.text() - if (!response.ok) { - outputDebug( - outputContent`Token exchange failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, - ) - throw new AbortError( - `Failed to exchange OAuth code for an access token (HTTP ${response.status}).`, - body || response.statusText, - ) - } - - let parsed: StoreTokenResponse - try { - parsed = JSON.parse(body) as StoreTokenResponse - } catch { - throw new AbortError('Received an invalid token response from Shopify.') - } - - outputDebug( - outputContent`Token exchange succeeded: access_token=${outputToken.raw(maskToken(parsed.access_token))}, refresh_token=${outputToken.raw(parsed.refresh_token ? maskToken(parsed.refresh_token) : 'none')}, expires_in=${outputToken.raw(String(parsed.expires_in ?? 'unknown'))}s, user=${outputToken.raw(String(parsed.associated_user?.id ?? 'unknown'))} (${outputToken.raw(parsed.associated_user?.email ?? 'no email')})`, - ) - - return parsed -} - -interface StoreAuthPresenter { - openingBrowser: () => void - manualAuthUrl: (authorizationUrl: string) => void - success: (store: string, email?: string) => void -} - -interface StoreAuthDependencies { - openURL: typeof openURL - waitForStoreAuthCode: typeof waitForStoreAuthCode - exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken - resolveExistingScopes?: (store: string) => Promise - presenter: StoreAuthPresenter -} - -const defaultStoreAuthPresenter: StoreAuthPresenter = { - openingBrowser() { - outputInfo('Shopify CLI will open the app authorization page in your browser.') - outputInfo('') - }, - manualAuthUrl(authorizationUrl: string) { - outputInfo('Browser did not open automatically. Open this URL manually:') - outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) - outputInfo('') - }, - success(store: string, email?: string) { - const displayName = email ? ` as ${email}` : '' - - outputCompleted('Logged in.') - outputCompleted(`Authenticated${displayName} against ${store}.`) - outputInfo('') - outputInfo('To verify that authentication worked, run:') - outputInfo(`shopify store execute --store ${store} --query 'query { shop { name id } }'`) - }, -} - -const defaultStoreAuthDependencies: StoreAuthDependencies = { - openURL, - waitForStoreAuthCode, - exchangeStoreAuthCodeForToken, - presenter: defaultStoreAuthPresenter, -} - -function createPkceBootstrap(options: { - store: string - scopes: string[] - exchangeCodeForToken: typeof exchangeStoreAuthCodeForToken -}): StoreAuthBootstrap { - const {store, scopes, exchangeCodeForToken} = options - const port = DEFAULT_STORE_AUTH_PORT - const state = randomUUID() - const redirectUri = storeAuthRedirectUri(port) - const codeVerifier = generateCodeVerifier() - const codeChallenge = computeCodeChallenge(codeVerifier) - const authorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge}) - - outputDebug( - outputContent`Starting PKCE auth for ${outputToken.raw(store)} with scopes ${outputToken.raw(scopes.join(','))} (redirect_uri=${outputToken.raw(redirectUri)})`, - ) - - return { - authorization: { - store, - scopes, - state, - port, - redirectUri, - authorizationUrl, - codeVerifier, - }, - waitForAuthCodeOptions: { - store, - state, - port, - }, - exchangeCodeForToken: (code: string) => exchangeCodeForToken({store, code, codeVerifier, redirectUri}), - } -} - -export async function authenticateStoreWithApp( - input: StoreAuthInput, - dependencies: StoreAuthDependencies = defaultStoreAuthDependencies, -): Promise { - const store = normalizeStoreFqdn(input.store) - const requestedScopes = parseStoreAuthScopes(input.scopes) - const existingScopeResolution = await (dependencies.resolveExistingScopes ?? resolveExistingStoreAuthScopes)(store) - const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) - const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes - - if (existingScopeResolution.scopes.length > 0) { - outputDebug( - outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`, - ) - } - - const bootstrap = createPkceBootstrap({store, scopes, exchangeCodeForToken: dependencies.exchangeStoreAuthCodeForToken}) - const {authorization: {authorizationUrl}} = bootstrap - - dependencies.presenter.openingBrowser() - - const code = await dependencies.waitForStoreAuthCode({ - ...bootstrap.waitForAuthCodeOptions, - onListening: async () => { - const opened = await dependencies.openURL(authorizationUrl) - if (!opened) dependencies.presenter.manualAuthUrl(authorizationUrl) - }, - }) - const tokenResponse = await bootstrap.exchangeCodeForToken(code) - - const userId = tokenResponse.associated_user?.id?.toString() - if (!userId) { - throw new AbortError('Shopify did not return associated user information for the online access token.') - } - - const now = Date.now() - const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined - - setStoredStoreAppSession({ - store, - clientId: STORE_AUTH_APP_CLIENT_ID, - userId, - accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token, - // Store the raw scopes returned by Shopify. Validation may treat implied - // write_* -> read_* permissions as satisfied, so callers should not assume - // session.scopes is an expanded/effective permission set. - scopes: resolveGrantedScopes(tokenResponse, validationScopes), - acquiredAt: new Date(now).toISOString(), - expiresAt, - refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in - ? new Date(now + tokenResponse.refresh_token_expires_in * 1000).toISOString() - : undefined, - associatedUser: tokenResponse.associated_user - ? { - id: tokenResponse.associated_user.id, - email: tokenResponse.associated_user.email, - firstName: tokenResponse.associated_user.first_name, - lastName: tokenResponse.associated_user.last_name, - accountOwner: tokenResponse.associated_user.account_owner, - } - : undefined, - }) - - outputDebug( - outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, - ) - - dependencies.presenter.success(store, tokenResponse.associated_user?.email) -} diff --git a/packages/cli/src/cli/services/store/auth/callback.test.ts b/packages/cli/src/cli/services/store/auth/callback.test.ts new file mode 100644 index 00000000000..2cdb74427c7 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/callback.test.ts @@ -0,0 +1,189 @@ +import {createServer} from 'http' +import {describe, expect, test} from 'vitest' +import {waitForStoreAuthCode} from './callback.js' + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer() + + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + reject(new Error('Expected an ephemeral port.')) + return + } + + server.close((error) => { + if (error) { + reject(error) + return + } + + resolve(address.port) + }) + }) + }) +} + +function callbackParams(options?: { + code?: string + shop?: string + state?: string + error?: string +}): URLSearchParams { + const params = new URLSearchParams() + params.set('shop', options?.shop ?? 'shop.myshopify.com') + params.set('state', options?.state ?? 'state-123') + + if (options?.code) params.set('code', options.code) + if (options?.error) params.set('error', options.error) + if (!options?.code && !options?.error) params.set('code', 'abc123') + + return params +} + +describe('store auth callback server', () => { + test('waitForStoreAuthCode resolves after a valid callback', async () => { + const port = await getAvailablePort() + const params = callbackParams() + const onListening = async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(200) + await response.text() + } + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening, + }), + ).resolves.toBe('abc123') + }) + + test('waitForStoreAuthCode rejects when callback state does not match', async () => { + const port = await getAvailablePort() + const params = callbackParams({state: 'wrong-state'}) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('OAuth callback state does not match the original request.') + }) + + test('waitForStoreAuthCode rejects when callback store does not match and suggests the returned permanent domain', async () => { + const port = await getAvailablePort() + const params = callbackParams({shop: 'other-shop.myshopify.com'}) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toMatchObject({ + message: 'OAuth callback store does not match the requested store.', + tryMessage: 'Shopify returned other-shop.myshopify.com during authentication. Re-run using the permanent store domain:', + nextSteps: [[{command: 'shopify store auth --store other-shop.myshopify.com --scopes '}]], + }) + }) + + test('waitForStoreAuthCode rejects when Shopify returns an OAuth error', async () => { + const port = await getAvailablePort() + const params = callbackParams({error: 'access_denied'}) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('Shopify returned an OAuth error: access_denied') + }) + + test('waitForStoreAuthCode rejects when callback does not include an authorization code', async () => { + const port = await getAvailablePort() + const params = callbackParams() + params.delete('code') + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('OAuth callback did not include an authorization code.') + }) + + test('waitForStoreAuthCode rejects when the port is already in use', async () => { + const port = await getAvailablePort() + const server = createServer() + await new Promise((resolve, reject) => { + server.on('error', reject) + server.listen(port, '127.0.0.1', () => resolve()) + }) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + }), + ).rejects.toThrow(`Port ${port} is already in use.`) + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }) + }) + + test('waitForStoreAuthCode rejects on timeout', async () => { + const port = await getAvailablePort() + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 25, + }), + ).rejects.toThrow('Timed out waiting for OAuth callback.') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/callback.ts b/packages/cli/src/cli/services/store/auth/callback.ts new file mode 100644 index 00000000000..6cf394c5952 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/callback.ts @@ -0,0 +1,185 @@ +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {timingSafeEqual} from 'crypto' +import {createServer} from 'http' +import {STORE_AUTH_CALLBACK_PATH, maskToken} from './config.js' +import {retryStoreAuthWithPermanentDomainError} from './recovery.js' + +export interface WaitForAuthCodeOptions { + store: string + state: string + port: number + timeoutMs?: number + onListening?: () => void | Promise +} + +function renderAuthCallbackPage(title: string, message: string): string { + const safeTitle = title + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + const safeMessage = message + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + + return ` + + + + + ${safeTitle} + + +
+
+

${safeTitle}

+

${safeMessage}

+
+
+ +` +} + +export async function waitForStoreAuthCode({ + store, + state, + port, + timeoutMs = 5 * 60 * 1000, + onListening, +}: WaitForAuthCodeOptions): Promise { + const normalizedStore = normalizeStoreFqdn(store) + + return new Promise((resolve, reject) => { + let settled = false + let isListening = false + + const timeout = setTimeout(() => { + settleWithError(new AbortError('Timed out waiting for OAuth callback.')) + }, timeoutMs) + + const server = createServer((req, res) => { + const requestUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`) + + if (requestUrl.pathname !== STORE_AUTH_CALLBACK_PATH) { + res.statusCode = 404 + res.end('Not found') + return + } + + const {searchParams} = requestUrl + + const fail = (error: AbortError | string, tryMessage?: string) => { + const abortError = typeof error === 'string' ? new AbortError(error, tryMessage) : error + + res.statusCode = 400 + res.setHeader('Content-Type', 'text/html') + res.setHeader('Connection', 'close') + res.once('finish', () => settleWithError(abortError)) + res.end(renderAuthCallbackPage('Authentication failed', abortError.message)) + } + + const returnedStore = searchParams.get('shop') + outputDebug(outputContent`Received OAuth callback for shop ${outputToken.raw(returnedStore ?? 'unknown')}`) + + if (!returnedStore) { + fail('OAuth callback store does not match the requested store.') + return + } + + const normalizedReturnedStore = normalizeStoreFqdn(returnedStore) + if (normalizedReturnedStore !== normalizedStore) { + fail(retryStoreAuthWithPermanentDomainError(normalizedReturnedStore)) + return + } + + const returnedState = searchParams.get('state') + if (!returnedState || !constantTimeEqual(returnedState, state)) { + fail('OAuth callback state does not match the original request.') + return + } + + const error = searchParams.get('error') + if (error) { + fail(`Shopify returned an OAuth error: ${error}`) + return + } + + const code = searchParams.get('code') + if (!code) { + fail('OAuth callback did not include an authorization code.') + return + } + + outputDebug(outputContent`Received authorization code ${outputToken.raw(maskToken(code))}`) + + res.statusCode = 200 + res.setHeader('Content-Type', 'text/html') + res.setHeader('Connection', 'close') + res.once('finish', () => settle(() => resolve(code))) + res.end(renderAuthCallbackPage('Authentication succeeded', 'You can close this window and return to the terminal.')) + }) + + const settle = (callback: () => void) => { + if (settled) return + settled = true + clearTimeout(timeout) + + const finalize = () => { + callback() + } + + if (!isListening) { + finalize() + return + } + + server.close(() => { + isListening = false + finalize() + }) + server.closeIdleConnections?.() + } + + const settleWithError = (error: Error) => { + settle(() => reject(error)) + } + + server.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + settleWithError( + new AbortError( + `Port ${port} is already in use.`, + `Free port ${port} and re-run ${outputToken.genericShellCommand(`shopify store auth --store ${store} --scopes `).value}. Ensure that redirect URI is allowed in the app configuration.`, + ), + ) + return + } + + settleWithError(error) + }) + + server.listen(port, '127.0.0.1', async () => { + isListening = true + outputDebug( + outputContent`PKCE callback server listening on http://127.0.0.1:${outputToken.raw(String(port))}${outputToken.raw(STORE_AUTH_CALLBACK_PATH)}`, + ) + + if (!onListening) return + + try { + await onListening() + } catch (error) { + settleWithError(error instanceof Error ? error : new Error(String(error))) + } + }) + }) +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false + return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')) +} diff --git a/packages/cli/src/cli/services/store/auth-config.ts b/packages/cli/src/cli/services/store/auth/config.ts similarity index 100% rename from packages/cli/src/cli/services/store/auth-config.ts rename to packages/cli/src/cli/services/store/auth/config.ts diff --git a/packages/cli/src/cli/services/store/auth/existing-scopes.test.ts b/packages/cli/src/cli/services/store/auth/existing-scopes.test.ts new file mode 100644 index 00000000000..ac53c16f13f --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/existing-scopes.test.ts @@ -0,0 +1,164 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {resolveExistingStoreAuthScopes} from './existing-scopes.js' +import {loadStoredStoreSession} from './session-lifecycle.js' +import {getCurrentStoredStoreAppSession} from './session-store.js' + +vi.mock('./session-store.js') +vi.mock('./session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) +vi.mock('@shopify/cli-kit/node/api/graphql') +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + adminUrl: vi.fn(), + } +}) + +describe('resolveExistingStoreAuthScopes', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json') + }) + + afterEach(() => { + vi.restoreAllMocks() + mockAndCaptureOutput().clear() + }) + + test('returns no scopes when no stored auth exists', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({scopes: [], authoritative: true}) + expect(loadStoredStoreSession).not.toHaveBeenCalled() + expect(graphqlRequest).not.toHaveBeenCalled() + }) + + test('prefers current remote scopes over stale local scopes', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: {accessScopes: [{handle: 'read_products'}, {handle: 'read_customers'}]}, + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_products', 'read_customers'], + authoritative: true, + }) + expect(adminUrl).toHaveBeenCalledWith('shop.myshopify.com', 'unstable') + expect(graphqlRequest).toHaveBeenCalledWith({ + query: expect.stringContaining('currentAppInstallation'), + api: 'Admin', + url: 'https://shop.myshopify.com/admin/api/unstable/graphql.json', + token: 'fresh-token', + responseOptions: {handleErrors: false}, + }) + }) + + test('falls back to locally stored scopes when remote lookup fails', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockRejectedValue(new Error('boom')) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) + + test('falls back to locally stored scopes when access scopes request fails', async () => { + const output = mockAndCaptureOutput() + + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + const scopeLookupError = new Error('GraphQL Error (Code: 401)') + Object.assign(scopeLookupError, { + response: { + status: 401, + errors: '[API] Invalid API key or access token (unrecognized login or wrong password)', + }, + request: { + query: '#graphql query CurrentAppInstallationAccessScopes { currentAppInstallation { accessScopes { handle } } }', + }, + }) + vi.mocked(graphqlRequest).mockRejectedValue(scopeLookupError) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + expect(output.debug()).toContain('after remote scope lookup failed: HTTP 401: [API] Invalid API key or access token') + expect(output.debug()).not.toContain('CurrentAppInstallationAccessScopes') + }) + + test('falls back to locally stored scopes when access scopes response is invalid', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'existing-token', + refreshToken: 'existing-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + }) + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + scopes: ['read_orders'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: undefined, + } as any) + + await expect(resolveExistingStoreAuthScopes('shop.myshopify.com')).resolves.toEqual({ + scopes: ['read_orders'], + authoritative: false, + }) + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/existing-scopes.ts b/packages/cli/src/cli/services/store/auth/existing-scopes.ts new file mode 100644 index 00000000000..0dcc398e1be --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/existing-scopes.ts @@ -0,0 +1,54 @@ +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {getCurrentStoredStoreAppSession} from './session-store.js' +import {loadStoredStoreSession} from './session-lifecycle.js' +import {fetchCurrentStoreAuthScopes} from './token-client.js' + +export interface ResolvedStoreAuthScopes { + scopes: string[] + authoritative: boolean +} + +function truncateDebugMessage(message: string, length = 300): string { + return message.slice(0, length) +} + +function formatStoreScopeLookupError(error: unknown): string { + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as {response?: {status?: number; errors?: unknown}}).response + const status = response?.status + const details = response?.errors + + if (typeof status === 'number') { + const summary = typeof details === 'string' ? details : JSON.stringify(details) + return truncateDebugMessage(summary ? `HTTP ${status}: ${summary}` : `HTTP ${status}`) + } + } + + return truncateDebugMessage(error instanceof Error ? error.message : String(error)) +} + +export async function resolveExistingStoreAuthScopes(store: string): Promise { + const normalizedStore = normalizeStoreFqdn(store) + const storedSession = getCurrentStoredStoreAppSession(normalizedStore) + if (!storedSession) return {scopes: [], authoritative: true} + + try { + const usableSession = await loadStoredStoreSession(normalizedStore) + const remoteScopes = await fetchCurrentStoreAuthScopes({ + store: usableSession.store, + accessToken: usableSession.accessToken, + }) + + outputDebug( + outputContent`Resolved current remote scopes for ${outputToken.raw(normalizedStore)}: ${outputToken.raw(remoteScopes.join(',') || 'none')}`, + ) + + return {scopes: remoteScopes, authoritative: true} + } catch (error) { + outputDebug( + outputContent`Falling back to locally stored scopes for ${outputToken.raw(normalizedStore)} after remote scope lookup failed: ${outputToken.raw(formatStoreScopeLookupError(error))}`, + ) + return {scopes: storedSession.scopes, authoritative: false} + } +} diff --git a/packages/cli/src/cli/services/store/auth/index.test.ts b/packages/cli/src/cli/services/store/auth/index.test.ts new file mode 100644 index 00000000000..3d438c29770 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/index.test.ts @@ -0,0 +1,447 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {authenticateStoreWithApp} from './index.js' +import {setStoredStoreAppSession} from './session-store.js' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' + +vi.mock('./session-store.js') +vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) +vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) + +describe('store auth service', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('authenticateStoreWithApp opens the browser and stores the session with refresh token', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + refresh_token: 'refresh-token', + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter, + }, + ) + + expect(presenter.openingBrowser).toHaveBeenCalledOnce() + expect(openURL).toHaveBeenCalledWith(expect.stringContaining('/admin/oauth/authorize?')) + expect(presenter.manualAuthUrl).not.toHaveBeenCalled() + expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') + + const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] + expect(storedSession.store).toBe('shop.myshopify.com') + expect(storedSession.clientId).toBe(STORE_AUTH_APP_CLIENT_ID) + expect(storedSession.userId).toBe('42') + expect(storedSession.accessToken).toBe('token') + expect(storedSession.refreshToken).toBe('refresh-token') + expect(storedSession.scopes).toEqual(['read_products']) + expect(storedSession.expiresAt).toBeDefined() + expect(storedSession.associatedUser).toEqual({ + id: 42, + email: 'test@example.com', + firstName: undefined, + lastName: undefined, + accountOwner: undefined, + }) + }) + + test('authenticateStoreWithApp uses remote scopes by default when available', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_customers,read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_customers'], authoritative: true}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_customers,read_products') + }) + + test('authenticateStoreWithApp reuses resolved existing scopes when requesting additional access', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_orders,read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: true}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_orders', 'read_products'], + }), + ) + }) + + test('authenticateStoreWithApp does not require non-authoritative cached scopes to still be granted', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: false}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('read_orders,read_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_products'], + }), + ) + }) + + test('authenticateStoreWithApp avoids requesting redundant read scopes already implied by existing write scopes', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'write_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['write_products'], authoritative: true}), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('scope')).toBe('write_products') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['write_products'], + }), + ) + }) + + test('authenticateStoreWithApp shows a manual auth URL when the browser does not open automatically', async () => { + const openURL = vi.fn().mockResolvedValue(false) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter, + }, + ) + + expect(presenter.openingBrowser).toHaveBeenCalledOnce() + expect(presenter.manualAuthUrl).toHaveBeenCalledWith( + expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'), + ) + expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') + }) + + test('authenticateStoreWithApp rejects when Shopify grants fewer scopes than requested', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await expect( + authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, + }, + ), + ).rejects.toMatchObject({ + message: 'Shopify granted fewer scopes than were requested.', + tryMessage: 'Missing scopes: write_products.', + nextSteps: [ + 'Update the app or store installation scopes.', + 'See https://shopify.dev/app/scopes', + 'Re-run shopify store auth.', + ], + }) + + expect(setStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('authenticateStoreWithApp accepts compressed write scopes that imply requested read scopes', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'write_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, + }, + ) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['write_products'], + }), + ) + }) + + test('authenticateStoreWithApp still rejects when other requested scopes are missing', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await expect( + authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products,read_orders', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'write_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, + }, + ), + ).rejects.toThrow('Shopify granted fewer scopes than were requested.') + + expect(setStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('authenticateStoreWithApp falls back to requested scopes when Shopify omits granted scopes', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, + }, + ) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_products'], + }), + ) + }) + + test('authenticateStoreWithApp accepts compressed unauthenticated write scopes that imply requested unauthenticated read scopes', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'unauthenticated_read_product_listings,unauthenticated_write_product_listings', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'unauthenticated_write_product_listings', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, + }, + ) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['unauthenticated_write_product_listings'], + }), + ) + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/index.ts b/packages/cli/src/cli/services/store/auth/index.ts new file mode 100644 index 00000000000..d96e287f364 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/index.ts @@ -0,0 +1,134 @@ +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputCompleted, outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {openURL} from '@shopify/cli-kit/node/system' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {setStoredStoreAppSession} from './session-store.js' +import {exchangeStoreAuthCodeForToken} from './token-client.js' +import {waitForStoreAuthCode} from './callback.js' +import {createPkceBootstrap} from './pkce.js' +import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js' +import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js' + +interface StoreAuthInput { + store: string + scopes: string +} + +interface StoreAuthPresenter { + openingBrowser: () => void + manualAuthUrl: (authorizationUrl: string) => void + success: (store: string, email?: string) => void +} + +interface StoreAuthDependencies { + openURL: typeof openURL + waitForStoreAuthCode: typeof waitForStoreAuthCode + exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken + resolveExistingScopes: (store: string) => Promise + presenter: StoreAuthPresenter +} + +const defaultStoreAuthPresenter: StoreAuthPresenter = { + openingBrowser() { + outputInfo('Shopify CLI will open the app authorization page in your browser.') + outputInfo('') + }, + manualAuthUrl(authorizationUrl: string) { + outputInfo('Browser did not open automatically. Open this URL manually:') + outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) + outputInfo('') + }, + success(store: string, email?: string) { + const displayName = email ? ` as ${email}` : '' + + outputCompleted('Logged in.') + outputCompleted(`Authenticated${displayName} against ${store}.`) + outputInfo('') + outputInfo('To verify that authentication worked, run:') + outputInfo(`shopify store execute --store ${store} --query 'query { shop { name id } }'`) + }, +} + +const defaultStoreAuthDependencies: StoreAuthDependencies = { + openURL, + waitForStoreAuthCode, + exchangeStoreAuthCodeForToken, + resolveExistingScopes: resolveExistingStoreAuthScopes, + presenter: defaultStoreAuthPresenter, +} + +export async function authenticateStoreWithApp( + input: StoreAuthInput, + dependencies: Partial = {}, +): Promise { + const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies} + const store = normalizeStoreFqdn(input.store) + const requestedScopes = parseStoreAuthScopes(input.scopes) + const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store) + const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) + const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes + + if (existingScopeResolution.scopes.length > 0) { + outputDebug( + outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`, + ) + } + + const bootstrap = createPkceBootstrap({ + store, + scopes, + exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken, + }) + const { + authorization: {authorizationUrl}, + } = bootstrap + + resolvedDependencies.presenter.openingBrowser() + + const code = await resolvedDependencies.waitForStoreAuthCode({ + ...bootstrap.waitForAuthCodeOptions, + onListening: async () => { + const opened = await resolvedDependencies.openURL(authorizationUrl) + if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl) + }, + }) + const tokenResponse = await bootstrap.exchangeCodeForToken(code) + + const userId = tokenResponse.associated_user?.id?.toString() + if (!userId) { + throw new AbortError('Shopify did not return associated user information for the online access token.') + } + + const now = Date.now() + const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined + + setStoredStoreAppSession({ + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + scopes: resolveGrantedScopes(tokenResponse, validationScopes), + acquiredAt: new Date(now).toISOString(), + expiresAt, + refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in + ? new Date(now + tokenResponse.refresh_token_expires_in * 1000).toISOString() + : undefined, + associatedUser: tokenResponse.associated_user + ? { + id: tokenResponse.associated_user.id, + email: tokenResponse.associated_user.email, + firstName: tokenResponse.associated_user.first_name, + lastName: tokenResponse.associated_user.last_name, + accountOwner: tokenResponse.associated_user.account_owner, + } + : undefined, + }) + + outputDebug( + outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, + ) + + resolvedDependencies.presenter.success(store, tokenResponse.associated_user?.email) +} diff --git a/packages/cli/src/cli/services/store/auth/pkce.test.ts b/packages/cli/src/cli/services/store/auth/pkce.test.ts new file mode 100644 index 00000000000..d096446c94d --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/pkce.test.ts @@ -0,0 +1,45 @@ +import {describe, expect, test} from 'vitest' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {buildStoreAuthUrl, computeCodeChallenge, generateCodeVerifier} from './pkce.js' + +describe('store auth PKCE helpers', () => { + test('generateCodeVerifier produces a base64url string of 43 chars', () => { + const verifier = generateCodeVerifier() + expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) + + test('generateCodeVerifier produces unique values', () => { + const a = generateCodeVerifier() + const b = generateCodeVerifier() + expect(a).not.toBe(b) + }) + + test('computeCodeChallenge produces a deterministic S256 hash', () => { + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' + expect(computeCodeChallenge(verifier)).toBe(expected) + }) + + test('buildStoreAuthUrl includes PKCE params and response_type=code', () => { + const url = new URL( + buildStoreAuthUrl({ + store: 'shop.myshopify.com', + scopes: ['read_products', 'write_products'], + state: 'state-123', + redirectUri: 'http://127.0.0.1:13387/auth/callback', + codeChallenge: 'test-challenge-value', + }), + ) + + expect(url.hostname).toBe('shop.myshopify.com') + expect(url.pathname).toBe('/admin/oauth/authorize') + expect(url.searchParams.get('client_id')).toBe(STORE_AUTH_APP_CLIENT_ID) + expect(url.searchParams.get('scope')).toBe('read_products,write_products') + expect(url.searchParams.get('state')).toBe('state-123') + expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:13387/auth/callback') + expect(url.searchParams.get('response_type')).toBe('code') + expect(url.searchParams.get('code_challenge')).toBe('test-challenge-value') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('grant_options[]')).toBeNull() + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/pkce.ts b/packages/cli/src/cli/services/store/auth/pkce.ts new file mode 100644 index 00000000000..c9944eee30f --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/pkce.ts @@ -0,0 +1,90 @@ +import {randomUUID} from '@shopify/cli-kit/node/crypto' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {createHash, randomBytes} from 'crypto' +import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, storeAuthRedirectUri} from './config.js' +import type {StoreTokenResponse} from './token-client.js' +import type {WaitForAuthCodeOptions} from './callback.js' + +interface StoreAuthorizationContext { + store: string + scopes: string[] + state: string + port: number + redirectUri: string + authorizationUrl: string + codeVerifier: string +} + +interface StoreAuthBootstrap { + authorization: StoreAuthorizationContext + waitForAuthCodeOptions: WaitForAuthCodeOptions + exchangeCodeForToken: (code: string) => Promise +} + +export function generateCodeVerifier(): string { + return randomBytes(32).toString('base64url') +} + +export function computeCodeChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url') +} + +export function buildStoreAuthUrl(options: { + store: string + scopes: string[] + state: string + redirectUri: string + codeChallenge: string +}): string { + const params = new URLSearchParams() + params.set('client_id', STORE_AUTH_APP_CLIENT_ID) + params.set('scope', options.scopes.join(',')) + params.set('redirect_uri', options.redirectUri) + params.set('state', options.state) + params.set('response_type', 'code') + params.set('code_challenge', options.codeChallenge) + params.set('code_challenge_method', 'S256') + + return `https://${options.store}/admin/oauth/authorize?${params.toString()}` +} + +export function createPkceBootstrap(options: { + store: string + scopes: string[] + exchangeCodeForToken: (options: { + store: string + code: string + codeVerifier: string + redirectUri: string + }) => Promise +}): StoreAuthBootstrap { + const {store, scopes, exchangeCodeForToken} = options + const port = DEFAULT_STORE_AUTH_PORT + const state = randomUUID() + const redirectUri = storeAuthRedirectUri(port) + const codeVerifier = generateCodeVerifier() + const codeChallenge = computeCodeChallenge(codeVerifier) + const authorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge}) + + outputDebug( + outputContent`Starting PKCE auth for ${outputToken.raw(store)} with scopes ${outputToken.raw(scopes.join(','))} (redirect_uri=${outputToken.raw(redirectUri)})`, + ) + + return { + authorization: { + store, + scopes, + state, + port, + redirectUri, + authorizationUrl, + codeVerifier, + }, + waitForAuthCodeOptions: { + store, + state, + port, + }, + exchangeCodeForToken: (code: string) => exchangeCodeForToken({store, code, codeVerifier, redirectUri}), + } +} diff --git a/packages/cli/src/cli/services/store/auth-recovery.ts b/packages/cli/src/cli/services/store/auth/recovery.ts similarity index 100% rename from packages/cli/src/cli/services/store/auth-recovery.ts rename to packages/cli/src/cli/services/store/auth/recovery.ts diff --git a/packages/cli/src/cli/services/store/auth/scopes.test.ts b/packages/cli/src/cli/services/store/auth/scopes.test.ts new file mode 100644 index 00000000000..6df7fa07512 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/scopes.test.ts @@ -0,0 +1,54 @@ +import {describe, expect, test} from 'vitest' +import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js' + +describe('store auth scope helpers', () => { + test('parseStoreAuthScopes splits and deduplicates scopes', () => { + expect(parseStoreAuthScopes('read_products, write_products,read_products')).toEqual([ + 'read_products', + 'write_products', + ]) + }) + + test('mergeRequestedAndStoredScopes avoids redundant reads already implied by existing writes', () => { + expect(mergeRequestedAndStoredScopes(['read_products'], ['write_products'])).toEqual(['write_products']) + }) + + test('mergeRequestedAndStoredScopes adds newly requested scopes', () => { + expect(mergeRequestedAndStoredScopes(['read_products'], ['read_orders'])).toEqual(['read_orders', 'read_products']) + }) + + test('resolveGrantedScopes accepts compressed write scopes that imply requested reads', () => { + expect( + resolveGrantedScopes( + { + access_token: 'token', + scope: 'write_products', + }, + ['read_products', 'write_products'], + ), + ).toEqual(['write_products']) + }) + + test('resolveGrantedScopes falls back to requested scopes when Shopify omits scope', () => { + expect( + resolveGrantedScopes( + { + access_token: 'token', + }, + ['read_products'], + ), + ).toEqual(['read_products']) + }) + + test('resolveGrantedScopes rejects when required scopes are missing', () => { + expect(() => + resolveGrantedScopes( + { + access_token: 'token', + scope: 'read_products', + }, + ['read_products', 'write_products'], + ), + ).toThrow('Shopify granted fewer scopes than were requested.') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/scopes.ts b/packages/cli/src/cli/services/store/auth/scopes.ts new file mode 100644 index 00000000000..efe8a2b5913 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/scopes.ts @@ -0,0 +1,70 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug} from '@shopify/cli-kit/node/output' +import type {StoreTokenResponse} from './token-client.js' + +export function parseStoreAuthScopes(input: string): string[] { + const scopes = input + .split(',') + .map((scope) => scope.trim()) + .filter(Boolean) + + if (scopes.length === 0) { + throw new AbortError('At least one scope is required.', 'Pass --scopes as a comma-separated list.') + } + + return [...new Set(scopes)] +} + +function expandImpliedStoreScopes(scopes: string[]): Set { + const expandedScopes = new Set(scopes) + + for (const scope of scopes) { + const matches = scope.match(/^(unauthenticated_)?write_(.*)$/) + if (matches) { + expandedScopes.add(`${matches[1] ?? ''}read_${matches[2]}`) + } + } + + return expandedScopes +} + +export function mergeRequestedAndStoredScopes(requestedScopes: string[], storedScopes: string[]): string[] { + const mergedScopes = [...storedScopes] + const expandedScopes = expandImpliedStoreScopes(storedScopes) + + for (const scope of requestedScopes) { + if (expandedScopes.has(scope)) continue + + mergedScopes.push(scope) + for (const expandedScope of expandImpliedStoreScopes([scope])) { + expandedScopes.add(expandedScope) + } + } + + return mergedScopes +} + +export function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes: string[]): string[] { + if (!tokenResponse.scope) { + outputDebug(outputContent`Token response did not include scope; falling back to requested scopes`) + return requestedScopes + } + + const grantedScopes = parseStoreAuthScopes(tokenResponse.scope) + const expandedGrantedScopes = expandImpliedStoreScopes(grantedScopes) + const missingScopes = requestedScopes.filter((scope) => !expandedGrantedScopes.has(scope)) + + if (missingScopes.length > 0) { + throw new AbortError( + 'Shopify granted fewer scopes than were requested.', + `Missing scopes: ${missingScopes.join(', ')}.`, + [ + 'Update the app or store installation scopes.', + 'See https://shopify.dev/app/scopes', + 'Re-run shopify store auth.', + ], + ) + } + + return grantedScopes +} diff --git a/packages/cli/src/cli/services/store/auth/session-lifecycle.test.ts b/packages/cli/src/cli/services/store/auth/session-lifecycle.test.ts new file mode 100644 index 00000000000..c1d3c652870 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/session-lifecycle.test.ts @@ -0,0 +1,179 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {AbortError} from '@shopify/cli-kit/node/error' +import { + isSessionExpired, + loadStoredStoreSession, +} from './session-lifecycle.js' +import { + clearStoredStoreAppSession, + getCurrentStoredStoreAppSession, + setStoredStoreAppSession, + type StoredStoreAppSession, +} from './session-store.js' +import {refreshStoreAccessToken} from './token-client.js' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' + +vi.mock('./session-store.js') +vi.mock('./token-client.js') + +function buildSession(overrides: Partial = {}): StoredStoreAppSession { + return { + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + refreshToken: 'refresh-token', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + associatedUser: {id: 42, email: 'merchant@example.com'}, + ...overrides, + } +} + +describe('isSessionExpired', () => { + test('returns false when expiresAt is not set', () => { + expect(isSessionExpired(buildSession())).toBe(false) + }) + + test('returns false when token is still valid', () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: future}))).toBe(false) + }) + + test('returns true when token is expired', () => { + const past = new Date(Date.now() - 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: past}))).toBe(true) + }) + + test('returns true within the 4-minute expiry margin', () => { + const almostExpired = new Date(Date.now() + 3 * 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: almostExpired}))).toBe(true) + }) + + test('returns false just outside the 4-minute expiry margin', () => { + const safelyValid = new Date(Date.now() + 5 * 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: safelyValid}))).toBe(false) + }) + + test('returns true when expiresAt is invalid', () => { + expect(isSessionExpired(buildSession({expiresAt: 'not-a-date'}))).toBe(true) + }) +}) + +describe('loadStoredStoreSession', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('throws when no stored auth exists', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ + message: 'No stored app authentication found for shop.myshopify.com.', + }) + }) + + test('returns the current stored session when it is still valid', async () => { + const session = buildSession({expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + + await expect(loadStoredStoreSession('shop.myshopify.com')).resolves.toEqual(session) + expect(refreshStoreAccessToken).not.toHaveBeenCalled() + expect(setStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('throws when an expired session has no refresh token', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue( + buildSession({refreshToken: undefined, expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}), + ) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ + message: 'No refresh token stored for shop.myshopify.com.', + }) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('refreshes expired sessions and persists the refreshed identity-preserving session', async () => { + const session = buildSession({expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockResolvedValue({ + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + expiresIn: 3600, + refreshTokenExpiresIn: 7200, + }) + + const refreshed = await loadStoredStoreSession('shop.myshopify.com') + + expect(refreshStoreAccessToken).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + refreshToken: 'refresh-token', + }) + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: session.store, + clientId: session.clientId, + userId: session.userId, + scopes: session.scopes, + associatedUser: session.associatedUser, + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + }), + ) + expect(refreshed).toEqual(expect.objectContaining({accessToken: 'fresh-token', userId: '42'})) + }) + + test('preserves existing optional refresh fields when Shopify omits them', async () => { + const session = buildSession({ + expiresAt: new Date(Date.now() - 60 * 1000).toISOString(), + refreshTokenExpiresAt: '2026-04-03T00:00:00.000Z', + }) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockResolvedValue({ + accessToken: 'fresh-token', + }) + + const refreshed = await loadStoredStoreSession('shop.myshopify.com') + + expect(refreshed.refreshToken).toBe('refresh-token') + expect(refreshed.refreshTokenExpiresAt).toBe('2026-04-03T00:00:00.000Z') + expect(refreshed.expiresAt).toBe(session.expiresAt) + }) + + test('clears only the current stored auth and throws re-auth when refresh fails', async () => { + const session = buildSession({expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockRejectedValue( + new AbortError('Token refresh failed for shop.myshopify.com (HTTP 401).'), + ) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ + message: 'Token refresh failed for shop.myshopify.com (HTTP 401).', + tryMessage: 'To re-authenticate, run:', + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42') + }) + + test('clears only the current stored auth and throws on malformed refresh JSON', async () => { + const session = buildSession({expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockRejectedValue(new AbortError('Received an invalid refresh response from Shopify.')) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toThrow('Received an invalid refresh response') + expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42') + }) + + test('clears only the current stored auth and throws re-auth when refresh returns an invalid response', async () => { + const session = buildSession({expiresAt: new Date(Date.now() - 60 * 1000).toISOString()}) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session) + vi.mocked(refreshStoreAccessToken).mockRejectedValue( + new AbortError('Token refresh returned an invalid response for shop.myshopify.com.'), + ) + + await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ + message: 'Token refresh returned an invalid response for shop.myshopify.com.', + tryMessage: 'To re-authenticate, run:', + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/session-lifecycle.ts b/packages/cli/src/cli/services/store/auth/session-lifecycle.ts new file mode 100644 index 00000000000..309e6db71a2 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/session-lifecycle.ts @@ -0,0 +1,105 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {maskToken} from './config.js' +import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './recovery.js' +import { + clearStoredStoreAppSession, + getCurrentStoredStoreAppSession, + setStoredStoreAppSession, +} from './session-store.js' +import type {StoredStoreAppSession} from './session-store.js' +import {refreshStoreAccessToken} from './token-client.js' + +const EXPIRY_MARGIN_MS = 4 * 60 * 1000 + +export function isSessionExpired(session: StoredStoreAppSession): boolean { + if (!session.expiresAt) return false + + const expiresAtMs = new Date(session.expiresAt).getTime() + if (Number.isNaN(expiresAtMs)) return true + + return expiresAtMs - EXPIRY_MARGIN_MS < Date.now() +} + +function buildRefreshedStoredSession( + session: StoredStoreAppSession, + refresh: { + accessToken: string + refreshToken?: string + expiresIn?: number + refreshTokenExpiresIn?: number + }, +): StoredStoreAppSession { + const now = Date.now() + const expiresAt = refresh.expiresIn ? new Date(now + refresh.expiresIn * 1000).toISOString() : session.expiresAt + + return { + ...session, + accessToken: refresh.accessToken, + refreshToken: refresh.refreshToken ?? session.refreshToken, + expiresAt, + refreshTokenExpiresAt: refresh.refreshTokenExpiresIn + ? new Date(now + refresh.refreshTokenExpiresIn * 1000).toISOString() + : session.refreshTokenExpiresAt, + acquiredAt: new Date(now).toISOString(), + } +} + +export async function loadStoredStoreSession(store: string): Promise { + let session = getCurrentStoredStoreAppSession(store) + + if (!session) { + throw createStoredStoreAuthError(store) + } + + outputDebug( + outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, + ) + + if (!isSessionExpired(session)) { + return session + } + + if (!session.refreshToken) { + throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) + } + + outputDebug( + outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, + ) + + const previousAccessToken = session.accessToken + + let refreshed + try { + refreshed = await refreshStoreAccessToken({ + store: session.store, + refreshToken: session.refreshToken, + }) + } catch (error) { + clearStoredStoreAppSession(session.store, session.userId) + + if (error instanceof AbortError && error.message.startsWith(`Token refresh failed for ${session.store} (HTTP `)) { + throw reauthenticateStoreAuthError(error.message, session.store, session.scopes.join(',')) + } + + if (error instanceof AbortError && error.message === `Token refresh returned an invalid response for ${session.store}.`) { + throw reauthenticateStoreAuthError(error.message, session.store, session.scopes.join(',')) + } + + if (error instanceof AbortError && error.message === 'Received an invalid refresh response from Shopify.') { + throw error + } + + throw error + } + + session = buildRefreshedStoredSession(session, refreshed) + + outputDebug( + outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(previousAccessToken))} → ${outputToken.raw(maskToken(session.accessToken))}, new expiry ${outputToken.raw(session.expiresAt ?? 'unknown')}`, + ) + + setStoredStoreAppSession(session) + return session +} diff --git a/packages/cli/src/cli/services/store/session.test.ts b/packages/cli/src/cli/services/store/auth/session-store.test.ts similarity index 50% rename from packages/cli/src/cli/services/store/session.test.ts rename to packages/cli/src/cli/services/store/auth/session-store.test.ts index 373fb0d4286..db501895604 100644 --- a/packages/cli/src/cli/services/store/session.test.ts +++ b/packages/cli/src/cli/services/store/auth/session-store.test.ts @@ -1,13 +1,12 @@ import {describe, test, expect} from 'vitest' import {LocalStorage} from '@shopify/cli-kit/node/local-storage' -import {STORE_AUTH_APP_CLIENT_ID, storeAuthSessionKey} from './auth-config.js' +import {STORE_AUTH_APP_CLIENT_ID, storeAuthSessionKey} from './config.js' import { clearStoredStoreAppSession, - getStoredStoreAppSession, + getCurrentStoredStoreAppSession, setStoredStoreAppSession, - isSessionExpired, type StoredStoreAppSession, -} from './session.js' +} from './session-store.js' function inMemoryStorage() { const values = new Map() @@ -43,7 +42,7 @@ describe('store session storage', () => { setStoredStoreAppSession(buildSession(), storage as any) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(buildSession()) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(buildSession()) }) test('keeps multiple user sessions per store and returns the current one', () => { @@ -54,7 +53,7 @@ describe('store session storage', () => { setStoredStoreAppSession(firstSession, storage as any) setStoredStoreAppSession(secondSession, storage as any) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(secondSession) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(secondSession) }) test('clears all stored sessions for a store', () => { @@ -63,7 +62,7 @@ describe('store session storage', () => { setStoredStoreAppSession(buildSession(), storage as any) clearStoredStoreAppSession('shop.myshopify.com', storage as any) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() }) test('clears only the specified user session and preserves the rest of the bucket', () => { @@ -75,7 +74,7 @@ describe('store session storage', () => { setStoredStoreAppSession(secondSession, storage as any) clearStoredStoreAppSession('shop.myshopify.com', '84', storage as any) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(firstSession) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(firstSession) }) test('returns undefined and clears the bucket when the current user session is missing', () => { @@ -87,7 +86,7 @@ describe('store session storage', () => { }, }) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) @@ -98,37 +97,73 @@ describe('store session storage', () => { sessionsByUserId: null, }) - expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) -}) -describe('isSessionExpired', () => { - test('returns false when expiresAt is not set', () => { - expect(isSessionExpired(buildSession())).toBe(false) - }) + test('returns undefined and clears the bucket when the current stored session is malformed', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: { + '42': {userId: '42'}, + }, + }) - test('returns false when token is still valid', () => { - const future = new Date(Date.now() + 60 * 60 * 1000).toISOString() - expect(isSessionExpired(buildSession({expiresAt: future}))).toBe(false) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) - test('returns true when token is expired', () => { - const past = new Date(Date.now() - 60 * 1000).toISOString() - expect(isSessionExpired(buildSession({expiresAt: past}))).toBe(true) - }) + test('drops malformed optional fields from a stored session instead of rejecting the whole session', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: { + '42': { + ...buildSession(), + refreshToken: 123, + expiresAt: 456, + refreshTokenExpiresAt: true, + associatedUser: { + id: 42, + email: 123, + firstName: 'Merchant', + lastName: false, + accountOwner: 'yes', + }, + }, + }, + }) - test('returns true within the 4-minute expiry margin', () => { - const almostExpired = new Date(Date.now() + 3 * 60 * 1000).toISOString() - expect(isSessionExpired(buildSession({expiresAt: almostExpired}))).toBe(true) + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual({ + ...buildSession(), + associatedUser: { + id: 42, + firstName: 'Merchant', + }, + }) }) - test('returns false just outside the 4-minute expiry margin', () => { - const safelyValid = new Date(Date.now() + 5 * 60 * 1000).toISOString() - expect(isSessionExpired(buildSession({expiresAt: safelyValid}))).toBe(false) + test('overwrites a malformed bucket when writing a new session', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: null, + }) + + setStoredStoreAppSession(buildSession(), storage as any) + + expect(getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(buildSession()) }) - test('returns true when expiresAt is invalid', () => { - expect(isSessionExpired(buildSession({expiresAt: 'not-a-date'}))).toBe(true) + test('clears malformed buckets without throwing when removing a specific user', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: null, + }) + + expect(() => clearStoredStoreAppSession('shop.myshopify.com', '42', storage as any)).not.toThrow() + expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) }) diff --git a/packages/cli/src/cli/services/store/auth/session-store.ts b/packages/cli/src/cli/services/store/auth/session-store.ts new file mode 100644 index 00000000000..868db8487cc --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/session-store.ts @@ -0,0 +1,192 @@ +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {storeAuthSessionKey} from './config.js' + +export interface StoredStoreAppSession { + store: string + clientId: string + userId: string + accessToken: string + refreshToken?: string + scopes: string[] + acquiredAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + associatedUser?: { + id: number + email?: string + firstName?: string + lastName?: string + accountOwner?: boolean + } +} + +interface StoredStoreAppSessionBucket { + currentUserId: string + sessionsByUserId: {[userId: string]: StoredStoreAppSession} +} + +interface StoreSessionSchema { + [key: string]: StoredStoreAppSessionBucket +} + +let _storeSessionStorage: LocalStorage | undefined + +function storeSessionStorage() { + _storeSessionStorage ??= new LocalStorage({projectName: 'shopify-cli-store'}) + return _storeSessionStorage +} + +function isString(value: unknown): value is string { + return typeof value === 'string' +} + +function sanitizeAssociatedUser(value: unknown): StoredStoreAppSession['associatedUser'] | undefined { + if (!value || typeof value !== 'object') return undefined + + const associatedUser = value as Record + if (typeof associatedUser.id !== 'number') return undefined + + return { + id: associatedUser.id, + ...(isString(associatedUser.email) ? {email: associatedUser.email} : {}), + ...(isString(associatedUser.firstName) ? {firstName: associatedUser.firstName} : {}), + ...(isString(associatedUser.lastName) ? {lastName: associatedUser.lastName} : {}), + ...(typeof associatedUser.accountOwner === 'boolean' ? {accountOwner: associatedUser.accountOwner} : {}), + } +} + +function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | undefined { + if (!value || typeof value !== 'object') return undefined + + const session = value as Record + if ( + !isString(session.store) || + !isString(session.clientId) || + !isString(session.userId) || + !isString(session.accessToken) || + !Array.isArray(session.scopes) || + !session.scopes.every(isString) || + !isString(session.acquiredAt) + ) { + return undefined + } + + return { + store: session.store, + clientId: session.clientId, + userId: session.userId, + accessToken: session.accessToken, + scopes: session.scopes, + acquiredAt: session.acquiredAt, + ...(isString(session.refreshToken) ? {refreshToken: session.refreshToken} : {}), + ...(isString(session.expiresAt) ? {expiresAt: session.expiresAt} : {}), + ...(isString(session.refreshTokenExpiresAt) ? {refreshTokenExpiresAt: session.refreshTokenExpiresAt} : {}), + ...(sanitizeAssociatedUser(session.associatedUser) ? {associatedUser: sanitizeAssociatedUser(session.associatedUser)} : {}), + } +} + +function readStoredStoreAppSessionBucket( + store: string, + storage: LocalStorage, +): StoredStoreAppSessionBucket | undefined { + const key = storeAuthSessionKey(store) + const storedBucket = storage.get(key) + if (!storedBucket || typeof storedBucket !== 'object') return undefined + + const {sessionsByUserId, currentUserId} = storedBucket as Partial + if (!sessionsByUserId || typeof sessionsByUserId !== 'object' || Array.isArray(sessionsByUserId) || typeof currentUserId !== 'string') { + storage.delete(key) + return undefined + } + + const sanitizedSessionsByUserId = Object.fromEntries( + Object.entries(sessionsByUserId).flatMap(([userId, session]) => { + const sanitizedSession = sanitizeStoredStoreAppSession(session) + return sanitizedSession ? [[userId, sanitizedSession]] : [] + }), + ) + + if (Object.keys(sanitizedSessionsByUserId).length !== Object.keys(sessionsByUserId).length) { + if (sanitizedSessionsByUserId[currentUserId]) { + storage.set(key, { + currentUserId, + sessionsByUserId: sanitizedSessionsByUserId, + }) + } else { + storage.delete(key) + return undefined + } + } + + return { + currentUserId, + sessionsByUserId: sanitizedSessionsByUserId, + } +} + +export function getCurrentStoredStoreAppSession( + store: string, + storage: LocalStorage = storeSessionStorage(), +): StoredStoreAppSession | undefined { + const bucket = readStoredStoreAppSessionBucket(store, storage) + if (!bucket) return undefined + + const session = bucket.sessionsByUserId[bucket.currentUserId] + if (!session) { + storage.delete(storeAuthSessionKey(store)) + return undefined + } + + return session +} + +export function setStoredStoreAppSession( + session: StoredStoreAppSession, + storage: LocalStorage = storeSessionStorage(), +): void { + const key = storeAuthSessionKey(session.store) + const existingBucket = readStoredStoreAppSessionBucket(session.store, storage) + + const nextBucket: StoredStoreAppSessionBucket = { + currentUserId: session.userId, + sessionsByUserId: { + ...(existingBucket?.sessionsByUserId ?? {}), + [session.userId]: session, + }, + } + + storage.set(key, nextBucket) +} + +export function clearStoredStoreAppSession( + store: string, + userIdOrStorage?: string | LocalStorage, + maybeStorage?: LocalStorage, +): void { + const userId = typeof userIdOrStorage === 'string' ? userIdOrStorage : undefined + const storage = + (typeof userIdOrStorage === 'string' ? maybeStorage : userIdOrStorage) ?? storeSessionStorage() + + const key = storeAuthSessionKey(store) + + if (!userId) { + storage.delete(key) + return + } + + const existingBucket = readStoredStoreAppSessionBucket(store, storage) + if (!existingBucket) return + + const {[userId]: _removedSession, ...remainingSessions} = existingBucket.sessionsByUserId + + const remainingUserIds = Object.keys(remainingSessions) + if (remainingUserIds.length === 0) { + storage.delete(key) + return + } + + storage.set(key, { + currentUserId: existingBucket.currentUserId === userId ? remainingUserIds[0]! : existingBucket.currentUserId, + sessionsByUserId: remainingSessions, + }) +} diff --git a/packages/cli/src/cli/services/store/auth/token-client.test.ts b/packages/cli/src/cli/services/store/auth/token-client.test.ts new file mode 100644 index 00000000000..eab22b6abd0 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/token-client.test.ts @@ -0,0 +1,167 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {fetch} from '@shopify/cli-kit/node/http' +import {outputDebug} from '@shopify/cli-kit/node/output' +import { + exchangeStoreAuthCodeForToken, + fetchCurrentStoreAuthScopes, + refreshStoreAccessToken, +} from './token-client.js' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' + +vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/api/graphql') +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + adminUrl: vi.fn(), + } +}) +vi.mock('@shopify/cli-kit/node/output', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/output') + return { + ...actual, + outputDebug: vi.fn(), + } +}) + +describe('token client', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json') + }) + + test('exchangeStoreAuthCodeForToken sends PKCE params and returns token response', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + refresh_token: 'refresh-token', + associated_user: {id: 42, email: 'test@example.com'}, + }), + ), + } as any) + + const response = await exchangeStoreAuthCodeForToken({ + store: 'shop.myshopify.com', + code: 'abc123', + codeVerifier: 'test-verifier', + redirectUri: 'http://127.0.0.1:13387/auth/callback', + }) + + expect(response.access_token).toBe('token') + expect(response.refresh_token).toBe('refresh-token') + expect(fetch).toHaveBeenCalledWith( + 'https://shop.myshopify.com/admin/oauth/access_token', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"code_verifier":"test-verifier"'), + }), + ) + + const sentBody = JSON.parse((fetch as any).mock.calls[0][1].body) + expect(sentBody.client_id).toBe(STORE_AUTH_APP_CLIENT_ID) + expect(sentBody.code).toBe('abc123') + expect(sentBody.code_verifier).toBe('test-verifier') + expect(sentBody.redirect_uri).toBe('http://127.0.0.1:13387/auth/callback') + }) + + test('refreshStoreAccessToken sends refresh params and returns normalized payload', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + access_token: 'fresh-token', + refresh_token: 'fresh-refresh-token', + expires_in: 3600, + refresh_token_expires_in: 7200, + }), + ), + } as any) + + await expect( + refreshStoreAccessToken({store: 'shop.myshopify.com', refreshToken: 'refresh-token'}), + ).resolves.toEqual({ + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + expiresIn: 3600, + refreshTokenExpiresIn: 7200, + }) + + expect(fetch).toHaveBeenCalledWith( + 'https://shop.myshopify.com/admin/oauth/access_token', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: 'refresh-token', + }), + }), + ) + }) + + test('refreshStoreAccessToken throws on malformed JSON', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('not-json'), + } as any) + + await expect( + refreshStoreAccessToken({store: 'shop.myshopify.com', refreshToken: 'refresh-token'}), + ).rejects.toThrow('Received an invalid refresh response from Shopify.') + }) + + test('refreshStoreAccessToken throws when access token is missing', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})), + } as any) + + await expect( + refreshStoreAccessToken({store: 'shop.myshopify.com', refreshToken: 'refresh-token'}), + ).rejects.toThrow('Token refresh returned an invalid response for shop.myshopify.com.') + }) + + test('fetchCurrentStoreAuthScopes returns current scope handles', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: {accessScopes: [{handle: 'read_products'}, {handle: 'read_orders'}]}, + } as any) + + await expect( + fetchCurrentStoreAuthScopes({store: 'shop.myshopify.com', accessToken: 'token'}), + ).resolves.toEqual(['read_products', 'read_orders']) + + expect(adminUrl).toHaveBeenCalledWith('shop.myshopify.com', 'unstable') + expect(graphqlRequest).toHaveBeenCalledWith({ + query: expect.stringContaining('currentAppInstallation'), + api: 'Admin', + url: 'https://shop.myshopify.com/admin/api/unstable/graphql.json', + token: 'token', + responseOptions: {handleErrors: false}, + }) + }) + + test('fetchCurrentStoreAuthScopes throws on GraphQL lookup failure', async () => { + vi.mocked(graphqlRequest).mockRejectedValue(new Error('shopify exploded')) + + await expect( + fetchCurrentStoreAuthScopes({store: 'shop.myshopify.com', accessToken: 'token'}), + ).rejects.toThrow('shopify exploded') + }) + + test('fetchCurrentStoreAuthScopes throws on invalid response shape', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({ + currentAppInstallation: undefined, + } as any) + + await expect( + fetchCurrentStoreAuthScopes({store: 'shop.myshopify.com', accessToken: 'token'}), + ).rejects.toThrow('Shopify did not return currentAppInstallation.accessScopes.') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/token-client.ts b/packages/cli/src/cli/services/store/auth/token-client.ts new file mode 100644 index 00000000000..5931e4de9ae --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/token-client.ts @@ -0,0 +1,171 @@ +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {AbortError} from '@shopify/cli-kit/node/error' +import {fetch} from '@shopify/cli-kit/node/http' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './config.js' + +export interface StoreTokenResponse { + access_token: string + token_type?: string + scope?: string + expires_in?: number + refresh_token?: string + refresh_token_expires_in?: number + associated_user_scope?: string + associated_user?: { + id: number + first_name?: string + last_name?: string + email?: string + account_owner?: boolean + locale?: string + collaborator?: boolean + email_verified?: boolean + } +} + +interface StoreAccessScopesResponse { + currentAppInstallation?: { + accessScopes?: {handle?: string}[] + } +} + +interface StoreTokenRefreshPayload { + accessToken: string + refreshToken?: string + expiresIn?: number + refreshTokenExpiresIn?: number +} + +function truncateHttpErrorBody(body: string, length = 300): string { + return body.slice(0, length) +} + +export async function exchangeStoreAuthCodeForToken(options: { + store: string + code: string + codeVerifier: string + redirectUri: string +}): Promise { + const endpoint = `https://${options.store}/admin/oauth/access_token` + + outputDebug(outputContent`Exchanging authorization code for token at ${outputToken.raw(endpoint)}`) + + const response = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + code: options.code, + code_verifier: options.codeVerifier, + redirect_uri: options.redirectUri, + }), + }) + + const body = await response.text() + if (!response.ok) { + outputDebug( + outputContent`Token exchange failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(truncateHttpErrorBody(body || response.statusText))}`, + ) + throw new AbortError( + `Failed to exchange OAuth code for an access token (HTTP ${response.status}).`, + body || response.statusText, + ) + } + + let parsed: StoreTokenResponse + try { + parsed = JSON.parse(body) as StoreTokenResponse + } catch { + throw new AbortError('Received an invalid token response from Shopify.') + } + + outputDebug( + outputContent`Token exchange succeeded: access_token=${outputToken.raw(maskToken(parsed.access_token))}, refresh_token=${outputToken.raw(parsed.refresh_token ? maskToken(parsed.refresh_token) : 'none')}, expires_in=${outputToken.raw(String(parsed.expires_in ?? 'unknown'))}s, user=${outputToken.raw(String(parsed.associated_user?.id ?? 'unknown'))} (${outputToken.raw(parsed.associated_user?.email ?? 'no email')})`, + ) + + return parsed +} + +export async function refreshStoreAccessToken(options: { + store: string + refreshToken: string +}): Promise { + const endpoint = `https://${options.store}/admin/oauth/access_token` + + outputDebug( + outputContent`Refreshing access token for ${outputToken.raw(options.store)} using refresh_token=${outputToken.raw(maskToken(options.refreshToken))}`, + ) + + const response = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: options.refreshToken, + }), + }) + + const body = await response.text() + if (!response.ok) { + outputDebug( + outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(truncateHttpErrorBody(body || response.statusText))}`, + ) + throw new AbortError(`Token refresh failed for ${options.store} (HTTP ${response.status}).`) + } + + let parsed: StoreTokenResponse + try { + parsed = JSON.parse(body) as StoreTokenResponse + } catch { + throw new AbortError('Received an invalid refresh response from Shopify.') + } + + if (!parsed.access_token) { + throw new AbortError(`Token refresh returned an invalid response for ${options.store}.`) + } + + return { + accessToken: parsed.access_token, + refreshToken: parsed.refresh_token, + expiresIn: parsed.expires_in, + refreshTokenExpiresIn: parsed.refresh_token_expires_in, + } +} + +const CurrentAppInstallationAccessScopesQuery = `#graphql + query CurrentAppInstallationAccessScopes { + currentAppInstallation { + accessScopes { + handle + } + } + } +` + +export async function fetchCurrentStoreAuthScopes(options: { + store: string + accessToken: string +}): Promise { + outputDebug( + outputContent`Fetching current app installation scopes for ${outputToken.raw(options.store)} using token ${outputToken.raw(maskToken(options.accessToken))}`, + ) + + const data = await graphqlRequest({ + query: CurrentAppInstallationAccessScopesQuery, + api: 'Admin', + url: adminUrl(options.store, 'unstable'), + token: options.accessToken, + responseOptions: {handleErrors: false}, + }) + + if (!Array.isArray(data.currentAppInstallation?.accessScopes)) { + throw new Error('Shopify did not return currentAppInstallation.accessScopes.') + } + + return data.currentAppInstallation.accessScopes.flatMap((scope) => + typeof scope.handle === 'string' ? [scope.handle] : [], + ) +} diff --git a/packages/cli/src/cli/services/store/execute.test.ts b/packages/cli/src/cli/services/store/execute.test.ts index e9ab1276937..0f40ed1d9af 100644 --- a/packages/cli/src/cli/services/store/execute.test.ts +++ b/packages/cli/src/cli/services/store/execute.test.ts @@ -1,14 +1,14 @@ import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' import {executeStoreOperation} from './execute.js' -import {getStoredStoreAppSession} from './session.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' +import {getCurrentStoredStoreAppSession} from './auth/session-store.js' +import {STORE_AUTH_APP_CLIENT_ID} from './auth/config.js' import {fetchApiVersions, adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {renderSingleTask, renderSuccess} from '@shopify/cli-kit/node/ui' import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -vi.mock('./session.js') +vi.mock('./auth/session-store.js') vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/fs') @@ -35,7 +35,7 @@ describe('executeStoreOperation', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(getStoredStoreAppSession).mockReturnValue(storedSession) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedSession) vi.mocked(fetchApiVersions).mockResolvedValue([ {handle: '2025-10', supported: true}, {handle: '2025-07', supported: true}, @@ -63,7 +63,7 @@ describe('executeStoreOperation', () => { title: expect.anything(), }), ) - expect(getStoredStoreAppSession).toHaveBeenCalledWith(store) + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith(store) expect(fetchApiVersions).toHaveBeenCalledWith(session) expect(graphqlRequest).toHaveBeenCalledWith({ query: 'query { shop { name } }', @@ -131,11 +131,11 @@ describe('executeStoreOperation', () => { }), ).rejects.toThrow('Mutations are disabled by default') - expect(getStoredStoreAppSession).not.toHaveBeenCalled() + expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() }) test('throws when no stored app session exists', async () => { - vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) await expect( executeStoreOperation({ diff --git a/packages/cli/src/cli/services/store/session.ts b/packages/cli/src/cli/services/store/session.ts deleted file mode 100644 index 53100a27e87..00000000000 --- a/packages/cli/src/cli/services/store/session.ts +++ /dev/null @@ -1,125 +0,0 @@ -import {LocalStorage} from '@shopify/cli-kit/node/local-storage' -import {storeAuthSessionKey} from './auth-config.js' - -export interface StoredStoreAppSession { - store: string - clientId: string - userId: string - accessToken: string - refreshToken?: string - scopes: string[] - acquiredAt: string - expiresAt?: string - refreshTokenExpiresAt?: string - associatedUser?: { - id: number - email?: string - firstName?: string - lastName?: string - accountOwner?: boolean - } -} - -interface StoredStoreAppSessionBucket { - currentUserId: string - sessionsByUserId: {[userId: string]: StoredStoreAppSession} -} - -interface StoreSessionSchema { - [key: string]: StoredStoreAppSessionBucket -} - -let _storeSessionStorage: LocalStorage | undefined - -// Per-store, per-user session storage for PKCE online tokens. -function storeSessionStorage() { - _storeSessionStorage ??= new LocalStorage({projectName: 'shopify-cli-store'}) - return _storeSessionStorage -} - -export function getStoredStoreAppSession( - store: string, - storage: LocalStorage = storeSessionStorage(), -): StoredStoreAppSession | undefined { - const key = storeAuthSessionKey(store) - const storedBucket = storage.get(key) - if (!storedBucket || typeof storedBucket !== 'object') return undefined - - const {sessionsByUserId, currentUserId} = storedBucket as Partial - - if (!sessionsByUserId || typeof sessionsByUserId !== 'object' || typeof currentUserId !== 'string') { - storage.delete(key) - return undefined - } - - const session = sessionsByUserId[currentUserId] - if (!session) { - storage.delete(key) - return undefined - } - - return session -} - -export function setStoredStoreAppSession( - session: StoredStoreAppSession, - storage: LocalStorage = storeSessionStorage(), -): void { - const key = storeAuthSessionKey(session.store) - const existingBucket = storage.get(key) - - const nextBucket: StoredStoreAppSessionBucket = { - currentUserId: session.userId, - sessionsByUserId: { - ...(existingBucket?.sessionsByUserId ?? {}), - [session.userId]: session, - }, - } - - storage.set(key, nextBucket) -} - -export function clearStoredStoreAppSession( - store: string, - userIdOrStorage?: string | LocalStorage, - maybeStorage?: LocalStorage, -): void { - const userId = typeof userIdOrStorage === 'string' ? userIdOrStorage : undefined - const storage = - (typeof userIdOrStorage === 'string' ? maybeStorage : userIdOrStorage) ?? storeSessionStorage() - - const key = storeAuthSessionKey(store) - - if (!userId) { - storage.delete(key) - return - } - - const existingBucket = storage.get(key) - if (!existingBucket) return - - const {[userId]: _removedSession, ...remainingSessions} = existingBucket.sessionsByUserId - - const remainingUserIds = Object.keys(remainingSessions) - if (remainingUserIds.length === 0) { - storage.delete(key) - return - } - - storage.set(key, { - currentUserId: - existingBucket.currentUserId === userId ? remainingUserIds[0]! : existingBucket.currentUserId, - sessionsByUserId: remainingSessions, - }) -} - -const EXPIRY_MARGIN_MS = 4 * 60 * 1000 - -export function isSessionExpired(session: StoredStoreAppSession): boolean { - if (!session.expiresAt) return false - - const expiresAtMs = new Date(session.expiresAt).getTime() - if (Number.isNaN(expiresAtMs)) return true - - return expiresAtMs - EXPIRY_MARGIN_MS < Date.now() -} diff --git a/packages/cli/src/cli/services/store/stored-session.ts b/packages/cli/src/cli/services/store/stored-session.ts deleted file mode 100644 index f41329b6b0b..00000000000 --- a/packages/cli/src/cli/services/store/stored-session.ts +++ /dev/null @@ -1,104 +0,0 @@ -import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' -import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' -import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' -import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './auth-recovery.js' -import { - clearStoredStoreAppSession, - getStoredStoreAppSession, - isSessionExpired, - setStoredStoreAppSession, -} from './session.js' -import type {StoredStoreAppSession} from './session.js' - -async function refreshStoreToken(session: StoredStoreAppSession): Promise { - if (!session.refreshToken) { - throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) - } - - const endpoint = `https://${session.store}/admin/oauth/access_token` - - outputDebug( - outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, - ) - - const response = await fetch(endpoint, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - client_id: STORE_AUTH_APP_CLIENT_ID, - grant_type: 'refresh_token', - refresh_token: session.refreshToken, - }), - }) - - const body = await response.text() - - if (!response.ok) { - outputDebug( - outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, - ) - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Token refresh failed for ${session.store} (HTTP ${response.status}).`, - session.store, - session.scopes.join(','), - ) - } - - let data: {access_token?: string; refresh_token?: string; expires_in?: number; refresh_token_expires_in?: number} - try { - data = JSON.parse(body) - } catch { - clearStoredStoreAppSession(session.store, session.userId) - throw new AbortError('Received an invalid refresh response from Shopify.') - } - - if (!data.access_token) { - clearStoredStoreAppSession(session.store, session.userId) - throw reauthenticateStoreAuthError( - `Token refresh returned an invalid response for ${session.store}.`, - session.store, - session.scopes.join(','), - ) - } - - const now = Date.now() - const expiresAt = data.expires_in ? new Date(now + data.expires_in * 1000).toISOString() : session.expiresAt - - const refreshedSession: StoredStoreAppSession = { - ...session, - accessToken: data.access_token, - refreshToken: data.refresh_token ?? session.refreshToken, - expiresAt, - refreshTokenExpiresAt: data.refresh_token_expires_in - ? new Date(now + data.refresh_token_expires_in * 1000).toISOString() - : session.refreshTokenExpiresAt, - acquiredAt: new Date(now).toISOString(), - } - - outputDebug( - outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(session.accessToken))} → ${outputToken.raw(maskToken(refreshedSession.accessToken))}, new expiry ${outputToken.raw(expiresAt ?? 'unknown')}`, - ) - - setStoredStoreAppSession(refreshedSession) - return refreshedSession -} - -export async function loadStoredStoreSession(store: string): Promise { - let session = getStoredStoreAppSession(store) - - if (!session) { - throw createStoredStoreAuthError(store) - } - - outputDebug( - outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, - ) - - if (isSessionExpired(session)) { - session = await refreshStoreToken(session) - } - - return session -} From 5cebcc543c69d33b4fc371f4ff45504bb5d9be46 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Fri, 3 Apr 2026 11:32:57 -0400 Subject: [PATCH 3/5] store execute restructure --- .../src/cli/commands/store/execute.test.ts | 4 +- .../cli/src/cli/commands/store/execute.ts | 2 +- .../store/admin-graphql-context.test.ts | 182 --------------- .../src/cli/services/store/execute.test.ts | 219 ------------------ .../store/execute/admin-context.test.ts | 116 ++++++++++ .../admin-context.ts} | 15 +- .../admin-transport.test.ts} | 46 ++-- .../admin-transport.ts} | 25 +- .../cli/services/store/execute/index.test.ts | 77 ++++++ .../store/{execute.ts => execute/index.ts} | 12 +- .../request.test.ts} | 2 +- .../request.ts} | 0 .../result.test.ts} | 2 +- .../{execute-result.ts => execute/result.ts} | 0 .../targets.test.ts} | 35 ++- .../targets.ts} | 19 +- 16 files changed, 265 insertions(+), 491 deletions(-) delete mode 100644 packages/cli/src/cli/services/store/admin-graphql-context.test.ts delete mode 100644 packages/cli/src/cli/services/store/execute.test.ts create mode 100644 packages/cli/src/cli/services/store/execute/admin-context.test.ts rename packages/cli/src/cli/services/store/{admin-graphql-context.ts => execute/admin-context.ts} (80%) rename packages/cli/src/cli/services/store/{admin-graphql-transport.test.ts => execute/admin-transport.test.ts} (68%) rename packages/cli/src/cli/services/store/{admin-graphql-transport.ts => execute/admin-transport.ts} (66%) create mode 100644 packages/cli/src/cli/services/store/execute/index.test.ts rename packages/cli/src/cli/services/store/{execute.ts => execute/index.ts} (78%) rename packages/cli/src/cli/services/store/{execute-request.test.ts => execute/request.test.ts} (98%) rename packages/cli/src/cli/services/store/{execute-request.ts => execute/request.ts} (100%) rename packages/cli/src/cli/services/store/{execute-result.test.ts => execute/result.test.ts} (94%) rename packages/cli/src/cli/services/store/{execute-result.ts => execute/result.ts} (100%) rename packages/cli/src/cli/services/store/{graphql-targets.test.ts => execute/targets.test.ts} (57%) rename packages/cli/src/cli/services/store/{graphql-targets.ts => execute/targets.ts} (61%) diff --git a/packages/cli/src/cli/commands/store/execute.test.ts b/packages/cli/src/cli/commands/store/execute.test.ts index e890b5332a0..4d7bd1e2ba7 100644 --- a/packages/cli/src/cli/commands/store/execute.test.ts +++ b/packages/cli/src/cli/commands/store/execute.test.ts @@ -1,8 +1,8 @@ import {describe, test, expect, vi, beforeEach} from 'vitest' import StoreExecute from './execute.js' -import {executeStoreOperation} from '../../services/store/execute.js' +import {executeStoreOperation} from '../../services/store/execute/index.js' -vi.mock('../../services/store/execute.js') +vi.mock('../../services/store/execute/index.js') describe('store execute command', () => { beforeEach(() => { diff --git a/packages/cli/src/cli/commands/store/execute.ts b/packages/cli/src/cli/commands/store/execute.ts index 661c8af9a24..bffc3e21ec4 100644 --- a/packages/cli/src/cli/commands/store/execute.ts +++ b/packages/cli/src/cli/commands/store/execute.ts @@ -3,7 +3,7 @@ import {globalFlags} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {resolvePath} from '@shopify/cli-kit/node/path' import {Flags} from '@oclif/core' -import {executeStoreOperation} from '../../services/store/execute.js' +import {executeStoreOperation} from '../../services/store/execute/index.js' export default class StoreExecute extends Command { static summary = 'Execute GraphQL queries and mutations on a store.' diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts deleted file mode 100644 index 83371130436..00000000000 --- a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import {beforeEach, describe, expect, test, vi} from 'vitest' -import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' -import {AbortError} from '@shopify/cli-kit/node/error' -import {fetch} from '@shopify/cli-kit/node/http' -import { - clearStoredStoreAppSession, - getCurrentStoredStoreAppSession, - setStoredStoreAppSession, -} from './auth/session-store.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth/config.js' -import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' - -vi.mock('./auth/session-store.js') -vi.mock('@shopify/cli-kit/node/http') -vi.mock('@shopify/cli-kit/node/api/admin', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') - return { - ...actual, - fetchApiVersions: vi.fn(), - } -}) - -describe('prepareAdminStoreGraphQLContext', () => { - const store = 'shop.myshopify.com' - const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString() - const expiredAt = new Date(Date.now() - 60 * 1000).toISOString() - - const storedSession = { - store, - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'token', - refreshToken: 'refresh-token', - scopes: ['read_products'], - acquiredAt: '2026-03-27T00:00:00.000Z', - expiresAt: futureExpiry, - } - - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedSession) - vi.mocked(fetchApiVersions).mockResolvedValue([ - {handle: '2025-10', supported: true}, - {handle: '2025-07', supported: true}, - {handle: 'unstable', supported: false}, - ] as any) - }) - - test('returns the stored admin session and latest supported version by default', async () => { - const result = await prepareAdminStoreGraphQLContext({store}) - - expect(result).toEqual({ - adminSession: { - token: 'token', - storeFqdn: store, - }, - version: '2025-10', - sessionUserId: '42', - }) - }) - - test('refreshes expired sessions before resolving the API version', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue( - JSON.stringify({ - access_token: 'fresh-token', - refresh_token: 'fresh-refresh-token', - expires_in: 3600, - refresh_token_expires_in: 7200, - }), - ), - } as any) - - const result = await prepareAdminStoreGraphQLContext({store}) - - expect(result.adminSession.token).toBe('fresh-token') - expect(setStoredStoreAppSession).toHaveBeenCalledWith( - expect.objectContaining({ - store, - accessToken: 'fresh-token', - refreshToken: 'fresh-refresh-token', - }), - ) - }) - - test('returns the requested API version when provided', async () => { - const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '2025-07'}) - - expect(result.version).toBe('2025-07') - }) - - test('allows unstable without validating against fetched versions', async () => { - const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: 'unstable'}) - - expect(result.version).toBe('unstable') - expect(fetchApiVersions).not.toHaveBeenCalled() - }) - - test('throws when no stored auth exists', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `No stored app authentication found for ${store}.`, - tryMessage: 'To create stored auth for this store, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes `}]], - }) - }) - - test('clears stored auth when token refresh fails', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) - vi.mocked(fetch).mockResolvedValue({ - ok: false, - status: 401, - text: vi.fn().mockResolvedValue('bad refresh'), - } as any) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Token refresh failed for ${store} (HTTP 401).`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('throws when an expired session cannot be refreshed because no refresh token is stored', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, refreshToken: undefined, expiresAt: expiredAt}) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `No refresh token stored for ${store}.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).not.toHaveBeenCalled() - }) - - test('clears only the current stored auth when token refresh returns an invalid response body', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})), - } as any) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Token refresh returned an invalid response for ${store}.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('clears only the current stored auth when token refresh returns malformed JSON', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({...storedSession, expiresAt: expiredAt}) - vi.mocked(fetch).mockResolvedValue({ - ok: true, - text: vi.fn().mockResolvedValue('not-json'), - } as any) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Received an invalid refresh response') - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('clears stored auth and prompts re-auth when API version lookup fails with invalid auth', async () => { - vi.mocked(fetchApiVersions).mockRejectedValue( - new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`), - ) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Stored app authentication for ${store} is no longer valid.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], - }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('throws when the requested API version is invalid', async () => { - await expect(prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '1999-01'})).rejects.toThrow( - 'Invalid API version', - ) - }) -}) diff --git a/packages/cli/src/cli/services/store/execute.test.ts b/packages/cli/src/cli/services/store/execute.test.ts deleted file mode 100644 index 0f40ed1d9af..00000000000 --- a/packages/cli/src/cli/services/store/execute.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' -import {executeStoreOperation} from './execute.js' -import {getCurrentStoredStoreAppSession} from './auth/session-store.js' -import {STORE_AUTH_APP_CLIENT_ID} from './auth/config.js' -import {fetchApiVersions, adminUrl} from '@shopify/cli-kit/node/api/admin' -import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' -import {renderSingleTask, renderSuccess} from '@shopify/cli-kit/node/ui' -import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' - -vi.mock('./auth/session-store.js') -vi.mock('@shopify/cli-kit/node/api/graphql') -vi.mock('@shopify/cli-kit/node/ui') -vi.mock('@shopify/cli-kit/node/fs') -vi.mock('@shopify/cli-kit/node/api/admin', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') - return { - ...actual, - fetchApiVersions: vi.fn(), - adminUrl: vi.fn(), - } -}) - -describe('executeStoreOperation', () => { - const store = 'shop.myshopify.com' - const session = {token: 'token', storeFqdn: store} - const storedSession = { - store, - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'token', - scopes: ['read_products'], - acquiredAt: '2026-03-27T00:00:00.000Z', - } - - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedSession) - vi.mocked(fetchApiVersions).mockResolvedValue([ - {handle: '2025-10', supported: true}, - {handle: '2025-07', supported: true}, - {handle: 'unstable', supported: false}, - ] as any) - vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/2025-10/graphql.json') - vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) - }) - - afterEach(() => { - mockAndCaptureOutput().clear() - }) - - test('executes a query successfully', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - const output = mockAndCaptureOutput() - - await executeStoreOperation({ - store, - query: 'query { shop { name } }', - }) - - expect(renderSingleTask).toHaveBeenCalledWith( - expect.objectContaining({ - title: expect.anything(), - }), - ) - expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith(store) - expect(fetchApiVersions).toHaveBeenCalledWith(session) - expect(graphqlRequest).toHaveBeenCalledWith({ - query: 'query { shop { name } }', - api: 'Admin', - url: 'https://shop.myshopify.com/admin/api/2025-10/graphql.json', - token: 'token', - variables: undefined, - responseOptions: {handleErrors: false}, - }) - expect(output.info()).toContain('"name": "Test shop"') - expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) - }) - - test('passes parsed variables when provided inline', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {id: 'gid://shopify/Shop/1'}}}) - - await executeStoreOperation({ - store, - query: 'query Shop($id: ID!) { shop { id } }', - variables: '{"id":"gid://shopify/Shop/1"}', - }) - - expect(graphqlRequest).toHaveBeenCalledWith( - expect.objectContaining({ - variables: {id: 'gid://shopify/Shop/1'}, - }), - ) - }) - - test('reads variables from a file', async () => { - vi.mocked(fileExists).mockResolvedValue(true) - vi.mocked(readFile).mockResolvedValue('{"id":"gid://shopify/Shop/1"}' as any) - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {id: 'gid://shopify/Shop/1'}}}) - - await executeStoreOperation({ - store, - query: 'query Shop($id: ID!) { shop { id } }', - variableFile: '/tmp/variables.json', - }) - - expect(graphqlRequest).toHaveBeenCalledWith( - expect.objectContaining({ - variables: {id: 'gid://shopify/Shop/1'}, - }), - ) - }) - - test('throws when variables contain invalid JSON', async () => { - await expect( - executeStoreOperation({ - store, - query: 'query { shop { name } }', - variables: '{invalid json}', - }), - ).rejects.toThrow('Invalid JSON') - - expect(graphqlRequest).not.toHaveBeenCalled() - }) - - test('throws when mutations are not explicitly allowed', async () => { - await expect( - executeStoreOperation({ - store, - query: 'mutation { productCreate(product: {title: "Hat"}) { product { id } } }', - }), - ).rejects.toThrow('Mutations are disabled by default') - - expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() - }) - - test('throws when no stored app session exists', async () => { - vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) - - await expect( - executeStoreOperation({ - store, - query: 'query { shop { name } }', - }), - ).rejects.toThrow('No stored app authentication found') - }) - - test('allows mutations when explicitly enabled', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {productCreate: {product: {id: 'gid://shopify/Product/1'}}}}) - - await executeStoreOperation({ - store, - query: 'mutation { productCreate(product: {title: "Hat"}) { product { id } } }', - allowMutations: true, - }) - - expect(graphqlRequest).toHaveBeenCalled() - }) - - test('uses the specified API version when provided', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/2025-07/graphql.json') - - await executeStoreOperation({ - store, - query: 'query { shop { name } }', - version: '2025-07', - }) - - expect(adminUrl).toHaveBeenCalledWith(store, '2025-07', session) - }) - - test('writes results to a file when outputFile is provided', async () => { - vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - - await executeStoreOperation({ - store, - query: 'query { shop { name } }', - outputFile: '/tmp/results.json', - }) - - expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) - expect(renderSuccess).toHaveBeenCalledWith({ - headline: 'Operation succeeded.', - body: 'Results written to /tmp/results.json', - }) - }) - - test('throws when stored auth is no longer valid', async () => { - vi.mocked(graphqlRequest).mockRejectedValue({ - response: { - status: 401, - }, - }) - - await expect( - executeStoreOperation({ - store, - query: 'query { shop { name } }', - }), - ).rejects.toThrow('Stored app authentication for') - }) - - test('throws on GraphQL errors', async () => { - vi.mocked(graphqlRequest).mockRejectedValue({ - response: { - errors: [{message: 'Field does not exist'}], - }, - }) - - await expect( - executeStoreOperation({ - store, - query: 'query { nope }', - }), - ).rejects.toThrow('GraphQL operation failed.') - }) - -}) diff --git a/packages/cli/src/cli/services/store/execute/admin-context.test.ts b/packages/cli/src/cli/services/store/execute/admin-context.test.ts new file mode 100644 index 00000000000..d82ed61de96 --- /dev/null +++ b/packages/cli/src/cli/services/store/execute/admin-context.test.ts @@ -0,0 +1,116 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' +import {AbortError} from '@shopify/cli-kit/node/error' +import {prepareAdminStoreGraphQLContext} from './admin-context.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import {loadStoredStoreSession} from '../auth/session-lifecycle.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' + +vi.mock('../auth/session-store.js') +vi.mock('../auth/session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + fetchApiVersions: vi.fn(), + } +}) + +describe('prepareAdminStoreGraphQLContext', () => { + const store = 'shop.myshopify.com' + const storedSession = { + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + refreshToken: 'refresh-token', + scopes: ['read_products', 'write_orders'], + acquiredAt: '2026-03-27T00:00:00.000Z', + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(loadStoredStoreSession).mockResolvedValue(storedSession) + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: '2025-10', supported: true}, + {handle: '2025-07', supported: true}, + {handle: 'unstable', supported: false}, + ] as any) + }) + + test('returns the stored admin session, version, and full auth session', async () => { + const result = await prepareAdminStoreGraphQLContext({store}) + + expect(loadStoredStoreSession).toHaveBeenCalledWith(store) + expect(fetchApiVersions).toHaveBeenCalledWith({ + token: 'token', + storeFqdn: store, + }) + expect(result).toEqual({ + adminSession: { + token: 'token', + storeFqdn: store, + }, + version: '2025-10', + session: storedSession, + }) + }) + + test('uses the loaded refreshed session for both admin auth and returned context', async () => { + const refreshedSession = { + ...storedSession, + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + expiresAt: '2026-04-03T00:00:00.000Z', + } + vi.mocked(loadStoredStoreSession).mockResolvedValue(refreshedSession) + + const result = await prepareAdminStoreGraphQLContext({store}) + + expect(fetchApiVersions).toHaveBeenCalledWith({ + token: 'fresh-token', + storeFqdn: store, + }) + expect(result.adminSession.token).toBe('fresh-token') + expect(result.session).toEqual(refreshedSession) + }) + + test('returns the requested API version when provided', async () => { + const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '2025-07'}) + + expect(result.version).toBe('2025-07') + }) + + test('allows unstable without validating against fetched versions', async () => { + const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: 'unstable'}) + + expect(result.version).toBe('unstable') + expect(fetchApiVersions).not.toHaveBeenCalled() + }) + + test('clears the current stored auth and prompts re-auth with real scopes when API version lookup gets invalid auth', async () => { + vi.mocked(fetchApiVersions).mockRejectedValue( + new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`), + ) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ + message: `Stored app authentication for ${store} is no longer valid.`, + tryMessage: 'To re-authenticate, run:', + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products,write_orders`}]], + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + + test('rethrows unrelated API version lookup failures', async () => { + vi.mocked(fetchApiVersions).mockRejectedValue(new AbortError('upstream exploded')) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('upstream exploded') + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('throws when the requested API version is invalid', async () => { + await expect(prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '1999-01'})).rejects.toThrow( + 'Invalid API version', + ) + }) +}) diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.ts b/packages/cli/src/cli/services/store/execute/admin-context.ts similarity index 80% rename from packages/cli/src/cli/services/store/admin-graphql-context.ts rename to packages/cli/src/cli/services/store/execute/admin-context.ts index e0a493fbc09..dfabaa2eb6f 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.ts +++ b/packages/cli/src/cli/services/store/execute/admin-context.ts @@ -1,16 +1,15 @@ import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {AbortError} from '@shopify/cli-kit/node/error' -import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' -import {AdminSession} from '@shopify/cli-kit/node/session' -import {reauthenticateStoreAuthError} from './auth/recovery.js' -import {clearStoredStoreAppSession} from './auth/session-store.js' -import type {StoredStoreAppSession} from './auth/session-store.js' -import {loadStoredStoreSession} from './auth/session-lifecycle.js' +import type {AdminSession} from '@shopify/cli-kit/node/session' +import {reauthenticateStoreAuthError} from '../auth/recovery.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import type {StoredStoreAppSession} from '../auth/session-store.js' +import {loadStoredStoreSession} from '../auth/session-lifecycle.js' export interface AdminStoreGraphQLContext { adminSession: AdminSession version: string - sessionUserId: string + session: StoredStoreAppSession } async function resolveApiVersion(options: { @@ -64,5 +63,5 @@ export async function prepareAdminStoreGraphQLContext(input: { } const version = await resolveApiVersion({session, adminSession, userSpecifiedVersion: input.userSpecifiedVersion}) - return {adminSession, version, sessionUserId: session.userId} + return {adminSession, version, session} } diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts b/packages/cli/src/cli/services/store/execute/admin-transport.test.ts similarity index 68% rename from packages/cli/src/cli/services/store/admin-graphql-transport.test.ts rename to packages/cli/src/cli/services/store/execute/admin-transport.test.ts index 3a994321fce..0d39950d7aa 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts +++ b/packages/cli/src/cli/services/store/execute/admin-transport.test.ts @@ -2,11 +2,12 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {clearStoredStoreAppSession} from './auth/session-store.js' -import {prepareStoreExecuteRequest} from './execute-request.js' -import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import {prepareStoreExecuteRequest} from './request.js' +import {runAdminStoreGraphQLOperation} from './admin-transport.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' -vi.mock('./auth/session-store.js') +vi.mock('../auth/session-store.js') vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/api/admin', async () => { @@ -19,7 +20,18 @@ vi.mock('@shopify/cli-kit/node/api/admin', async () => { describe('runAdminStoreGraphQLOperation', () => { const store = 'shop.myshopify.com' - const adminSession = {token: 'token', storeFqdn: store} + const context = { + adminSession: {token: 'token', storeFqdn: store}, + version: '2025-10', + session: { + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + scopes: ['read_products', 'write_orders'], + acquiredAt: '2026-03-27T00:00:00.000Z', + }, + } beforeEach(() => { vi.clearAllMocks() @@ -31,13 +43,7 @@ describe('runAdminStoreGraphQLOperation', () => { vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) - const result = await runAdminStoreGraphQLOperation({ - store, - adminSession, - sessionUserId: '42', - version: '2025-10', - request, - }) + const result = await runAdminStoreGraphQLOperation({context, request}) expect(result).toEqual({data: {shop: {name: 'Test shop'}}}) expect(graphqlRequest).toHaveBeenCalledWith({ @@ -50,16 +56,14 @@ describe('runAdminStoreGraphQLOperation', () => { }) }) - test('clears stored auth and throws a re-auth error on 401', async () => { + test('clears stored auth and throws a re-auth error on 401 using the real session scopes', async () => { vi.mocked(graphqlRequest).mockRejectedValue({response: {status: 401}}) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) - await expect( - runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toMatchObject({ + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toMatchObject({ message: `Stored app authentication for ${store} is no longer valid.`, tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes `}]], + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products,write_orders`}]], }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) @@ -68,17 +72,13 @@ describe('runAdminStoreGraphQLOperation', () => { vi.mocked(graphqlRequest).mockRejectedValue({response: {errors: [{message: 'Field does not exist'}]}}) const request = await prepareStoreExecuteRequest({query: 'query { nope }'}) - await expect( - runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toThrow('GraphQL operation failed.') + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toThrow('GraphQL operation failed.') }) test('rethrows non-GraphQL errors', async () => { vi.mocked(graphqlRequest).mockRejectedValue(new Error('boom')) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) - await expect( - runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toThrow('boom') + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toThrow('boom') }) }) diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.ts b/packages/cli/src/cli/services/store/execute/admin-transport.ts similarity index 66% rename from packages/cli/src/cli/services/store/admin-graphql-transport.ts rename to packages/cli/src/cli/services/store/execute/admin-transport.ts index 6c1a3f8a5db..9ba1dced9c1 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.ts +++ b/packages/cli/src/cli/services/store/execute/admin-transport.ts @@ -2,11 +2,11 @@ import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent} from '@shopify/cli-kit/node/output' -import {AdminSession} from '@shopify/cli-kit/node/session' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {reauthenticateStoreAuthError} from './auth/recovery.js' -import {PreparedStoreExecuteRequest} from './execute-request.js' -import {clearStoredStoreAppSession} from './auth/session-store.js' +import {reauthenticateStoreAuthError} from '../auth/recovery.js' +import {clearStoredStoreAppSession} from '../auth/session-store.js' +import type {PreparedStoreExecuteRequest} from './request.js' +import type {AdminStoreGraphQLContext} from './admin-context.js' function isGraphQLClientError(error: unknown): error is {response: {errors?: unknown; status?: number}} { if (!error || typeof error !== 'object' || !('response' in error)) return false @@ -15,10 +15,7 @@ function isGraphQLClientError(error: unknown): error is {response: {errors?: unk } export async function runAdminStoreGraphQLOperation(input: { - store: string - adminSession: AdminSession - sessionUserId: string - version: string + context: AdminStoreGraphQLContext request: PreparedStoreExecuteRequest }): Promise { try { @@ -28,8 +25,8 @@ export async function runAdminStoreGraphQLOperation(input: { return graphqlRequest({ query: input.request.query, api: 'Admin', - url: adminUrl(input.adminSession.storeFqdn, input.version, input.adminSession), - token: input.adminSession.token, + url: adminUrl(input.context.adminSession.storeFqdn, input.context.version, input.context.adminSession), + token: input.context.adminSession.token, variables: input.request.parsedVariables, responseOptions: {handleErrors: false}, }) @@ -38,11 +35,11 @@ export async function runAdminStoreGraphQLOperation(input: { }) } catch (error) { if (isGraphQLClientError(error) && error.response.status === 401) { - clearStoredStoreAppSession(input.store, input.sessionUserId) + clearStoredStoreAppSession(input.context.session.store, input.context.session.userId) throw reauthenticateStoreAuthError( - `Stored app authentication for ${input.store} is no longer valid.`, - input.store, - '', + `Stored app authentication for ${input.context.session.store} is no longer valid.`, + input.context.session.store, + input.context.session.scopes.join(','), ) } diff --git a/packages/cli/src/cli/services/store/execute/index.test.ts b/packages/cli/src/cli/services/store/execute/index.test.ts new file mode 100644 index 00000000000..5c69b6fa0c3 --- /dev/null +++ b/packages/cli/src/cli/services/store/execute/index.test.ts @@ -0,0 +1,77 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {executeStoreOperation} from './index.js' +import {prepareStoreExecuteRequest} from './request.js' +import {writeOrOutputStoreExecuteResult} from './result.js' +import {getStoreGraphQLTarget} from './targets.js' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('./request.js') +vi.mock('./result.js') +vi.mock('./targets.js') +vi.mock('@shopify/cli-kit/node/ui') + +describe('executeStoreOperation', () => { + const request = { + query: 'query { shop { name } }', + parsedOperation: {operationDefinition: {operation: 'query'}}, + parsedVariables: {id: 'gid://shopify/Shop/1'}, + requestedVersion: '2025-10', + outputFile: '/tmp/result.json', + } as any + const context = {kind: 'admin-context'} as any + const result = {data: {shop: {name: 'Test shop'}}} + const target = { + prepareContext: vi.fn(), + execute: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(prepareStoreExecuteRequest).mockResolvedValue(request) + vi.mocked(getStoreGraphQLTarget).mockReturnValue(target as any) + target.prepareContext.mockResolvedValue(context) + target.execute.mockResolvedValue(result) + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) + }) + + afterEach(() => { + mockAndCaptureOutput().clear() + }) + + test('prepares the request, loads context, executes the target, and writes the result', async () => { + await executeStoreOperation({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + variables: '{"id":"gid://shopify/Shop/1"}', + outputFile: '/tmp/result.json', + version: '2025-10', + }) + + expect(getStoreGraphQLTarget).toHaveBeenCalledWith('admin') + expect(prepareStoreExecuteRequest).toHaveBeenCalledWith({ + query: 'query { shop { name } }', + queryFile: undefined, + variables: '{"id":"gid://shopify/Shop/1"}', + variableFile: undefined, + outputFile: '/tmp/result.json', + version: '2025-10', + allowMutations: undefined, + }) + expect(target.prepareContext).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + requestedVersion: '2025-10', + }) + expect(target.execute).toHaveBeenCalledWith({context, request}) + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith(result, '/tmp/result.json') + }) + + test('defaults to the admin target', async () => { + await executeStoreOperation({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + }) + + expect(getStoreGraphQLTarget).toHaveBeenCalledWith('admin') + }) +}) diff --git a/packages/cli/src/cli/services/store/execute.ts b/packages/cli/src/cli/services/store/execute/index.ts similarity index 78% rename from packages/cli/src/cli/services/store/execute.ts rename to packages/cli/src/cli/services/store/execute/index.ts index 899bf5a662d..a25556e2de9 100644 --- a/packages/cli/src/cli/services/store/execute.ts +++ b/packages/cli/src/cli/services/store/execute/index.ts @@ -1,8 +1,8 @@ import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {outputContent} from '@shopify/cli-kit/node/output' -import {prepareStoreExecuteRequest} from './execute-request.js' -import {writeOrOutputStoreExecuteResult} from './execute-result.js' -import {getStoreGraphQLTarget, StoreGraphQLApi} from './graphql-targets.js' +import {prepareStoreExecuteRequest} from './request.js' +import {writeOrOutputStoreExecuteResult} from './result.js' +import {getStoreGraphQLTarget, type StoreGraphQLApi} from './targets.js' interface ExecuteStoreOperationInput { store: string @@ -35,11 +35,7 @@ export async function executeStoreOperation(input: ExecuteStoreOperationInput): renderOptions: {stdout: process.stderr}, }) - const result = await target.execute({ - store: input.store, - context, - request, - }) + const result = await target.execute({context, request}) await writeOrOutputStoreExecuteResult(result, request.outputFile) } diff --git a/packages/cli/src/cli/services/store/execute-request.test.ts b/packages/cli/src/cli/services/store/execute/request.test.ts similarity index 98% rename from packages/cli/src/cli/services/store/execute-request.test.ts rename to packages/cli/src/cli/services/store/execute/request.test.ts index 7146746bced..df625bb994f 100644 --- a/packages/cli/src/cli/services/store/execute-request.test.ts +++ b/packages/cli/src/cli/services/store/execute/request.test.ts @@ -1,6 +1,6 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {fileExists, readFile} from '@shopify/cli-kit/node/fs' -import {prepareStoreExecuteRequest} from './execute-request.js' +import {prepareStoreExecuteRequest} from './request.js' vi.mock('@shopify/cli-kit/node/fs') diff --git a/packages/cli/src/cli/services/store/execute-request.ts b/packages/cli/src/cli/services/store/execute/request.ts similarity index 100% rename from packages/cli/src/cli/services/store/execute-request.ts rename to packages/cli/src/cli/services/store/execute/request.ts diff --git a/packages/cli/src/cli/services/store/execute-result.test.ts b/packages/cli/src/cli/services/store/execute/result.test.ts similarity index 94% rename from packages/cli/src/cli/services/store/execute-result.test.ts rename to packages/cli/src/cli/services/store/execute/result.test.ts index 2a370560acf..bbde05a73cf 100644 --- a/packages/cli/src/cli/services/store/execute-result.test.ts +++ b/packages/cli/src/cli/services/store/execute/result.test.ts @@ -2,7 +2,7 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {writeFile} from '@shopify/cli-kit/node/fs' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -import {writeOrOutputStoreExecuteResult} from './execute-result.js' +import {writeOrOutputStoreExecuteResult} from './result.js' vi.mock('@shopify/cli-kit/node/fs') vi.mock('@shopify/cli-kit/node/ui') diff --git a/packages/cli/src/cli/services/store/execute-result.ts b/packages/cli/src/cli/services/store/execute/result.ts similarity index 100% rename from packages/cli/src/cli/services/store/execute-result.ts rename to packages/cli/src/cli/services/store/execute/result.ts diff --git a/packages/cli/src/cli/services/store/graphql-targets.test.ts b/packages/cli/src/cli/services/store/execute/targets.test.ts similarity index 57% rename from packages/cli/src/cli/services/store/graphql-targets.test.ts rename to packages/cli/src/cli/services/store/execute/targets.test.ts index a5fba30398d..28fc67d8b92 100644 --- a/packages/cli/src/cli/services/store/graphql-targets.test.ts +++ b/packages/cli/src/cli/services/store/execute/targets.test.ts @@ -1,11 +1,12 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' -import {prepareStoreExecuteRequest} from './execute-request.js' -import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' -import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' -import {getStoreGraphQLTarget} from './graphql-targets.js' +import {prepareStoreExecuteRequest} from './request.js' +import {prepareAdminStoreGraphQLContext} from './admin-context.js' +import {runAdminStoreGraphQLOperation} from './admin-transport.js' +import {getStoreGraphQLTarget} from './targets.js' +import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' -vi.mock('./admin-graphql-context.js') -vi.mock('./admin-graphql-transport.js') +vi.mock('./admin-context.js') +vi.mock('./admin-transport.js') describe('getStoreGraphQLTarget', () => { beforeEach(() => { @@ -18,28 +19,26 @@ describe('getStoreGraphQLTarget', () => { const context = { adminSession: {token: 'token', storeFqdn: 'shop.myshopify.com'}, version: '2025-10', - sessionUserId: '42', + session: { + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + }, } vi.mocked(prepareAdminStoreGraphQLContext).mockResolvedValue(context) vi.mocked(runAdminStoreGraphQLOperation).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) await expect(target.prepareContext({store: 'shop.myshopify.com', requestedVersion: '2025-10'})).resolves.toEqual(context) - - await expect(target.execute({store: 'shop.myshopify.com', context, request})).resolves.toEqual({ - data: {shop: {name: 'Test shop'}}, - }) + await expect(target.execute({context, request})).resolves.toEqual({data: {shop: {name: 'Test shop'}}}) expect(prepareAdminStoreGraphQLContext).toHaveBeenCalledWith({ store: 'shop.myshopify.com', userSpecifiedVersion: '2025-10', }) - expect(runAdminStoreGraphQLOperation).toHaveBeenCalledWith({ - store: 'shop.myshopify.com', - adminSession: context.adminSession, - sessionUserId: context.sessionUserId, - version: context.version, - request, - }) + expect(runAdminStoreGraphQLOperation).toHaveBeenCalledWith({context, request}) }) }) diff --git a/packages/cli/src/cli/services/store/graphql-targets.ts b/packages/cli/src/cli/services/store/execute/targets.ts similarity index 61% rename from packages/cli/src/cli/services/store/graphql-targets.ts rename to packages/cli/src/cli/services/store/execute/targets.ts index 809568d7e2e..125a86410b1 100644 --- a/packages/cli/src/cli/services/store/graphql-targets.ts +++ b/packages/cli/src/cli/services/store/execute/targets.ts @@ -1,7 +1,7 @@ import {BugError} from '@shopify/cli-kit/node/error' -import {PreparedStoreExecuteRequest} from './execute-request.js' -import {prepareAdminStoreGraphQLContext, AdminStoreGraphQLContext} from './admin-graphql-context.js' -import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' +import type {PreparedStoreExecuteRequest} from './request.js' +import {prepareAdminStoreGraphQLContext, type AdminStoreGraphQLContext} from './admin-context.js' +import {runAdminStoreGraphQLOperation} from './admin-transport.js' export type StoreGraphQLApi = 'admin' @@ -11,13 +11,10 @@ interface PrepareStoreGraphQLTargetContextInput { } interface ExecuteStoreGraphQLTargetInput { - store: string context: TContext request: PreparedStoreExecuteRequest } -// Internal seam for store-scoped GraphQL APIs. Different targets may need different -// auth/context preparation and execution behavior, so each target owns both phases. interface StoreGraphQLTarget { id: StoreGraphQLApi prepareContext(input: PrepareStoreGraphQLTargetContextInput): Promise @@ -29,14 +26,8 @@ const adminStoreGraphQLTarget: StoreGraphQLTarget = { prepareContext: async ({store, requestedVersion}) => { return prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: requestedVersion}) }, - execute: async ({store, context, request}) => { - return runAdminStoreGraphQLOperation({ - store, - adminSession: context.adminSession, - sessionUserId: context.sessionUserId, - version: context.version, - request, - }) + execute: async ({context, request}) => { + return runAdminStoreGraphQLOperation({context, request}) }, } From 5d836bb6fc865d2caaa0d208511266f5b8d32855 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Fri, 3 Apr 2026 16:05:05 -0400 Subject: [PATCH 4/5] Add/enforce --json for store commands --- .../interfaces/store-auth.interface.ts | 6 ++ .../interfaces/store-execute.interface.ts | 6 ++ .../generated/generated_docs_data.json | 22 +++- packages/cli/README.md | 10 +- packages/cli/oclif.manifest.json | 24 ++++- .../cli/src/cli/commands/store/auth.test.ts | 32 +++++- packages/cli/src/cli/commands/store/auth.ts | 22 ++-- .../src/cli/commands/store/execute.test.ts | 24 ++++- .../cli/src/cli/commands/store/execute.ts | 10 +- .../src/cli/services/store/auth/index.test.ts | 19 +++- .../cli/src/cli/services/store/auth/index.ts | 56 ++++------ .../cli/services/store/auth/result.test.ts | 102 ++++++++++++++++++ .../cli/src/cli/services/store/auth/result.ts | 71 ++++++++++++ .../cli/services/store/execute/index.test.ts | 22 ++-- .../src/cli/services/store/execute/index.ts | 9 +- .../services/store/execute/request.test.ts | 3 +- .../src/cli/services/store/execute/request.ts | 3 - .../cli/services/store/execute/result.test.ts | 63 ++++++++++- .../src/cli/services/store/execute/result.ts | 32 ++++-- 19 files changed, 440 insertions(+), 96 deletions(-) create mode 100644 packages/cli/src/cli/services/store/auth/result.test.ts create mode 100644 packages/cli/src/cli/services/store/auth/result.ts diff --git a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts index e38a6d147e8..66957c80ac8 100644 --- a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts @@ -1,5 +1,11 @@ // This is an autogenerated file. Don't edit this file manually. export interface storeauth { + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + /** * Disable color output. * @environment SHOPIFY_FLAG_NO_COLOR diff --git a/docs-shopify.dev/commands/interfaces/store-execute.interface.ts b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts index 3bff68a8f95..39550404db6 100644 --- a/docs-shopify.dev/commands/interfaces/store-execute.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts @@ -6,6 +6,12 @@ export interface storeexecute { */ '--allow-mutations'?: '' + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + /** * Disable color output. * @environment SHOPIFY_FLAG_NO_COLOR diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 75162409d11..caac62cb2d3 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -5829,6 +5829,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_VERBOSE" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "\"\"", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts", "syntaxKind": "PropertySignature", @@ -5838,7 +5847,7 @@ "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface storeauth {\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes ': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface storeauth {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes ': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } } } @@ -5938,6 +5947,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_VERSION" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "\"\"", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", "syntaxKind": "PropertySignature", @@ -5965,7 +5983,7 @@ "environmentValue": "SHOPIFY_FLAG_VARIABLES" } ], - "value": "export interface storeexecute {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store to execute against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" + "value": "export interface storeexecute {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store to execute against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } } } diff --git a/packages/cli/README.md b/packages/cli/README.md index ce2d7756c14..9a25dfeb007 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2057,9 +2057,10 @@ Authenticate an app against a store for store commands. ``` USAGE - $ shopify store auth --scopes -s [--no-color] [--verbose] + $ shopify store auth --scopes -s [-j] [--no-color] [--verbose] FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate against. --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. @@ -2076,6 +2077,8 @@ DESCRIPTION EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products + + $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products --json ``` ## `shopify store execute` @@ -2084,10 +2087,11 @@ Execute GraphQL queries and mutations on a store. ``` USAGE - $ shopify store execute -s [--allow-mutations] [--no-color] [--output-file ] [-q ] + $ shopify store execute -s [--allow-mutations] [-j] [--no-color] [--output-file ] [-q ] [--query-file ] [--variable-file | -v ] [--verbose] [--version ] FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. -q, --query= [env: SHOPIFY_FLAG_QUERY] The GraphQL query or mutation, as a string. -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to execute against. @@ -2121,6 +2125,8 @@ EXAMPLES $ shopify store execute --store shop.myshopify.com --query-file ./operation.graphql --variables '{"id":"gid://shopify/Product/1"}' $ shopify store execute --store shop.myshopify.com --query "mutation { shop { id } }" --allow-mutations + + $ shopify store execute --store shop.myshopify.com --query "query { shop { name } }" --json ``` ## `shopify theme check` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index b9be73bdcd4..82dbca3b896 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5743,9 +5743,19 @@ "descriptionWithMarkdown": "Authenticates the app against the specified store for store commands and stores an online access token for later reuse.\n\nRe-run this command if the stored token is missing, expires, or no longer has the scopes you need.", "enableJsonFlag": false, "examples": [ - "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products" + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json" ], "flags": { + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "no-color": { "allowNo": false, "description": "Disable color output.", @@ -5803,7 +5813,8 @@ "examples": [ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\"", "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query-file ./operation.graphql --variables '{\"id\":\"gid://shopify/Product/1\"}'", - "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"mutation { shop { id } }\" --allow-mutations" + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"mutation { shop { id } }\" --allow-mutations", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\" --json" ], "flags": { "allow-mutations": { @@ -5813,6 +5824,15 @@ "name": "allow-mutations", "type": "boolean" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "no-color": { "allowNo": false, "description": "Disable color output.", diff --git a/packages/cli/src/cli/commands/store/auth.test.ts b/packages/cli/src/cli/commands/store/auth.test.ts index 21701dff48b..d3b5276b1ba 100644 --- a/packages/cli/src/cli/commands/store/auth.test.ts +++ b/packages/cli/src/cli/commands/store/auth.test.ts @@ -1,8 +1,12 @@ -import {describe, test, expect, vi, beforeEach} from 'vitest' +import {beforeEach, describe, expect, test, vi} from 'vitest' import StoreAuth from './auth.js' import {authenticateStoreWithApp} from '../../services/store/auth/index.js' +import {createStoreAuthPresenter} from '../../services/store/auth/result.js' vi.mock('../../services/store/auth/index.js') +vi.mock('../../services/store/auth/result.js', () => ({ + createStoreAuthPresenter: vi.fn((format: 'text' | 'json') => ({format})), +})) describe('store auth command', () => { beforeEach(() => { @@ -12,15 +16,33 @@ describe('store auth command', () => { test('passes parsed flags through to the auth service', async () => { await StoreAuth.run(['--store', 'shop.myshopify.com', '--scopes', 'read_products,write_products']) - expect(authenticateStoreWithApp).toHaveBeenCalledWith({ - store: 'shop.myshopify.com', - scopes: 'read_products,write_products', - }) + expect(createStoreAuthPresenter).toHaveBeenCalledWith('text') + expect(authenticateStoreWithApp).toHaveBeenCalledWith( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }, + {presenter: {format: 'text'}}, + ) + }) + + test('passes a json presenter when --json is provided', async () => { + await StoreAuth.run(['--store', 'shop.myshopify.com', '--scopes', 'read_products', '--json']) + + expect(createStoreAuthPresenter).toHaveBeenCalledWith('json') + expect(authenticateStoreWithApp).toHaveBeenCalledWith( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + {presenter: {format: 'json'}}, + ) }) test('defines the expected flags', () => { expect(StoreAuth.flags.store).toBeDefined() expect(StoreAuth.flags.scopes).toBeDefined() + expect(StoreAuth.flags.json).toBeDefined() expect('port' in StoreAuth.flags).toBe(false) expect('client-secret-file' in StoreAuth.flags).toBe(false) }) diff --git a/packages/cli/src/cli/commands/store/auth.ts b/packages/cli/src/cli/commands/store/auth.ts index 6b46ece1257..a42d4dc327e 100644 --- a/packages/cli/src/cli/commands/store/auth.ts +++ b/packages/cli/src/cli/commands/store/auth.ts @@ -1,8 +1,9 @@ import Command from '@shopify/cli-kit/node/base-command' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' import {authenticateStoreWithApp} from '../../services/store/auth/index.js' +import {createStoreAuthPresenter} from '../../services/store/auth/result.js' export default class StoreAuth extends Command { static summary = 'Authenticate an app against a store for store commands.' @@ -13,10 +14,14 @@ Re-run this command if the stored token is missing, expires, or no longer has th static description = this.descriptionWithoutMarkdown() - static examples = ['<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products'] + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json', + ] static flags = { ...globalFlags, + ...jsonFlag, store: Flags.string({ char: 's', description: 'The myshopify.com domain of the store to authenticate against.', @@ -34,9 +39,14 @@ Re-run this command if the stored token is missing, expires, or no longer has th async run(): Promise { const {flags} = await this.parse(StoreAuth) - await authenticateStoreWithApp({ - store: flags.store, - scopes: flags.scopes, - }) + await authenticateStoreWithApp( + { + store: flags.store, + scopes: flags.scopes, + }, + { + presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'), + }, + ) } } diff --git a/packages/cli/src/cli/commands/store/execute.test.ts b/packages/cli/src/cli/commands/store/execute.test.ts index 4d7bd1e2ba7..ffd339ac323 100644 --- a/packages/cli/src/cli/commands/store/execute.test.ts +++ b/packages/cli/src/cli/commands/store/execute.test.ts @@ -1,15 +1,18 @@ -import {describe, test, expect, vi, beforeEach} from 'vitest' +import {beforeEach, describe, expect, test, vi} from 'vitest' import StoreExecute from './execute.js' import {executeStoreOperation} from '../../services/store/execute/index.js' +import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js' vi.mock('../../services/store/execute/index.js') +vi.mock('../../services/store/execute/result.js') describe('store execute command', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(executeStoreOperation).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) }) - test('passes the inline query through to the service', async () => { + test('passes the inline query through to the service and writes the result', async () => { await StoreExecute.run(['--store', 'shop.myshopify.com', '--query', 'query { shop { name } }']) expect(executeStoreOperation).toHaveBeenCalledWith({ @@ -18,10 +21,14 @@ describe('store execute command', () => { queryFile: undefined, variables: undefined, variableFile: undefined, - outputFile: undefined, version: undefined, allowMutations: false, }) + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith( + {data: {shop: {name: 'Test shop'}}}, + undefined, + 'text', + ) }) test('passes the query file through to the service', async () => { @@ -36,6 +43,16 @@ describe('store execute command', () => { ) }) + test('writes json output when --json is provided', async () => { + await StoreExecute.run(['--store', 'shop.myshopify.com', '--query', 'query { shop { name } }', '--json']) + + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith( + {data: {shop: {name: 'Test shop'}}}, + undefined, + 'json', + ) + }) + test('defines the expected flags', () => { expect(StoreExecute.flags.store).toBeDefined() expect(StoreExecute.flags.query).toBeDefined() @@ -43,5 +60,6 @@ describe('store execute command', () => { expect(StoreExecute.flags.variables).toBeDefined() expect(StoreExecute.flags['variable-file']).toBeDefined() expect(StoreExecute.flags['allow-mutations']).toBeDefined() + expect(StoreExecute.flags.json).toBeDefined() }) }) diff --git a/packages/cli/src/cli/commands/store/execute.ts b/packages/cli/src/cli/commands/store/execute.ts index bffc3e21ec4..a184d6d3d8a 100644 --- a/packages/cli/src/cli/commands/store/execute.ts +++ b/packages/cli/src/cli/commands/store/execute.ts @@ -1,9 +1,10 @@ import Command from '@shopify/cli-kit/node/base-command' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {resolvePath} from '@shopify/cli-kit/node/path' import {Flags} from '@oclif/core' import {executeStoreOperation} from '../../services/store/execute/index.js' +import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js' export default class StoreExecute extends Command { static summary = 'Execute GraphQL queries and mutations on a store.' @@ -20,10 +21,12 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "query { shop { name } }"', `<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query-file ./operation.graphql --variables '{"id":"gid://shopify/Product/1"}'`, '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "mutation { shop { id } }" --allow-mutations', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "query { shop { name } }" --json', ] static flags = { ...globalFlags, + ...jsonFlag, query: Flags.string({ char: 'q', description: 'The GraphQL query or mutation, as a string.', @@ -75,15 +78,16 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte async run(): Promise { const {flags} = await this.parse(StoreExecute) - await executeStoreOperation({ + const result = await executeStoreOperation({ store: flags.store, query: flags.query, queryFile: flags['query-file'], variables: flags.variables, variableFile: flags['variable-file'], - outputFile: flags['output-file'], version: flags.version, allowMutations: flags['allow-mutations'], }) + + await writeOrOutputStoreExecuteResult(result, flags['output-file'], flags.json ? 'json' : 'text') } } diff --git a/packages/cli/src/cli/services/store/auth/index.test.ts b/packages/cli/src/cli/services/store/auth/index.test.ts index 3d438c29770..2a4323cb4e4 100644 --- a/packages/cli/src/cli/services/store/auth/index.test.ts +++ b/packages/cli/src/cli/services/store/auth/index.test.ts @@ -16,7 +16,7 @@ describe('store auth service', () => { vi.restoreAllMocks() }) - test('authenticateStoreWithApp opens the browser and stores the session with refresh token', async () => { + test('authenticateStoreWithApp opens the browser, stores the session, and returns auth result', async () => { const openURL = vi.fn().mockResolvedValue(true) const presenter = { openingBrowser: vi.fn(), @@ -28,7 +28,7 @@ describe('store auth service', () => { return 'abc123' }) - await authenticateStoreWithApp( + const result = await authenticateStoreWithApp( { store: 'shop.myshopify.com', scopes: 'read_products', @@ -50,7 +50,16 @@ describe('store auth service', () => { expect(presenter.openingBrowser).toHaveBeenCalledOnce() expect(openURL).toHaveBeenCalledWith(expect.stringContaining('/admin/oauth/authorize?')) expect(presenter.manualAuthUrl).not.toHaveBeenCalled() - expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') + expect(result).toEqual( + expect.objectContaining({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + hasRefreshToken: true, + associatedUser: expect.objectContaining({email: 'test@example.com'}), + }), + ) + expect(presenter.success).toHaveBeenCalledWith(result) const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] expect(storedSession.store).toBe('shop.myshopify.com') @@ -239,7 +248,7 @@ describe('store auth service', () => { return 'abc123' }) - await authenticateStoreWithApp( + const result = await authenticateStoreWithApp( { store: 'shop.myshopify.com', scopes: 'read_products', @@ -261,7 +270,7 @@ describe('store auth service', () => { expect(presenter.manualAuthUrl).toHaveBeenCalledWith( expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'), ) - expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') + expect(presenter.success).toHaveBeenCalledWith(result) }) test('authenticateStoreWithApp rejects when Shopify grants fewer scopes than requested', async () => { diff --git a/packages/cli/src/cli/services/store/auth/index.ts b/packages/cli/src/cli/services/store/auth/index.ts index d96e287f364..eabd3bf3b37 100644 --- a/packages/cli/src/cli/services/store/auth/index.ts +++ b/packages/cli/src/cli/services/store/auth/index.ts @@ -1,6 +1,6 @@ import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {AbortError} from '@shopify/cli-kit/node/error' -import {outputCompleted, outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {openURL} from '@shopify/cli-kit/node/system' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' import {setStoredStoreAppSession} from './session-store.js' @@ -9,18 +9,13 @@ import {waitForStoreAuthCode} from './callback.js' import {createPkceBootstrap} from './pkce.js' import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js' import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js' +import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult} from './result.js' interface StoreAuthInput { store: string scopes: string } -interface StoreAuthPresenter { - openingBrowser: () => void - manualAuthUrl: (authorizationUrl: string) => void - success: (store: string, email?: string) => void -} - interface StoreAuthDependencies { openURL: typeof openURL waitForStoreAuthCode: typeof waitForStoreAuthCode @@ -29,39 +24,18 @@ interface StoreAuthDependencies { presenter: StoreAuthPresenter } -const defaultStoreAuthPresenter: StoreAuthPresenter = { - openingBrowser() { - outputInfo('Shopify CLI will open the app authorization page in your browser.') - outputInfo('') - }, - manualAuthUrl(authorizationUrl: string) { - outputInfo('Browser did not open automatically. Open this URL manually:') - outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) - outputInfo('') - }, - success(store: string, email?: string) { - const displayName = email ? ` as ${email}` : '' - - outputCompleted('Logged in.') - outputCompleted(`Authenticated${displayName} against ${store}.`) - outputInfo('') - outputInfo('To verify that authentication worked, run:') - outputInfo(`shopify store execute --store ${store} --query 'query { shop { name id } }'`) - }, -} - const defaultStoreAuthDependencies: StoreAuthDependencies = { openURL, waitForStoreAuthCode, exchangeStoreAuthCodeForToken, resolveExistingScopes: resolveExistingStoreAuthScopes, - presenter: defaultStoreAuthPresenter, + presenter: createStoreAuthPresenter('text'), } export async function authenticateStoreWithApp( input: StoreAuthInput, dependencies: Partial = {}, -): Promise { +): Promise { const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies} const store = normalizeStoreFqdn(input.store) const requestedScopes = parseStoreAuthScopes(input.scopes) @@ -103,18 +77,16 @@ export async function authenticateStoreWithApp( const now = Date.now() const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined - setStoredStoreAppSession({ + const result: StoreAuthResult = { store, - clientId: STORE_AUTH_APP_CLIENT_ID, userId, - accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token, scopes: resolveGrantedScopes(tokenResponse, validationScopes), acquiredAt: new Date(now).toISOString(), expiresAt, refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in ? new Date(now + tokenResponse.refresh_token_expires_in * 1000).toISOString() : undefined, + hasRefreshToken: !!tokenResponse.refresh_token, associatedUser: tokenResponse.associated_user ? { id: tokenResponse.associated_user.id, @@ -124,11 +96,25 @@ export async function authenticateStoreWithApp( accountOwner: tokenResponse.associated_user.account_owner, } : undefined, + } + + setStoredStoreAppSession({ + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + scopes: result.scopes, + acquiredAt: result.acquiredAt, + expiresAt, + refreshTokenExpiresAt: result.refreshTokenExpiresAt, + associatedUser: result.associatedUser, }) outputDebug( outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, ) - resolvedDependencies.presenter.success(store, tokenResponse.associated_user?.email) + resolvedDependencies.presenter.success(result) + return result } diff --git a/packages/cli/src/cli/services/store/auth/result.test.ts b/packages/cli/src/cli/services/store/auth/result.test.ts new file mode 100644 index 00000000000..83c1e9e9280 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/result.test.ts @@ -0,0 +1,102 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {createStoreAuthPresenter} from './result.js' + +function captureStandardStreams() { + const stdout: string[] = [] + const stderr: string[] = [] + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }, + } +} + +describe('store auth presenter', () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + + beforeEach(() => { + mockAndCaptureOutput().clear() + }) + + afterEach(() => { + process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnv + }) + + test('renders human success output in text mode', () => { + const output = mockAndCaptureOutput() + const presenter = createStoreAuthPresenter('text') + + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) + + expect(output.completed()).toContain('Logged in.') + expect(output.completed()).toContain('Authenticated as merchant@example.com against shop.myshopify.com.') + expect(output.info()).toContain("shopify store execute --store shop.myshopify.com --query 'query { shop { name id } }'") + expect(output.output()).not.toContain('"store": "shop.myshopify.com"') + }) + + test('writes json success output through the result channel', () => { + const output = mockAndCaptureOutput() + const presenter = createStoreAuthPresenter('json') + + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) + + expect(output.output()).toContain('"store": "shop.myshopify.com"') + expect(output.completed()).not.toContain('Authenticated') + expect(output.info()).not.toContain('shopify store execute') + }) + + test('writes browser guidance to stderr and json success to stdout', () => { + process.env.SHOPIFY_UNIT_TEST = 'false' + const streams = captureStandardStreams() + const presenter = createStoreAuthPresenter('json') + + try { + presenter.openingBrowser() + presenter.manualAuthUrl('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) + } finally { + streams.restore() + } + + expect(streams.stderr()).toContain('Shopify CLI will open the app authorization page in your browser.') + expect(streams.stderr()).toContain('Browser did not open automatically. Open this URL manually:') + expect(streams.stderr()).toContain('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') + expect(streams.stdout()).toContain('"store": "shop.myshopify.com"') + expect(streams.stdout()).not.toContain('Authenticated') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/result.ts b/packages/cli/src/cli/services/store/auth/result.ts new file mode 100644 index 00000000000..4b7eec807e2 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/result.ts @@ -0,0 +1,71 @@ +import {outputCompleted, outputInfo, outputResult, outputToken, outputContent} from '@shopify/cli-kit/node/output' + +export interface StoreAuthResult { + store: string + userId: string + scopes: string[] + acquiredAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + hasRefreshToken: boolean + associatedUser?: { + id: number + email?: string + firstName?: string + lastName?: string + accountOwner?: boolean + } +} + +type StoreAuthOutputFormat = 'text' | 'json' + +export interface StoreAuthPresenter { + openingBrowser: () => void + manualAuthUrl: (authorizationUrl: string) => void + success: (result: StoreAuthResult) => void +} + +function serializeStoreAuthResult(result: StoreAuthResult): string { + return JSON.stringify(result, null, 2) +} + +function buildStoreAuthSuccessText(result: StoreAuthResult): {completed: string[]; info: string[]} { + const displayName = result.associatedUser?.email ? ` as ${result.associatedUser.email}` : '' + + return { + completed: ['Logged in.', `Authenticated${displayName} against ${result.store}.`], + info: ['', 'To verify that authentication worked, run:', `shopify store execute --store ${result.store} --query 'query { shop { name id } }'`], + } +} + +function displayStoreAuthOpeningBrowser(): void { + outputInfo('Shopify CLI will open the app authorization page in your browser.') + outputInfo('') +} + +function displayStoreAuthManualAuthUrl(authorizationUrl: string): void { + outputInfo('Browser did not open automatically. Open this URL manually:') + outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) + outputInfo('') +} + +function displayStoreAuthResult(result: StoreAuthResult, format: StoreAuthOutputFormat = 'text'): void { + if (format === 'json') { + outputResult(serializeStoreAuthResult(result)) + return + } + + const text = buildStoreAuthSuccessText(result) + text.completed.forEach((line) => outputCompleted(line)) + text.info.forEach((line) => outputInfo(line)) +} + +export function createStoreAuthPresenter(format: StoreAuthOutputFormat = 'text'): StoreAuthPresenter { + return { + openingBrowser: displayStoreAuthOpeningBrowser, + manualAuthUrl: displayStoreAuthManualAuthUrl, + success(result: StoreAuthResult) { + displayStoreAuthResult(result, format) + }, + } +} diff --git a/packages/cli/src/cli/services/store/execute/index.test.ts b/packages/cli/src/cli/services/store/execute/index.test.ts index 5c69b6fa0c3..f29b1aed02a 100644 --- a/packages/cli/src/cli/services/store/execute/index.test.ts +++ b/packages/cli/src/cli/services/store/execute/index.test.ts @@ -2,12 +2,10 @@ import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {executeStoreOperation} from './index.js' import {prepareStoreExecuteRequest} from './request.js' -import {writeOrOutputStoreExecuteResult} from './result.js' import {getStoreGraphQLTarget} from './targets.js' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('./request.js') -vi.mock('./result.js') vi.mock('./targets.js') vi.mock('@shopify/cli-kit/node/ui') @@ -17,7 +15,6 @@ describe('executeStoreOperation', () => { parsedOperation: {operationDefinition: {operation: 'query'}}, parsedVariables: {id: 'gid://shopify/Shop/1'}, requestedVersion: '2025-10', - outputFile: '/tmp/result.json', } as any const context = {kind: 'admin-context'} as any const result = {data: {shop: {name: 'Test shop'}}} @@ -39,14 +36,15 @@ describe('executeStoreOperation', () => { mockAndCaptureOutput().clear() }) - test('prepares the request, loads context, executes the target, and writes the result', async () => { - await executeStoreOperation({ - store: 'shop.myshopify.com', - query: 'query { shop { name } }', - variables: '{"id":"gid://shopify/Shop/1"}', - outputFile: '/tmp/result.json', - version: '2025-10', - }) + test('prepares the request, loads context, and returns the execution result', async () => { + await expect( + executeStoreOperation({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + variables: '{"id":"gid://shopify/Shop/1"}', + version: '2025-10', + }), + ).resolves.toEqual(result) expect(getStoreGraphQLTarget).toHaveBeenCalledWith('admin') expect(prepareStoreExecuteRequest).toHaveBeenCalledWith({ @@ -54,7 +52,6 @@ describe('executeStoreOperation', () => { queryFile: undefined, variables: '{"id":"gid://shopify/Shop/1"}', variableFile: undefined, - outputFile: '/tmp/result.json', version: '2025-10', allowMutations: undefined, }) @@ -63,7 +60,6 @@ describe('executeStoreOperation', () => { requestedVersion: '2025-10', }) expect(target.execute).toHaveBeenCalledWith({context, request}) - expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith(result, '/tmp/result.json') }) test('defaults to the admin target', async () => { diff --git a/packages/cli/src/cli/services/store/execute/index.ts b/packages/cli/src/cli/services/store/execute/index.ts index a25556e2de9..1394a37d262 100644 --- a/packages/cli/src/cli/services/store/execute/index.ts +++ b/packages/cli/src/cli/services/store/execute/index.ts @@ -1,7 +1,6 @@ import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {outputContent} from '@shopify/cli-kit/node/output' import {prepareStoreExecuteRequest} from './request.js' -import {writeOrOutputStoreExecuteResult} from './result.js' import {getStoreGraphQLTarget, type StoreGraphQLApi} from './targets.js' interface ExecuteStoreOperationInput { @@ -11,12 +10,11 @@ interface ExecuteStoreOperationInput { queryFile?: string variables?: string variableFile?: string - outputFile?: string version?: string allowMutations?: boolean } -export async function executeStoreOperation(input: ExecuteStoreOperationInput): Promise { +export async function executeStoreOperation(input: ExecuteStoreOperationInput): Promise { const target = getStoreGraphQLTarget(input.api ?? 'admin') const request = await prepareStoreExecuteRequest({ @@ -24,7 +22,6 @@ export async function executeStoreOperation(input: ExecuteStoreOperationInput): queryFile: input.queryFile, variables: input.variables, variableFile: input.variableFile, - outputFile: input.outputFile, version: input.version, allowMutations: input.allowMutations, }) @@ -35,7 +32,5 @@ export async function executeStoreOperation(input: ExecuteStoreOperationInput): renderOptions: {stdout: process.stderr}, }) - const result = await target.execute({context, request}) - - await writeOrOutputStoreExecuteResult(result, request.outputFile) + return await target.execute({context, request}) } diff --git a/packages/cli/src/cli/services/store/execute/request.test.ts b/packages/cli/src/cli/services/store/execute/request.test.ts index df625bb994f..ce995fa9919 100644 --- a/packages/cli/src/cli/services/store/execute/request.test.ts +++ b/packages/cli/src/cli/services/store/execute/request.test.ts @@ -13,16 +13,15 @@ describe('prepareStoreExecuteRequest', () => { const request = await prepareStoreExecuteRequest({ query: 'query { shop { name } }', variables: '{"id":"gid://shopify/Shop/1"}', - outputFile: '/tmp/result.json', version: '2025-07', }) expect(request).toMatchObject({ query: 'query { shop { name } }', parsedVariables: {id: 'gid://shopify/Shop/1'}, - outputFile: '/tmp/result.json', requestedVersion: '2025-07', }) + expect(request).not.toHaveProperty('outputFile') }) test('reads the query from a file', async () => { diff --git a/packages/cli/src/cli/services/store/execute/request.ts b/packages/cli/src/cli/services/store/execute/request.ts index 7524b74b87d..2e605392682 100644 --- a/packages/cli/src/cli/services/store/execute/request.ts +++ b/packages/cli/src/cli/services/store/execute/request.ts @@ -11,7 +11,6 @@ export interface PreparedStoreExecuteRequest { query: string parsedOperation: ParsedGraphQLOperation parsedVariables?: {[key: string]: unknown} - outputFile?: string requestedVersion?: string } @@ -132,7 +131,6 @@ export async function prepareStoreExecuteRequest(input: { queryFile?: string variables?: string variableFile?: string - outputFile?: string version?: string allowMutations?: boolean }): Promise { @@ -145,7 +143,6 @@ export async function prepareStoreExecuteRequest(input: { query, parsedOperation, parsedVariables, - outputFile: input.outputFile, requestedVersion: input.version, } } diff --git a/packages/cli/src/cli/services/store/execute/result.test.ts b/packages/cli/src/cli/services/store/execute/result.test.ts index bbde05a73cf..083e5329019 100644 --- a/packages/cli/src/cli/services/store/execute/result.test.ts +++ b/packages/cli/src/cli/services/store/execute/result.test.ts @@ -1,4 +1,4 @@ -import {beforeEach, describe, expect, test, vi} from 'vitest' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {writeFile} from '@shopify/cli-kit/node/fs' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' @@ -7,12 +7,41 @@ import {writeOrOutputStoreExecuteResult} from './result.js' vi.mock('@shopify/cli-kit/node/fs') vi.mock('@shopify/cli-kit/node/ui') +function captureStandardStreams() { + const stdout: string[] = [] + const stderr: string[] = [] + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }, + } +} + describe('writeOrOutputStoreExecuteResult', () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + beforeEach(() => { vi.clearAllMocks() mockAndCaptureOutput().clear() }) + afterEach(() => { + process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnv + }) + test('writes results to a file when outputFile is provided', async () => { await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, '/tmp/results.json') @@ -29,6 +58,36 @@ describe('writeOrOutputStoreExecuteResult', () => { await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}) expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) - expect(output.info()).toContain('Test shop') + expect(output.output()).toContain('Test shop') + }) + + test('suppresses success rendering in json mode', async () => { + const output = mockAndCaptureOutput() + + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, undefined, 'json') + + expect(renderSuccess).not.toHaveBeenCalled() + expect(output.output()).toContain('Test shop') + }) + + test('writes json results to stdout without writing to stderr', async () => { + process.env.SHOPIFY_UNIT_TEST = 'false' + const streams = captureStandardStreams() + + try { + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, undefined, 'json') + } finally { + streams.restore() + } + + expect(streams.stdout()).toContain('"name": "Test shop"') + expect(streams.stderr()).toBe('') + }) + + test('suppresses success rendering when writing a file in json mode', async () => { + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, '/tmp/results.json', 'json') + + expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) + expect(renderSuccess).not.toHaveBeenCalled() }) }) diff --git a/packages/cli/src/cli/services/store/execute/result.ts b/packages/cli/src/cli/services/store/execute/result.ts index dd284a5deae..47e71bd678f 100644 --- a/packages/cli/src/cli/services/store/execute/result.ts +++ b/packages/cli/src/cli/services/store/execute/result.ts @@ -2,17 +2,37 @@ import {writeFile} from '@shopify/cli-kit/node/fs' import {outputResult} from '@shopify/cli-kit/node/output' import {renderSuccess} from '@shopify/cli-kit/node/ui' -export async function writeOrOutputStoreExecuteResult(result: unknown, outputFile?: string): Promise { - const resultString = JSON.stringify(result, null, 2) +type StoreExecuteOutputFormat = 'text' | 'json' +function serializeStoreExecuteResult(result: unknown): string { + return JSON.stringify(result, null, 2) +} + +function renderStoreExecuteSuccess(outputFile?: string): void { if (outputFile) { - await writeFile(outputFile, resultString) renderSuccess({ headline: 'Operation succeeded.', body: `Results written to ${outputFile}`, }) - } else { - renderSuccess({headline: 'Operation succeeded.'}) - outputResult(resultString) + return } + + renderSuccess({headline: 'Operation succeeded.'}) +} + +export async function writeOrOutputStoreExecuteResult( + result: unknown, + outputFile?: string, + format: StoreExecuteOutputFormat = 'text', +): Promise { + const serializedResult = serializeStoreExecuteResult(result) + + if (outputFile) { + await writeFile(outputFile, serializedResult) + if (format === 'text') renderStoreExecuteSuccess(outputFile) + return + } + + if (format === 'text') renderStoreExecuteSuccess() + outputResult(serializedResult) } From 84875b87acec0edc734b8f33a8e7a40a7377dcd9 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 6 Apr 2026 14:57:56 -0400 Subject: [PATCH 5/5] Prepare 3.93 patch release for store commands --- .changeset/stable-store-commands-patch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/stable-store-commands-patch.md diff --git a/.changeset/stable-store-commands-patch.md b/.changeset/stable-store-commands-patch.md new file mode 100644 index 00000000000..34a5f75c616 --- /dev/null +++ b/.changeset/stable-store-commands-patch.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': patch +--- + +Improve `shopify store` command behavior by preserving existing auth scopes during re-auth and adding JSON output support for store auth and execute flows.