diff --git a/.changeset/theme-access-permanent-domain.md b/.changeset/theme-access-permanent-domain.md new file mode 100644 index 00000000000..88805df1cfa --- /dev/null +++ b/.changeset/theme-access-permanent-domain.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli-kit': patch +'@shopify/theme': patch +--- + +Theme Access passwords now work when `--store` is a store domain other than its permanent `.myshopify.com` domain (for example a renamed `.myshopify.com` domain): the CLI looks up the permanent domain and uses it. When a Theme Access password is rejected, the error now explains how to find the permanent domain. diff --git a/packages/cli-kit/src/private/node/session/permanent-store-domain.test.ts b/packages/cli-kit/src/private/node/session/permanent-store-domain.test.ts new file mode 100644 index 00000000000..2045c4b9ce3 --- /dev/null +++ b/packages/cli-kit/src/private/node/session/permanent-store-domain.test.ts @@ -0,0 +1,144 @@ +import {clearPermanentStoreFqdnCache, resolvePermanentStoreFqdn} from './permanent-store-domain.js' +import {fetch} from '../../../public/node/http.js' +import {mockAndCaptureOutput} from '../../../public/node/testing/output.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {Response} from 'node-fetch' + +vi.mock('../../../public/node/http.js') + +function metaJson(body: unknown, status = 200) { + return new Response(JSON.stringify(body), {status, headers: {'Content-Type': 'application/json'}}) +} + +describe('resolvePermanentStoreFqdn', () => { + beforeEach(() => { + clearPermanentStoreFqdnCache() + mockAndCaptureOutput().clear() + vi.unstubAllEnvs() + }) + + test("returns the store's permanent domain from its public meta.json", async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(fetch).mockResolvedValueOnce(metaJson({myshopify_domain: 'abc123-xy.myshopify.com'})) + + // When + const got = await resolvePermanentStoreFqdn('renamed-store.myshopify.com') + + // Then + expect(got).toEqual('abc123-xy.myshopify.com') + expect(fetch).toHaveBeenCalledWith( + 'https://renamed-store.myshopify.com/meta.json', + {headers: {Accept: 'application/json'}}, + {useNetworkLevelRetry: false, useAbortSignal: true, timeoutMs: 5000}, + ) + expect(outputMock.info()).toContain( + 'Using the permanent domain abc123-xy.myshopify.com for renamed-store.myshopify.com.', + ) + }) + + test('returns the domain unchanged, without a message, when it already is the permanent domain', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(fetch).mockResolvedValueOnce(metaJson({myshopify_domain: 'my-store.myshopify.com'})) + + // When + const got = await resolvePermanentStoreFqdn('my-store.myshopify.com') + + // Then + expect(got).toEqual('my-store.myshopify.com') + expect(outputMock.info()).toEqual('') + }) + + test('normalises the case of the permanent domain', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce(metaJson({myshopify_domain: 'My-Store.myshopify.com'})) + + // When + const got = await resolvePermanentStoreFqdn('alias.myshopify.com') + + // Then + expect(got).toEqual('my-store.myshopify.com') + }) + + test('caches the result for the store and its permanent domain', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce(metaJson({myshopify_domain: 'abc123-xy.myshopify.com'})) + + // When + const first = await resolvePermanentStoreFqdn('renamed-store.myshopify.com') + const second = await resolvePermanentStoreFqdn('renamed-store.myshopify.com') + const third = await resolvePermanentStoreFqdn('abc123-xy.myshopify.com') + + // Then + expect([first, second, third]).toEqual([ + 'abc123-xy.myshopify.com', + 'abc123-xy.myshopify.com', + 'abc123-xy.myshopify.com', + ]) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + test('returns the domain unchanged when meta.json is not a success', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce(metaJson({errors: 'Not Found'}, 404)) + + // When + const got = await resolvePermanentStoreFqdn('my-store.myshopify.com') + + // Then + expect(got).toEqual('my-store.myshopify.com') + }) + + test.each([ + ['is missing', {}], + ['is not a string', {myshopify_domain: 42}], + ['is not a store domain', {myshopify_domain: 'evil.example.com'}], + ['is not a .myshopify.com domain', {myshopify_domain: 'my-store.myshopify.io'}], + ['has more than one label before .myshopify.com', {myshopify_domain: 'evil.my-store.myshopify.com'}], + ['contains a path', {myshopify_domain: 'my-store.myshopify.com/admin'}], + ])('returns the domain unchanged when myshopify_domain %s', async (_description, body) => { + // Given + vi.mocked(fetch).mockResolvedValueOnce(metaJson(body)) + + // When + const got = await resolvePermanentStoreFqdn('alias.myshopify.com') + + // Then + expect(got).toEqual('alias.myshopify.com') + }) + + test('returns the domain unchanged when meta.json is not JSON', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce(new Response('Challenge', {status: 200})) + + // When + const got = await resolvePermanentStoreFqdn('alias.myshopify.com') + + // Then + expect(got).toEqual('alias.myshopify.com') + }) + + test('returns the domain unchanged when the request fails', async () => { + // Given + vi.mocked(fetch).mockRejectedValueOnce(new Error('The operation was aborted')) + + // When + const got = await resolvePermanentStoreFqdn('alias.myshopify.com') + + // Then + expect(got).toEqual('alias.myshopify.com') + }) + + test('does not look up the domain against a local development server', async () => { + // Given + vi.stubEnv('SHOPIFY_SERVICE_ENV', 'local') + + // When + const got = await resolvePermanentStoreFqdn('my-store.shop.dev') + + // Then + expect(got).toEqual('my-store.shop.dev') + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli-kit/src/private/node/session/permanent-store-domain.ts b/packages/cli-kit/src/private/node/session/permanent-store-domain.ts new file mode 100644 index 00000000000..a25222aaf0f --- /dev/null +++ b/packages/cli-kit/src/private/node/session/permanent-store-domain.ts @@ -0,0 +1,87 @@ +import {serviceEnvironment} from '../context/service.js' +import {extractMyshopifyHandle} from '../../../public/common/url.js' +import {fetch} from '../../../public/node/http.js' +import {outputContent, outputDebug, outputInfo, outputToken} from '../../../public/node/output.js' + +/** + * How long to wait for a store's public `/meta.json` before giving up and using the domain as given. + */ +const PERMANENT_DOMAIN_LOOKUP_TIMEOUT_MS = 5000 + +const resolvedStoreFqdns = new Map>() + +/** + * Resolves the permanent `.myshopify.com` domain of a store. + * + * A store can be reached through several domains (a renamed `.myshopify.com` domain, a custom domain), but some + * Shopify services only recognise the store's permanent domain. The Theme Access app is one of them: its passwords + * are tied to the permanent domain, so a request made with any other domain fails with a 401. + * + * The permanent domain is read from the store's public `/meta.json` endpoint, which needs no authentication and is + * served even when the storefront is password protected. When the lookup fails for any reason, the domain is + * returned unchanged, so callers behave exactly as they would without this lookup. + * + * Results are cached for the lifetime of the process. + * + * @param storeFqdn - The store domain the user provided, for example `my-store.myshopify.com`. + * @returns The store's permanent domain, or `storeFqdn` when it cannot be determined. + */ +export async function resolvePermanentStoreFqdn(storeFqdn: string): Promise { + if (serviceEnvironment() === 'local') return storeFqdn + + const cached = resolvedStoreFqdns.get(storeFqdn) + if (cached) return cached + + const resolution = fetchPermanentStoreFqdn(storeFqdn) + resolvedStoreFqdns.set(storeFqdn, resolution) + + const permanentStoreFqdn = await resolution + if (permanentStoreFqdn !== storeFqdn) { + resolvedStoreFqdns.set(permanentStoreFqdn, Promise.resolve(permanentStoreFqdn)) + outputInfo( + outputContent`Using the permanent domain ${outputToken.raw(permanentStoreFqdn)} for ${outputToken.raw( + storeFqdn, + )}.`, + ) + } + return permanentStoreFqdn +} + +/** + * Clears the in-process cache used by {@link resolvePermanentStoreFqdn}. Intended for tests. + */ +export function clearPermanentStoreFqdnCache(): void { + resolvedStoreFqdns.clear() +} + +async function fetchPermanentStoreFqdn(storeFqdn: string): Promise { + const url = `https://${storeFqdn}/meta.json` + try { + const response = await fetch( + url, + {headers: {Accept: 'application/json'}}, + {useNetworkLevelRetry: false, useAbortSignal: true, timeoutMs: PERMANENT_DOMAIN_LOOKUP_TIMEOUT_MS}, + ) + if (!response.ok) { + outputDebug(`Could not look up the permanent domain of ${storeFqdn}: ${url} returned HTTP ${response.status}`) + return storeFqdn + } + + const body = (await response.json()) as {myshopify_domain?: unknown} | null + const permanentStoreFqdn = typeof body?.myshopify_domain === 'string' ? body.myshopify_domain.toLowerCase() : '' + // Accept only a bare `.myshopify.com`: `extractMyshopifyHandle` also accepts a URL with a path, so + // compare the round trip to reject anything but the domain itself. + const handle = extractMyshopifyHandle(permanentStoreFqdn) + if (!handle || `${handle}.myshopify.com` !== permanentStoreFqdn) { + outputDebug(`Could not look up the permanent domain of ${storeFqdn}: ${url} has no valid myshopify_domain`) + return storeFqdn + } + + return permanentStoreFqdn + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + outputDebug(`Could not look up the permanent domain of ${storeFqdn}: ${message}`) + return storeFqdn + } +} diff --git a/packages/cli-kit/src/public/node/api/admin.test.ts b/packages/cli-kit/src/public/node/api/admin.test.ts index 976f8d2bf6a..d7b240b4323 100644 --- a/packages/cli-kit/src/public/node/api/admin.test.ts +++ b/packages/cli-kit/src/public/node/api/admin.test.ts @@ -310,4 +310,25 @@ describe('fetchApiVersions error classification', () => { expect(unauthorized).toBeInstanceOf(AbortError) expect((unauthorized as AbortError).message).toContain('Error connecting to your store') }) + + test('explains a rejected Theme Access password, including how to find the permanent domain', async () => { + // Given + const themeAccessSession: AdminSession = {token: 'shptka_password', storeFqdn: 'alias.myshopify.com'} + vi.mocked(graphqlRequestDoc).mockRejectedValue(clientError(401, 'Unauthorized')) + + // When + const error = await admin.fetchApiVersions(themeAccessSession).catch((err: unknown) => err) + + // Then + expect(error).toBeInstanceOf(AbortError) + expect(shouldReportErrorAsUnexpected(error)).toBe(false) + expect((error as AbortError).message).toBe( + 'The Theme Access password was rejected for the store alias.myshopify.com.', + ) + expect(String((error as AbortError).tryMessage)).toContain('permanent .myshopify.com domain') + const nextSteps = JSON.stringify((error as AbortError).nextSteps) + expect(nextSteps).toContain('Settings > Domains') + // The domain the user passed may not be a real store, so the error must not send them to a URL built from it. + expect(nextSteps).not.toContain('alias.myshopify.com') + }) }) diff --git a/packages/cli-kit/src/public/node/api/admin.ts b/packages/cli-kit/src/public/node/api/admin.ts index c70ad55e1f8..7ed3eda69a8 100644 --- a/packages/cli-kit/src/public/node/api/admin.ts +++ b/packages/cli-kit/src/public/node/api/admin.ts @@ -186,6 +186,23 @@ export async function fetchApiVersions( outputContent`If you're not the owner, create a dev store staff account for yourself`, ) } + if (error instanceof ClientError && error.response.status === 401 && isThemeAccessSession(session)) { + throw new AbortError( + `The Theme Access password was rejected for the store ${session.storeFqdn}.`, + 'Theme Access passwords only work on the store they were generated for, and only with its permanent .myshopify.com domain.', + [ + 'Check that the password was created in the Theme Access app on this store and has not been deleted.', + [ + 'Pass the permanent domain to', + {command: '--store'}, + {char: '.'}, + 'It is the .myshopify.com domain listed in the Shopify admin under', + {subdued: 'Settings > Domains'}, + {char: '.'}, + ], + ], + ) + } if (error instanceof ClientError && (error.response.status === 401 || error.response.status === 404)) { throw new AbortError( `Error connecting to your store ${session.storeFqdn}: ${error.message} ${error.response.status} ${error.response.data}`, diff --git a/packages/cli-kit/src/public/node/session.test.ts b/packages/cli-kit/src/public/node/session.test.ts index a0785a5a578..0d7774807fe 100644 --- a/packages/cli-kit/src/public/node/session.test.ts +++ b/packages/cli-kit/src/public/node/session.test.ts @@ -29,6 +29,7 @@ import { exchangeAppAutomationTokenForAppManagementAccessToken, exchangeAppAutomationTokenForBusinessPlatformAccessToken, } from '../../private/node/session/exchange.js' +import {resolvePermanentStoreFqdn} from '../../private/node/session/permanent-store-domain.js' import {vi, describe, expect, test} from 'vitest' @@ -44,6 +45,7 @@ vi.mock('../../private/node/session.js') vi.mock('../../private/node/session/exchange.js') vi.mock('../../private/node/session/store.js') vi.mock('../../private/node/session/automation-token.js') +vi.mock('../../private/node/session/permanent-store-domain.js') vi.mock('./environment.js') vi.mock('./http.js') @@ -284,9 +286,13 @@ describe('ensureAuthenticatedTheme', () => { expect(got).toEqual({token: 'password', storeFqdn: 'mystore.myshopify.com'}) expect(setLastSeenAuthMethod).toBeCalledWith('custom_app_token') expect(setLastSeenUserIdAfterAuth).toBeCalledWith(nonRandomUUID('password')) + expect(resolvePermanentStoreFqdn).not.toHaveBeenCalled() }) test('returns the password when is provided and theme_access_token', async () => { + // Given + vi.mocked(resolvePermanentStoreFqdn).mockImplementation(async (store) => store) + // When const got = await ensureAuthenticatedThemes('mystore.myshopify.com', 'shptka_password') @@ -295,6 +301,18 @@ describe('ensureAuthenticatedTheme', () => { expect(setLastSeenAuthMethod).toBeCalledWith('theme_access_token') expect(setLastSeenUserIdAfterAuth).toBeCalledWith(nonRandomUUID('shptka_password')) }) + + test("uses the store's permanent domain with a theme_access_token", async () => { + // Given + vi.mocked(resolvePermanentStoreFqdn).mockResolvedValueOnce('abc123-xy.myshopify.com') + + // When + const got = await ensureAuthenticatedThemes('renamed-store.myshopify.com', 'shptka_password') + + // Then + expect(resolvePermanentStoreFqdn).toHaveBeenCalledWith('renamed-store.myshopify.com') + expect(got).toEqual({token: 'shptka_password', storeFqdn: 'abc123-xy.myshopify.com'}) + }) }) describe('ensureAuthenticatedBusinessPlatform', () => { diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 659f9aa89d6..008effd3777 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -24,6 +24,7 @@ import { setLastSeenUserIdAfterAuth, } from '../../private/node/session.js' import {isThemeAccessSession} from '../../private/node/api/rest.js' +import {resolvePermanentStoreFqdn} from '../../private/node/session/permanent-store-domain.js' /** * Session Object to access the Admin API, includes the token and the store FQDN. @@ -275,6 +276,10 @@ ${outputToken.json(scopes)} * If a password is provided, that token will be used against Theme Access API. * Otherwise, it will ensure that the user is authenticated with the Admin API. * + * Theme Access passwords only work with the store's permanent `.myshopify.com` domain, so when the password is a + * Theme Access password the returned session uses the permanent domain of `store`, even if `store` is another of the + * store's domains. + * * @param store - Store fqdn to request auth for. * @param password - Password generated from Theme Access app. * @param scopes - Optional array of extra scopes to authenticate with. @@ -292,9 +297,10 @@ ${outputToken.json(scopes)} `) if (password) { const session = {token: password, storeFqdn: store} - const authMethod = isThemeAccessSession(session) ? 'theme_access_token' : 'custom_app_token' - setLastSeenAuthMethod(authMethod) + const themeAccess = isThemeAccessSession(session) + setLastSeenAuthMethod(themeAccess ? 'theme_access_token' : 'custom_app_token') setLastSeenUserIdAfterAuth(nonRandomUUID(password)) + if (themeAccess) session.storeFqdn = await resolvePermanentStoreFqdn(store) return session } return ensureAuthenticatedAdmin(store, scopes, options)