From ba47dd464c28366d1fe2218f6f6a5b16d9296882 Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Tue, 28 Jul 2026 15:08:55 +0200 Subject: [PATCH] Make store auth session reuse opt-in per theme command Store auth session reuse was added to the shared ThemeCommand base class, so every theme command with a password flag inherited it, while only theme pull and theme push declare the scopes they require. The base storeAuthScopes() returned [], and requiredScopes.every() on an empty array is always true, so commands like theme dev adopted any cached store auth session, including ones without theme scopes, causing 401s and missing-scope errors that could not be fixed via auth logout. Commands now opt in to store auth session reuse by returning the scopes they require from storeAuthScopes(); the default (undefined) opts the command out entirely, so only theme pull and theme push reuse stored store auth sessions. Assisted-By: devx/60503589-d48c-43b2-8fba-b0df1e076f15 --- .changeset/theme-store-auth-opt-in.md | 5 ++ .../src/cli/utilities/theme-command.test.ts | 70 ++++++++++++++----- .../theme/src/cli/utilities/theme-command.ts | 30 +++++--- 3 files changed, 80 insertions(+), 25 deletions(-) create mode 100644 .changeset/theme-store-auth-opt-in.md diff --git a/.changeset/theme-store-auth-opt-in.md b/.changeset/theme-store-auth-opt-in.md new file mode 100644 index 00000000000..f9ad0240898 --- /dev/null +++ b/.changeset/theme-store-auth-opt-in.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': patch +--- + +Fix `theme dev` and other theme commands adopting `store auth` sessions with insufficient scopes; only `theme pull` and `theme push` reuse them now. diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index 616e8563d78..289eb330446 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -92,6 +92,12 @@ class TestThemeCommandWithPathFlag extends TestThemeCommandWithForce { static multiEnvironmentsFlags: RequiredFlags = ['store', 'password', 'path'] } +class TestScopedThemeCommandWithPathFlag extends TestThemeCommandWithPathFlag { + protected storeAuthScopes(): string[] { + return ['read_themes'] + } +} + class TestThemeCommandWithUnionFlags extends TestThemeCommand { static multiEnvironmentsFlags: RequiredFlags = ['store', ['live', 'development', 'theme']] @@ -262,12 +268,12 @@ describe('ThemeCommand', () => { clientId: 'store-auth-client-id', userId: 'preview:123', accessToken: 'shpat_preview_token', - scopes: [], + scopes: ['read_themes'], acquiredAt: '2026-06-08T11:00:00.000Z', }) await CommandConfig.load() - const command = new TestThemeCommand([], CommandConfig) + const command = new TestScopedThemeCommand([], CommandConfig) await command.run() @@ -284,12 +290,12 @@ describe('ThemeCommand', () => { clientId: 'store-auth-client-id', userId: 'preview:123', accessToken: 'shpat_preview_token', - scopes: [], + scopes: ['read_themes'], acquiredAt: '2026-06-08T11:00:00.000Z', }) await CommandConfig.load() - const command = new TestThemeCommand(['--password', 'shptka_password'], CommandConfig) + const command = new TestScopedThemeCommand(['--password', 'shptka_password'], CommandConfig) await command.run() @@ -300,7 +306,7 @@ describe('ThemeCommand', () => { test('falls back to theme authentication when no matching store auth cache session exists', async () => { await CommandConfig.load() - const command = new TestThemeCommand([], CommandConfig) + const command = new TestScopedThemeCommand([], CommandConfig) await command.run() @@ -309,26 +315,24 @@ describe('ThemeCommand', () => { expect(command.commandCalls[0]).toMatchObject({session: mockSession}) }) - test('checks required scopes from the stored session before using a matching store auth cache session', async () => { + test('ignores the store auth cache when the command does not declare store auth scopes', async () => { vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ store: 'test-store.myshopify.com', clientId: 'store-auth-client-id', userId: 'preview:123', accessToken: 'shpat_preview_token', - scopes: ['read_themes'], + scopes: ['read_themes', 'write_themes'], acquiredAt: '2026-06-08T11:00:00.000Z', }) await CommandConfig.load() - const command = new TestScopedThemeCommand([], CommandConfig) + const command = new TestThemeCommand([], CommandConfig) await command.run() - expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') - expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() - expect(command.commandCalls[0]).toMatchObject({ - session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, - }) + expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined) + expect(command.commandCalls[0]).toMatchObject({session: mockSession}) }) test('treats a matching write scope in the stored session as satisfying a required read scope', async () => { @@ -421,7 +425,7 @@ describe('ThemeCommand', () => { }) await CommandConfig.load() - const command = new TestThemeCommand([], CommandConfig) + const command = new TestScopedThemeCommand([], CommandConfig) await expect(command.run()).rejects.toThrow('cache read failed') expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() @@ -1032,7 +1036,7 @@ describe('ThemeCommand', () => { clientId: 'store-auth-client-id', userId: 'preview:123', accessToken: 'shpat_preview_token', - scopes: [], + scopes: ['read_themes'], acquiredAt: '2026-06-08T11:00:00.000Z', }, ]) @@ -1046,7 +1050,7 @@ describe('ThemeCommand', () => { vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) await CommandConfig.load() - const command = new TestThemeCommandWithPathFlag( + const command = new TestScopedThemeCommandWithPathFlag( ['--environment', 'preview', '--environment', 'another-preview'], CommandConfig, ) @@ -1068,6 +1072,39 @@ describe('ThemeCommand', () => { .mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'}) vi.mocked(renderConcurrent).mockResolvedValue(undefined) + await CommandConfig.load() + const command = new TestScopedThemeCommandWithPathFlag( + ['--environment', 'preview', '--environment', 'another-preview'], + CommandConfig, + ) + + await command.run() + + expect(renderWarning).toHaveBeenCalledWith( + expect.objectContaining({ + body: ['Missing required flags in environment configuration for preview:', {list: {items: ['password']}}], + }), + ) + expect(renderConcurrent).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + }) + + test('multiple environment commands ignore the store auth cache when the command does not declare store auth scopes', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/home/path/to/theme1'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'}) + vi.mocked(listCurrentStoredStoreAppSessions).mockReturnValue([ + { + store: 'store1.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: ['read_themes'], + acquiredAt: '2026-06-08T11:00:00.000Z', + }, + ]) + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + await CommandConfig.load() const command = new TestThemeCommandWithPathFlag( ['--environment', 'preview', '--environment', 'another-preview'], @@ -1076,6 +1113,7 @@ describe('ThemeCommand', () => { await command.run() + expect(listCurrentStoredStoreAppSessions).not.toHaveBeenCalled() expect(renderWarning).toHaveBeenCalledWith( expect.objectContaining({ body: ['Missing required flags in environment configuration for preview:', {list: {items: ['password']}}], diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index f8a7d578fcc..c31f79ca0ec 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -139,8 +139,13 @@ export default abstract class ThemeCommand extends Command { await this.runConcurrent(validationResults.valid) } - protected storeAuthScopes(): string[] { - return [] + /** + * Admin API scopes that a stored `store auth` session must include for this + * command to reuse it. Commands opt in to reusing store auth sessions by + * returning the scopes they require; the default opts the command out. + */ + protected storeAuthScopes(): string[] | undefined { + return undefined } /** @@ -362,6 +367,9 @@ export default abstract class ThemeCommand extends Command { } private async storeAuthSessionForTheme(flags: FlagValues): Promise { + const requiredScopes = this.storeAuthScopes() + if (!requiredScopes) return undefined + const store = typeof flags.store === 'string' ? flags.store : undefined const password = flags.password if (!store || password) return undefined @@ -370,10 +378,13 @@ export default abstract class ThemeCommand extends Command { const storedSession = getCurrentStoredStoreAppSession(storeFqdn) if (!storedSession) return undefined - return this.adminSessionFromStoreAuthSession(storedSession, storeFqdn) + return this.adminSessionFromStoreAuthSession(storedSession, storeFqdn, requiredScopes) } private storeAuthSessionsForTheme(flagsList: FlagValues[]): Map { + const requiredScopes = this.storeAuthScopes() + if (!requiredScopes) return new Map() + const stores = new Set( flagsList .filter(({store, password}) => typeof store === 'string' && !password) @@ -387,7 +398,7 @@ export default abstract class ThemeCommand extends Command { const storeFqdn = normalizeStoreFqdn(storedSession.store) if (!stores.has(storeFqdn)) return undefined - const session = this.adminSessionFromStoreAuthSession(storedSession, storeFqdn) + const session = this.adminSessionFromStoreAuthSession(storedSession, storeFqdn, requiredScopes) return session ? ([storeFqdn, session] as const) : undefined }) .filter((entry): entry is readonly [string, AdminSession] => entry !== undefined), @@ -408,8 +419,9 @@ export default abstract class ThemeCommand extends Command { private adminSessionFromStoreAuthSession( storedSession: StoredStoreAppSession, storeFqdn: string, + requiredScopes: string[], ): AdminSession | undefined { - if (!this.hasRequiredStoreAuthScopes(storedSession.scopes)) { + if (!this.hasRequiredStoreAuthScopes(storedSession.scopes, requiredScopes)) { return undefined } @@ -434,11 +446,11 @@ export default abstract class ThemeCommand extends Command { return expandedScopes } - private hasRequiredStoreAuthScopes(scopes: string[]): boolean { - if (scopes.length === 0) return true + private hasRequiredStoreAuthScopes(sessionScopes: string[], requiredScopes: string[]): boolean { + if (sessionScopes.length === 0) return true - const expandedScopes = this.expandImpliedStoreAuthScopes(scopes) - return this.storeAuthScopes().every((scope) => expandedScopes.has(scope)) + const expandedScopes = this.expandImpliedStoreAuthScopes(sessionScopes) + return requiredScopes.every((scope) => expandedScopes.has(scope)) } /**