Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs-shopify.dev/commands/store-auth.doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs'

const data: ReferenceEntityTemplateSchema = {
name: 'store auth',
description: `Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by \`shopify store execute\`.
description: `Authenticates the app against the specified store for store commands and stores an online access token for later reuse.

This flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`,
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`,
overviewPreviewDescription: `Authenticate an app against a store for store commands.`,
type: 'command',
isVisualComponent: false,
Expand Down
2 changes: 1 addition & 1 deletion docs-shopify.dev/generated/generated_docs_data.json
Original file line number Diff line number Diff line change
Expand Up @@ -5776,7 +5776,7 @@
},
{
"name": "store auth",
"description": "Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store execute`.\n\nThis flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.",
"description": "Authenticates the app against the specified store for store commands and stores an online access token for later reuse.\n\nRe-run this command if the stored token is missing, expires, or no longer has the scopes you need.",
"overviewPreviewDescription": "Authenticate an app against a store for store commands.",
"type": "command",
"isVisualComponent": false,
Expand Down
7 changes: 3 additions & 4 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2069,11 +2069,10 @@ FLAGS
DESCRIPTION
Authenticate an app against a store for store commands.

Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store
execute`.
Authenticates the app against the specified store for store commands and stores an online access token for later
reuse.

This flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing,
expires, or no longer has the scopes you need.
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.

EXAMPLES
$ shopify store auth --store shop.myshopify.com --scopes read_products,write_products
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5739,8 +5739,8 @@
],
"args": {
},
"description": "Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store execute`.\n\nThis flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.",
"descriptionWithMarkdown": "Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store execute`.\n\nThis flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.",
"description": "Authenticates the app against the specified store for store commands and stores an online access token for later reuse.\n\nRe-run this command if the stored token is missing, expires, or no longer has the scopes you need.",
"descriptionWithMarkdown": "Authenticates the app against the specified store for store commands and stores an online access token for later reuse.\n\nRe-run this command if the stored token is missing, expires, or no longer has the scopes you need.",
"enableJsonFlag": false,
"examples": [
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products"
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/cli/commands/store/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {authenticateStoreWithApp} from '../../services/store/auth.js'
export default class StoreAuth extends Command {
static summary = 'Authenticate an app against a store for store commands.'

static descriptionWithMarkdown = `Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by \`shopify store execute\`.
static descriptionWithMarkdown = `Authenticates the app against the specified store for store commands and stores an online access token for later reuse.

This flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`

static description = this.descriptionWithoutMarkdown()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ describe('prepareAdminStoreGraphQLContext', () => {
test('throws when no stored auth exists', async () => {
vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined)

await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('No stored app authentication found')
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({
message: `No stored app authentication found for ${store}.`,
tryMessage: 'To create stored auth for this store, run:',
nextSteps: [[{command: `shopify store auth --store ${store} --scopes <comma-separated-scopes>`}]],
})
})

test('clears stored auth when token refresh fails', async () => {
Expand All @@ -111,15 +115,23 @@ describe('prepareAdminStoreGraphQLContext', () => {
text: vi.fn().mockResolvedValue('bad refresh'),
} as any)

await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Token refresh failed')
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({
message: `Token refresh failed for ${store} (HTTP 401).`,
tryMessage: 'To re-authenticate, run:',
nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]],
})
expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42')
})

test('throws when an expired session cannot be refreshed because no refresh token is stored', async () => {
vi.mocked(isSessionExpired).mockReturnValue(true)
vi.mocked(getStoredStoreAppSession).mockReturnValue({...storedSession, refreshToken: undefined})

await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('No refresh token stored')
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({
message: `No refresh token stored for ${store}.`,
tryMessage: 'To re-authenticate, run:',
nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]],
})
expect(clearStoredStoreAppSession).not.toHaveBeenCalled()
})

Expand All @@ -130,7 +142,11 @@ describe('prepareAdminStoreGraphQLContext', () => {
text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})),
} as any)

await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Token refresh returned an invalid response')
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({
message: `Token refresh returned an invalid response for ${store}.`,
tryMessage: 'To re-authenticate, run:',
nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]],
})
expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42')
})

Expand All @@ -150,7 +166,11 @@ describe('prepareAdminStoreGraphQLContext', () => {
new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`),
)

await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Stored app authentication for')
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({
message: `Stored app authentication for ${store} is no longer valid.`,
tryMessage: 'To re-authenticate, run:',
nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]],
})
expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42')
})

