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
5 changes: 5 additions & 0 deletions .changeset/prompt-account-switch-on-auth-error.md
Original file line number Diff line number Diff line change
@@ -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.
86 changes: 86 additions & 0 deletions packages/cli-kit/src/private/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
})
})
25 changes: 25 additions & 0 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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.
Expand All @@ -194,6 +201,24 @@ export interface EnsureAuthenticatedAdditionalOptions {
export async function ensureAuthenticated(
applications: OAuthApplications,
_env?: NodeJS.ProcessEnv,
options: EnsureAuthenticatedAdditionalOptions = {},
): Promise<OAuthSession> {
Comment on lines 201 to +205
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureAuthenticated still accepts an _env parameter (and the JSDoc says it affects behavior), but it’s not used anywhere in the implementation. This makes the API misleading (callers like public/node/session.ts pass an env expecting it to be honored). Consider either wiring env through to the helpers that read from process.env (e.g., firstPartyDev, getIdentityTokenInformation, etc.) or removing the parameter/JSDoc to avoid implying it has an effect.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, but this is pre-existing behavior — the _env parameter was already unused (prefixed with _) before this PR. This change preserves the existing function signature to avoid breaking callers. Removing or wiring up _env would be a separate refactor outside the scope of this PR.

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<OAuthSession> {
const fqdn = await identityFqdn()
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-kit/src/private/node/session/exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
Loading