From a312aca62f55ee23339b851a18ebbb0a2649e4f4 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 2 Apr 2026 21:22:46 -0400 Subject: [PATCH] Add store auth info command --- .../examples/store-auth-info.example.sh | 1 + .../interfaces/store-auth-info.interface.ts | 26 +++ .../commands/store-auth-info.doc.ts | 34 +++ docs-shopify.dev/commands/store-auth.doc.ts | 4 +- packages/cli/README.md | 30 +++ packages/cli/oclif.manifest.json | 63 ++++- .../{auth.test.ts => auth/index.test.ts} | 6 +- .../commands/store/{auth.ts => auth/index.ts} | 6 +- .../src/cli/commands/store/auth/info.test.ts | 34 +++ .../cli/src/cli/commands/store/auth/info.ts | 36 +++ .../store/admin-graphql-context.test.ts | 32 +++ .../src/cli/services/store/auth-info.test.ts | 219 ++++++++++++++++++ .../cli/src/cli/services/store/auth-info.ts | 78 +++++++ .../cli/src/cli/services/store/auth.test.ts | 15 ++ packages/cli/src/cli/services/store/auth.ts | 4 + .../src/cli/services/store/session.test.ts | 42 ++++ .../cli/src/cli/services/store/session.ts | 68 +++++- .../src/cli/services/store/stored-session.ts | 4 + packages/cli/src/index.ts | 4 +- 19 files changed, 696 insertions(+), 10 deletions(-) create mode 100644 docs-shopify.dev/commands/examples/store-auth-info.example.sh create mode 100644 docs-shopify.dev/commands/interfaces/store-auth-info.interface.ts create mode 100644 docs-shopify.dev/commands/store-auth-info.doc.ts rename packages/cli/src/cli/commands/store/{auth.test.ts => auth/index.test.ts} (83%) rename packages/cli/src/cli/commands/store/{auth.ts => auth/index.ts} (84%) create mode 100644 packages/cli/src/cli/commands/store/auth/info.test.ts create mode 100644 packages/cli/src/cli/commands/store/auth/info.ts create mode 100644 packages/cli/src/cli/services/store/auth-info.test.ts create mode 100644 packages/cli/src/cli/services/store/auth-info.ts diff --git a/docs-shopify.dev/commands/examples/store-auth-info.example.sh b/docs-shopify.dev/commands/examples/store-auth-info.example.sh new file mode 100644 index 00000000000..63503786a3e --- /dev/null +++ b/docs-shopify.dev/commands/examples/store-auth-info.example.sh @@ -0,0 +1 @@ +shopify store auth info [flags] \ No newline at end of file diff --git a/docs-shopify.dev/commands/interfaces/store-auth-info.interface.ts b/docs-shopify.dev/commands/interfaces/store-auth-info.interface.ts new file mode 100644 index 00000000000..91267db4079 --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/store-auth-info.interface.ts @@ -0,0 +1,26 @@ +// This is an autogenerated file. Don't edit this file manually. +export interface storeauthinfo { + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * The myshopify.com domain of the store to inspect. + * @environment SHOPIFY_FLAG_STORE + */ + '-s, --store ': string + + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' +} diff --git a/docs-shopify.dev/commands/store-auth-info.doc.ts b/docs-shopify.dev/commands/store-auth-info.doc.ts new file mode 100644 index 00000000000..c4362094ef0 --- /dev/null +++ b/docs-shopify.dev/commands/store-auth-info.doc.ts @@ -0,0 +1,34 @@ +// This is an autogenerated file. Don't edit this file manually. +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs' + +const data: ReferenceEntityTemplateSchema = { + name: 'store auth info', + description: `Shows the locally stored store auth information for the specified store, including scopes, associated user, and token status.`, + overviewPreviewDescription: `Show locally stored store auth information for a store.`, + type: 'command', + isVisualComponent: false, + defaultExample: { + codeblock: { + tabs: [ + { + title: 'store auth info', + code: './examples/store-auth-info.example.sh', + language: 'bash', + }, + ], + title: 'store auth info', + }, + }, + definitions: [ + { + title: 'Flags', + description: 'The following flags are available for the `store auth info` command:', + type: 'storeauthinfo', + }, + ], + category: 'store', + related: [ + ], +} + +export default data \ No newline at end of file diff --git a/docs-shopify.dev/commands/store-auth.doc.ts b/docs-shopify.dev/commands/store-auth.doc.ts index 8f06c2b8c9e..0d743672628 100644 --- a/docs-shopify.dev/commands/store-auth.doc.ts +++ b/docs-shopify.dev/commands/store-auth.doc.ts @@ -5,7 +5,9 @@ const data: ReferenceEntityTemplateSchema = { name: 'store auth', description: `Authenticates the app against the specified store for store commands and stores an online access token for later reuse. -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. + +To inspect the locally stored auth state for a store, run [\`shopify store auth info\`](/docs/api/shopify-cli/store/store-auth-info).`, overviewPreviewDescription: `Authenticate an app against a store for store commands.`, type: 'command', isVisualComponent: false, diff --git a/packages/cli/README.md b/packages/cli/README.md index ce2d7756c14..34dfb0ac246 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -74,6 +74,7 @@ * [`shopify plugins update`](#shopify-plugins-update) * [`shopify search [query]`](#shopify-search-query) * [`shopify store auth`](#shopify-store-auth) +* [`shopify store auth info`](#shopify-store-auth-info) * [`shopify store execute`](#shopify-store-execute) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) @@ -2074,10 +2075,39 @@ DESCRIPTION Re-run this command if the stored token is missing, expires, or no longer has the scopes you need. + To inspect the locally stored auth state for a store, run "`shopify store auth info`" + (https://shopify.dev/docs/api/shopify-cli/store/store-auth-info). + EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products ``` +## `shopify store auth info` + +Show locally stored store auth information for a store. + +``` +USAGE + $ shopify store auth info -s [-j] [--no-color] [--verbose] + +FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to inspect. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + +DESCRIPTION + Show locally stored store auth information for a store. + + Shows the locally stored store auth information for the specified store, including scopes, associated user, and token + status. + +EXAMPLES + $ shopify store auth info --store shop.myshopify.com + + $ shopify store auth info --store shop.myshopify.com --json +``` + ## `shopify store execute` Execute GraphQL queries and mutations on a store. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index b9be73bdcd4..5bc51d2d439 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5739,8 +5739,8 @@ ], "args": { }, - "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.", + "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.\n\nTo inspect the locally stored auth state for a store, run \"`shopify store auth info`\" (https://shopify.dev/docs/api/shopify-cli/store/store-auth-info).", + "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.\n\nTo inspect the locally stored auth state for a store, run [`shopify store auth info`](https://shopify.dev/docs/api/shopify-cli/store/store-auth-info).", "enableJsonFlag": false, "examples": [ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products" @@ -5792,6 +5792,65 @@ "strict": true, "summary": "Authenticate an app against a store for store commands." }, + "store:auth:info": { + "aliases": [ + ], + "args": { + }, + "description": "Shows the locally stored store auth information for the specified store, including scopes, associated user, and token status.", + "descriptionWithMarkdown": "Shows the locally stored store auth information for the specified store, including scopes, associated user, and token status.", + "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --json" + ], + "flags": { + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "store": { + "char": "s", + "description": "The myshopify.com domain of the store to inspect.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "required": true, + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "store:auth:info", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Show locally stored store auth information for a store." + }, "store:execute": { "aliases": [ ], diff --git a/packages/cli/src/cli/commands/store/auth.test.ts b/packages/cli/src/cli/commands/store/auth/index.test.ts similarity index 83% rename from packages/cli/src/cli/commands/store/auth.test.ts rename to packages/cli/src/cli/commands/store/auth/index.test.ts index d2bc2c9a33d..d048232205e 100644 --- a/packages/cli/src/cli/commands/store/auth.test.ts +++ b/packages/cli/src/cli/commands/store/auth/index.test.ts @@ -1,8 +1,8 @@ import {describe, test, expect, vi, beforeEach} from 'vitest' -import StoreAuth from './auth.js' -import {authenticateStoreWithApp} from '../../services/store/auth.js' +import StoreAuth from './index.js' +import {authenticateStoreWithApp} from '../../../services/store/auth.js' -vi.mock('../../services/store/auth.js') +vi.mock('../../../services/store/auth.js') describe('store auth command', () => { beforeEach(() => { diff --git a/packages/cli/src/cli/commands/store/auth.ts b/packages/cli/src/cli/commands/store/auth/index.ts similarity index 84% rename from packages/cli/src/cli/commands/store/auth.ts rename to packages/cli/src/cli/commands/store/auth/index.ts index 1fe48e3f3dc..ec380d70f32 100644 --- a/packages/cli/src/cli/commands/store/auth.ts +++ b/packages/cli/src/cli/commands/store/auth/index.ts @@ -2,14 +2,16 @@ import Command from '@shopify/cli-kit/node/base-command' import {globalFlags} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' -import {authenticateStoreWithApp} from '../../services/store/auth.js' +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 = `Authenticates the app against the specified store for store commands and stores an online access token for later reuse. -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. + +To inspect the locally stored auth state for a store, run [\`shopify store auth info\`](https://shopify.dev/docs/api/shopify-cli/store/store-auth-info).` static description = this.descriptionWithoutMarkdown() diff --git a/packages/cli/src/cli/commands/store/auth/info.test.ts b/packages/cli/src/cli/commands/store/auth/info.test.ts new file mode 100644 index 00000000000..337f33f1985 --- /dev/null +++ b/packages/cli/src/cli/commands/store/auth/info.test.ts @@ -0,0 +1,34 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import StoreAuthInfo from './info.js' +import {showStoreAuthInfo} from '../../../services/store/auth-info.js' + +vi.mock('../../../services/store/auth-info.js') + +describe('store auth info command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('passes parsed flags through to the auth info service', async () => { + await StoreAuthInfo.run(['--store', 'shop.myshopify.com']) + + expect(showStoreAuthInfo).toHaveBeenCalledWith('shop.myshopify.com', 'text') + }) + + test('normalizes the store flag before calling the auth info service', async () => { + await StoreAuthInfo.run(['--store', 'https://shop.myshopify.com/admin']) + + expect(showStoreAuthInfo).toHaveBeenCalledWith('shop.myshopify.com', 'text') + }) + + test('supports json output', async () => { + await StoreAuthInfo.run(['--store', 'shop.myshopify.com', '--json']) + + expect(showStoreAuthInfo).toHaveBeenCalledWith('shop.myshopify.com', 'json') + }) + + test('defines the expected flags', () => { + expect(StoreAuthInfo.flags.store).toBeDefined() + expect(StoreAuthInfo.flags.json).toBeDefined() + }) +}) diff --git a/packages/cli/src/cli/commands/store/auth/info.ts b/packages/cli/src/cli/commands/store/auth/info.ts new file mode 100644 index 00000000000..f937e8ae2a1 --- /dev/null +++ b/packages/cli/src/cli/commands/store/auth/info.ts @@ -0,0 +1,36 @@ +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {Flags} from '@oclif/core' +import {showStoreAuthInfo} from '../../../services/store/auth-info.js' + +export default class StoreAuthInfo extends Command { + static summary = 'Show locally stored store auth information for a store.' + + static descriptionWithMarkdown = `Shows the locally stored store auth information for the specified store, including scopes, associated user, and token status.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --json', + ] + + static flags = { + ...globalFlags, + ...jsonFlag, + store: Flags.string({ + char: 's', + description: 'The myshopify.com domain of the store to inspect.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + required: true, + }), + } + + async run(): Promise { + const {flags} = await this.parse(StoreAuthInfo) + + showStoreAuthInfo(flags.store, flags.json ? 'json' : 'text') + } +} diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts index 137c3f2761f..900ccd5c568 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts @@ -2,6 +2,7 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {AbortError} from '@shopify/cli-kit/node/error' import {fetch} from '@shopify/cli-kit/node/http' +import {outputDebug} from '@shopify/cli-kit/node/output' import { clearStoredStoreAppSession, getStoredStoreAppSession, @@ -13,6 +14,13 @@ import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' vi.mock('./session.js') vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/output', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/output') + return { + ...actual, + outputDebug: vi.fn(), + } +}) vi.mock('@shopify/cli-kit/node/api/admin', async () => { const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') return { @@ -107,6 +115,30 @@ describe('prepareAdminStoreGraphQLContext', () => { }) }) + test('logs when refresh token expiry is not returned during refresh', async () => { + vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + access_token: 'fresh-token', + refresh_token: 'fresh-refresh-token', + expires_in: 3600, + }), + ), + } as any) + + await prepareAdminStoreGraphQLContext({store}) + + expect( + vi.mocked(outputDebug).mock.calls.some(([message]) => + String((message as {value?: string})?.value ?? message).includes( + 'Token refresh response did not include refresh_token_expires_in', + ), + ), + ).toBe(true) + }) + test('clears stored auth when token refresh fails', async () => { vi.mocked(isSessionExpired).mockReturnValue(true) vi.mocked(fetch).mockResolvedValue({ diff --git a/packages/cli/src/cli/services/store/auth-info.test.ts b/packages/cli/src/cli/services/store/auth-info.test.ts new file mode 100644 index 00000000000..4543c8aa1d5 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth-info.test.ts @@ -0,0 +1,219 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {getStoreAuthInfo, displayStoreAuthInfo, showStoreAuthInfo} from './auth-info.js' +import {createStoredStoreAuthError} from './auth-recovery.js' +import {getStoredStoreAppSession, isSessionExpired} from './session.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo} from '@shopify/cli-kit/node/ui' + +vi.mock('./session.js') +vi.mock('./auth-recovery.js', () => ({ + createStoredStoreAuthError: vi.fn((store: string) => new Error(`No stored app authentication found for ${store}.`)), +})) +vi.mock('@shopify/cli-kit/node/output') +vi.mock('@shopify/cli-kit/node/ui') + +describe('store auth info service', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('returns stored auth info for a store', () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: 'client-id', + userId: '42', + accessToken: 'token', + refreshToken: 'refresh-token', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + expiresAt: '2026-04-02T01:00:00.000Z', + refreshTokenExpiresAt: '2026-04-03T00:00:00.000Z', + associatedUser: {id: 42, email: 'test@example.com'}, + } as any) + vi.mocked(isSessionExpired).mockReturnValue(false) + + expect(getStoreAuthInfo('shop.myshopify.com')).toEqual({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + expiresAt: '2026-04-02T01:00:00.000Z', + refreshTokenExpiresAt: '2026-04-03T00:00:00.000Z', + hasRefreshToken: true, + isExpired: false, + associatedUser: {id: 42, email: 'test@example.com'}, + }) + }) + + test('throws the stored auth recovery error when no stored auth exists for the store', () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) + + expect(() => getStoreAuthInfo('shop.myshopify.com')).toThrow('No stored app authentication found for shop.myshopify.com.') + expect(createStoredStoreAuthError).toHaveBeenCalledWith('shop.myshopify.com') + }) + + test('renders text output', () => { + displayStoreAuthInfo( + { + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products', 'write_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + expiresAt: '2026-04-02T01:00:00.000Z', + refreshTokenExpiresAt: '2026-04-03T00:00:00.000Z', + hasRefreshToken: true, + isExpired: false, + associatedUser: {id: 42, email: 'test@example.com'}, + }, + 'text', + ) + + expect(renderInfo).toHaveBeenCalledWith({ + headline: 'Stored auth for shop.myshopify.com', + customSections: [ + { + body: { + tabularData: [ + ['User', 'test@example.com (42)'], + ['Scopes', 'read_products,write_products'], + ['Stored at', '2026-04-02T00:00:00.000Z'], + ['Expires at', '2026-04-02T01:00:00.000Z'], + ['Refresh token', 'stored'], + ['Refresh token expires at', '2026-04-03T00:00:00.000Z'], + ['Status', 'valid'], + ], + firstColumnSubdued: true, + }, + }, + ], + }) + }) + + test('renders text output for sparse session details', () => { + displayStoreAuthInfo( + { + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: false, + isExpired: true, + }, + 'text', + ) + + expect(renderInfo).toHaveBeenCalledWith({ + headline: 'Stored auth for shop.myshopify.com', + customSections: [ + { + body: { + tabularData: [ + ['User', '42'], + ['Scopes', 'read_products'], + ['Stored at', '2026-04-02T00:00:00.000Z'], + ['Expires at', 'unknown'], + ['Refresh token', 'missing'], + ['Refresh token expires at', 'not applicable'], + ['Status', 'expired'], + ], + firstColumnSubdued: true, + }, + }, + ], + }) + }) + + test('renders when Shopify does not provide refresh token expiry', () => { + displayStoreAuthInfo( + { + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + isExpired: false, + }, + 'text', + ) + + expect(renderInfo).toHaveBeenCalledWith({ + headline: 'Stored auth for shop.myshopify.com', + customSections: [ + { + body: { + tabularData: [ + ['User', '42'], + ['Scopes', 'read_products'], + ['Stored at', '2026-04-02T00:00:00.000Z'], + ['Expires at', 'unknown'], + ['Refresh token', 'stored'], + ['Refresh token expires at', 'not provided by Shopify'], + ['Status', 'valid'], + ], + firstColumnSubdued: true, + }, + }, + ], + }) + }) + + test('renders json output', () => { + displayStoreAuthInfo( + { + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: false, + isExpired: true, + }, + 'json', + ) + + expect(outputResult).toHaveBeenCalledWith(`{ + "store": "shop.myshopify.com", + "userId": "42", + "scopes": [ + "read_products" + ], + "acquiredAt": "2026-04-02T00:00:00.000Z", + "hasRefreshToken": false, + "isExpired": true +}`) + }) + + test('loads and displays stored auth info for the requested store', () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: 'client-id', + userId: '42', + accessToken: 'token', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(isSessionExpired).mockReturnValue(false) + + showStoreAuthInfo('shop.myshopify.com') + + expect(getStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com') + expect(renderInfo).toHaveBeenCalledWith({ + headline: 'Stored auth for shop.myshopify.com', + customSections: [ + { + body: { + tabularData: [ + ['User', '42'], + ['Scopes', 'read_products'], + ['Stored at', '2026-04-02T00:00:00.000Z'], + ['Expires at', 'unknown'], + ['Refresh token', 'missing'], + ['Refresh token expires at', 'not applicable'], + ['Status', 'valid'], + ], + firstColumnSubdued: true, + }, + }, + ], + }) + }) +}) diff --git a/packages/cli/src/cli/services/store/auth-info.ts b/packages/cli/src/cli/services/store/auth-info.ts new file mode 100644 index 00000000000..05bbca5e600 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth-info.ts @@ -0,0 +1,78 @@ +import {createStoredStoreAuthError} from './auth-recovery.js' +import {getStoredStoreAppSession, isSessionExpired} from './session.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo} from '@shopify/cli-kit/node/ui' + +type StoreAuthInfoFormat = 'text' | 'json' + +interface StoreAuthInfo { + store: string + userId: string + scopes: string[] + acquiredAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + hasRefreshToken: boolean + isExpired: boolean + associatedUser?: { + id: number + email?: string + firstName?: string + lastName?: string + accountOwner?: boolean + } +} + +export function getStoreAuthInfo(store: string): StoreAuthInfo { + const session = getStoredStoreAppSession(store) + if (!session) throw createStoredStoreAuthError(store) + + return { + store: session.store, + userId: session.userId, + scopes: session.scopes, + acquiredAt: session.acquiredAt, + expiresAt: session.expiresAt, + refreshTokenExpiresAt: session.refreshTokenExpiresAt, + hasRefreshToken: !!session.refreshToken, + isExpired: isSessionExpired(session), + associatedUser: session.associatedUser, + } +} + +export function displayStoreAuthInfo(info: StoreAuthInfo, format: StoreAuthInfoFormat = 'text'): void { + if (format === 'json') { + outputResult(JSON.stringify(info, null, 2)) + return + } + + const displayUser = info.associatedUser?.email ? `${info.associatedUser.email} (${info.userId})` : info.userId + const refreshTokenExpiry = !info.hasRefreshToken + ? 'not applicable' + : (info.refreshTokenExpiresAt ?? 'not provided by Shopify') + + renderInfo({ + headline: `Stored auth for ${info.store}`, + customSections: [ + { + body: { + tabularData: [ + ['User', displayUser], + ['Scopes', info.scopes.join(',')], + ['Stored at', info.acquiredAt], + ['Expires at', info.expiresAt ?? 'unknown'], + ['Refresh token', info.hasRefreshToken ? 'stored' : 'missing'], + ['Refresh token expires at', refreshTokenExpiry], + ['Status', info.isExpired ? 'expired' : 'valid'], + ], + firstColumnSubdued: true, + }, + }, + ], + }) +} + +export function showStoreAuthInfo(store: string, format: StoreAuthInfoFormat = 'text'): void { + const info = getStoreAuthInfo(store) + displayStoreAuthInfo(info, format) +} diff --git a/packages/cli/src/cli/services/store/auth.test.ts b/packages/cli/src/cli/services/store/auth.test.ts index e15f620950b..43d3c493d0a 100644 --- a/packages/cli/src/cli/services/store/auth.test.ts +++ b/packages/cli/src/cli/services/store/auth.test.ts @@ -14,10 +14,18 @@ import {loadStoredStoreSession} from './stored-session.js' import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' import {fetch} from '@shopify/cli-kit/node/http' +import {outputDebug} from '@shopify/cli-kit/node/output' vi.mock('./session.js') vi.mock('./stored-session.js', () => ({loadStoredStoreSession: vi.fn()})) vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/output', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/output') + return { + ...actual, + outputDebug: vi.fn(), + } +}) vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) @@ -427,6 +435,13 @@ describe('store auth service', () => { expect(sentBody.code_verifier).toBe('test-verifier') expect(sentBody.redirect_uri).toBe('http://127.0.0.1:13387/auth/callback') expect(sentBody.client_secret).toBeUndefined() + expect( + vi.mocked(outputDebug).mock.calls.some(([message]) => + String((message as {value?: string})?.value ?? message).includes( + 'Token exchange response did not include refresh_token_expires_in', + ), + ), + ).toBe(true) }) test('authenticateStoreWithApp opens the browser and stores the session with refresh token', async () => { diff --git a/packages/cli/src/cli/services/store/auth.ts b/packages/cli/src/cli/services/store/auth.ts index 8fd9e08d137..5bdc26f3fd4 100644 --- a/packages/cli/src/cli/services/store/auth.ts +++ b/packages/cli/src/cli/services/store/auth.ts @@ -433,6 +433,10 @@ export async function exchangeStoreAuthCodeForToken(options: { outputContent`Token exchange succeeded: access_token=${outputToken.raw(maskToken(parsed.access_token))}, refresh_token=${outputToken.raw(parsed.refresh_token ? maskToken(parsed.refresh_token) : 'none')}, expires_in=${outputToken.raw(String(parsed.expires_in ?? 'unknown'))}s, user=${outputToken.raw(String(parsed.associated_user?.id ?? 'unknown'))} (${outputToken.raw(parsed.associated_user?.email ?? 'no email')})`, ) + if (parsed.refresh_token && !parsed.refresh_token_expires_in) { + outputDebug(outputContent`Token exchange response did not include refresh_token_expires_in; refresh token expiry will remain unknown`) + } + return parsed } diff --git a/packages/cli/src/cli/services/store/session.test.ts b/packages/cli/src/cli/services/store/session.test.ts index 373fb0d4286..c1a9c0f5d08 100644 --- a/packages/cli/src/cli/services/store/session.test.ts +++ b/packages/cli/src/cli/services/store/session.test.ts @@ -101,6 +101,48 @@ describe('store session storage', () => { expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) + + test('returns undefined and clears malformed current sessions', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: { + '42': { + ...buildSession(), + scopes: 'read_products', + }, + }, + }) + + expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() + }) + + test('ignores malformed optional fields in an otherwise valid session', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: { + '42': { + ...buildSession(), + refreshTokenExpiresAt: 123, + associatedUser: { + id: 42, + email: 'merchant@example.com', + accountOwner: 'yes', + }, + }, + }, + }) + + expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual({ + ...buildSession(), + associatedUser: { + id: 42, + email: 'merchant@example.com', + }, + }) + }) }) describe('isSessionExpired', () => { diff --git a/packages/cli/src/cli/services/store/session.ts b/packages/cli/src/cli/services/store/session.ts index 53100a27e87..89ffbd3cf4c 100644 --- a/packages/cli/src/cli/services/store/session.ts +++ b/packages/cli/src/cli/services/store/session.ts @@ -31,6 +31,72 @@ interface StoreSessionSchema { let _storeSessionStorage: LocalStorage | undefined +function sanitizeAssociatedUser(associatedUser: unknown): StoredStoreAppSession['associatedUser'] | undefined { + if (!associatedUser || typeof associatedUser !== 'object') return undefined + + const { + id, + email, + firstName, + lastName, + accountOwner, + } = associatedUser as Partial> + + if (typeof id !== 'number') return undefined + + return { + id, + ...(typeof email === 'string' ? {email} : {}), + ...(typeof firstName === 'string' ? {firstName} : {}), + ...(typeof lastName === 'string' ? {lastName} : {}), + ...(typeof accountOwner === 'boolean' ? {accountOwner} : {}), + } +} + +function sanitizeStoredStoreAppSession(session: unknown): StoredStoreAppSession | undefined { + if (!session || typeof session !== 'object') return undefined + + const { + store, + clientId, + userId, + accessToken, + refreshToken, + scopes, + acquiredAt, + expiresAt, + refreshTokenExpiresAt, + associatedUser, + } = session as Partial + + if ( + typeof store !== 'string' || + typeof clientId !== 'string' || + typeof userId !== 'string' || + typeof accessToken !== 'string' || + !Array.isArray(scopes) || + !scopes.every((scope) => typeof scope === 'string') || + typeof acquiredAt !== 'string' + ) { + return undefined + } + + const sanitizedAssociatedUser = sanitizeAssociatedUser(associatedUser) + + return { + store, + clientId, + userId, + accessToken, + scopes, + acquiredAt, + ...(typeof refreshToken === 'string' ? {refreshToken} : {}), + ...(typeof expiresAt === 'string' ? {expiresAt} : {}), + ...(typeof refreshTokenExpiresAt === 'string' ? {refreshTokenExpiresAt} : {}), + ...(sanitizedAssociatedUser ? {associatedUser: sanitizedAssociatedUser} : {}), + } +} + // Per-store, per-user session storage for PKCE online tokens. function storeSessionStorage() { _storeSessionStorage ??= new LocalStorage({projectName: 'shopify-cli-store'}) @@ -52,7 +118,7 @@ export function getStoredStoreAppSession( return undefined } - const session = sessionsByUserId[currentUserId] + const session = sanitizeStoredStoreAppSession(sessionsByUserId[currentUserId]) if (!session) { storage.delete(key) return undefined diff --git a/packages/cli/src/cli/services/store/stored-session.ts b/packages/cli/src/cli/services/store/stored-session.ts index f41329b6b0b..8c0882aef2e 100644 --- a/packages/cli/src/cli/services/store/stored-session.ts +++ b/packages/cli/src/cli/services/store/stored-session.ts @@ -81,6 +81,10 @@ async function refreshStoreToken(session: StoredStoreAppSession): Promise