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
7 changes: 7 additions & 0 deletions .changeset/auth-alias-command-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@shopify/app': patch
'@shopify/cli-kit': patch
'@shopify/theme': patch
---

Allow app and theme commands to authenticate with a Shopify account alias without changing the current CLI session.
1,097 changes: 990 additions & 107 deletions docs-shopify.dev/generated/generated_docs_data_v2.json

Large diffs are not rendered by default.

84 changes: 83 additions & 1 deletion packages/cli-kit/src/private/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
getLastSeenUserIdAfterAuth,
OAuthApplications,
OAuthSession,
setCommandSessionId,
setLastSeenAuthMethod,
setLastSeenUserIdAfterAuth,
} from './session.js'
Expand All @@ -19,7 +20,7 @@ import {ApplicationToken, IdentityToken, Sessions} from './session/schema.js'
import {validateSession} from './session/validate.js'
import {applicationId} from './session/identity.js'
import {pollForDeviceAuthorization, requestDeviceAuthorization} from './session/device-authorization.js'
import {getCurrentSessionId} from './conf-store.js'
import {getCurrentSessionId, setCurrentSessionId} from './conf-store.js'
import * as fqdnModule from '../../public/node/context/fqdn.js'
import {themeToken} from '../../public/node/context/local.js'
import {partnersRequest} from '../../public/node/api/partners.js'
Expand Down Expand Up @@ -133,6 +134,7 @@ beforeEach(() => {
vi.mocked(allDefaultScopes).mockImplementation((scopes) => scopes ?? [])
setLastSeenUserIdAfterAuth(undefined as any)
setLastSeenAuthMethod('none')
setCommandSessionId(undefined)

vi.mocked(requestDeviceAuthorization).mockResolvedValue({
deviceCode: 'device_code',
Expand Down Expand Up @@ -313,6 +315,61 @@ describe('when existing session is valid', () => {
expect(fetchSessions).toHaveBeenCalledOnce()
})

test('uses an explicitly selected session without reading the current session ID', async () => {
// Given
const selectedUserId = 'selected-user-id'
const sessions: Sessions = {
[fqdn]: {
[userId]: {
identity: validIdentityToken,
applications: {},
},
[selectedUserId]: {
identity: {...validIdentityToken, userId: selectedUserId},
applications: appTokens,
},
},
}
vi.mocked(validateSession).mockResolvedValueOnce('ok')
vi.mocked(fetchSessions).mockResolvedValue(sessions)

// When
const got = await ensureAuthenticated(defaultApplications, process.env, {sessionId: selectedUserId})

// Then
expect(getCurrentSessionId).not.toHaveBeenCalled()
expect(validateSession).toHaveBeenCalledWith(expect.any(Array), expect.any(Object), sessions[fqdn]![selectedUserId])
expect(got).toEqual({...validTokens, userId: selectedUserId})
})

test('uses the command selected session without changing the current session ID', async () => {
// Given
const selectedUserId = 'selected-user-id'
const sessions: Sessions = {
[fqdn]: {
[userId]: {
identity: validIdentityToken,
applications: {},
},
[selectedUserId]: {
identity: {...validIdentityToken, userId: selectedUserId},
applications: appTokens,
},
},
}
vi.mocked(validateSession).mockResolvedValueOnce('ok')
vi.mocked(fetchSessions).mockResolvedValue(sessions)
setCommandSessionId(selectedUserId)

// When
const got = await ensureAuthenticated(defaultApplications)

// Then
expect(getCurrentSessionId).not.toHaveBeenCalled()
expect(validateSession).toHaveBeenCalledWith(expect.any(Array), expect.any(Object), sessions[fqdn]![selectedUserId])
expect(got).toEqual({...validTokens, userId: selectedUserId})
})

test('overwrites partners token if provided with a custom CLI token', async () => {
// Given
vi.mocked(validateSession).mockResolvedValueOnce('ok')
Expand Down Expand Up @@ -350,6 +407,31 @@ describe('when existing session is valid', () => {
await expect(getLastSeenAuthMethod()).resolves.toEqual('device_auth')
expect(fetchSessions).toHaveBeenCalledOnce()
})

test('refreshes an explicitly selected session without changing the current session ID', async () => {
// Given
const selectedUserId = 'selected-user-id'
const sessions: Sessions = {
[fqdn]: {
[selectedUserId]: {
identity: {...validIdentityToken, userId: selectedUserId},
applications: appTokens,
},
},
}
vi.mocked(validateSession).mockResolvedValueOnce('needs_refresh')
vi.mocked(fetchSessions).mockResolvedValue(sessions)
vi.mocked(refreshAccessToken).mockResolvedValueOnce({...validIdentityToken, userId: selectedUserId})

// When
const got = await ensureAuthenticated(defaultApplications, process.env, {sessionId: selectedUserId})

// Then
expect(refreshAccessToken).toHaveBeenCalled()
expect(storeSessions).toHaveBeenCalledWith(sessions)
expect(setCurrentSessionId).not.toHaveBeenCalled()
expect(got).toEqual({...validTokens, userId: selectedUserId})
})
})

describe('when existing session is expired', () => {
Expand Down
20 changes: 16 additions & 4 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ type AuthMethod = 'partners_token' | 'device_auth' | 'theme_access_token' | 'cus

let userId: undefined | string
let authMethod: AuthMethod = 'none'
let commandSessionId: string | undefined

/**
* Retrieves a stable user identifier for analytics, or `'unknown'` if none applies.
Expand Down Expand Up @@ -179,10 +180,15 @@ export function setLastSeenAuthMethod(method: AuthMethod) {
authMethod = method
}

export function setCommandSessionId(sessionId: string | undefined) {
commandSessionId = sessionId
}

export interface EnsureAuthenticatedAdditionalOptions {
noPrompt?: boolean
forceRefresh?: boolean
forceNewSession?: boolean
sessionId?: string
}

/**
Expand All @@ -196,7 +202,12 @@ export interface EnsureAuthenticatedAdditionalOptions {
export async function ensureAuthenticated(
applications: OAuthApplications,
_env?: NodeJS.ProcessEnv,
{forceRefresh = false, noPrompt = false, forceNewSession = false}: EnsureAuthenticatedAdditionalOptions = {},
{
forceRefresh = false,
noPrompt = false,
forceNewSession = false,
sessionId,
}: EnsureAuthenticatedAdditionalOptions = {},
): Promise<OAuthSession> {
const fqdn = await identityFqdn()

Expand All @@ -209,9 +220,10 @@ export async function ensureAuthenticated(
}

const sessions = (await sessionStore.fetch()) ?? {}
const selectedSessionId = sessionId ?? commandSessionId

let currentSessionId = getCurrentSessionId()
if (!currentSessionId) {
let currentSessionId = forceNewSession ? undefined : (selectedSessionId ?? getCurrentSessionId())
if (!currentSessionId && !selectedSessionId) {
const userIds = Object.keys(sessions[fqdn] ?? {})
if (userIds.length > 0) currentSessionId = userIds[0]
}
Expand Down Expand Up @@ -260,7 +272,7 @@ ${outputToken.json(applications)}
// Save the new session info if it has changed
if (!isEmpty(newSession)) {
await sessionStore.store(updatedSessions)
setCurrentSessionId(newSessionId)
if (!selectedSessionId) setCurrentSessionId(newSessionId)
}

const tokens = await tokensFor(applications, completeSession)
Expand Down
14 changes: 11 additions & 3 deletions packages/cli-kit/src/public/node/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import {isDevelopment} from './context/local.js'
import {addPublicMetadata} from './metadata.js'
import {AbortError} from './error.js'
import {outputContent, outputResult, outputToken} from './output.js'
import {setCurrentSessionAlias} from './session.js'
import {terminalSupportsPrompting} from './system.js'
import {hashString} from './crypto.js'
import {isTruthy} from './context/utilities.js'
import {setCurrentCommandId} from './global-context.js'
import {JsonMap} from '../../private/common/json.js'
import {underscore} from '../common/string.js'
import {Command, Config, Errors} from '@oclif/core'
import {Command, Config, Errors, Flags} from '@oclif/core'
import {OutputFlags, Input, ParserOutput, FlagInput, OutputArgs} from '@oclif/core/parser'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -17,12 +18,18 @@ export type ArgOutput = OutputArgs<any>
export type FlagOutput = OutputFlags<any>

interface EnvironmentFlags {
'auth-alias'?: string
environment?: string[]
path?: string
}

abstract class BaseCommand extends Command {
static baseFlags: FlagInput<{}> = {}
static baseFlags: FlagInput<{}> = {
'auth-alias': Flags.string({
description: 'Alias of the Shopify account to use for authentication.',
env: 'SHOPIFY_FLAG_AUTH_ALIAS',
}),
}

// Replace markdown links to plain text like: "link label" (url)
public static descriptionWithoutMarkdown(): string | undefined {
Expand Down Expand Up @@ -95,7 +102,7 @@ abstract class BaseCommand extends Command {
}

protected async parse<
TFlags extends FlagOutput & {path?: string; verbose?: boolean},
TFlags extends FlagOutput & {path?: string; verbose?: boolean; 'auth-alias'?: string},
TGlobalFlags extends FlagOutput,
TArgs extends ArgOutput,
>(
Expand All @@ -104,6 +111,7 @@ abstract class BaseCommand extends Command {
): Promise<ParserOutput<TFlags, TGlobalFlags, TArgs> & {argv: string[]}> {
let result = await super.parse<TFlags, TGlobalFlags, TArgs>(options, argv)
result = await this.resultWithEnvironment<TFlags, TGlobalFlags, TArgs>(result, options, argv)
await setCurrentSessionAlias(result.flags['auth-alias'])
await addFromParsedFlags(result.flags)
return {...result, ...{argv: result.argv as string[]}}
}
Expand Down
72 changes: 71 additions & 1 deletion packages/cli-kit/src/public/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,21 @@ import {
ensureAuthenticatedPartners,
ensureAuthenticatedStorefront,
ensureAuthenticatedThemes,
findSessionIdByAlias,
setCurrentSessionAlias,
setLastSeenUserId,
} from './session.js'

import {nonRandomUUID} from './crypto.js'
import {getAppAutomationToken} from './environment.js'
import {shopifyFetch} from './http.js'
import {ensureAuthenticated, setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../../private/node/session.js'
import {
ensureAuthenticated,
setCommandSessionId,
setLastSeenAuthMethod,
setLastSeenUserIdAfterAuth,
} from '../../private/node/session.js'
import * as sessionStore from '../../private/node/session/store.js'
import {ApplicationToken} from '../../private/node/session/schema.js'
import {
exchangeCustomPartnerToken,
Expand All @@ -32,6 +40,7 @@ const partnersToken: ApplicationToken = {

vi.mock('../../private/node/session.js')
vi.mock('../../private/node/session/exchange.js')
vi.mock('../../private/node/session/store.js')
vi.mock('./environment.js')
vi.mock('./http.js')

Expand All @@ -43,6 +52,50 @@ describe('store command analytics session helpers', () => {
})
})

describe('findSessionIdByAlias', () => {
test('returns the matching session ID without selecting it', async () => {
// Given
vi.mocked(sessionStore.findSessionByAlias).mockResolvedValueOnce('user-id')

// When
const got = await findSessionIdByAlias('work')

// Then
expect(got).toEqual('user-id')
expect(sessionStore.findSessionByAlias).toHaveBeenCalledWith('work')
})
})

describe('setCurrentSessionAlias', () => {
test('selects a stored session by alias', async () => {
// Given
vi.mocked(sessionStore.findSessionByAlias).mockResolvedValueOnce('user-id')

// When
await setCurrentSessionAlias('work')

// Then
expect(sessionStore.findSessionByAlias).toHaveBeenCalledWith('work')
expect(setCommandSessionId).toHaveBeenCalledWith('user-id')
})

test('clears the selected session when no alias is provided', async () => {
// When
await setCurrentSessionAlias(undefined)

// Then
expect(setCommandSessionId).toHaveBeenCalledWith(undefined)
})

test('throws when the alias is unknown', async () => {
// Given
vi.mocked(sessionStore.findSessionByAlias).mockResolvedValueOnce(undefined)

// When/Then
await expect(setCurrentSessionAlias('missing')).rejects.toThrow('No authenticated account found for alias')
})
})

describe('ensureAuthenticatedStorefront', () => {
test('returns only storefront token if success', async () => {
// Given
Expand Down Expand Up @@ -173,6 +226,23 @@ describe('ensureAuthenticatedTheme', () => {
expect(setLastSeenUserIdAfterAuth).not.toBeCalled()
})

test('passes additional auth options through to the shared authenticator', async () => {
// Given
vi.mocked(ensureAuthenticated).mockResolvedValueOnce({
admin: {token: 'admin_token', storeFqdn: 'mystore.myshopify.com'},
userId: '1234-5678',
})

// When
const got = await ensureAuthenticatedThemes('mystore', undefined, [], {sessionId: 'user-id'})

// Then
expect(got).toEqual({token: 'admin_token', storeFqdn: 'mystore.myshopify.com'})
expect(ensureAuthenticated).toHaveBeenCalledWith({adminApi: {scopes: [], storeFqdn: 'mystore'}}, process.env, {
sessionId: 'user-id',
})
})

test('throws error if there is no token when no password is provided', async () => {
// Given
vi.mocked(ensureAuthenticated).mockResolvedValueOnce({userId: ''})
Expand Down
32 changes: 32 additions & 0 deletions packages/cli-kit/src/public/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
PartnersAPIScope,
StorefrontRendererScope,
ensureAuthenticated,
setCommandSessionId,
setLastSeenAuthMethod,
setLastSeenUserIdAfterAuth,
} from '../../private/node/session.js'
Expand Down Expand Up @@ -51,6 +52,37 @@ export function setLastSeenUserId(userId: string): void {
setLastSeenUserIdAfterAuth(userId)
}

/**
* Finds a stored Shopify account session by alias without changing the current session.
*
* @param alias - The account alias to find.
* @returns The matching session ID, or undefined if no session matches.
*/
export async function findSessionIdByAlias(alias: string): Promise<string | undefined> {
return sessionStore.findSessionByAlias(alias)
}

/**
* Selects a stored Shopify account session by alias for the current command process.
*
* @param alias - The account alias to select. Passing undefined clears the command selection.
*/
export async function setCurrentSessionAlias(alias?: string): Promise<void> {
if (!alias) {
setCommandSessionId(undefined)
return
}

const sessionId = await findSessionIdByAlias(alias)
if (!sessionId) {
throw new AbortError(
outputContent`No authenticated account found for alias ${outputToken.yellow(alias)}.`,
outputContent`Run ${outputToken.genericShellCommand(`shopify auth login --alias ${alias}`)} first.`,
)
}
setCommandSessionId(sessionId)
}

interface UserAccountInfo {
type: 'UserAccount'
email: string
Expand Down
Loading
Loading