diff --git a/packages/cli-kit/src/private/node/session.test.ts b/packages/cli-kit/src/private/node/session.test.ts index b18a6a85452..cf0d9fc6445 100644 --- a/packages/cli-kit/src/private/node/session.test.ts +++ b/packages/cli-kit/src/private/node/session.test.ts @@ -20,7 +20,11 @@ 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 {automationTokenVariable, automationTokenVariablesProblem} from './session/automation-token.js' +import { + automationTokenAuthMethod, + automationTokenVariable, + automationTokenVariablesProblem, +} from './session/automation-token.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' @@ -660,6 +664,7 @@ describe('getLastSeenAuthMethod', () => { test('returns partners_token if there is a partners token in the environment', async () => { // Given vi.mocked(getAppAutomationToken).mockReturnValue('partners-token-456') + vi.mocked(automationTokenAuthMethod).mockReturnValue('partners_token') // When const method = await getLastSeenAuthMethod() @@ -669,6 +674,18 @@ describe('getLastSeenAuthMethod', () => { expect(getCurrentSessionId).toHaveBeenCalledOnce() }) + test('returns service_account_token if the service account token is in the environment', async () => { + // Given + vi.mocked(getAppAutomationToken).mockReturnValue('service-account-token-789') + vi.mocked(automationTokenAuthMethod).mockReturnValue('service_account_token') + + // When + const method = await getLastSeenAuthMethod() + + // Then + expect(method).toBe('service_account_token') + }) + test('returns custom_app_token if there is a theme token in the environment and doesnt start with shptka_', async () => { // Given vi.mocked(themeToken).mockReturnValue('theme-token-123') diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index aec2781a0c1..d8562e6c17f 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -12,7 +12,7 @@ import { import {IdentityToken, Session, Sessions} from './session/schema.js' import * as sessionStore from './session/store.js' import {pollForDeviceAuthorization, requestDeviceAuthorization} from './session/device-authorization.js' -import {automationTokenVariablesProblem} from './session/automation-token.js' +import {automationTokenAuthMethod, automationTokenVariablesProblem} from './session/automation-token.js' import {isThemeAccessSession} from './api/rest.js' import {getCurrentSessionId, setCurrentSessionId} from './conf-store.js' import {UserEmailQueryString, UserEmailQuery} from './api/graphql/business-platform-destinations/user-email.js' @@ -115,7 +115,13 @@ export interface OAuthSession { userId: string } -type AuthMethod = 'partners_token' | 'device_auth' | 'theme_access_token' | 'custom_app_token' | 'none' +type AuthMethod = + | 'partners_token' + | 'service_account_token' + | 'device_auth' + | 'theme_access_token' + | 'custom_app_token' + | 'none' let userId: undefined | string let authMethod: AuthMethod = 'none' @@ -155,7 +161,8 @@ export function setLastSeenUserIdAfterAuth(id: string) { * This function checks for the authentication method in the following order: * 1. Returns the cached auth method if it's not 'none'. * 2. Checks for a cached session, which implies 'device_auth' was used. - * 3. Checks for a partners token in the environment. + * 3. Checks for an automation token in the environment: `service_account_token` for SHOPIFY_SERVICE_ACCOUNT_TOKEN, + * `partners_token` for the app and Partner variables. * 4. Checks for a theme password in the environment. * 5. If none of the above are true, returns 'none'. * @@ -167,7 +174,7 @@ export async function getLastSeenAuthMethod(): Promise { if (getCurrentSessionId()) return 'device_auth' const appAutomationToken = getAppAutomationToken() - if (appAutomationToken) return 'partners_token' + if (appAutomationToken) return automationTokenAuthMethod() const themePassword = themeToken() if (themePassword) { diff --git a/packages/cli-kit/src/private/node/session/automation-token.test.ts b/packages/cli-kit/src/private/node/session/automation-token.test.ts index 5e630ec565f..3769e50fba1 100644 --- a/packages/cli-kit/src/private/node/session/automation-token.test.ts +++ b/packages/cli-kit/src/private/node/session/automation-token.test.ts @@ -1,4 +1,8 @@ -import {automationTokenVariable, automationTokenVariablesProblem} from './automation-token.js' +import { + automationTokenAuthMethod, + automationTokenVariable, + automationTokenVariablesProblem, +} from './automation-token.js' import {environmentVariables} from '../constants.js' import {describe, expect, test} from 'vitest' @@ -28,6 +32,21 @@ describe('automationTokenVariable', () => { }) }) +describe('automationTokenAuthMethod', () => { + test.each([ + {name: 'the service account variable', env: {[SERVICE_ACCOUNT]: 's'}, expected: 'service_account_token'}, + {name: 'the app variable', env: {[APP]: 'a'}, expected: 'partners_token'}, + {name: 'the Partners variable', env: {[PARTNERS]: 'p'}, expected: 'partners_token'}, + { + name: 'the service account variable over another one', + env: {[SERVICE_ACCOUNT]: 's', [APP]: 'a'}, + expected: 'service_account_token', + }, + ])('reports $expected for $name', ({env, expected}) => { + expect(automationTokenAuthMethod(env)).toBe(expected) + }) +}) + describe('automationTokenVariablesProblem', () => { test.each([ {name: 'no automation token variable', env: {}}, diff --git a/packages/cli-kit/src/private/node/session/automation-token.ts b/packages/cli-kit/src/private/node/session/automation-token.ts index 63825990423..53b4ee4f86a 100644 --- a/packages/cli-kit/src/private/node/session/automation-token.ts +++ b/packages/cli-kit/src/private/node/session/automation-token.ts @@ -22,6 +22,23 @@ export function automationTokenVariable(env: NodeJS.ProcessEnv = process.env): s return [SERVICE_ACCOUNT_TOKEN, APP_TOKEN, PARTNERS_TOKEN].find((name) => env[name] !== undefined) } +/** + * Returns the `env_auth_method` analytics value for the automation token variable the CLI authenticates with: + * `service_account_token` for SHOPIFY_SERVICE_ACCOUNT_TOKEN, and `partners_token` for SHOPIFY_APP_AUTOMATION_TOKEN and + * SHOPIFY_CLI_PARTNERS_TOKEN, which existing dashboards already expect. + * + * The value tells which variable held the token, not which kind of token it is: the CLI can't tell tokens apart, and + * any of them works in any of the variables. It shows how much usage has moved to SHOPIFY_SERVICE_ACCOUNT_TOKEN. + * + * @param env - Environment variables to read. + * @returns The auth method to report. + */ +export function automationTokenAuthMethod( + env: NodeJS.ProcessEnv = process.env, +): 'service_account_token' | 'partners_token' { + return automationTokenVariable(env) === SERVICE_ACCOUNT_TOKEN ? 'service_account_token' : 'partners_token' +} + /** * Explains why the automation token variables can't be used, so a misconfigured environment fails instead of * falling back to the logged-in user. diff --git a/packages/cli-kit/src/private/node/session/exchange.test.ts b/packages/cli-kit/src/private/node/session/exchange.test.ts index 3114970836b..24e09cdaa68 100644 --- a/packages/cli-kit/src/private/node/session/exchange.test.ts +++ b/packages/cli-kit/src/private/node/session/exchange.test.ts @@ -18,7 +18,7 @@ import {AbortError} from '../../../public/node/error.js' import {outputDebug} from '../../../public/node/output.js' import {err, ok} from '../../../public/node/result.js' -import {describe, test, expect, vi, afterAll, beforeEach} from 'vitest' +import {describe, test, expect, vi, afterAll, afterEach, beforeEach} from 'vitest' import {Response} from 'node-fetch' const currentDate = new Date(2022, 1, 1, 10) @@ -275,6 +275,10 @@ describe.each(tokenExchangeMethods)( const grantType = 'urn:ietf:params:oauth:grant-type:token-exchange' const accessTokenType = 'urn:ietf:params:oauth:token-type:access_token' + afterEach(() => { + vi.unstubAllEnvs() + }) + test(`Executing ${tokenExchangeMethod.name} returns access token and user ID for a valid CLI token`, async () => { // Given let capturedUrl = '' @@ -325,6 +329,20 @@ describe.each(tokenExchangeMethods)( expect(params.get('subject_token')).toBe(automationToken) }) + test(`Executing ${tokenExchangeMethod.name} reports service_account_token when SHOPIFY_SERVICE_ACCOUNT_TOKEN is used`, async () => { + // Given + vi.stubEnv('SHOPIFY_SERVICE_ACCOUNT_TOKEN', automationToken) + vi.mocked(shopifyFetch).mockResolvedValue( + new Response(JSON.stringify({access_token: 'expected_access_token', expires_in: 300, scope: 'scope'})), + ) + + // When + await tokenExchangeMethod(automationToken) + + // Then + await expect(getLastSeenAuthMethod()).resolves.toBe('service_account_token') + }) + test(`Executing ${tokenExchangeMethod.name} throws AbortError and logs the caught error if an error is caught`, async () => { const expectedErrorMessage = `The custom token provided can't be used for the ${expectedErrorName} API.` vi.mocked(shopifyFetch).mockImplementation(async () => { diff --git a/packages/cli-kit/src/private/node/session/exchange.ts b/packages/cli-kit/src/private/node/session/exchange.ts index a0c75fd1ffa..8bf24e513d5 100644 --- a/packages/cli-kit/src/private/node/session/exchange.ts +++ b/packages/cli-kit/src/private/node/session/exchange.ts @@ -1,6 +1,7 @@ import {ApplicationToken, IdentityToken} from './schema.js' import {applicationId, clientId as getIdentityClientId} from './identity.js' import {tokenExchangeScopes} from './scopes.js' +import {automationTokenAuthMethod} from './automation-token.js' import {API} from '../api.js' import {identityFqdn} from '../../../public/node/context/fqdn.js' import {shopifyFetch} from '../../../public/node/http.js' @@ -89,7 +90,7 @@ async function exchangeAppAutomationTokenForAccessToken( const accessToken = newToken[appId]!.accessToken const userId = nonRandomUUID(token) setLastSeenUserIdAfterAuth(userId) - setLastSeenAuthMethod('partners_token') + setLastSeenAuthMethod(automationTokenAuthMethod()) return {accessToken, userId} } catch (error) { const prettyName = apiName.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase())