Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/theme-access-permanent-domain.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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('<html>Challenge</html>', {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()
})
})
Original file line number Diff line number Diff line change
@@ -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<string, Promise<string>>()

/**
* 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<string> {
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<string> {
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 `<handle>.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
}
}
21 changes: 21 additions & 0 deletions packages/cli-kit/src/public/node/api/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
17 changes: 17 additions & 0 deletions packages/cli-kit/src/public/node/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
18 changes: 18 additions & 0 deletions packages/cli-kit/src/public/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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')

Expand Down Expand Up @@ -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')

Expand All @@ -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', () => {
Expand Down
10 changes: 8 additions & 2 deletions packages/cli-kit/src/public/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down
Loading