diff --git a/.changeset/prompt-account-switch-on-auth-error.md b/.changeset/prompt-account-switch-on-auth-error.md new file mode 100644 index 00000000000..2ae605addf0 --- /dev/null +++ b/.changeset/prompt-account-switch-on-auth-error.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': minor +--- + +Prompt inline account selection when authentication fails due to wrong account, instead of requiring a separate `shopify auth login` command. diff --git a/packages/cli-kit/src/private/node/session.test.ts b/packages/cli-kit/src/private/node/session.test.ts index 061ade8d185..f721cb93a2a 100644 --- a/packages/cli-kit/src/private/node/session.test.ts +++ b/packages/cli-kit/src/private/node/session.test.ts @@ -12,6 +12,7 @@ import { exchangeCustomPartnerToken, refreshAccessToken, InvalidGrantError, + InvalidTargetError, } from './session/exchange.js' import {allDefaultScopes} from './session/scopes.js' import {store as storeSessions, fetch as fetchSessions, remove as secureRemove} from './session/store.js' @@ -27,6 +28,7 @@ import {businessPlatformRequest} from '../../public/node/api/business-platform.j import {getAppAutomationToken} from '../../public/node/environment.js' import {nonRandomUUID} from '../../public/node/crypto.js' import {terminalSupportsPrompting} from '../../public/node/system.js' +import {promptSessionSelect} from '../../public/node/session-prompt.js' import {vi, describe, expect, test, beforeEach} from 'vitest' @@ -119,6 +121,7 @@ vi.mock('../../public/node/environment.js') vi.mock('./session/device-authorization') vi.mock('./conf-store') vi.mock('../../public/node/system.js') +vi.mock('../../public/node/session-prompt') beforeEach(() => { vi.spyOn(fqdnModule, 'identityFqdn').mockResolvedValue(fqdn) @@ -734,3 +737,86 @@ describe('ensureAuthenticated email fetch functionality', () => { expect(got).toEqual(validTokens) }) }) + +describe('when auth fails with InvalidTargetError', () => { + test('prompts account selection and retries on InvalidTargetError during full auth', async () => { + // Given + vi.mocked(validateSession).mockResolvedValue('needs_full_auth') + vi.mocked(fetchSessions).mockResolvedValue(undefined) + vi.mocked(exchangeAccessForApplicationTokens) + .mockRejectedValueOnce( + new InvalidTargetError('You are not authorized to use the CLI to develop in the provided store: my-store'), + ) + .mockResolvedValueOnce(appTokens) + vi.mocked(promptSessionSelect).mockResolvedValue('other-account') + + // When + const got = await ensureAuthenticated(defaultApplications) + + // Then + expect(promptSessionSelect).toHaveBeenCalledOnce() + expect(got).toEqual(validTokens) + }) + + test('prompts account selection and retries on InvalidTargetError during refresh', async () => { + // Given + vi.mocked(validateSession).mockResolvedValue('needs_refresh') + vi.mocked(fetchSessions).mockResolvedValue(validSessions) + vi.mocked(refreshAccessToken).mockResolvedValue(validIdentityToken) + vi.mocked(exchangeAccessForApplicationTokens) + .mockRejectedValueOnce( + new InvalidTargetError('You are not authorized to use the CLI to develop in the provided store: my-store'), + ) + .mockResolvedValueOnce(appTokens) + vi.mocked(promptSessionSelect).mockResolvedValue('other-account') + + // When + const got = await ensureAuthenticated(defaultApplications) + + // Then + expect(promptSessionSelect).toHaveBeenCalledOnce() + expect(got).toEqual(validTokens) + }) + + test('throws InvalidTargetError without prompt when noPrompt is true', async () => { + // Given — use needs_refresh because needs_full_auth triggers throwOnNoPrompt before token exchange + vi.mocked(validateSession).mockResolvedValue('needs_refresh') + vi.mocked(fetchSessions).mockResolvedValue(validSessions) + vi.mocked(refreshAccessToken).mockResolvedValue(validIdentityToken) + vi.mocked(exchangeAccessForApplicationTokens).mockRejectedValueOnce( + new InvalidTargetError('You are not authorized to use the CLI to develop in the provided store: my-store'), + ) + + // When/Then + await expect(ensureAuthenticated(defaultApplications, process.env, {noPrompt: true})).rejects.toThrow() + expect(promptSessionSelect).not.toHaveBeenCalled() + }) + + test('throws InvalidTargetError without prompt in non-interactive terminal', async () => { + // Given + vi.mocked(terminalSupportsPrompting).mockReturnValue(false) + vi.mocked(validateSession).mockResolvedValue('needs_full_auth') + vi.mocked(fetchSessions).mockResolvedValue(undefined) + vi.mocked(exchangeAccessForApplicationTokens).mockRejectedValueOnce( + new InvalidTargetError('You are not authorized to use the CLI to develop in the provided store: my-store'), + ) + + // When/Then + await expect(ensureAuthenticated(defaultApplications)).rejects.toThrow() + expect(promptSessionSelect).not.toHaveBeenCalled() + }) + + test('throws on second consecutive InvalidTargetError after retry', async () => { + // Given + vi.mocked(validateSession).mockResolvedValue('needs_full_auth') + vi.mocked(fetchSessions).mockResolvedValue(undefined) + vi.mocked(exchangeAccessForApplicationTokens).mockRejectedValue( + new InvalidTargetError('You are not authorized to use the CLI to develop in the provided store: my-store'), + ) + vi.mocked(promptSessionSelect).mockResolvedValue('other-account') + + // When/Then + await expect(ensureAuthenticated(defaultApplications)).rejects.toThrow() + expect(promptSessionSelect).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index d838b529a9d..74bfb0b9dd2 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -8,6 +8,7 @@ import { refreshAccessToken, InvalidGrantError, InvalidRequestError, + InvalidTargetError, } from './session/exchange.js' import {IdentityToken, Session, Sessions} from './session/schema.js' import * as sessionStore from './session/store.js' @@ -16,6 +17,8 @@ import {isThemeAccessSession} from './api/rest.js' import {getCurrentSessionId, setCurrentSessionId} from './conf-store.js' import {UserEmailQueryString, UserEmailQuery} from './api/graphql/business-platform-destinations/user-email.js' import {outputContent, outputToken, outputDebug, outputCompleted} from '../../public/node/output.js' +import {terminalSupportsPrompting} from '../../public/node/system.js' +import {renderWarning} from '../../public/node/ui.js' import {firstPartyDev, themeToken} from '../../public/node/context/local.js' import {AbortError} from '../../public/node/error.js' import {normalizeStoreFqdn, identityFqdn} from '../../public/node/context/fqdn.js' @@ -186,6 +189,10 @@ export interface EnsureAuthenticatedAdditionalOptions { /** * This method ensures that we have a valid session to authenticate against the given applications using the provided scopes. * + * If the current account lacks authorization for the requested store and the terminal supports prompting, + * the user is offered an inline account selection prompt to switch accounts and retry authentication + * without having to re-run the command. + * * @param applications - An object containing the applications we need to be authenticated with. * @param _env - Optional environment variables to use. * @param options - Optional extra options to use. @@ -194,6 +201,24 @@ export interface EnsureAuthenticatedAdditionalOptions { export async function ensureAuthenticated( applications: OAuthApplications, _env?: NodeJS.ProcessEnv, + options: EnsureAuthenticatedAdditionalOptions = {}, +): Promise { + try { + return await performAuthentication(applications, options) + } catch (error) { + if (error instanceof InvalidTargetError && !options.noPrompt && terminalSupportsPrompting()) { + renderWarning({headline: error.message}) + // Dynamic import to avoid circular dependency: session-prompt → session (public) → session (private) + const {promptSessionSelect} = await import('../../public/node/session-prompt.js') + await promptSessionSelect() + return performAuthentication(applications, options) + } + throw error + } +} + +async function performAuthentication( + applications: OAuthApplications, {forceRefresh = false, noPrompt = false, forceNewSession = false}: EnsureAuthenticatedAdditionalOptions = {}, ): Promise { const fqdn = await identityFqdn() diff --git a/packages/cli-kit/src/private/node/session/exchange.ts b/packages/cli-kit/src/private/node/session/exchange.ts index 8ddf858eb86..ba7d7aae4d9 100644 --- a/packages/cli-kit/src/private/node/session/exchange.ts +++ b/packages/cli-kit/src/private/node/session/exchange.ts @@ -13,7 +13,7 @@ import * as jose from 'jose' export class InvalidGrantError extends ExtendableError {} export class InvalidRequestError extends ExtendableError {} -class InvalidTargetError extends AbortError {} +export class InvalidTargetError extends AbortError {} export interface ExchangeScopes { admin: string[]