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
2 changes: 2 additions & 0 deletions packages/cli-kit/src/private/node/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export const environmentVariables = {
spinAppHost: 'SPIN_APP_HOST',
organization: 'SHOPIFY_CLI_ORGANIZATION',
identityToken: 'SHOPIFY_CLI_IDENTITY_TOKEN',
identityTokenUserId: 'SHOPIFY_CLI_IDENTITY_USER_ID',
identityTokenExpiresAt: 'SHOPIFY_CLI_IDENTITY_TOKEN_EXPIRES_AT',
refreshToken: 'SHOPIFY_CLI_REFRESH_TOKEN',
otelURL: 'SHOPIFY_CLI_OTEL_EXPORTER_OTLP_ENDPOINT',
themeKitAccessDomain: 'SHOPIFY_CLI_THEME_KIT_ACCESS_DOMAIN',
Expand Down
63 changes: 62 additions & 1 deletion packages/cli-kit/src/private/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ 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'
import {businessPlatformRequest} from '../../public/node/api/business-platform.js'
import {getAppAutomationToken} from '../../public/node/environment.js'
import {getAppAutomationToken, getIdentityTokenInformation} from '../../public/node/environment.js'
import {nonRandomUUID} from '../../public/node/crypto.js'
import {terminalSupportsPrompting} from '../../public/node/system.js'

Expand Down Expand Up @@ -177,6 +177,67 @@ describe('ensureAuthenticated when previous session is invalid', () => {
expect(fetchSessions).toHaveBeenCalledOnce()
})

test('imports identity bootstrap from the provided env with explicit user id and expiry', async () => {
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
vi.mocked(fetchSessions).mockResolvedValue(undefined)
vi.mocked(exchangeAccessForApplicationTokens).mockResolvedValue({})

const bootstrapEnv = {
SHOPIFY_CLI_IDENTITY_TOKEN: 'identity-token',
SHOPIFY_CLI_REFRESH_TOKEN: 'refresh-token',
SHOPIFY_CLI_IDENTITY_USER_ID: 'placeholder-user-id',
SHOPIFY_CLI_IDENTITY_TOKEN_EXPIRES_AT: '2026-05-14T12:00:00.000Z',
}

vi.mocked(getIdentityTokenInformation).mockReturnValue({
accessToken: 'identity-token',
refreshToken: 'refresh-token',
userId: 'placeholder-user-id',
expiresAt: new Date('2026-05-14T12:00:00.000Z'),
})

const got = await ensureAuthenticated({}, bootstrapEnv)

expect(got).toEqual({userId: 'placeholder-user-id'})
expect(getIdentityTokenInformation).toHaveBeenCalledWith(bootstrapEnv)
expect(requestDeviceAuthorization).not.toHaveBeenCalled()
expect(pollForDeviceAuthorization).not.toHaveBeenCalled()

const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
expect(storedSession[fqdn]!['placeholder-user-id']!.identity.userId).toBe('placeholder-user-id')
expect(storedSession[fqdn]!['placeholder-user-id']!.identity.expiresAt).toEqual(
new Date('2026-05-14T12:00:00.000Z'),
)
})

test('imports identity bootstrap from the provided env when prompting is disabled', async () => {
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
vi.mocked(fetchSessions).mockResolvedValue(undefined)
vi.mocked(exchangeAccessForApplicationTokens).mockResolvedValue({})
vi.mocked(getCurrentSessionId).mockReturnValue(undefined)

const bootstrapEnv = {
SHOPIFY_CLI_IDENTITY_TOKEN: 'identity-token',
SHOPIFY_CLI_REFRESH_TOKEN: 'refresh-token',
SHOPIFY_CLI_IDENTITY_USER_ID: 'placeholder-user-id',
SHOPIFY_CLI_IDENTITY_TOKEN_EXPIRES_AT: '2026-05-14T12:00:00.000Z',
}

vi.mocked(getIdentityTokenInformation).mockReturnValue({
accessToken: 'identity-token',
refreshToken: 'refresh-token',
userId: 'placeholder-user-id',
expiresAt: new Date('2026-05-14T12:00:00.000Z'),
})

const got = await ensureAuthenticated({}, bootstrapEnv, {noPrompt: true})

expect(got).toEqual({userId: 'placeholder-user-id'})
expect(secureRemove).not.toHaveBeenCalled()
expect(requestDeviceAuthorization).not.toHaveBeenCalled()
expect(pollForDeviceAuthorization).not.toHaveBeenCalled()
})

test('throws an error and logs out if there is no session and prompting is disabled,', async () => {
// Given
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
Expand Down
31 changes: 22 additions & 9 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ export async function ensureAuthenticated(
{forceRefresh = false, noPrompt = false, forceNewSession = false}: EnsureAuthenticatedAdditionalOptions = {},
): Promise<OAuthSession> {
const fqdn = await identityFqdn()
const canAuthenticateWithoutPrompt = canAuthenticateWithoutPromptFromEnvironment(_env)

const previousStoreFqdn = applications.adminApi?.storeFqdn
if (previousStoreFqdn) {
Expand Down Expand Up @@ -230,17 +231,21 @@ ${outputToken.json(applications)}
let newSession = {}

if (validationResult === 'needs_full_auth') {
await throwOnNoPrompt(noPrompt)
if (!canAuthenticateWithoutPrompt) {
await throwOnNoPrompt(noPrompt)
}
outputDebug(outputContent`Initiating the full authentication flow...`)
newSession = await executeCompleteFlow(applications, currentSession?.identity.alias)
newSession = await executeCompleteFlow(applications, _env, currentSession?.identity.alias)
} else if (validationResult === 'needs_refresh' || forceRefresh) {
outputDebug(outputContent`The current session is valid but needs refresh. Refreshing...`)
try {
newSession = await refreshTokens(currentSession!, applications)
} catch (error) {
if (error instanceof InvalidGrantError) {
await throwOnNoPrompt(noPrompt)
newSession = await executeCompleteFlow(applications, currentSession?.identity.alias)
if (!canAuthenticateWithoutPrompt) {
await throwOnNoPrompt(noPrompt)
}
newSession = await executeCompleteFlow(applications, _env, currentSession?.identity.alias)
} else if (error instanceof InvalidRequestError) {
await sessionStore.remove()
throw new AbortError('\nError validating auth session', "We've cleared the current session, please try again")
Expand All @@ -265,7 +270,7 @@ ${outputToken.json(applications)}

const tokens = await tokensFor(applications, completeSession)

const envToken = getAppAutomationToken()
const envToken = getAppAutomationToken(_env)
if (envToken && applications.partnersApi) {
tokens.partners = (await exchangeCustomPartnerToken(envToken)).accessToken
}
Expand All @@ -275,6 +280,10 @@ ${outputToken.json(applications)}
return tokens
}

function canAuthenticateWithoutPromptFromEnvironment(env?: NodeJS.ProcessEnv): boolean {
return Boolean(getIdentityTokenInformation(env) || getAppAutomationToken(env))
}

async function throwOnNoPrompt(noPrompt: boolean) {
if (!noPrompt) return
await logout()
Expand All @@ -292,7 +301,11 @@ The CLI is currently unable to prompt for reauthentication.`,
* @param applications - An object containing the applications we need to be authenticated with.
* @param existingAlias - Optional alias from a previous session to preserve if the email fetch fails.
*/
async function executeCompleteFlow(applications: OAuthApplications, existingAlias?: string): Promise<Session> {
async function executeCompleteFlow(
applications: OAuthApplications,
env: NodeJS.ProcessEnv | undefined,
existingAlias?: string,
): Promise<Session> {
const scopes = getFlattenScopes(applications)
const exchangeScopes = getExchangeScopes(applications)
const store = applications.adminApi?.storeFqdn
Expand All @@ -302,7 +315,7 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia
}

let identityToken: IdentityToken
const identityTokenInformation = getIdentityTokenInformation()
const identityTokenInformation = getIdentityTokenInformation(env)
if (identityTokenInformation) {
identityToken = buildIdentityTokenFromEnv(scopes, identityTokenInformation)
} else {
Expand Down Expand Up @@ -442,11 +455,11 @@ function getExchangeScopes(apps: OAuthApplications): ExchangeScopes {

function buildIdentityTokenFromEnv(
scopes: string[],
identityTokenInformation: {accessToken: string; refreshToken: string; userId: string},
identityTokenInformation: {accessToken: string; refreshToken: string; userId: string; expiresAt?: Date},
) {
return {
...identityTokenInformation,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
expiresAt: identityTokenInformation.expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
scopes,
alias: undefined,
}
Expand Down
11 changes: 11 additions & 0 deletions packages/cli-kit/src/public/node/context/fqdn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
businessPlatformFqdn,
appDevFqdn,
adminFqdn,
previewStoreApiHost,
} from './fqdn.js'
import {Environment, serviceEnvironment} from '../../../private/node/context/service.js'
import {expect, describe, test, vi} from 'vitest'
Expand Down Expand Up @@ -218,3 +219,13 @@ describe('normalizeStore', () => {
expect(got).toEqual('example.myshopify.com')
})
})

describe('previewStoreApiHost', () => {
test('maps preview-store permanent domains to the local api host', () => {
expect(previewStoreApiHost('preview-shop.my.shop.dev')).toEqual('preview-shop.dev-api.shop.dev')
})

test('leaves non-preview-store domains unchanged', () => {
expect(previewStoreApiHost('shop.myshopify.com')).toEqual('shop.myshopify.com')
})
})
15 changes: 15 additions & 0 deletions packages/cli-kit/src/public/node/context/fqdn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,18 @@ export function storeAdminUrl(storeFqdn: string): string {
}
return storeFqdn
}

/**
* Maps a preview-store permanent domain to the local Admin API host.
* Leaves all non-preview-store domains unchanged.
*
* @param storeFqdn - Normalized store FQDN.
* @returns Store API host for preview stores, otherwise the original FQDN.
*/
export function previewStoreApiHost(storeFqdn: string): string {
if (storeFqdn.endsWith('.my.shop.dev')) {
return storeFqdn.replace('.my.shop.dev', '.dev-api.shop.dev')
}

return storeFqdn
}
41 changes: 40 additions & 1 deletion packages/cli-kit/src/public/node/environment.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import {getAppAutomationToken} from './environment.js'
import {getAppAutomationToken, getIdentityTokenInformation} from './environment.js'
import {environmentVariables} from '../../private/node/constants.js'
import {describe, expect, test, beforeEach} from 'vitest'

beforeEach(() => {
delete process.env[environmentVariables.appAutomationToken]
delete process.env[environmentVariables.partnersToken]
delete process.env[environmentVariables.identityToken]
delete process.env[environmentVariables.refreshToken]
delete process.env[environmentVariables.identityTokenUserId]
delete process.env[environmentVariables.identityTokenExpiresAt]
})

describe('getAppAutomationToken', () => {
Expand All @@ -31,3 +35,38 @@ describe('getAppAutomationToken', () => {
expect(getAppAutomationToken()).toBeUndefined()
})
})

describe('getIdentityTokenInformation', () => {
test('returns undefined when either identity token or refresh token is missing', () => {
process.env[environmentVariables.identityToken] = 'identity-token'

expect(getIdentityTokenInformation()).toBeUndefined()
})

test('uses explicit user id and expiry when provided', () => {
process.env[environmentVariables.identityToken] = 'identity-token'
process.env[environmentVariables.refreshToken] = 'refresh-token'
process.env[environmentVariables.identityTokenUserId] = 'placeholder-user-id'
process.env[environmentVariables.identityTokenExpiresAt] = '2026-05-14T12:00:00.000Z'

expect(getIdentityTokenInformation()).toEqual({
accessToken: 'identity-token',
refreshToken: 'refresh-token',
userId: 'placeholder-user-id',
expiresAt: new Date('2026-05-14T12:00:00.000Z'),
})
})

test('falls back to hashing the identity token when no explicit user id is provided', () => {
process.env[environmentVariables.identityToken] = 'identity-token'
process.env[environmentVariables.refreshToken] = 'refresh-token'

expect(getIdentityTokenInformation()).toEqual(
expect.objectContaining({
accessToken: 'identity-token',
refreshToken: 'refresh-token',
}),
)
expect(getIdentityTokenInformation()?.userId).toBeTruthy()
})
})
20 changes: 13 additions & 7 deletions packages/cli-kit/src/public/node/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@ export function getEnvironmentVariables(): NodeJS.ProcessEnv {
*
* @returns The app automation token value, or undefined if neither env var is set.
*/
export function getAppAutomationToken(): string | undefined {
const env = getEnvironmentVariables()
return env[environmentVariables.appAutomationToken] ?? env[environmentVariables.partnersToken]
export function getAppAutomationToken(environment = getEnvironmentVariables()): string | undefined {
return environment[environmentVariables.appAutomationToken] ?? environment[environmentVariables.partnersToken]
}

/**
Expand Down Expand Up @@ -55,14 +54,21 @@ export function getBackendPort(): number | undefined {
*
* @returns The identity token information in case it exists.
*/
export function getIdentityTokenInformation(): {accessToken: string; refreshToken: string; userId: string} | undefined {
const identityToken = getEnvironmentVariables()[environmentVariables.identityToken]
const refreshToken = getEnvironmentVariables()[environmentVariables.refreshToken]
export function getIdentityTokenInformation(
environment = getEnvironmentVariables(),
): {accessToken: string; refreshToken: string; userId: string; expiresAt?: Date} | undefined {
const identityToken = environment[environmentVariables.identityToken]
const refreshToken = environment[environmentVariables.refreshToken]
if (!identityToken || !refreshToken) return undefined

const expiresAtValue = environment[environmentVariables.identityTokenExpiresAt]
const expiresAt = expiresAtValue ? new Date(expiresAtValue) : undefined

return {
accessToken: identityToken,
refreshToken,
userId: nonRandomUUID(identityToken),
userId: environment[environmentVariables.identityTokenUserId] ?? nonRandomUUID(identityToken),
...(expiresAt && !Number.isNaN(expiresAt.getTime()) ? {expiresAt} : {}),
}
}

Expand Down
26 changes: 26 additions & 0 deletions packages/cli-kit/src/public/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ensureAuthenticatedPartners,
ensureAuthenticatedStorefront,
ensureAuthenticatedThemes,
importIdentitySession,
setLastSeenUserId,
} from './session.js'

Expand Down Expand Up @@ -42,6 +43,31 @@ describe('store command analytics session helpers', () => {
})
})

describe('importIdentitySession', () => {
test('routes preview bootstrap through ensureAuthenticated with env bootstrap variables', async () => {
vi.mocked(ensureAuthenticated).mockResolvedValueOnce({userId: 'placeholder-user-id'})

const result = await importIdentitySession({
accessToken: 'identity-token',
refreshToken: 'refresh-token',
expiresAt: new Date('2026-05-14T12:00:00.000Z'),
userId: 'placeholder-user-id',
})

expect(result).toEqual({userId: 'placeholder-user-id'})
expect(ensureAuthenticated).toHaveBeenCalledWith(
{},
expect.objectContaining({
SHOPIFY_CLI_IDENTITY_TOKEN: 'identity-token',
SHOPIFY_CLI_REFRESH_TOKEN: 'refresh-token',
SHOPIFY_CLI_IDENTITY_USER_ID: 'placeholder-user-id',
SHOPIFY_CLI_IDENTITY_TOKEN_EXPIRES_AT: '2026-05-14T12:00:00.000Z',
}),
{noPrompt: true, forceNewSession: true},
)
})
})

describe('ensureAuthenticatedStorefront', () => {
test('returns only storefront token if success', async () => {
// Given
Expand Down
23 changes: 23 additions & 0 deletions packages/cli-kit/src/public/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
exchangeAppAutomationTokenForAppManagementAccessToken,
exchangeAppAutomationTokenForBusinessPlatformAccessToken,
} from '../../private/node/session/exchange.js'
import {environmentVariables} from '../../private/node/constants.js'
import {
AdminAPIScope,
AppManagementAPIScope,
Expand Down Expand Up @@ -51,6 +52,28 @@ export function setLastSeenUserId(userId: string): void {
setLastSeenUserIdAfterAuth(userId)
}

export interface IdentitySessionBootstrap {
accessToken: string
refreshToken: string
expiresAt: Date
userId?: string
}

export async function importIdentitySession(
bootstrap: IdentitySessionBootstrap,
env = process.env,
): Promise<{userId: string}> {
const bootstrapEnv = {
...env,
[environmentVariables.identityToken]: bootstrap.accessToken,
[environmentVariables.refreshToken]: bootstrap.refreshToken,
[environmentVariables.identityTokenExpiresAt]: bootstrap.expiresAt.toISOString(),
...(bootstrap.userId ? {[environmentVariables.identityTokenUserId]: bootstrap.userId} : {}),
}

return ensureAuthenticatedUser(bootstrapEnv, {noPrompt: true, forceNewSession: true})
}

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