Expand Down
26 changes: 12 additions & 14 deletions packages/cli/src/cli/services/store/admin-graphql-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {fetch} from '@shopify/cli-kit/node/http'
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
import {AdminSession} from '@shopify/cli-kit/node/session'
import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js'
import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './auth-recovery.js'
import {
clearStoredStoreAppSession,
getStoredStoreAppSession,
Expand All @@ -20,10 +21,7 @@ export interface AdminStoreGraphQLContext {

async function refreshStoreToken(session: StoredStoreAppSession): Promise<StoredStoreAppSession> {
if (!session.refreshToken) {
throw new AbortError(
`No refresh token stored for ${session.store}.`,
`Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`,
)
throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(','))
}

const endpoint = `https://${session.store}/admin/oauth/access_token`
Expand All @@ -49,9 +47,10 @@ async function refreshStoreToken(session: StoredStoreAppSession): Promise<Stored
outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`,
)
clearStoredStoreAppSession(session.store, session.userId)
throw new AbortError(
throw reauthenticateStoreAuthError(
`Token refresh failed for ${session.store} (HTTP ${response.status}).`,
`Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`,
session.store,
session.scopes.join(','),
)
}

Expand All @@ -65,9 +64,10 @@ async function refreshStoreToken(session: StoredStoreAppSession): Promise<Stored

if (!data.access_token) {
clearStoredStoreAppSession(session.store, session.userId)
throw new AbortError(
throw reauthenticateStoreAuthError(
`Token refresh returned an invalid response for ${session.store}.`,
`Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`,
session.store,
session.scopes.join(','),
)
}

Expand Down Expand Up @@ -97,10 +97,7 @@ async function loadStoredStoreSession(store: string): Promise<StoredStoreAppSess
let session = getStoredStoreAppSession(store)

if (!session) {
throw new AbortError(
`No stored app authentication found for ${store}.`,
`Run ${outputToken.genericShellCommand(`shopify store auth --store ${store} --scopes <comma-separated-scopes>`).value} to create stored auth for this store.`,
)
throw createStoredStoreAuthError(store)
}

outputDebug(
Expand Down Expand Up @@ -133,9 +130,10 @@ async function resolveApiVersion(options: {
/\b(?:401|404)\b/.test(error.message)
) {
clearStoredStoreAppSession(session.store, session.userId)
throw new AbortError(
throw reauthenticateStoreAuthError(
`Stored app authentication for ${session.store} is no longer valid.`,
`Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`,
session.store,
session.scopes.join(','),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ describe('runAdminStoreGraphQLOperation', () => {

await expect(
runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}),
).rejects.toThrow('Stored app authentication for')
).rejects.toMatchObject({
message: `Stored app authentication for ${store} is no longer valid.`,
tryMessage: 'To re-authenticate, run:',
nextSteps: [[{command: `shopify store auth --store ${store} --scopes <comma-separated-scopes>`}]],
})
expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42')
})

Expand Down
10 changes: 6 additions & 4 deletions packages/cli/src/cli/services/store/admin-graphql-transport.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {adminUrl} from '@shopify/cli-kit/node/api/admin'
import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql'
import {AbortError} from '@shopify/cli-kit/node/error'
import {clearStoredStoreAppSession} from './session.js'
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
import {outputContent} from '@shopify/cli-kit/node/output'
import {AdminSession} from '@shopify/cli-kit/node/session'
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
import {reauthenticateStoreAuthError} from './auth-recovery.js'
import {PreparedStoreExecuteRequest} from './execute-request.js'
import {clearStoredStoreAppSession} from './session.js'

function isGraphQLClientError(error: unknown): error is {response: {errors?: unknown; status?: number}} {
if (!error || typeof error !== 'object' || !('response' in error)) return false
Expand Down Expand Up @@ -38,9 +39,10 @@ export async function runAdminStoreGraphQLOperation(input: {
} catch (error) {
if (isGraphQLClientError(error) && error.response.status === 401) {
clearStoredStoreAppSession(input.store, input.sessionUserId)
throw new AbortError(
throw reauthenticateStoreAuthError(
`Stored app authentication for ${input.store} is no longer valid.`,
`Run ${outputToken.genericShellCommand(`shopify store auth --store ${input.store} --scopes <comma-separated-scopes>`).value} to re-authenticate.`,
input.store,
'<comma-separated-scopes>',
)
}

Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/cli/services/store/auth-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {AbortError} from '@shopify/cli-kit/node/error'

function storeAuthCommand(store: string, scopes: string): {command: string} {
return {command: `shopify store auth --store ${store} --scopes ${scopes}`}
}

function storeAuthCommandNextSteps(store: string, scopes: string) {
return [[storeAuthCommand(store, scopes)]]
}

export function createStoredStoreAuthError(store: string): AbortError {
return new AbortError(
`No stored app authentication found for ${store}.`,
'To create stored auth for this store, run:',
storeAuthCommandNextSteps(store, '<comma-separated-scopes>'),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

couldn't we pass the scopes here and build the full command?

)
}

export function reauthenticateStoreAuthError(message: string, store: string, scopes: string): AbortError {
return new AbortError(message, 'To re-authenticate, run:', storeAuthCommandNextSteps(store, scopes))
}

export function retryStoreAuthWithPermanentDomainError(returnedStore: string): AbortError {
return new AbortError(
'OAuth callback store does not match the requested store.',
`Shopify returned ${returnedStore} during authentication. Re-run using the permanent store domain:`,
storeAuthCommandNextSteps(returnedStore, '<comma-separated-scopes>'),
)
}
Loading
Loading