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 @@ -42,6 +42,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
15 changes: 7 additions & 8 deletions packages/cli-kit/src/private/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ describe('ensureAuthenticated when previous session is invalid', () => {
expect(fetchSessions).toHaveBeenCalledOnce()
})

test('throws an error and logs out if there is no session and prompting is disabled,', async () => {
test('throws an error when there is no session and prompting is disabled, without wiping the Sessions store', async () => {
// Given
vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth')
vi.mocked(fetchSessions).mockResolvedValue(undefined)
Expand All @@ -189,13 +189,12 @@ describe('ensureAuthenticated when previous session is invalid', () => {

The CLI is currently unable to prompt for reauthentication.`,
)
expect(secureRemove).toHaveBeenCalled()

// Then
await expect(getLastSeenAuthMethod()).resolves.toEqual('none')

// If there never was an auth event, the userId is 'unknown'
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('unknown')
// `throwOnNoPrompt` intentionally does NOT call `logout()` anymore: wiping the
// full Sessions store on every noPrompt failure is destructive for callers that
// rely on a backend-issued session (e.g. preview-store placeholders) that can't
// be reconstructed without re-creating a shop. Users who explicitly want to
// clear sessions can run `shopify auth logout`.
expect(secureRemove).not.toHaveBeenCalled()
})

test('executes complete auth flow if session is for a different fqdn', async () => {
Expand Down
59 changes: 46 additions & 13 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,22 +228,39 @@ ${outputToken.json(applications)}
const validationResult = await validateSession(scopes, applications, currentSession)

let newSession = {}
const canAuthenticateWithoutPrompt = canAuthenticateWithoutPromptFromEnvironment(_env)

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")
// Surface the error scoped to the failed refresh; do NOT wipe the entire
// Sessions store. The previous behavior (`sessionStore.remove()`) was
// destructive when called against backend-issued sessions (preview-store
// placeholders): a single Identity-side refresh rejection — e.g. because
// a downstream command called `ensureAuthenticatedAdmin` without a
// `storeFqdn` and validation tried to refresh a non-existent application
// token — wiped the imported placeholder session that can't be
// reconstructed without re-creating the shop. Now the error message tells
// the user how to recover (`shopify auth logout` if they actually want
// to clear) without doing the wipe automatically.
throw new AbortError(
'\nError validating auth session',
"The active session couldn't be refreshed. If you need to clear it, run `shopify auth logout` and try again.",
)
} else {
throw error
}
Expand Down Expand Up @@ -275,14 +292,25 @@ ${outputToken.json(applications)}
return tokens
}

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

async function throwOnNoPrompt(noPrompt: boolean) {
if (!noPrompt) return
await logout()
// Intentionally NOT calling `logout()` here. The original behavior was to wipe the
// entire Sessions store on every noPrompt failure, but that's destructive when the
// caller is in a 401-retry cascade (e.g. an `unauthorizedHandler` calling
// `unsafeRefreshToken({noPrompt: true})` after a single API rejects the token).
// For backend-issued / imported sessions (preview-store placeholders, app-automation
// tokens), the cached session is the only source of truth on disk and we'd rather
// surface a clear error than silently destroy state the user can't reconstruct.
// Users who explicitly want to clear sessions can run `shopify auth logout`.
throw new AbortError(
`The currently available CLI credentials are invalid.

The CLI is currently unable to prompt for reauthentication.`,
'Restart the CLI process you were running. If in an interactive terminal, you will be prompted to reauthenticate. If in a non-interactive terminal, ensure the correct credentials are available in the program environment.',
'Restart the CLI process you were running. If in an interactive terminal, you will be prompted to reauthenticate. If in a non-interactive terminal, ensure the correct credentials are available in the program environment. If you imported a backend-issued session (e.g. via `store create preview`), run `shopify auth logout` to clear it before retrying.',
)
}

Expand All @@ -292,7 +320,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,
existingAlias?: string,
): Promise<Session> {
const scopes = getFlattenScopes(applications)
const exchangeScopes = getExchangeScopes(applications)
const store = applications.adminApi?.storeFqdn
Expand All @@ -302,7 +334,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 +474,12 @@ 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},
) {
const {expiresAt, ...rest} = identityTokenInformation
return {
...identityTokenInformation,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
...rest,
expiresAt: expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
scopes,
alias: undefined,
}
Expand Down
30 changes: 30 additions & 0 deletions packages/cli-kit/src/private/node/session/exchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,36 @@ describe('exchange identity token for application tokens', () => {
}
expect(got).toEqual(expected)
})

test('tolerates per-audience invalid_request rejections and returns the subset of tokens that did mint', async () => {
// Simulates the preview-store / placeholder case: the Identity token is
// bound to a single OAuth application, so the exchanges for the audiences
// that aren't authorized come back with `invalid_request`, while the ones
// that are authorized mint normally.
const okBody = new Response(JSON.stringify(data))
const invalidRequest = () => new Response(JSON.stringify({error: 'invalid_request'}), {status: 400})
vi.mocked(shopifyFetch)
.mockResolvedValueOnce(invalidRequest()) // partners
.mockResolvedValueOnce(invalidRequest()) // storefront-renderer
.mockResolvedValueOnce(okBody.clone()) // business-platform
.mockResolvedValueOnce(okBody.clone()) // admin (we pass a store)
.mockResolvedValueOnce(invalidRequest()) // app-management

const got = await exchangeAccessForApplicationTokens(identityToken, scopes, 'storeFQDN')

expect(Object.keys(got).sort()).toEqual(['business-platform', 'storeFQDN-admin'])
expect(got['business-platform']?.accessToken).toBe('access_token')
expect(got['storeFQDN-admin']?.accessToken).toBe('access_token')
})

test('re-throws InvalidRequestError when every exchange fails so the outer flow prompts for re-auth', async () => {
const invalidRequest = () => new Response(JSON.stringify({error: 'invalid_request'}), {status: 400})
vi.mocked(shopifyFetch).mockImplementation(async () => invalidRequest())

await expect(
exchangeAccessForApplicationTokens(identityToken, scopes, 'storeFQDN'),
).rejects.toBeInstanceOf(InvalidRequestError)
})
})

describe('refresh access tokens', () => {
Expand Down
65 changes: 55 additions & 10 deletions packages/cli-kit/src/private/node/session/exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {identityFqdn} from '../../../public/node/context/fqdn.js'
import {shopifyFetch} from '../../../public/node/http.js'
import {err, ok, Result} from '../../../public/node/result.js'
import {AbortError, BugError, ExtendableError} from '../../../public/node/error.js'
import {outputContent, outputDebug, outputToken} from '../../../public/node/output.js'
import {setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../session.js'
import {nonRandomUUID} from '../../../public/node/crypto.js'

Expand All @@ -24,10 +25,26 @@ export interface ExchangeScopes {
}

/**
* Given an identity token, request an application token.
* Given an identity token, request an application token for each Shopify API
* (partners / storefront-renderer / business-platform / admin / app-management).
*
* Per-API failures are tolerated: a token exchange that returns `invalid_request`
* or other non-fatal errors for one audience is logged at debug level and skipped,
* while the successful exchanges are merged into the returned record. Callers
* already validate that the specific API token they need is present (see the
* `No <api> token found after ensuring authenticated` BugError throws in
* `public/node/session.ts`), so partial success surfaces a clear, scoped error at
* the call site rather than a confusing `invalid_request` mid-Promise.all.
*
* The motivating case is server-issued Identity bootstraps (e.g. preview-store
* `cli_identity_bootstrap`) whose Identity token is bound to a single OAuth
* application and therefore can only be exchanged for a subset of audiences. For
* normal device-auth logins all five exchanges still succeed exactly as before.
*
* @param identityToken - access token obtained in a previous step
* @param scopes - per-API scope sets to request
* @param store - the store to use, only needed for admin API
* @returns An array with the application access tokens.
* @returns A merged record of every application token that was successfully minted.
*/
export async function exchangeAccessForApplicationTokens(
identityToken: IdentityToken,
Expand All @@ -36,21 +53,49 @@ export async function exchangeAccessForApplicationTokens(
): Promise<Record<string, ApplicationToken>> {
const token = identityToken.accessToken

const [partners, storefront, businessPlatform, admin, appManagement] = await Promise.all([
const settled = await Promise.allSettled([
requestAppToken('partners', token, scopes.partners),
requestAppToken('storefront-renderer', token, scopes.storefront),
requestAppToken('business-platform', token, scopes.businessPlatform),
store ? requestAppToken('admin', token, scopes.admin, store) : {},
store ? requestAppToken('admin', token, scopes.admin, store) : Promise.resolve({}),
requestAppToken('app-management', token, scopes.appManagement),
])

return {
...partners,
...storefront,
...businessPlatform,
...admin,
...appManagement,
const apiOrder = ['partners', 'storefront-renderer', 'business-platform', 'admin', 'app-management'] as const
const result: Record<string, ApplicationToken> = {}
const rejections: unknown[] = []

for (const [index, outcome] of settled.entries()) {
if (outcome.status === 'fulfilled') {
Object.assign(result, outcome.value)
continue
}
rejections.push(outcome.reason)
outputDebug(
outputContent`Application-token exchange skipped for ${outputToken.raw(
apiOrder[index] ?? 'unknown',
)}: ${outputToken.raw(
outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason),
)}`,
)
}

// If at least one exchange succeeded the identity token is intact and the
// remaining failures are per-audience authorization issues we tolerate. If
// *every* exchange failed and the failures are identity-level (InvalidGrant /
// InvalidRequest), surface the first one so the outer flow can prompt for
// re-auth as before. Any non-identity-level failure (e.g. InvalidTargetError)
// is also surfaced verbatim so users see the actionable message.
if (Object.keys(result).length === 0 && rejections.length > 0) {
const fatal =
rejections.find((reason) => reason instanceof InvalidGrantError) ??
rejections.find((reason) => reason instanceof InvalidRequestError) ??
rejections.find((reason) => !(reason instanceof InvalidGrantError || reason instanceof InvalidRequestError)) ??
rejections[0]
throw fatal
}

return result
}

/**
Expand Down
18 changes: 14 additions & 4 deletions packages/cli-kit/src/public/node/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,24 @@ 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(
env: NodeJS.ProcessEnv = getEnvironmentVariables(),
): {accessToken: string; refreshToken: string; userId: string; expiresAt?: Date} | undefined {
const identityToken = env[environmentVariables.identityToken]
const refreshToken = env[environmentVariables.refreshToken]
if (!identityToken || !refreshToken) return undefined

const explicitUserId = env[environmentVariables.identityTokenUserId]
const explicitExpiresAtIso = env[environmentVariables.identityTokenExpiresAt]
const parsedExpiresAt = explicitExpiresAtIso ? new Date(explicitExpiresAtIso) : undefined
const validExpiresAt =
parsedExpiresAt && !Number.isNaN(parsedExpiresAt.getTime()) ? parsedExpiresAt : undefined

return {
accessToken: identityToken,
refreshToken,
userId: nonRandomUUID(identityToken),
userId: explicitUserId ?? nonRandomUUID(identityToken),
...(validExpiresAt ? {expiresAt: validExpiresAt} : {}),
}
}

Expand Down
Loading
Loading