From d2680ce6dfca229da104054aa2056fd8cf231ac4 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Fri, 3 Apr 2026 11:27:32 -0400 Subject: [PATCH] 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 -}