Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/clever-auth-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli': minor
'@shopify/cli-kit': minor
---

Add `shopify auth status` with JSON output for checking Shopify CLI account authentication.
12 changes: 12 additions & 0 deletions docs-shopify.dev/commands/interfaces/auth-status.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// This is an autogenerated file. Don't edit this file manually.
/**
* The following flags are available for the `auth status` command:
* @publicDocs
*/
export interface authstatus {
/**
* Output the result as JSON. Automatically disables color output.
* @environment SHOPIFY_FLAG_JSON
*/
'-j, --json'?: ''
}
20 changes: 20 additions & 0 deletions docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -2607,6 +2607,26 @@
"value": "export interface authlogout {\n\n}"
}
},
"authstatus": {
"docs-shopify.dev/commands/interfaces/auth-status.interface.ts": {
"filePath": "docs-shopify.dev/commands/interfaces/auth-status.interface.ts",
"name": "authstatus",
"description": "The following flags are available for the `auth status` command:",
"isPublicDocs": true,
"members": [
{
"filePath": "docs-shopify.dev/commands/interfaces/auth-status.interface.ts",
"syntaxKind": "PropertySignature",
"name": "-j, --json",
"value": "''",
"description": "Output the result as JSON. Automatically disables color output.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_JSON"
}
],
"value": "export interface authstatus {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n}"
}
},
"commands": {
"docs-shopify.dev/commands/interfaces/commands.interface.ts": {
"filePath": "docs-shopify.dev/commands/interfaces/commands.interface.ts",
Expand Down
117 changes: 117 additions & 0 deletions packages/cli-kit/src/public/node/session-auth-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {getAuthStatus} from './session.js'
import {identityFqdn} from './context/fqdn.js'
import {getCurrentSessionId} from '../../private/node/conf-store.js'
import * as sessionStore from '../../private/node/session/store.js'
import {validateSession} from '../../private/node/session/validate.js'
import {Session} from '../../private/node/session/schema.js'

import {beforeEach, describe, expect, test, vi} from 'vitest'

vi.mock('./context/fqdn.js')
vi.mock('../../private/node/conf-store.js')
vi.mock('../../private/node/session/store.js')
vi.mock('../../private/node/session/validate.js')

const expiresAt = new Date('2030-01-01T00:00:00.000Z')
const session: Session = {
identity: {
accessToken: 'identity-token',
refreshToken: 'refresh-token',
expiresAt,
scopes: ['scope'],
userId: 'user-id',
alias: 'user@example.com',
},
applications: {},
}

describe('getAuthStatus', () => {
beforeEach(() => {
vi.mocked(identityFqdn).mockResolvedValue('accounts.shopify.com')
vi.mocked(getCurrentSessionId).mockReturnValue('user-id')
vi.mocked(sessionStore.fetch).mockResolvedValue({
'accounts.shopify.com': {
'user-id': session,
},
})
vi.mocked(validateSession).mockResolvedValue('ok')
})

test('returns authenticated for a valid current session', async () => {
// When
const got = await getAuthStatus()

// Then
expect(got).toEqual({
status: 'authenticated',
authenticated: true,
account: {
userId: 'user-id',
alias: 'user@example.com',
},
identityFqdn: 'accounts.shopify.com',
expiresAt: '2030-01-01T00:00:00.000Z',
agentGuidance: {
instruction: 'A Shopify CLI session is available. Continue with the requested Shopify CLI command.',
},
})
})

test('returns needs_refresh for a refreshable current session', async () => {
// Given
vi.mocked(validateSession).mockResolvedValue('needs_refresh')

// When
const got = await getAuthStatus()

// Then
expect(got.status).toBe('needs_refresh')
expect(got.authenticated).toBe(true)
expect(got.account?.userId).toBe('user-id')
})

test('falls back to the first stored session when no current session is configured', async () => {
// Given
vi.mocked(getCurrentSessionId).mockReturnValue(undefined)

// When
const got = await getAuthStatus()

// Then
expect(got.status).toBe('authenticated')
expect(got.account?.userId).toBe('user-id')
})

test('returns not_authenticated when no session exists', async () => {
// Given
vi.mocked(getCurrentSessionId).mockReturnValue(undefined)
vi.mocked(sessionStore.fetch).mockResolvedValue(undefined)

// When
const got = await getAuthStatus()

// Then
expect(got).toEqual({
status: 'not_authenticated',
authenticated: false,
identityFqdn: 'accounts.shopify.com',
agentGuidance: {
instruction:
'No usable Shopify CLI session is available. Run `shopify auth login`, show the verification URL and user code to the user, and keep the command running until authentication completes.',
nextCommand: 'shopify auth login',
},
})
})

test('returns invalid when the current session id is missing from storage', async () => {
// Given
vi.mocked(getCurrentSessionId).mockReturnValue('missing-user-id')

// When
const got = await getAuthStatus()

// Then
expect(got.status).toBe('invalid')
expect(got.authenticated).toBe(false)
})
})
93 changes: 93 additions & 0 deletions packages/cli-kit/src/public/node/session.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import {shopifyFetch} from './http.js'
import {nonRandomUUID} from './crypto.js'
import {getAppAutomationToken} from './environment.js'
import {identityFqdn} from './context/fqdn.js'
import {AbortError, BugError} from './error.js'
import {outputContent, outputToken, outputDebug} from './output.js'
import {getCurrentSessionId} from '../../private/node/conf-store.js'
import * as sessionStore from '../../private/node/session/store.js'
import {allDefaultScopes} from '../../private/node/session/scopes.js'
import {validateSession} from '../../private/node/session/validate.js'
import {
exchangeCustomPartnerToken,
exchangeAppAutomationTokenForAppManagementAccessToken,
Expand Down Expand Up @@ -65,6 +69,23 @@ interface UnknownAccountInfo {
type: 'UnknownAccount'
}

export type AuthStatusName = 'authenticated' | 'needs_refresh' | 'not_authenticated' | 'invalid'

export interface AuthStatus {
status: AuthStatusName
authenticated: boolean
account?: {
userId: string
alias?: string
}
identityFqdn?: string
expiresAt?: string
agentGuidance: {
instruction: string
nextCommand?: string
}
}

/**
* Type guard to check if an account is a UserAccount.
*
Expand All @@ -85,6 +106,78 @@ export function isServiceAccount(account: AccountInfo): account is ServiceAccoun
return account.type === 'ServiceAccount'
}

function authStatusGuidance(status: AuthStatusName): AuthStatus['agentGuidance'] {
if (status === 'authenticated') {
return {instruction: 'A Shopify CLI session is available. Continue with the requested Shopify CLI command.'}
}

if (status === 'needs_refresh') {
return {
instruction:
'A Shopify CLI session is available, but it may refresh before the next command. Continue with the requested Shopify CLI command.',
}
}

return {
instruction:
'No usable Shopify CLI session is available. Run `shopify auth login`, show the verification URL and user code to the user, and keep the command running until authentication completes.',
nextCommand: 'shopify auth login',
}
}

function validationResultToAuthStatus(validationResult: Awaited<ReturnType<typeof validateSession>>): AuthStatusName {
if (validationResult === 'ok') return 'authenticated'
if (validationResult === 'needs_refresh') return 'needs_refresh'
return 'invalid'
}

/**
* Returns the current Shopify CLI authentication status without starting a login flow.
*
* @returns The current authentication status.
*/
export async function getAuthStatus(): Promise<AuthStatus> {
const fqdn = await identityFqdn()
const sessions = await sessionStore.fetch()
const fqdnSessions = sessions?.[fqdn] ?? {}
const currentSessionId = getCurrentSessionId()
const sessionId = currentSessionId ?? Object.keys(fqdnSessions)[0]

if (!sessionId) {
return {
status: 'not_authenticated',
authenticated: false,
identityFqdn: fqdn,
agentGuidance: authStatusGuidance('not_authenticated'),
}
}

const session = fqdnSessions[sessionId]
if (!session) {
return {
status: 'invalid',
authenticated: false,
identityFqdn: fqdn,
agentGuidance: authStatusGuidance('invalid'),
}
}

const validationResult = await validateSession(allDefaultScopes(), {}, session)
const status = validationResultToAuthStatus(validationResult)

return {
status,
authenticated: status !== 'invalid',
account: {
userId: session.identity.userId,
alias: session.identity.alias,
},
identityFqdn: fqdn,
expiresAt: session.identity.expiresAt.toISOString(),
agentGuidance: authStatusGuidance(status),
}
}

/**
* Ensure that we have a valid session with no particular scopes.
*
Expand Down
26 changes: 26 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
* [`shopify app webhook trigger`](#shopify-app-webhook-trigger)
* [`shopify auth login`](#shopify-auth-login)
* [`shopify auth logout`](#shopify-auth-logout)
* [`shopify auth status`](#shopify-auth-status)
* [`shopify commands`](#shopify-commands)
* [`shopify config autocorrect off`](#shopify-config-autocorrect-off)
* [`shopify config autocorrect on`](#shopify-config-autocorrect-on)
Expand Down Expand Up @@ -1072,6 +1073,31 @@ DESCRIPTION
Logs you out of the Shopify account or Partner account and store.
```

## `shopify auth status`

Show Shopify account authentication status.

```
USAGE
$ shopify auth status [-j]

FLAGS
-j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output.

DESCRIPTION
Show Shopify account authentication status.

Shows whether Shopify CLI has a usable Shopify account session.

Use `--json` for stable machine-readable output. Agents should check this command before starting workflows that need
Shopify account authentication.

EXAMPLES
$ shopify auth status

$ shopify auth status --json
```

## `shopify commands`

List all shopify commands.
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3081,6 +3081,39 @@
"pluginType": "core",
"strict": true
},
"auth:status": {
"aliases": [
],
"args": {
},
"description": "Shows whether Shopify CLI has a usable Shopify account session.\n\nUse `--json` for stable machine-readable output. Agents should check this command before starting workflows that need Shopify account authentication.",
"descriptionWithMarkdown": "Shows whether Shopify CLI has a usable Shopify account session.\n\nUse `--json` for stable machine-readable output. Agents should check this command before starting workflows that need Shopify account authentication.",
"enableJsonFlag": false,
"examples": [
"<%= config.bin %> <%= command.id %>",
"<%= config.bin %> <%= command.id %> --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"
}
},
"hasDynamicHelp": false,
"hiddenAliases": [
],
"id": "auth:status",
"pluginAlias": "@shopify/cli",
"pluginName": "@shopify/cli",
"pluginType": "core",
"strict": true,
"summary": "Show Shopify account authentication status."
},
"cache:clear": {
"aliases": [
],
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/cli/commands/auth/status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Status from './status.js'
import {authStatusService} from '../../services/commands/auth-status.js'

import {describe, expect, test, vi} from 'vitest'

vi.mock('../../services/commands/auth-status.js')

describe('auth status command', () => {
test('checks auth status as text by default', async () => {
// When
await Status.run([])

// Then
expect(authStatusService).toHaveBeenCalledWith(false)
})

test('checks auth status as JSON', async () => {
// When
await Status.run(['--json'])

// Then
expect(authStatusService).toHaveBeenCalledWith(true)
})
})
Loading
Loading