From 6fa83de6571aec932aa9a60c0f208db06199b4af Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Tue, 11 Nov 2025 15:39:48 -0500 Subject: [PATCH 01/11] prototype identity client segmentation --- packages/app/tsconfig.json | 2 +- .../cli-kit/src/private/node/session.test.ts | 6 +- packages/cli-kit/src/private/node/session.ts | 8 +- .../node/session/device-authorization.test.ts | 16 +- .../node/session/device-authorization.ts | 189 +---------- .../src/private/node/session/exchange.test.ts | 3 +- .../src/private/node/session/exchange.ts | 30 +- .../src/private/node/session/identity.ts | 11 - .../src/public/node/api/identity-client.ts | 300 ++++++++++++++++++ .../cli-kit/src/public/node/context/fqdn.ts | 2 + 10 files changed, 331 insertions(+), 236 deletions(-) create mode 100644 packages/cli-kit/src/public/node/api/identity-client.ts diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 0f666a7411f..09d11b9e538 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../configurations/tsconfig.json", - "include": ["./src/**/*.ts", "./src/**/*.js", "./src/**/*.tsx"], + "include": ["./src/**/*.ts", "./src/**/*.js", "./src/**/*.tsx", "../cli-kit/src/public/node/api/identity-client.ts"], "exclude": ["./dist", "./src/templates/**/*"], "compilerOptions": { "outDir": "dist", diff --git a/packages/cli-kit/src/private/node/session.test.ts b/packages/cli-kit/src/private/node/session.test.ts index a48945980c3..1538ccfeadb 100644 --- a/packages/cli-kit/src/private/node/session.test.ts +++ b/packages/cli-kit/src/private/node/session.test.ts @@ -18,7 +18,7 @@ import {store as storeSessions, fetch as fetchSessions, remove as secureRemove} 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 {pollForDeviceAuthorization} from './session/device-authorization.js' import {getCurrentSessionId} from './conf-store.js' import * as fqdnModule from '../../public/node/context/fqdn.js' import {themeToken} from '../../public/node/context/local.js' @@ -27,6 +27,7 @@ import {businessPlatformRequest} from '../../public/node/api/business-platform.j import {getPartnersToken} from '../../public/node/environment.js' import {nonRandomUUID} from '../../public/node/crypto.js' import {terminalSupportsPrompting} from '../../public/node/system.js' +import {ProdIC} from '../../public/node/api/identity-client.js' import {vi, describe, expect, test, beforeEach} from 'vitest' const futureDate = new Date(2022, 1, 1, 11) @@ -119,6 +120,7 @@ vi.mock('../../public/node/environment.js') vi.mock('./session/device-authorization') vi.mock('./conf-store') vi.mock('../../public/node/system.js') +vi.mock('../../public/node/api/identity-client.js') beforeEach(() => { vi.spyOn(fqdnModule, 'identityFqdn').mockResolvedValue(fqdn) @@ -134,7 +136,7 @@ beforeEach(() => { setLastSeenUserIdAfterAuth(undefined as any) setLastSeenAuthMethod('none') - vi.mocked(requestDeviceAuthorization).mockResolvedValue({ + vi.mocked(ProdIC.requestDeviceAuthorization).mockResolvedValue({ deviceCode: 'device_code', userCode: 'user_code', verificationUri: 'verification_uri', diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 41409c83e69..18a03214abc 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -11,7 +11,6 @@ import { } from './session/exchange.js' import {IdentityToken, Session, Sessions} from './session/schema.js' import * as sessionStore from './session/store.js' -import {pollForDeviceAuthorization, requestDeviceAuthorization} from './session/device-authorization.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' @@ -25,6 +24,8 @@ import {nonRandomUUID} from '../../public/node/crypto.js' import {isEmpty} from '../../public/common/object.js' import {businessPlatformRequest} from '../../public/node/api/business-platform.js' +import {getIdentityClient} from '../../public/node/api/identity-client.js' + /** * Fetches the user's email from the Business Platform API * @param businessPlatformToken - The business platform token @@ -308,11 +309,12 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise { vi.mocked(isTTY).mockReturnValue(true) @@ -53,7 +53,7 @@ describe('requestDeviceAuthorization', () => { vi.mocked(clientId).mockReturnValue('clientId') // When - const got = await requestDeviceAuthorization(['scope1', 'scope2']) + const got = await ProdIC.requestDeviceAuthorization(['scope1', 'scope2']) // Then expect(shopifyFetch).toBeCalledWith('https://fqdn.com/oauth/device_authorization', { @@ -74,7 +74,7 @@ describe('requestDeviceAuthorization', () => { vi.mocked(clientId).mockReturnValue('clientId') // When/Then - await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError( + await expect(ProdIC.requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError( 'Received invalid response from authorization service (HTTP 200). Response could not be parsed as valid JSON. If this issue persists, please contact support at https://help.shopify.com', ) }) @@ -89,7 +89,7 @@ describe('requestDeviceAuthorization', () => { vi.mocked(clientId).mockReturnValue('clientId') // When/Then - await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError( + await expect(ProdIC.requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError( 'Received invalid response from authorization service (HTTP 200). Received empty response body. If this issue persists, please contact support at https://help.shopify.com', ) }) @@ -105,7 +105,7 @@ describe('requestDeviceAuthorization', () => { vi.mocked(clientId).mockReturnValue('clientId') // When/Then - await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError( + await expect(ProdIC.requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError( 'Received invalid response from authorization service (HTTP 404). The request may be malformed or unauthorized. Received HTML instead of JSON - the service endpoint may have changed. If this issue persists, please contact support at https://help.shopify.com', ) }) @@ -120,7 +120,7 @@ describe('requestDeviceAuthorization', () => { vi.mocked(clientId).mockReturnValue('clientId') // When/Then - await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError( + await expect(ProdIC.requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError( 'Received invalid response from authorization service (HTTP 500). The service may be experiencing issues. Response could not be parsed as valid JSON. If this issue persists, please contact support at https://help.shopify.com', ) }) @@ -137,7 +137,7 @@ describe('requestDeviceAuthorization', () => { vi.mocked(clientId).mockReturnValue('clientId') // When/Then - await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError( + await expect(ProdIC.requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError( 'Failed to read response from authorization service (HTTP 200). Network or streaming error occurred.', ) }) diff --git a/packages/cli-kit/src/private/node/session/device-authorization.ts b/packages/cli-kit/src/private/node/session/device-authorization.ts index 711269ec46e..1e18c45453c 100644 --- a/packages/cli-kit/src/private/node/session/device-authorization.ts +++ b/packages/cli-kit/src/private/node/session/device-authorization.ts @@ -1,15 +1,3 @@ -import {clientId} from './identity.js' -import {exchangeDeviceCodeForAccessToken} from './exchange.js' -import {IdentityToken} from './schema.js' -import {identityFqdn} from '../../../public/node/context/fqdn.js' -import {shopifyFetch} from '../../../public/node/http.js' -import {outputContent, outputDebug, outputInfo, outputToken} from '../../../public/node/output.js' -import {AbortError, BugError} from '../../../public/node/error.js' -import {isCloudEnvironment} from '../../../public/node/context/local.js' -import {isCI, openURL} from '../../../public/node/system.js' -import {isTTY, keypress} from '../../../public/node/ui.js' -import {Response} from 'node-fetch' - export interface DeviceAuthorizationResponse { deviceCode: string userCode: string @@ -19,179 +7,4 @@ export interface DeviceAuthorizationResponse { interval?: number } -/** - * Initiate a device authorization flow. - * This will return a DeviceAuthorizationResponse containing the URL where user - * should go to authorize the device without the need of a callback to the CLI. - * - * Also returns a `deviceCode` used for polling the token endpoint in the next step. - * - * @param scopes - The scopes to request - * @returns An object with the device authorization response. - */ -export async function requestDeviceAuthorization(scopes: string[]): Promise { - const fqdn = await identityFqdn() - const identityClientId = clientId() - const queryParams = {client_id: identityClientId, scope: scopes.join(' ')} - const url = `https://${fqdn}/oauth/device_authorization` - - const response = await shopifyFetch(url, { - method: 'POST', - headers: {'Content-type': 'application/x-www-form-urlencoded'}, - body: convertRequestToParams(queryParams), - }) - - // First read the response body as text so we have it for debugging - let responseText: string - try { - responseText = await response.text() - } catch (error) { - throw new BugError( - `Failed to read response from authorization service (HTTP ${response.status}). Network or streaming error occurred.`, - 'Check your network connection and try again.', - ) - } - - // Now try to parse the text as JSON - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let jsonResult: any - try { - jsonResult = JSON.parse(responseText) - } catch { - // JSON.parse failed, handle the parsing error - const errorMessage = buildAuthorizationParseErrorMessage(response, responseText) - throw new BugError(errorMessage) - } - - outputDebug(outputContent`Received device authorization code: ${outputToken.json(jsonResult)}`) - if (!jsonResult.device_code || !jsonResult.verification_uri_complete) { - throw new BugError('Failed to start authorization process') - } - - outputInfo('\nTo run this command, log in to Shopify.') - - if (isCI()) { - throw new AbortError( - 'Authorization is required to continue, but the current environment does not support interactive prompts.', - 'To resolve this, specify credentials in your environment, or run the command in an interactive environment such as your local terminal.', - ) - } - - outputInfo(outputContent`User verification code: ${jsonResult.user_code}`) - const linkToken = outputToken.link(jsonResult.verification_uri_complete) - - const cloudMessage = () => { - outputInfo(outputContent`👉 Open this link to start the auth process: ${linkToken}`) - } - - if (isCloudEnvironment() || !isTTY()) { - cloudMessage() - } else { - outputInfo('👉 Press any key to open the login page on your browser') - await keypress() - const opened = await openURL(jsonResult.verification_uri_complete) - if (opened) { - outputInfo(outputContent`Opened link to start the auth process: ${linkToken}`) - } else { - cloudMessage() - } - } - - return { - deviceCode: jsonResult.device_code, - userCode: jsonResult.user_code, - verificationUri: jsonResult.verification_uri, - expiresIn: jsonResult.expires_in, - verificationUriComplete: jsonResult.verification_uri_complete, - interval: jsonResult.interval, - } -} - -/** - * Poll the Oauth token endpoint with the device code obtained from a DeviceAuthorizationResponse. - * The endpoint will return `authorization_pending` until the user completes the auth flow in the browser. - * Once the user completes the auth flow, the endpoint will return the identity token. - * - * Timeout for the polling is defined by the server and is around 600 seconds. - * - * @param code - The device code obtained after starting a device identity flow - * @param interval - The interval to poll the token endpoint - * @returns The identity token - */ -export async function pollForDeviceAuthorization(code: string, interval = 5): Promise { - let currentIntervalInSeconds = interval - - return new Promise((resolve, reject) => { - const onPoll = async () => { - const result = await exchangeDeviceCodeForAccessToken(code) - if (!result.isErr()) { - resolve(result.value) - return - } - - const error = result.error ?? 'unknown_failure' - - outputDebug(outputContent`Polling for device authorization... status: ${error}`) - switch (error) { - case 'authorization_pending': { - startPolling() - return - } - case 'slow_down': - currentIntervalInSeconds += 5 - startPolling() - return - case 'access_denied': - case 'expired_token': - case 'unknown_failure': { - reject(new Error(`Device authorization failed: ${error}`)) - } - } - } - - const startPolling = () => { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - setTimeout(onPoll, currentIntervalInSeconds * 1000) - } - - startPolling() - }) -} - -function convertRequestToParams(queryParams: {client_id: string; scope: string}): string { - return Object.entries(queryParams) - .map(([key, value]) => value && `${key}=${value}`) - .filter((hasValue) => Boolean(hasValue)) - .join('&') -} - -/** - * Build a detailed error message for JSON parsing failures from the authorization service. - * Provides context-specific error messages based on response status and content. - * - * @param response - The HTTP response object - * @param responseText - The raw response body text - * @returns Detailed error message about the failure - */ -function buildAuthorizationParseErrorMessage(response: Response, responseText: string): string { - // Build helpful error message based on response status and content - let errorMessage = `Received invalid response from authorization service (HTTP ${response.status}).` - - // Add status-based context - if (response.status >= 500) { - errorMessage += ' The service may be experiencing issues.' - } else if (response.status >= 400) { - errorMessage += ' The request may be malformed or unauthorized.' - } - - // Add content-based context (check these regardless of status) - if (responseText.trim().startsWith(' {} 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 fd2bebebabd..7491b2ef506 100644 --- a/packages/cli-kit/src/private/node/session/exchange.test.ts +++ b/packages/cli-kit/src/private/node/session/exchange.test.ts @@ -8,7 +8,8 @@ import { refreshAccessToken, requestAppToken, } from './exchange.js' -import {applicationId, clientId} from './identity.js' +import {applicationId} from './identity.js' +import {clientId} from '../../../public/node/api/identity-client.js' import {IdentityToken} from './schema.js' import {shopifyFetch} from '../../../public/node/http.js' import {identityFqdn} from '../../../public/node/context/fqdn.js' diff --git a/packages/cli-kit/src/private/node/session/exchange.ts b/packages/cli-kit/src/private/node/session/exchange.ts index 1fcb3024eea..530bff726b8 100644 --- a/packages/cli-kit/src/private/node/session/exchange.ts +++ b/packages/cli-kit/src/private/node/session/exchange.ts @@ -1,9 +1,8 @@ import {ApplicationToken, IdentityToken} from './schema.js' -import {applicationId, clientId as getIdentityClientId} from './identity.js' +import {applicationId} from './identity.js' import {tokenExchangeScopes} from './scopes.js' +import {getIdentityClient, clientId as getIdentityClientId} from '../../../public/node/api/identity-client.js' import {API} from '../api.js' -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 {setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../session.js' @@ -63,7 +62,8 @@ export async function refreshAccessToken(currentToken: IdentityToken): Promise> { - const fqdn = await identityFqdn() - const url = new URL(`https://${fqdn}/oauth/token`) - url.search = new URLSearchParams(Object.entries(params)).toString() - - const res = await shopifyFetch(url.href, {method: 'POST'}) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const payload: any = await res.json() - - if (res.ok) return ok(payload) - - return err({error: payload.error, store: params.store}) -} - function buildIdentityToken( result: TokenRequestResult, existingUserId?: string, diff --git a/packages/cli-kit/src/private/node/session/identity.ts b/packages/cli-kit/src/private/node/session/identity.ts index a155b8ff40a..6c2e199f0c4 100644 --- a/packages/cli-kit/src/private/node/session/identity.ts +++ b/packages/cli-kit/src/private/node/session/identity.ts @@ -2,17 +2,6 @@ import {API} from '../api.js' import {BugError} from '../../../public/node/error.js' import {Environment, serviceEnvironment} from '../context/service.js' -export function clientId(): string { - const environment = serviceEnvironment() - if (environment === Environment.Local) { - return 'e5380e02-312a-7408-5718-e07017e9cf52' - } else if (environment === Environment.Production) { - return 'fbdb2649-e327-4907-8f67-908d24cfd7e3' - } else { - return 'e5380e02-312a-7408-5718-e07017e9cf52' - } -} - export function applicationId(api: API): string { switch (api) { case 'admin': { diff --git a/packages/cli-kit/src/public/node/api/identity-client.ts b/packages/cli-kit/src/public/node/api/identity-client.ts new file mode 100644 index 00000000000..54f299ec559 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/identity-client.ts @@ -0,0 +1,300 @@ +/* eslint-disable jsdoc/require-jsdoc */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @nx/enforce-module-boundaries */ +/* eslint-disable jsdoc/require-description */ + +import {Environment, serviceEnvironment} from '../../../private/node/context/service.js' +import {err, ok, Result} from '../result.js' +import {exchangeDeviceCodeForAccessToken} from '../../../private/node/session/exchange.js' +import {identityFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {shopifyFetch} from '@shopify/cli-kit/node/http' +import {AbortError, BugError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {isCI, openURL} from '@shopify/cli-kit/node/system' +import {isCloudEnvironment} from '@shopify/cli-kit/node/context/local' +import {isTTY, keypress} from '@shopify/cli-kit/node/ui' + +import {zod} from '@shopify/cli-kit/node/schema' + +const DateSchema = zod.preprocess((arg: any) => { + if (typeof arg === 'string' || arg instanceof Date) return new Date(arg) + return null +}, zod.date()) + +interface TokenRequestResult { + access_token: string + expires_in: number + refresh_token: string + scope: string + id_token?: string +} + +/** + * The schema represents an Identity token. + */ +const IdentityTokenSchema = zod.object({ + accessToken: zod.string(), + refreshToken: zod.string(), + expiresAt: DateSchema, + scopes: zod.array(zod.string()), + userId: zod.string(), + alias: zod.string().optional(), +}) +export type IdentityToken = zod.infer + +export interface DeviceAuthorizationResponse { + deviceCode: string + userCode: string + verificationUri: string + expiresIn: number + verificationUriComplete?: string + interval?: number +} + +interface TokenRequestConfig { + [key: string]: string +} +type TokenRequestConfigResponse = Promise> +interface IdentityClientInterface { + requestDeviceAuthorization(scopes: string[]): Promise + pollForDeviceAuthorization(deviceAuth: DeviceAuthorizationResponse): Promise + tokenRequest(params: TokenRequestConfig): TokenRequestConfigResponse +} + +// +/** + * @returns Something. + */ +export function clientId(): string { + const environment = serviceEnvironment() + if (environment === Environment.Local) { + return 'e5380e02-312a-7408-5718-e07017e9cf52' + } else if (environment === Environment.Production) { + return 'fbdb2649-e327-4907-8f67-908d24cfd7e3' + } else { + return 'e5380e02-312a-7408-5718-e07017e9cf52' + } +} + +export class ProdIdentityClient implements IdentityClientInterface { + /** + * Initiate a device authorization flow. + * This will return a DeviceAuthorizationResponse containing the URL where user + * should go to authorize the device without the need of a callback to the CLI. + * + * Also returns a `deviceCode` used for polling the token endpoint in the next step. + * + * @param scopes - The scopes to request. + * @returns An object with the device authorization response. + */ + async requestDeviceAuthorization(scopes: string[]): Promise { + const fqdn = await identityFqdn() + const identityClientId = clientId() + const queryParams = {client_id: identityClientId, scope: scopes.join(' ')} + const url = `https://${fqdn}/oauth/device_authorization` + + const response = await shopifyFetch(url, { + method: 'POST', + headers: {'Content-type': 'application/x-www-form-urlencoded'}, + body: convertRequestToParams(queryParams), + }) + + // First read the response body as text so we have it for debugging + let responseText: string + try { + responseText = await response.text() + } catch (error) { + throw new BugError( + `Failed to read response from authorization service (HTTP ${response.status}). Network or streaming error occurred.`, + 'Check your network connection and try again.', + ) + } + + // Now try to parse the text as JSON + + let jsonResult: any + try { + jsonResult = JSON.parse(responseText) + } catch { + const errorMessage = buildAuthorizationParseErrorMessage(response as unknown as Response, responseText) + throw new BugError(errorMessage) + } + + outputDebug(outputContent`Received device authorization code: ${outputToken.json(jsonResult)}`) + if (!jsonResult.device_code || !jsonResult.verification_uri_complete) { + throw new BugError('Failed to start authorization process') + } + + outputInfo('\nTo run this command, log in to Shopify.') + + if (isCI()) { + throw new AbortError( + 'Authorization is required to continue, but the current environment does not support interactive prompts.', + 'To resolve this, specify credentials in your environment, or run the command in an interactive environment such as your local terminal.', + ) + } + + outputInfo(outputContent`User verification code: ${jsonResult.user_code}`) + const linkToken = outputToken.link(jsonResult.verification_uri_complete) + + const cloudMessage = () => { + outputInfo(outputContent`👉 Open this link to start the auth process: ${linkToken}`) + } + + if (isCloudEnvironment() || !isTTY()) { + cloudMessage() + } else { + outputInfo('👉 Press any key to open the login page on your browser') + await keypress() + const opened = await openURL(jsonResult.verification_uri_complete) + if (opened) { + outputInfo(outputContent`Opened link to start the auth process: ${linkToken}`) + } else { + cloudMessage() + } + } + + return { + deviceCode: jsonResult.device_code, + userCode: jsonResult.user_code, + verificationUri: jsonResult.verification_uri, + expiresIn: jsonResult.expires_in, + verificationUriComplete: jsonResult.verification_uri_complete, + interval: jsonResult.interval, + } + } + + /** + * Poll the Oauth token endpoint with the device code obtained from a DeviceAuthorizationResponse. + * The endpoint will return `authorization_pending` until the user completes the auth flow in the browser. + * Once the user completes the auth flow, the endpoint will return the identity token. + * + * Timeout for the polling is defined by the server and is around 600 seconds. + * + * @param deviceAuth - DeviceAuth. + * @returns The identity token. + */ + async pollForDeviceAuthorization(deviceAuth: DeviceAuthorizationResponse): Promise { + let currentIntervalInSeconds = deviceAuth.interval ?? 5 + + return new Promise((resolve, reject) => { + const onPoll = async () => { + const result = await exchangeDeviceCodeForAccessToken(deviceAuth.deviceCode) + if (!result.isErr()) { + resolve(result.value) + return + } + + const error = result.error ?? 'unknown_failure' + + outputDebug(outputContent`Polling for device authorization... status: ${error}`) + switch (error) { + case 'authorization_pending': { + startPolling() + return + } + case 'slow_down': + currentIntervalInSeconds += 5 + startPolling() + return + case 'access_denied': + case 'expired_token': + case 'unknown_failure': { + reject(new Error(`Device authorization failed: ${error}`)) + } + } + } + + const startPolling = () => { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + setTimeout(onPoll, currentIntervalInSeconds * 1000) + } + + startPolling() + }) + } + + async tokenRequest(params: TokenRequestConfig): TokenRequestConfigResponse { + const fqdn = await identityFqdn() + const url = new URL(`https://${fqdn}/oauth/token`) + url.search = new URLSearchParams(Object.entries(params)).toString() + + const res = await shopifyFetch(url.href, {method: 'POST'}) + + const payload: any = await res.json() + + if (res.ok) return ok(payload) + + return err({error: payload.error, store: params.store}) + } +} + +export class LocalIdentityClient implements IdentityClientInterface { + async requestDeviceAuthorization(_scopes: string[]): Promise { + return { + deviceCode: 'ABC', + userCode: 'ABC', + verificationUri: 'ABC', + expiresIn: 10_000, + verificationUriComplete: 'ABC', + interval: 1000, + } + } + + pollForDeviceAuthorization(_deviceAuth: DeviceAuthorizationResponse): Promise { + throw new Error('Method not implemented.') + } + + async tokenRequest(_params: TokenRequestConfig): TokenRequestConfigResponse { + throw new Error('Method not implemented.') + } +} + +function convertRequestToParams(queryParams: {client_id: string; scope: string}): string { + return Object.entries(queryParams) + .map(([key, value]) => value && `${key}=${value}`) + .filter((hasValue) => Boolean(hasValue)) + .join('&') +} + +/** + * Build a detailed error message for JSON parsing failures from the authorization service. + * Provides context-specific error messages based on response status and content. + * + * @param response - The HTTP response object. + * @param responseText - The raw response body text. + * @returns Detailed error message about the failure. + */ +function buildAuthorizationParseErrorMessage(response: Response, responseText: string): string { + // Build helpful error message based on response status and content + let errorMessage = `Received invalid response from authorization service (HTTP ${response.status}).` + + // Add status-based context + if (response.status >= 500) { + errorMessage += ' The service may be experiencing issues.' + } else if (response.status >= 400) { + errorMessage += ' The request may be malformed or unauthorized.' + } + + // Add content-based context (check these regardless of status) + if (responseText.trim().startsWith(' { const productionFqdn = 'accounts.shopify.com' switch (environment) { case 'local': + setAssertRunning((projectName) => projectName === 'identity') return new DevServer('identity').host() default: return productionFqdn From fe70e611e40d8af4337afd277887718e34dc21d6 Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Wed, 12 Nov 2025 14:38:40 -0500 Subject: [PATCH 02/11] stash merge 0 --- identity_authentication_flow_analysis.md | 445 ++++++++++++++++++ packages/cli-kit/src/private/node/session.ts | 3 +- .../src/public/node/api/business-platform.ts | 51 +- .../cli-kit/src/public/node/api/utilities.ts | 6 + 4 files changed, 500 insertions(+), 5 deletions(-) create mode 100644 identity_authentication_flow_analysis.md diff --git a/identity_authentication_flow_analysis.md b/identity_authentication_flow_analysis.md new file mode 100644 index 00000000000..d89c50e09a3 --- /dev/null +++ b/identity_authentication_flow_analysis.md @@ -0,0 +1,445 @@ +# Shopify CLI Identity Authentication Flow Analysis + +## Overview + +This document traces the complete authentication flow in the Shopify CLI from entry point to completion, focusing specifically on interactions with the Identity service. Two scenarios are analyzed: first-time authentication (after `shopify auth logout`) and subsequent commands with cached sessions. + +## Scenario 1: First-Time Authentication Flow (After Logout) + +### Initial State +After running `shopify auth logout`: +- **File**: `packages/cli-kit/src/public/node/session.ts:281-283` +- **Action**: `logout()` calls `sessionStore.remove()` +- **File**: `packages/cli-kit/src/private/node/session/store.ts:39-42` +- **Result**: All session data and current session ID are cleared from local storage + +### Step-by-Step Flow for Any Command Requiring Authentication + +#### 1. Command Entry Point +**Example**: Any CLI command that needs Partners API access (e.g., `shopify app generate`) + +#### 2. Domain-Specific Authentication Request +**File**: `packages/cli-kit/src/public/node/session.ts:104-122` +**Function**: `ensureAuthenticatedPartners(scopes, env, options)` + +```typescript +export async function ensureAuthenticatedPartners( + scopes: PartnersAPIScope[] = [], + env = process.env, + options: EnsureAuthenticatedAdditionalOptions = {}, +): Promise<{token: string; userId: string}> +``` + +**Actions**: +1. Check for environment token (`getPartnersToken()`) - returns `undefined` after logout +2. Call `ensureAuthenticated({partnersApi: {scopes}}, env, options)` + +#### 3. Core Authentication Orchestration +**File**: `packages/cli-kit/src/private/node/session.ts:195-277` +**Function**: `ensureAuthenticated(applications, _env, options)` + +**Step 3.1**: Identity Service Discovery +```typescript +const fqdn = await identityFqdn() // e.g., "accounts.shopify.com" +``` + +**Step 3.2**: Session State Check +```typescript +const sessions = (await sessionStore.fetch()) ?? {} +let currentSessionId = getCurrentSessionId() +// Both return empty/undefined after logout +``` + +**Step 3.3**: Session Validation +```typescript +const validationResult = await validateSession(scopes, applications, currentSession) +// Returns 'needs_full_auth' since currentSession is undefined +``` + +**Step 3.4**: Full Authentication Flow Trigger +```typescript +if (validationResult === 'needs_full_auth') { + await throwOnNoPrompt(noPrompt) + outputDebug(outputContent`Initiating the full authentication flow...`) + newSession = await executeCompleteFlow(applications) +} +``` + +#### 4. Complete Authentication Flow Execution +**File**: `packages/cli-kit/src/private/node/session.ts:295-339` +**Function**: `executeCompleteFlow(applications)` + +**Step 4.1**: Scope Preparation +```typescript +const scopes = getFlattenScopes(applications) // ['openid', 'https://api.shopify.com/auth/partners.app.cli.access'] +const exchangeScopes = getExchangeScopes(applications) +const store = applications.adminApi?.storeFqdn +``` + +**Step 4.2**: Device Authorization Request +```typescript +const deviceAuth = await requestDeviceAuthorization(scopes) +``` + +#### 5. Device Authorization Flow (Identity Service Interaction #1) +**File**: `packages/cli-kit/src/private/node/session/device-authorization.ts:32-108` +**Function**: `requestDeviceAuthorization(scopes)` + +**Step 5.1**: Identity Service Endpoint Preparation +```typescript +const fqdn = await identityFqdn() // "accounts.shopify.com" +const identityClientId = clientId() // Environment-specific client ID +const queryParams = {client_id: identityClientId, scope: scopes.join(' ')} +const url = `https://${fqdn}/oauth/device_authorization` +``` + +**Step 5.2**: HTTP Request to Identity Service +```typescript +const response = await shopifyFetch(url, { + method: 'POST', + headers: {'Content-type': 'application/x-www-form-urlencoded'}, + body: convertRequestToParams(queryParams), +}) +``` + +**Identity Service Call**: `POST https://accounts.shopify.com/oauth/device_authorization` +**Payload**: `client_id=&scope=openid https://api.shopify.com/auth/partners.app.cli.access` + +**Step 5.3**: Response Processing +```typescript +let responseText = await response.text() +let jsonResult = JSON.parse(responseText) +``` + +**Step 5.4**: User Interaction Setup +```typescript +outputInfo('\nTo run this command, log in to Shopify.') +outputInfo(outputContent`User verification code: ${jsonResult.user_code}`) +// Opens browser or shows URL for user to authorize +``` + +**Returns**: `DeviceAuthorizationResponse` with `deviceCode`, `userCode`, `verificationUri`, etc. + +#### 6. Device Authorization Polling (Identity Service Interaction #2) +**File**: `packages/cli-kit/src/private/node/session/device-authorization.ts:121-159` +**Function**: `pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval)` + +**Step 6.1**: Polling Loop Setup +```typescript +let currentIntervalInSeconds = interval // Default 5 seconds +return new Promise((resolve, reject) => { + const onPoll = async () => { + const result = await exchangeDeviceCodeForAccessToken(code) + // Handle response states: 'authorization_pending', 'slow_down', success, errors + } +}) +``` + +**Step 6.2**: Token Exchange Request (per poll) +**File**: `packages/cli-kit/src/private/node/session/exchange.ts:141-158` +**Function**: `exchangeDeviceCodeForAccessToken(deviceCode)` + +```typescript +const clientId = await getIdentityClientId() +const params = { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode, + client_id: clientId, +} +const tokenResult = await tokenRequest(params) +``` + +**Step 6.3**: Core Token Request (Identity Service Interaction) +**File**: `packages/cli-kit/src/private/node/session/exchange.ts:226-238` +**Function**: `tokenRequest(params)` + +```typescript +const fqdn = await identityFqdn() +const url = new URL(`https://${fqdn}/oauth/token`) +url.search = new URLSearchParams(Object.entries(params)).toString() + +const res = await shopifyFetch(url.href, {method: 'POST'}) +const payload = await res.json() +``` + +**Identity Service Call**: `POST https://accounts.shopify.com/oauth/token` +**Payload**: `grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=&client_id=` + +**Polling Behavior**: +- Initial requests return `authorization_pending` until user completes browser authorization +- Once authorized, returns `IdentityToken` with `accessToken`, `refreshToken`, `expiresAt`, `scopes`, `userId` + +#### 7. Application Token Exchange (Identity Service Interactions #3-7) +**File**: `packages/cli-kit/src/private/node/session.ts:320` +**Function**: `exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store)` + +**File**: `packages/cli-kit/src/private/node/session/exchange.ts:31-53` + +**Step 7.1**: Parallel Token Requests +```typescript +const token = identityToken.accessToken + +const [partners, storefront, businessPlatform, admin, appManagement] = await Promise.all([ + requestAppToken('partners', token, scopes.partners), + requestAppToken('storefront-renderer', token, scopes.storefront), + requestAppToken('business-platform', token, scopes.businessPlatform), + store ? requestAppToken('admin', token, scopes.admin, store) : {}, + requestAppToken('app-management', token, scopes.appManagement), +]) +``` + +**Step 7.2**: Individual Application Token Request +**File**: `packages/cli-kit/src/private/node/session/exchange.ts:160-188` +**Function**: `requestAppToken(api, token, scopes, store)` + +```typescript +const appId = applicationId(api) +const clientId = await getIdentityClientId() + +const params = { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + client_id: clientId, + audience: appId, + scope: scopes.join(' '), + subject_token: token, + ...(api === 'admin' && {destination: `https://${store}/admin`, store}), +} + +const tokenResult = await tokenRequest(params) +``` + +**Identity Service Calls** (one per API): +- `POST https://accounts.shopify.com/oauth/token` (Partners API) +- `POST https://accounts.shopify.com/oauth/token` (Storefront API) +- `POST https://accounts.shopify.com/oauth/token` (Business Platform API) +- `POST https://accounts.shopify.com/oauth/token` (App Management API) +- `POST https://accounts.shopify.com/oauth/token` (Admin API - if store specified) + +**Example Partners API Payload**: +``` +grant_type=urn:ietf:params:oauth:grant-type:token-exchange +&requested_token_type=urn:ietf:params:oauth:token-type:access_token +&subject_token_type=urn:ietf:params:oauth:token-type:access_token +&client_id= +&audience= +&scope=https://api.shopify.com/auth/partners.app.cli.access +&subject_token= +``` + +#### 8. Session Creation and Storage +**File**: `packages/cli-kit/src/private/node/session.ts:322-338` + +**Step 8.1**: Email Fetching (if Business Platform token available) +```typescript +const businessPlatformToken = result[applicationId('business-platform')]?.accessToken +const alias = (await fetchEmail(businessPlatformToken)) ?? identityToken.userId +``` + +**Step 8.2**: Session Object Creation +```typescript +const session: Session = { + identity: { + ...identityToken, + alias, + }, + applications: result, +} +``` + +**Step 8.3**: Session Persistence +**File**: `packages/cli-kit/src/private/node/session.ts:255-264` +```typescript +const completeSession = {...currentSession, ...newSession} as Session +const newSessionId = completeSession.identity.userId +const updatedSessions: Sessions = { + ...sessions, + [fqdn]: {...sessions[fqdn], [newSessionId]: completeSession}, +} + +await sessionStore.store(updatedSessions) +setCurrentSessionId(newSessionId) +``` + +#### 9. Token Return to Command +**File**: `packages/cli-kit/src/private/node/session.ts:265-276` +**Function**: `tokensFor(applications, completeSession)` + +**Returns**: `OAuthSession` object with domain-specific tokens: +```typescript +{ + admin?: AdminSession, + partners?: string, + storefront?: string, + businessPlatform?: string, + appManagement?: string, + userId: string +} +``` + +### Summary of Identity Service Interactions (First-Time Auth) +1. **Device Authorization Request**: `POST /oauth/device_authorization` +2. **Device Code Polling**: Multiple `POST /oauth/token` (device grant type) until authorized +3. **Partners Token Exchange**: `POST /oauth/token` (token exchange grant type) +4. **Storefront Token Exchange**: `POST /oauth/token` (token exchange grant type) +5. **Business Platform Token Exchange**: `POST /oauth/token` (token exchange grant type) +6. **App Management Token Exchange**: `POST /oauth/token` (token exchange grant type) +7. **Admin Token Exchange**: `POST /oauth/token` (token exchange grant type) - if store specified + +**Total Identity Service Calls**: 6-7 HTTP requests + +--- + +## Scenario 2: Subsequent Command with Cached Session + +### Initial State +- Valid session exists in local storage +- Current session ID is set +- All application tokens are within expiry threshold + +### Step-by-Step Flow + +#### 1. Command Entry Point +**Example**: Same command (`shopify app generate`) executed again + +#### 2. Domain-Specific Authentication Request +**File**: `packages/cli-kit/src/public/node/session.ts:104-122` +**Function**: `ensureAuthenticatedPartners(scopes, env, options)` + +Same entry point as Scenario 1. + +#### 3. Core Authentication Orchestration +**File**: `packages/cli-kit/src/private/node/session.ts:195-277` +**Function**: `ensureAuthenticated(applications, _env, options)` + +**Step 3.1**: Identity Service Discovery +```typescript +const fqdn = await identityFqdn() // "accounts.shopify.com" +``` + +**Step 3.2**: Session State Recovery +```typescript +const sessions = (await sessionStore.fetch()) ?? {} +let currentSessionId = getCurrentSessionId() // Returns cached user ID +const currentSession = sessions[fqdn]?.[currentSessionId] // Valid session object +``` + +**Step 3.3**: Session Validation +**File**: `packages/cli-kit/src/private/node/session/validate.ts:27-71` +**Function**: `validateSession(scopes, applications, currentSession)` + +```typescript +const scopesAreValid = validateScopes(scopes, session.identity) +let tokensAreExpired = isTokenExpired(session.identity) + +// Check each required application token +if (applications.partnersApi) { + const appId = applicationId('partners') + const token = session.applications[appId] + tokensAreExpired = tokensAreExpired || isTokenExpired(token) +} + +// Returns 'ok' if all tokens valid and not expired +// Returns 'needs_refresh' if expired but structure valid +// Returns 'needs_full_auth' if structure invalid +``` + +**Step 3.4**: Validation Result Handling +```typescript +// If validationResult === 'ok' +// Skip to Step 6 (Token Return) +``` + +#### 4. Token Refresh Flow (if tokens expired) +**If**: `validationResult === 'needs_refresh'` + +**File**: `packages/cli-kit/src/private/node/session.ts:235-250` +**Function**: `refreshTokens(currentSession, applications)` + +**Step 4.1**: Identity Token Refresh (Identity Service Interaction #1) +**File**: `packages/cli-kit/src/private/node/session/exchange.ts:57-68` +**Function**: `refreshAccessToken(currentToken)` + +```typescript +const clientId = getIdentityClientId() +const params = { + grant_type: 'refresh_token', + access_token: currentToken.accessToken, + refresh_token: currentToken.refreshToken, + client_id: clientId, +} +const tokenResult = await tokenRequest(params) +``` + +**Identity Service Call**: `POST https://accounts.shopify.com/oauth/token` +**Payload**: `grant_type=refresh_token&access_token=&refresh_token=&client_id=` + +**Step 4.2**: Application Token Re-exchange (Identity Service Interactions #2-6) +```typescript +const exchangeScopes = getExchangeScopes(applications) +const applicationTokens = await exchangeAccessForApplicationTokens( + identityToken, + exchangeScopes, + applications.adminApi?.storeFqdn, +) +``` + +Same parallel token exchange as Scenario 1, Step 7. + +#### 5. Session Update and Storage +```typescript +const updatedSession = { + identity: identityToken, + applications: applicationTokens, +} +await sessionStore.store(updatedSessions) +``` + +#### 6. Token Return to Command +**File**: `packages/cli-kit/src/private/node/session.ts:265-276` +**Function**: `tokensFor(applications, completeSession)` + +Returns cached or refreshed tokens to the calling command. + +### Summary of Identity Service Interactions (Cached Session) + +**If tokens are valid**: **0 HTTP requests** - all tokens returned from cache + +**If tokens need refresh**: **5-6 HTTP requests** +1. **Identity Token Refresh**: `POST /oauth/token` (refresh grant type) +2. **Partners Token Exchange**: `POST /oauth/token` (token exchange grant type) +3. **Storefront Token Exchange**: `POST /oauth/token` (token exchange grant type) +4. **Business Platform Token Exchange**: `POST /oauth/token` (token exchange grant type) +5. **App Management Token Exchange**: `POST /oauth/token` (token exchange grant type) +6. **Admin Token Exchange**: `POST /oauth/token` (token exchange grant type) - if store specified + +--- + +## Identity Service Interaction Patterns + +### HTTP Endpoints Used +All requests go to: `https://{identityFqdn}/oauth/token` and `https://{identityFqdn}/oauth/device_authorization` + +Where `identityFqdn` is typically `"accounts.shopify.com"` + +### Grant Types Used +1. **Device Authorization**: `POST /oauth/device_authorization` +2. **Device Code Exchange**: `grant_type=urn:ietf:params:oauth:grant-type:device_code` +3. **Token Exchange**: `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` +4. **Refresh Token**: `grant_type=refresh_token` + +### Request Patterns +- All token requests use the centralized `tokenRequest()` function +- All HTTP calls use `shopifyFetch()` wrapper +- All application token exchanges happen in parallel +- Error handling uses Result pattern with typed errors + +### Key Extraction Points for Client Architecture +1. **Device Authorization**: `requestDeviceAuthorization()` and `pollForDeviceAuthorization()` functions +2. **Token Exchange**: `tokenRequest()` function (centralized HTTP interface) +3. **Session Management**: Session validation, storage, and lifecycle management +4. **Error Handling**: Token request error handling and retry logic +5. **Configuration**: Client ID and application ID management per environment + +The current architecture shows clear separation between business logic and Identity service communication, with the `tokenRequest()` function serving as the primary HTTP interface to the Identity service. This function and the device authorization functions represent the core integration points that would be abstracted by an Identity service client. \ No newline at end of file diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 18a03214abc..e224d281a1d 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -13,7 +13,6 @@ import {IdentityToken, Session, Sessions} from './session/schema.js' import * as sessionStore from './session/store.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' import {outputContent, outputToken, outputDebug, outputCompleted} from '../../public/node/output.js' import {firstPartyDev, themeToken} from '../../public/node/context/local.js' import {AbortError} from '../../public/node/error.js' @@ -22,7 +21,7 @@ import {getIdentityTokenInformation, getPartnersToken} from '../../public/node/e import {AdminSession, logout} from '../../public/node/session.js' import {nonRandomUUID} from '../../public/node/crypto.js' import {isEmpty} from '../../public/common/object.js' -import {businessPlatformRequest} from '../../public/node/api/business-platform.js' +import {businessPlatformRequest, fetchEmail} from '../../public/node/api/business-platform.js' import {getIdentityClient} from '../../public/node/api/identity-client.js' diff --git a/packages/cli-kit/src/public/node/api/business-platform.ts b/packages/cli-kit/src/public/node/api/business-platform.ts index e35409f46d7..8304dbb15f7 100644 --- a/packages/cli-kit/src/public/node/api/business-platform.ts +++ b/packages/cli-kit/src/public/node/api/business-platform.ts @@ -1,9 +1,37 @@ import {CacheOptions, GraphQLVariables, UnauthorizedHandler, graphqlRequest, graphqlRequestDoc} from './graphql.js' import {handleDeprecations} from './partners.js' +import {USE_LOCAL_MOCKS} from './utilities.js' import {businessPlatformFqdn} from '../context/fqdn.js' +import {outputContent, outputDebug} from '../output.js' +import { + UserEmailQuery, + UserEmailQueryString, +} from '../../../private/node/api/graphql/business-platform-destinations/user-email.js' import {TypedDocumentNode} from '@graphql-typed-document-node/core' import {Variables} from 'graphql-request' +/** + * Fetches the user's email from the Business Platform API. + * + * @param businessPlatformToken - The business platform token. + * @returns The user's email address or undefined if not found. + */ +async function fetchEmail(businessPlatformToken: string | undefined): Promise { + if (USE_LOCAL_MOCKS) { + return LOCAL_OVERRIDES.fetchEmail() + } + if (!businessPlatformToken) return undefined + + try { + const userEmailResult = await businessPlatformRequest(UserEmailQueryString, businessPlatformToken) + return userEmailResult.currentUserAccount?.email + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputDebug(outputContent`Failed to fetch user email: ${(error as Error).message ?? String(error)}`) + return undefined + } +} + /** * Sets up the request to the Business Platform Destinations API. * @@ -64,7 +92,7 @@ export interface BusinessPlatformRequestOptions. */ -export async function businessPlatformRequestDoc( +async function businessPlatformRequestDoc( options: BusinessPlatformRequestOptions, ): Promise { return graphqlRequestDoc({ @@ -108,7 +136,7 @@ export interface BusinessPlatformOrganizationsRequestNonTypedOptions { * @param options - The options for the request. * @returns The response of the query of generic type . */ -export async function businessPlatformOrganizationsRequest( +async function businessPlatformOrganizationsRequest( options: BusinessPlatformOrganizationsRequestNonTypedOptions, ): Promise { return graphqlRequest({ @@ -130,7 +158,7 @@ export interface BusinessPlatformOrganizationsRequestOptions. */ -export async function businessPlatformOrganizationsRequestDoc( +async function businessPlatformOrganizationsRequestDoc( options: BusinessPlatformOrganizationsRequestOptions, ): Promise { return graphqlRequestDoc({ @@ -140,3 +168,20 @@ export async function businessPlatformOrganizationsRequestDoc Date: Wed, 12 Nov 2025 14:41:39 -0500 Subject: [PATCH 03/11] stash off main merge --- packages/cli-kit/src/private/node/session.ts | 20 +-------- .../node/session/device-authorization.ts | 10 ----- .../src/public/node/api/identity-client.ts | 42 +++++++++++++++++-- 3 files changed, 40 insertions(+), 32 deletions(-) delete mode 100644 packages/cli-kit/src/private/node/session/device-authorization.ts diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index e224d281a1d..645ca3d484b 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -21,28 +21,10 @@ import {getIdentityTokenInformation, getPartnersToken} from '../../public/node/e import {AdminSession, logout} from '../../public/node/session.js' import {nonRandomUUID} from '../../public/node/crypto.js' import {isEmpty} from '../../public/common/object.js' -import {businessPlatformRequest, fetchEmail} from '../../public/node/api/business-platform.js' +import {fetchEmail} from '../../public/node/api/business-platform.js' import {getIdentityClient} from '../../public/node/api/identity-client.js' -/** - * Fetches the user's email from the Business Platform API - * @param businessPlatformToken - The business platform token - * @returns The user's email address or undefined if not found - */ -async function fetchEmail(businessPlatformToken: string | undefined): Promise { - if (!businessPlatformToken) return undefined - - try { - const userEmailResult = await businessPlatformRequest(UserEmailQueryString, businessPlatformToken) - return userEmailResult.currentUserAccount?.email - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (error) { - outputDebug(outputContent`Failed to fetch user email: ${(error as Error).message ?? String(error)}`) - return undefined - } -} - /** * A scope supported by the Shopify Admin API. */ diff --git a/packages/cli-kit/src/private/node/session/device-authorization.ts b/packages/cli-kit/src/private/node/session/device-authorization.ts deleted file mode 100644 index 1e18c45453c..00000000000 --- a/packages/cli-kit/src/private/node/session/device-authorization.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface DeviceAuthorizationResponse { - deviceCode: string - userCode: string - verificationUri: string - expiresIn: number - verificationUriComplete?: string - interval?: number -} - -// export async function pollForDeviceAuthorization(code: string, interval = 5): Promise {} diff --git a/packages/cli-kit/src/public/node/api/identity-client.ts b/packages/cli-kit/src/public/node/api/identity-client.ts index 54f299ec559..320c6fa4caa 100644 --- a/packages/cli-kit/src/public/node/api/identity-client.ts +++ b/packages/cli-kit/src/public/node/api/identity-client.ts @@ -241,12 +241,47 @@ export class LocalIdentityClient implements IdentityClientInterface { } } + // use skip_test_mode_warning in one of these token exchanges pollForDeviceAuthorization(_deviceAuth: DeviceAuthorizationResponse): Promise { - throw new Error('Method not implemented.') + return new Promise((resolve) => + resolve({ + accessToken: 'MOCK_ACCESS_TOKEN_2_PLACEHOLDER', + refreshToken: 'MOCK_REFRESH_TOKEN_2_PLACEHOLDER', + expiresAt: new Date('November 11, 2099'), + scopes: [ + // use helper in scopes.ts for this? + 'openid', + 'https://api.shopify.com/auth/partners.app.cli.access', + 'https://api.shopify.com/auth/shop.admin.themes', + 'https://api.shopify.com/auth/partners.collaborator-relationships.readonly', + 'https://api.shopify.com/auth/shop.admin.graphql', + 'https://api.shopify.com/auth/destinations.readonly', + 'https://api.shopify.com/auth/organization.store-management', + 'https://api.shopify.com/auth/organization.on-demand-user-access', + 'https://api.shopify.com/auth/shop.storefront-renderer.devtools', + 'https://api.shopify.com/auth/organization.apps.manage', + ], + userId: '08978734-325e-44ce-bc65-34823a8d5180', + }), + ) } async tokenRequest(_params: TokenRequestConfig): TokenRequestConfigResponse { throw new Error('Method not implemented.') + + return new Promise((resolve) => + resolve( + ok({ + access_token: + 'atkn_Cp4CCMjqzsgGEOiiz8gGYo8CCAESEAvI8AcbUEq5g2Zuk75vWjMaPmh0dHBzOi8vYXBpLnNob3BpZnkuY29tL2F1dGgvc2hvcC5zdG9yZWZyb250LXJlbmRlcmVyLmRldnRvb2xzIDIoIDokMDg5Nzg3MzQtMzI1ZS00NGNlLWJjNjUtMzQ4MjNhOGQ1MTgwQgdBY2NvdW50ShC0HvgPwyJAaLrv9UsFUxQbUlB7InN1YiI6ImU1MzgwZTAyLTMxMmEtNzQwOC01NzE4LWUwNzAxN2U5Y2Y1MiIsImlzcyI6Imh0dHBzOi8vaWRlbnRpdHkuc2hvcC5kZXYifWIQ1OQu5VaTQUOTO4zG2NABO2oQbEi7u5iKQHqUTDtoYQcGCRJAof8oE4mOQVeMIybOMlurQqSqAmJXllCh3kuPQyfScccuxbwzjdzvXYGh4Ojutf9w2h7W55rPH4uZguprKoQOCA', + refresh_token: 'xyz', + expires_in: 7200, + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + scope: 'https://api.shopify.com/auth/shop.storefront-renderer.devtools', + token_type: 'bearer', + }), + ), + ) } } @@ -288,13 +323,14 @@ function buildAuthorizationParseErrorMessage(response: Response, responseText: s return `${errorMessage} If this issue persists, please contact support at https://help.shopify.com` } +// this can all be cleaned up better const ProdIC = new ProdIdentityClient() const LocalIC = new LocalIdentityClient() export function getIdentityClient(): IdentityClientInterface { // eslint-disable-next-line @shopify/prefer-module-scope-constants - const FORCE_PROD = true + const FORCE_NO_MOCKS = true const env = serviceEnvironment() - const client = env === 'local' && !FORCE_PROD ? LocalIC : ProdIC + const client = env === 'local' && !FORCE_NO_MOCKS ? LocalIC : ProdIC return client } From 5244331feb892ac78c0e3bc8b28b8f65321aafb5 Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Wed, 12 Nov 2025 14:48:40 -0500 Subject: [PATCH 04/11] fix stash merge conflicts --- packages/app/tsconfig.json | 2 +- .../src/public/node/api/identity-client.ts | 117 +++++++----------- 2 files changed, 49 insertions(+), 70 deletions(-) diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 09d11b9e538..d729f986137 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "../../configurations/tsconfig.json", - "include": ["./src/**/*.ts", "./src/**/*.js", "./src/**/*.tsx", "../cli-kit/src/public/node/api/identity-client.ts"], + "include": ["./src/**/*.ts", "./src/**/*.js", "./src/**/*.tsx", "../cli-kit/src/public/node/api/identity-clientv1.ts"], "exclude": ["./dist", "./src/templates/**/*"], "compilerOptions": { "outDir": "dist", diff --git a/packages/cli-kit/src/public/node/api/identity-client.ts b/packages/cli-kit/src/public/node/api/identity-client.ts index 320c6fa4caa..f16bf914dec 100644 --- a/packages/cli-kit/src/public/node/api/identity-client.ts +++ b/packages/cli-kit/src/public/node/api/identity-client.ts @@ -3,9 +3,14 @@ /* eslint-disable @nx/enforce-module-boundaries */ /* eslint-disable jsdoc/require-description */ +import {USE_LOCAL_MOCKS} from './utilities.js' import {Environment, serviceEnvironment} from '../../../private/node/context/service.js' -import {err, ok, Result} from '../result.js' -import {exchangeDeviceCodeForAccessToken} from '../../../private/node/session/exchange.js' +import { + exchangeDeviceCodeForAccessToken, + ExchangeScopes, + requestAppToken, +} from '../../../private/node/session/exchange.js' +import {ApplicationToken} from '../../../private/node/session/schema.js' import {identityFqdn} from '@shopify/cli-kit/node/context/fqdn' import {shopifyFetch} from '@shopify/cli-kit/node/http' import {AbortError, BugError} from '@shopify/cli-kit/node/error' @@ -21,14 +26,6 @@ const DateSchema = zod.preprocess((arg: any) => { return null }, zod.date()) -interface TokenRequestResult { - access_token: string - expires_in: number - refresh_token: string - scope: string - id_token?: string -} - /** * The schema represents an Identity token. */ @@ -51,14 +48,15 @@ export interface DeviceAuthorizationResponse { interval?: number } -interface TokenRequestConfig { - [key: string]: string -} -type TokenRequestConfigResponse = Promise> +type ExchangeAccessTokenResponse = Promise<{[x: string]: ApplicationToken}> interface IdentityClientInterface { requestDeviceAuthorization(scopes: string[]): Promise pollForDeviceAuthorization(deviceAuth: DeviceAuthorizationResponse): Promise - tokenRequest(params: TokenRequestConfig): TokenRequestConfigResponse + exchangeAccessForApplicationTokens( + identityToken: IdentityToken, + scopes: ExchangeScopes, + store?: string, + ): ExchangeAccessTokenResponse } // @@ -214,18 +212,28 @@ export class ProdIdentityClient implements IdentityClientInterface { }) } - async tokenRequest(params: TokenRequestConfig): TokenRequestConfigResponse { - const fqdn = await identityFqdn() - const url = new URL(`https://${fqdn}/oauth/token`) - url.search = new URLSearchParams(Object.entries(params)).toString() - - const res = await shopifyFetch(url.href, {method: 'POST'}) - - const payload: any = await res.json() - - if (res.ok) return ok(payload) + async exchangeAccessForApplicationTokens( + identityToken: IdentityToken, + scopes: ExchangeScopes, + store?: string, + ): ExchangeAccessTokenResponse { + const token = identityToken.accessToken + + const [partners, storefront, businessPlatform, admin, appManagement] = await Promise.all([ + requestAppToken('partners', token, scopes.partners), + requestAppToken('storefront-renderer', token, scopes.storefront), + requestAppToken('business-platform', token, scopes.businessPlatform), + store ? requestAppToken('admin', token, scopes.admin, store) : {}, + requestAppToken('app-management', token, scopes.appManagement), + ]) - return err({error: payload.error, store: params.store}) + return { + ...partners, + ...storefront, + ...businessPlatform, + ...admin, + ...appManagement, + } } } @@ -241,47 +249,22 @@ export class LocalIdentityClient implements IdentityClientInterface { } } - // use skip_test_mode_warning in one of these token exchanges pollForDeviceAuthorization(_deviceAuth: DeviceAuthorizationResponse): Promise { - return new Promise((resolve) => - resolve({ - accessToken: 'MOCK_ACCESS_TOKEN_2_PLACEHOLDER', - refreshToken: 'MOCK_REFRESH_TOKEN_2_PLACEHOLDER', - expiresAt: new Date('November 11, 2099'), - scopes: [ - // use helper in scopes.ts for this? - 'openid', - 'https://api.shopify.com/auth/partners.app.cli.access', - 'https://api.shopify.com/auth/shop.admin.themes', - 'https://api.shopify.com/auth/partners.collaborator-relationships.readonly', - 'https://api.shopify.com/auth/shop.admin.graphql', - 'https://api.shopify.com/auth/destinations.readonly', - 'https://api.shopify.com/auth/organization.store-management', - 'https://api.shopify.com/auth/organization.on-demand-user-access', - 'https://api.shopify.com/auth/shop.storefront-renderer.devtools', - 'https://api.shopify.com/auth/organization.apps.manage', - ], - userId: '08978734-325e-44ce-bc65-34823a8d5180', - }), - ) - } - - async tokenRequest(_params: TokenRequestConfig): TokenRequestConfigResponse { throw new Error('Method not implemented.') + } - return new Promise((resolve) => - resolve( - ok({ - access_token: - 'atkn_Cp4CCMjqzsgGEOiiz8gGYo8CCAESEAvI8AcbUEq5g2Zuk75vWjMaPmh0dHBzOi8vYXBpLnNob3BpZnkuY29tL2F1dGgvc2hvcC5zdG9yZWZyb250LXJlbmRlcmVyLmRldnRvb2xzIDIoIDokMDg5Nzg3MzQtMzI1ZS00NGNlLWJjNjUtMzQ4MjNhOGQ1MTgwQgdBY2NvdW50ShC0HvgPwyJAaLrv9UsFUxQbUlB7InN1YiI6ImU1MzgwZTAyLTMxMmEtNzQwOC01NzE4LWUwNzAxN2U5Y2Y1MiIsImlzcyI6Imh0dHBzOi8vaWRlbnRpdHkuc2hvcC5kZXYifWIQ1OQu5VaTQUOTO4zG2NABO2oQbEi7u5iKQHqUTDtoYQcGCRJAof8oE4mOQVeMIybOMlurQqSqAmJXllCh3kuPQyfScccuxbwzjdzvXYGh4Ojutf9w2h7W55rPH4uZguprKoQOCA', - refresh_token: 'xyz', - expires_in: 7200, - issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', - scope: 'https://api.shopify.com/auth/shop.storefront-renderer.devtools', - token_type: 'bearer', - }), - ), - ) + async exchangeAccessForApplicationTokens( + _identityToken: IdentityToken, + _scopes: ExchangeScopes, + _store?: string, + ): ExchangeAccessTokenResponse { + return { + ...{}, + ...{}, + ...{}, + ...{}, + ...{}, + } } } @@ -323,14 +306,10 @@ function buildAuthorizationParseErrorMessage(response: Response, responseText: s return `${errorMessage} If this issue persists, please contact support at https://help.shopify.com` } -// this can all be cleaned up better const ProdIC = new ProdIdentityClient() const LocalIC = new LocalIdentityClient() export function getIdentityClient(): IdentityClientInterface { - // eslint-disable-next-line @shopify/prefer-module-scope-constants - const FORCE_NO_MOCKS = true - const env = serviceEnvironment() - const client = env === 'local' && !FORCE_NO_MOCKS ? LocalIC : ProdIC + const client = USE_LOCAL_MOCKS ? LocalIC : ProdIC return client } From e24897386dc2a3fa63d4b903a74faa683f83b5bf Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Wed, 12 Nov 2025 14:51:35 -0500 Subject: [PATCH 05/11] fix stash merge conflicts --- .../src/private/node/session/exchange.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/cli-kit/src/private/node/session/exchange.ts b/packages/cli-kit/src/private/node/session/exchange.ts index 530bff726b8..09159b7a6f6 100644 --- a/packages/cli-kit/src/private/node/session/exchange.ts +++ b/packages/cli-kit/src/private/node/session/exchange.ts @@ -1,12 +1,14 @@ import {ApplicationToken, IdentityToken} from './schema.js' import {applicationId} from './identity.js' import {tokenExchangeScopes} from './scopes.js' -import {getIdentityClient, clientId as getIdentityClientId} from '../../../public/node/api/identity-client.js' +import {clientId as getIdentityClientId} from '../../../public/node/api/identity-client.js' import {API} from '../api.js' import {err, ok, Result} from '../../../public/node/result.js' import {AbortError, BugError, ExtendableError} from '../../../public/node/error.js' import {setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../session.js' import {nonRandomUUID} from '../../../public/node/crypto.js' +import {identityFqdn} from '../../../public/node/context/fqdn.js' +import {shopifyFetch} from '../../../public/node/http.js' import * as jose from 'jose' export class InvalidGrantError extends ExtendableError {} @@ -62,8 +64,7 @@ export async function refreshAccessToken(currentToken: IdentityToken): Promise> { + const fqdn = await identityFqdn() + const url = new URL(`https://${fqdn}/oauth/token`) + url.search = new URLSearchParams(Object.entries(params)).toString() + + const res = await shopifyFetch(url.href, {method: 'POST'}) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const payload: any = await res.json() + + if (res.ok) return ok(payload) + + return err({error: payload.error, store: params.store}) +} + function buildIdentityToken( result: TokenRequestResult, existingUserId?: string, From df572ff7dfb6e02498fc53ef77a24582deaeff3f Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Wed, 12 Nov 2025 18:00:57 -0500 Subject: [PATCH 06/11] mock out rest of main token exchange flow --- packages/app/src/cli/services/context.ts | 1 - .../app-management-client.ts | 1 + packages/cli-kit/src/private/node/session.ts | 4 +- .../src/public/node/api/business-platform.ts | 2 +- .../src/public/node/api/identity-client.ts | 40 +++++++++++++++---- .../cli-kit/src/public/node/api/utilities.ts | 4 +- 6 files changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/app/src/cli/services/context.ts b/packages/app/src/cli/services/context.ts index 93f2b5c274d..d7868424763 100644 --- a/packages/app/src/cli/services/context.ts +++ b/packages/app/src/cli/services/context.ts @@ -71,7 +71,6 @@ interface AppFromIdOptions { export const appFromIdentifiers = async (options: AppFromIdOptions): Promise => { const allClients = allDeveloperPlatformClients() - let app: OrganizationApp | undefined for (const client of allClients) { try { diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index f74ceebd240..d248c688642 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -274,6 +274,7 @@ export class AppManagementClient implements DeveloperPlatformClient { throw new Error('AppManagementClient.session() should not be invoked dynamically in a unit test') } + debugger const tokenResult = await ensureAuthenticatedAppManagementAndBusinessPlatform() const {appManagementToken, businessPlatformToken, userId} = tokenResult diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 645ca3d484b..b4ff218009b 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -283,6 +283,7 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise { - throw new Error('Method not implemented.') + return new Promise((resolve) => + resolve({ + accessToken: + 'MOCK_ACCESS_TOKEN_PLACEHOLDER', + alias: '', + expiresAt: getFutureDate(), + refreshToken: + 'MOCK_REFRESH_TOKEN_PLACEHOLDER', + scopes: allDefaultScopes(), + userId: '08978734-325e-44ce-bc65-34823a8d5180', + }), + ) } async exchangeAccessForApplicationTokens( @@ -258,12 +271,19 @@ export class LocalIdentityClient implements IdentityClientInterface { _scopes: ExchangeScopes, _store?: string, ): ExchangeAccessTokenResponse { + const fullScopeExchangeMockResult = { + accessToken: + 'atkn_CpUCCKv108gGEMut1MgGYoYCCAESEJ_CKyOGg00Jl0q4jUVIl2IaNWh0dHBzOi8vYXBpLnNob3BpZnkuY29tL2F1dGgvb3JnYW5pemF0aW9uLmFwcHMubWFuYWdlIAwoIDokMDg5Nzg3MzQtMzI1ZS00NGNlLWJjNjUtMzQ4MjNhOGQ1MTgwQgdBY2NvdW50ShAcrqYpOnFMYJbvMxKxqLvWUlB7InN1YiI6ImU1MzgwZTAyLTMxMmEtNzQwOC01NzE4LWUwNzAxN2U5Y2Y1MiIsImlzcyI6Imh0dHBzOi8vaWRlbnRpdHkuc2hvcC5kZXYifWIQZB-PM5BPSNC2Xn8OsL1zWGoQL8D3JzdaSaa8SUwlxxQuuxJAdBzL7VX5mXwP-8A5UZPUX-vvOa-CxX4fGZp50piE2bJDNSlGZa0SsKQRTHZqyu2plR4cUsglTLy1CxEYaIKbBQ', + expiresAt: getFutureDate(), + scopes: allDefaultScopes(), + } + return { - ...{}, - ...{}, - ...{}, - ...{}, - ...{}, + [applicationId('app-management')]: fullScopeExchangeMockResult, + [applicationId('business-platform')]: fullScopeExchangeMockResult, + [applicationId('admin')]: fullScopeExchangeMockResult, + [applicationId('partners')]: fullScopeExchangeMockResult, + [applicationId('storefront-renderer')]: fullScopeExchangeMockResult, } } } @@ -306,6 +326,12 @@ function buildAuthorizationParseErrorMessage(response: Response, responseText: s return `${errorMessage} If this issue persists, please contact support at https://help.shopify.com` } +function getFutureDate() { + const futureDate = new Date() + futureDate.setDate(futureDate.getDate() + 100) + return futureDate +} + const ProdIC = new ProdIdentityClient() const LocalIC = new LocalIdentityClient() diff --git a/packages/cli-kit/src/public/node/api/utilities.ts b/packages/cli-kit/src/public/node/api/utilities.ts index a3f1534a960..8e32830d5fd 100644 --- a/packages/cli-kit/src/public/node/api/utilities.ts +++ b/packages/cli-kit/src/public/node/api/utilities.ts @@ -25,6 +25,6 @@ export const addCursorAndFiltersToAppLogsUrl = ( return url.toString() } -const FORCE_PROD = true +const FORCE_USE_RUNNING_EXTERNAL_SERVICES = false const env = serviceEnvironment() -export const USE_LOCAL_MOCKS = !FORCE_PROD && env === 'local' +export const USE_LOCAL_MOCKS = !FORCE_USE_RUNNING_EXTERNAL_SERVICES && env === 'local' From 491face42e0c819866fb92cc33ed53a1c62459d7 Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Thu, 13 Nov 2025 16:55:31 -0500 Subject: [PATCH 07/11] stash before refactor --- .../app-management-client.ts | 27 ++++-- packages/cli-kit/src/private/node/session.ts | 5 +- .../node/session/device-authorization.test.ts | 7 +- .../src/private/node/session/identity.ts | 1 + .../src/public/node/api/business-platform.ts | 3 +- .../src/public/node/api/identity-client.ts | 94 +++++++++++++++---- .../cli-kit/src/public/node/api/utilities.ts | 2 +- 7 files changed, 102 insertions(+), 37 deletions(-) diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index d248c688642..681ac4a8bd2 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -116,7 +116,6 @@ import {CreateApp, CreateAppMutationVariables} from '../../api/graphql/app-manag import {FetchSpecifications} from '../../api/graphql/app-management/generated/specifications.js' import {ListApps} from '../../api/graphql/app-management/generated/apps.js' import {FindOrganizations} from '../../api/graphql/business-platform-destinations/generated/find-organizations.js' -import {UserInfo} from '../../api/graphql/business-platform-destinations/generated/user-info.js' import {AvailableTopics} from '../../api/graphql/webhooks/generated/available-topics.js' import {CliTesting} from '../../api/graphql/webhooks/generated/cli-testing.js' import {PublicApiVersions} from '../../api/graphql/webhooks/generated/public-api-versions.js' @@ -274,20 +273,28 @@ export class AppManagementClient implements DeveloperPlatformClient { throw new Error('AppManagementClient.session() should not be invoked dynamically in a unit test') } - debugger const tokenResult = await ensureAuthenticatedAppManagementAndBusinessPlatform() const {appManagementToken, businessPlatformToken, userId} = tokenResult // This one can't use the shared businessPlatformRequest because the token is not globally available yet. - const userInfoResult = await businessPlatformRequestDoc({ - query: UserInfo, - cacheOptions: { - cacheTTL: {hours: 6}, - cacheExtraKey: userId, + // const userInfoResult = await businessPlatformRequestDoc({ + // query: UserInfo, + // cacheOptions: { + // cacheTTL: {hours: 6}, + // cacheExtraKey: userId, + // }, + // token: businessPlatformToken, + // unauthorizedHandler: this.createUnauthorizedHandler('businessPlatform'), + // }) + const userInfoResult = { + currentUserAccount: { + uuid: '08978734-325e-44ce-bc65-34823a8d5180', + email: 'dev@shopify.com', + organizations: { + nodes: [{name: 'My Test Org FROM CLI MOCK'}], + }, }, - token: businessPlatformToken, - unauthorizedHandler: this.createUnauthorizedHandler('businessPlatform'), - }) + } if (getPartnersToken() && userInfoResult.currentUserAccount) { const organizations = userInfoResult.currentUserAccount.organizations.nodes.map((org) => ({ diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index b4ff218009b..42140e9dc90 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -5,9 +5,9 @@ import { exchangeAccessForApplicationTokens, exchangeCustomPartnerToken, ExchangeScopes, - refreshAccessToken, InvalidGrantError, InvalidRequestError, + refreshAccessToken, } from './session/exchange.js' import {IdentityToken, Session, Sessions} from './session/schema.js' import * as sessionStore from './session/store.js' @@ -209,12 +209,13 @@ ${outputToken.json(applications)} let newSession = {} + debugger + if (validationResult === 'needs_full_auth') { await throwOnNoPrompt(noPrompt) outputDebug(outputContent`Initiating the full authentication flow...`) newSession = await executeCompleteFlow(applications) } else if (validationResult === 'needs_refresh' || forceRefresh) { - outputDebug(outputContent`The current session is valid but needs refresh. Refreshing...`) try { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion newSession = await refreshTokens(currentSession!, applications) diff --git a/packages/cli-kit/src/private/node/session/device-authorization.test.ts b/packages/cli-kit/src/private/node/session/device-authorization.test.ts index f0d44ef15fe..3b8f8dc7f3f 100644 --- a/packages/cli-kit/src/private/node/session/device-authorization.test.ts +++ b/packages/cli-kit/src/private/node/session/device-authorization.test.ts @@ -1,10 +1,7 @@ -import { - DeviceAuthorizationResponse, - pollForDeviceAuthorization, -} from './device-authorization.js' -import {clientId, ProdIC} from '../../../public/node/api/identity-client.js' +import {DeviceAuthorizationResponse, pollForDeviceAuthorization} from './device-authorization.js' import {IdentityToken} from './schema.js' import {exchangeDeviceCodeForAccessToken} from './exchange.js' +import {clientId, ProdIC} from '../../../public/node/api/identity-client.js' import {identityFqdn} from '../../../public/node/context/fqdn.js' import {shopifyFetch} from '../../../public/node/http.js' import {isTTY} from '../../../public/node/ui.js' diff --git a/packages/cli-kit/src/private/node/session/identity.ts b/packages/cli-kit/src/private/node/session/identity.ts index 6c2e199f0c4..cf9340fceac 100644 --- a/packages/cli-kit/src/private/node/session/identity.ts +++ b/packages/cli-kit/src/private/node/session/identity.ts @@ -45,6 +45,7 @@ export function applicationId(api: API): string { } } case 'app-management': { + // this is the `aud` const environment = serviceEnvironment() if (environment === Environment.Production) { return '7ee65a63608843c577db8b23c4d7316ea0a01bd2f7594f8a9c06ea668c1b775c' diff --git a/packages/cli-kit/src/public/node/api/business-platform.ts b/packages/cli-kit/src/public/node/api/business-platform.ts index 798fd9e882c..695903536d1 100644 --- a/packages/cli-kit/src/public/node/api/business-platform.ts +++ b/packages/cli-kit/src/public/node/api/business-platform.ts @@ -105,7 +105,8 @@ async function businessPlatformRequestDoc } /** - * Sets up the request to the Business Platform Organizations API. + * Sets up the request to the Business + * Platform Organizations API. * * @param token - Business Platform token. * @param organizationId - Organization ID as a numeric (non-GID) value. diff --git a/packages/cli-kit/src/public/node/api/identity-client.ts b/packages/cli-kit/src/public/node/api/identity-client.ts index 71cbe7a9ac8..168bf95b145 100644 --- a/packages/cli-kit/src/public/node/api/identity-client.ts +++ b/packages/cli-kit/src/public/node/api/identity-client.ts @@ -51,15 +51,6 @@ export interface DeviceAuthorizationResponse { } type ExchangeAccessTokenResponse = Promise<{[x: string]: ApplicationToken}> -interface IdentityClientInterface { - requestDeviceAuthorization(scopes: string[]): Promise - pollForDeviceAuthorization(deviceAuth: DeviceAuthorizationResponse): Promise - exchangeAccessForApplicationTokens( - identityToken: IdentityToken, - scopes: ExchangeScopes, - store?: string, - ): ExchangeAccessTokenResponse -} // /** @@ -76,7 +67,26 @@ export function clientId(): string { } } -export class ProdIdentityClient implements IdentityClientInterface { +abstract class IdentityClient { + authTokenPrefix: string + + constructor() { + // atkn_ + // atkn_mock_token_ + // mtkn_ + this.authTokenPrefix = 'mtkn_' + } + + abstract requestDeviceAuthorization(scopes: string[]): Promise + abstract pollForDeviceAuthorization(deviceAuth: DeviceAuthorizationResponse): Promise + abstract exchangeAccessForApplicationTokens( + identityToken: IdentityToken, + scopes: ExchangeScopes, + store?: string, + ): ExchangeAccessTokenResponse +} + +export class ProdIdentityClient extends IdentityClient { /** * Initiate a device authorization flow. * This will return a DeviceAuthorizationResponse containing the URL where user @@ -220,6 +230,9 @@ export class ProdIdentityClient implements IdentityClientInterface { store?: string, ): ExchangeAccessTokenResponse { const token = identityToken.accessToken + // 'MOCK_COMMENTED_TOKEN_PLACEHOLDER' + // scopes ex. 'https://api.shopify.com/auth/organization.apps.manage' + // debugger const [partners, storefront, businessPlatform, admin, appManagement] = await Promise.all([ requestAppToken('partners', token, scopes.partners), @@ -239,7 +252,7 @@ export class ProdIdentityClient implements IdentityClientInterface { } } -export class LocalIdentityClient implements IdentityClientInterface { +export class LocalIdentityClient extends IdentityClient { async requestDeviceAuthorization(_scopes: string[]): Promise { return { deviceCode: 'ABC', @@ -254,12 +267,10 @@ export class LocalIdentityClient implements IdentityClientInterface { pollForDeviceAuthorization(_deviceAuth: DeviceAuthorizationResponse): Promise { return new Promise((resolve) => resolve({ - accessToken: - 'MOCK_ACCESS_TOKEN_PLACEHOLDER', + accessToken: `${this.authTokenPrefix}CjQIqvXTyAYQyq3UyAZSJggBEhAvwPcnN1pJprxJTCXHFC67GhBkH48zkE9I0LZefw6wvXNYEkCYFhPHBUYi5LzPy8fROCNwP8pBJ-Qfeceur4ies466WSxZv0VE8jbYrzMyKF-dhnv9WDrdj6sDIuACUjdJ9fQP`, alias: '', expiresAt: getFutureDate(), - refreshToken: - 'MOCK_REFRESH_TOKEN_PLACEHOLDER', + refreshToken: `${this.authTokenPrefix}CiEIqvXTyAYQqo_yyQaiARIKEGQfjzOQT0jQtl5_DrC9c1gSQInG2qNxycjj8UZ4uuaI-8R246Emx3clBE5AXBLrd_5WhcRny65gF6-GM-NsUud8AMvh2BYVyuG7QK7_n79B4AQ`, scopes: allDefaultScopes(), userId: '08978734-325e-44ce-bc65-34823a8d5180', }), @@ -271,13 +282,60 @@ export class LocalIdentityClient implements IdentityClientInterface { _scopes: ExchangeScopes, _store?: string, ): ExchangeAccessTokenResponse { + const actualToken = + 'mtkn_eyJ2YWxpZCI6dHJ1ZSwic2NvcGUiOiJodHRwczpcL1wvYXBpLnNob3BpZnkuY29tXC9hdXRoXC9vcmdhbml6YXRpb24uYXBwcy5tYW5hZ2UiLCJjbGllbnRfaWQiOiJlNTM4MGUwMi0zMTJhLTc0MDgtNTcxOC1lMDcwMTdlOWNmNTIiLCJ0b2tlbl90eXBlIjoiYmVhcmVyIiwiZXhwIjoxNzYzMDc1NTM2LCJpYXQiOjE3NjMwNjgzMzYsInN1YiI6IjA4OTc4NzM0LTMyNWUtNDRjZS1iYzY1LTM0ODIzYThkNTE4MCIsImF1ZCI6ImU5MjQ4MmNlYmI5YmZiOWZiNWEwMTk5Y2M3NzBmZGUzZGU2YzhkMTZiNzk4ZWU3M2UzNmM5ZDgxNWUwNzBlNTIiLCJpc3MiOiJodHRwczpcL1wvaWRlbnRpdHkuc2hvcC5kZXYiLCJzaWQiOiJkZjYzYzY1Yy0zNzMxLTQ4YWYtYTI4ZC03MmFiMTZhNjUyM2EiLCJhY3QiOnsic3ViIjoiZTUzODBlMDItMzEyYS03NDA4LTU3MTgtZTA3MDE3ZTljZjUyIiwiaXNzIjoiaHR0cHM6XC9cL2lkZW50aXR5LnNob3AuZGV2In0sImF1dGhfdGltZSI6MTc2MzA2ODMzMSwiYW1yIjpbInB3ZCIsImRldmljZS1hdXRoIl0sImRldmljZV91dWlkIjoiOGJhNjQ0YzgtN2QyZi00MjYwLTkzMTEtODZkZjA5MTk1ZWU4IiwiYXRsIjoxLjB9' const fullScopeExchangeMockResult = { - accessToken: - 'atkn_CpUCCKv108gGEMut1MgGYoYCCAESEJ_CKyOGg00Jl0q4jUVIl2IaNWh0dHBzOi8vYXBpLnNob3BpZnkuY29tL2F1dGgvb3JnYW5pemF0aW9uLmFwcHMubWFuYWdlIAwoIDokMDg5Nzg3MzQtMzI1ZS00NGNlLWJjNjUtMzQ4MjNhOGQ1MTgwQgdBY2NvdW50ShAcrqYpOnFMYJbvMxKxqLvWUlB7InN1YiI6ImU1MzgwZTAyLTMxMmEtNzQwOC01NzE4LWUwNzAxN2U5Y2Y1MiIsImlzcyI6Imh0dHBzOi8vaWRlbnRpdHkuc2hvcC5kZXYifWIQZB-PM5BPSNC2Xn8OsL1zWGoQL8D3JzdaSaa8SUwlxxQuuxJAdBzL7VX5mXwP-8A5UZPUX-vvOa-CxX4fGZp50piE2bJDNSlGZa0SsKQRTHZqyu2plR4cUsglTLy1CxEYaIKbBQ', + accessToken: actualToken, + // accessToken: `${this.authTokenPrefix}CpUCCKv108gGEMut1MgGYoYCCAESEJ_CKyOGg00Jl0q4jUVIl2IaNWh0dHBzOi8vYXBpLnNob3BpZnkuY29tL2F1dGgvb3JnYW5pemF0aW9uLmFwcHMubWFuYWdlIAwoIDokMDg5Nzg3MzQtMzI1ZS00NGNlLWJjNjUtMzQ4MjNhOGQ1MTgwQgdBY2NvdW50ShAcrqYpOnFMYJbvMxKxqLvWUlB7InN1YiI6ImU1MzgwZTAyLTMxMmEtNzQwOC01NzE4LWUwNzAxN2U5Y2Y1MiIsImlzcyI6Imh0dHBzOi8vaWRlbnRpdHkuc2hvcC5kZXYifWIQZB-PM5BPSNC2Xn8OsL1zWGoQL8D3JzdaSaa8SUwlxxQuuxJAdBzL7VX5mXwP-8A5UZPUX-vvOa-CxX4fGZp50piE2bJDNSlGZa0SsKQRTHZqyu2plR4cUsglTLy1CxEYaIKbBQ`, expiresAt: getFutureDate(), scopes: allDefaultScopes(), } + // Generate fresh timestamps + const now = Math.floor(Date.now() / 1000) + const exp = now + 7200 // 2 hours from now + + const generateAppToken = (appId: string, scopeArray: string[]) => { + const tokenInfo = { + act: { + iss: 'https://identity.shop.dev', + sub: clientId(), + }, + aud: appId, + client_id: clientId(), + exp, + iat: now, + iss: 'https://identity.shop.dev', + scope: scopeArray.join(' '), + token_type: 'bearer', + sub: '', // what is identityToken?? identityToken.userId, + sid: '', // identityToken.userId, // Or use a session ID + } + + const encoded = Buffer.from(JSON.stringify(tokenInfo)) + .toString('base64') + .replace(/[=]/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_') + + return { + accessToken: `mtkn_${encoded}`, + expiresAt: new Date(exp * 1000), + scopes: scopeArray, + } + } + + return { + [applicationId('app-management')]: generateAppToken(applicationId('app-management'), allDefaultScopes()), + [applicationId('business-platform')]: generateAppToken(applicationId('business-platform'), allDefaultScopes()), + [applicationId('admin')]: generateAppToken(applicationId('admin'), allDefaultScopes()), + [applicationId('partners')]: generateAppToken(applicationId('partners'), allDefaultScopes()), + [applicationId('storefront-renderer')]: generateAppToken( + applicationId('storefront-renderer'), + allDefaultScopes(), + ), + } + return { [applicationId('app-management')]: fullScopeExchangeMockResult, [applicationId('business-platform')]: fullScopeExchangeMockResult, @@ -335,7 +393,7 @@ function getFutureDate() { const ProdIC = new ProdIdentityClient() const LocalIC = new LocalIdentityClient() -export function getIdentityClient(): IdentityClientInterface { +export function getIdentityClient(): IdentityClient { const client = USE_LOCAL_MOCKS ? LocalIC : ProdIC return client } diff --git a/packages/cli-kit/src/public/node/api/utilities.ts b/packages/cli-kit/src/public/node/api/utilities.ts index 8e32830d5fd..80770163c8c 100644 --- a/packages/cli-kit/src/public/node/api/utilities.ts +++ b/packages/cli-kit/src/public/node/api/utilities.ts @@ -25,6 +25,6 @@ export const addCursorAndFiltersToAppLogsUrl = ( return url.toString() } -const FORCE_USE_RUNNING_EXTERNAL_SERVICES = false +const FORCE_USE_RUNNING_EXTERNAL_SERVICES = true const env = serviceEnvironment() export const USE_LOCAL_MOCKS = !FORCE_USE_RUNNING_EXTERNAL_SERVICES && env === 'local' From d0303bcb4589535c95296a93ca10e14f2038e296 Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Fri, 14 Nov 2025 13:54:28 -0500 Subject: [PATCH 08/11] app info works without identity --- packages/cli-kit/src/private/node/session.ts | 16 +- .../src/private/node/session/exchange.ts | 6 +- .../src/public/node/api/identity-client.ts | 183 +++++++++++++----- .../cli-kit/src/public/node/api/utilities.ts | 2 +- 4 files changed, 143 insertions(+), 64 deletions(-) diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 42140e9dc90..e22992c6413 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -1,14 +1,7 @@ import {applicationId} from './session/identity.js' import {validateSession} from './session/validate.js' import {allDefaultScopes, apiScopes} from './session/scopes.js' -import { - exchangeAccessForApplicationTokens, - exchangeCustomPartnerToken, - ExchangeScopes, - InvalidGrantError, - InvalidRequestError, - refreshAccessToken, -} from './session/exchange.js' +import {exchangeCustomPartnerToken, ExchangeScopes, InvalidGrantError, InvalidRequestError} from './session/exchange.js' import {IdentityToken, Session, Sessions} from './session/schema.js' import * as sessionStore from './session/store.js' import {isThemeAccessSession} from './api/rest.js' @@ -209,7 +202,7 @@ ${outputToken.json(applications)} let newSession = {} - debugger + // debugger if (validationResult === 'needs_full_auth') { await throwOnNoPrompt(noPrompt) @@ -326,11 +319,12 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise { + const client = getIdentityClient() // Refresh Identity Token - const identityToken = await refreshAccessToken(session.identity) + const identityToken = await client.refreshAccessToken(session.identity) // Exchange new identity token for application tokens const exchangeScopes = getExchangeScopes(applications) - const applicationTokens = await exchangeAccessForApplicationTokens( + const applicationTokens = await client.exchangeAccessForApplicationTokens( identityToken, exchangeScopes, applications.adminApi?.storeFqdn, diff --git a/packages/cli-kit/src/private/node/session/exchange.ts b/packages/cli-kit/src/private/node/session/exchange.ts index 09159b7a6f6..ea519d3ec8f 100644 --- a/packages/cli-kit/src/private/node/session/exchange.ts +++ b/packages/cli-kit/src/private/node/session/exchange.ts @@ -196,7 +196,7 @@ interface TokenRequestResult { id_token?: string } -function tokenRequestErrorHandler({error, store}: {error: string; store?: string}) { +export function tokenRequestErrorHandler({error, store}: {error: string; store?: string}) { const invalidTargetErrorMessage = `You are not authorized to use the CLI to develop in the provided store${ store ? `: ${store}` : '.' }` @@ -222,7 +222,7 @@ function tokenRequestErrorHandler({error, store}: {error: string; store?: string return new AbortError(error) } -async function tokenRequest(params: { +export async function tokenRequest(params: { [key: string]: string }): Promise> { const fqdn = await identityFqdn() @@ -238,7 +238,7 @@ async function tokenRequest(params: { return err({error: payload.error, store: params.store}) } -function buildIdentityToken( +export function buildIdentityToken( result: TokenRequestResult, existingUserId?: string, existingAlias?: string, diff --git a/packages/cli-kit/src/public/node/api/identity-client.ts b/packages/cli-kit/src/public/node/api/identity-client.ts index 168bf95b145..505db251d52 100644 --- a/packages/cli-kit/src/public/node/api/identity-client.ts +++ b/packages/cli-kit/src/public/node/api/identity-client.ts @@ -4,15 +4,18 @@ /* eslint-disable jsdoc/require-description */ import {USE_LOCAL_MOCKS} from './utilities.js' -import {Environment, serviceEnvironment} from '../../../private/node/context/service.js' import { + buildIdentityToken, exchangeDeviceCodeForAccessToken, ExchangeScopes, requestAppToken, + tokenRequest, + tokenRequestErrorHandler, } from '../../../private/node/session/exchange.js' import {ApplicationToken} from '../../../private/node/session/schema.js' import {allDefaultScopes} from '../../../private/node/session/scopes.js' import {applicationId} from '../../../private/node/session/identity.js' +import {Environment, serviceEnvironment} from '../../../private/node/context/service.js' import {identityFqdn} from '@shopify/cli-kit/node/context/fqdn' import {shopifyFetch} from '@shopify/cli-kit/node/http' import {AbortError, BugError} from '@shopify/cli-kit/node/error' @@ -84,6 +87,8 @@ abstract class IdentityClient { scopes: ExchangeScopes, store?: string, ): ExchangeAccessTokenResponse + + abstract refreshAccessToken(currentToken: IdentityToken): Promise } export class ProdIdentityClient extends IdentityClient { @@ -250,53 +255,93 @@ export class ProdIdentityClient extends IdentityClient { ...appManagement, } } + + /** + * Given an expired access token, refresh it to get a new one. + * + * @param currentToken - CurrentToken. + * @returns - Identity token. + */ + async refreshAccessToken(currentToken: IdentityToken): Promise { + const identityClientId = clientId() + const params = { + grant_type: 'refresh_token', + access_token: currentToken.accessToken, + refresh_token: currentToken.refreshToken, + client_id: identityClientId, + } + const tokenResult = await tokenRequest(params) + const value = tokenResult.mapError(tokenRequestErrorHandler).valueOrBug() + return buildIdentityToken(value, currentToken.userId, currentToken.alias) + } } export class LocalIdentityClient extends IdentityClient { + private readonly mockUserId = '08978734-325e-44ce-bc65-34823a8d5180' + private readonly mockSessionId = 'df63c65c-3731-48af-a28d-72ab16a6523a' + private readonly mockDeviceUuid = '8ba644c8-7d2f-4260-9311-86df09195ee8' + async requestDeviceAuthorization(_scopes: string[]): Promise { return { - deviceCode: 'ABC', - userCode: 'ABC', - verificationUri: 'ABC', - expiresIn: 100_000, - verificationUriComplete: 'ABC', - interval: 1000, + deviceCode: 'mock-device-code', + userCode: 'ABCD-EFGH', + verificationUri: 'https://identity.shop.dev/device', + expiresIn: 600, + verificationUriComplete: 'https://identity.shop.dev/device?code=ABCD-EFGH', + interval: 5, } } pollForDeviceAuthorization(_deviceAuth: DeviceAuthorizationResponse): Promise { - return new Promise((resolve) => - resolve({ - accessToken: `${this.authTokenPrefix}CjQIqvXTyAYQyq3UyAZSJggBEhAvwPcnN1pJprxJTCXHFC67GhBkH48zkE9I0LZefw6wvXNYEkCYFhPHBUYi5LzPy8fROCNwP8pBJ-Qfeceur4ies466WSxZv0VE8jbYrzMyKF-dhnv9WDrdj6sDIuACUjdJ9fQP`, - alias: '', - expiresAt: getFutureDate(), - refreshToken: `${this.authTokenPrefix}CiEIqvXTyAYQqo_yyQaiARIKEGQfjzOQT0jQtl5_DrC9c1gSQInG2qNxycjj8UZ4uuaI-8R246Emx3clBE5AXBLrd_5WhcRny65gF6-GM-NsUud8AMvh2BYVyuG7QK7_n79B4AQ`, - scopes: allDefaultScopes(), - userId: '08978734-325e-44ce-bc65-34823a8d5180', - }), - ) + const now = getCurrentUnixTimestamp() + const exp = now + 7200 // 2 hours from now + const scopes = allDefaultScopes() + + const identityTokenPayload = { + client_id: clientId(), + token_type: 'SLAT', + exp, + iat: now, + sub: this.mockUserId, + iss: 'https://identity.shop.dev', + sid: this.mockSessionId, + auth_time: now, + amr: ['pwd', 'device-auth'], + device_uuid: this.mockDeviceUuid, + scope: scopes.join(' '), + atl: 1.0, + } + + const refreshTokenPayload = { + ...identityTokenPayload, + token_use: 'refresh', + } + + return Promise.resolve({ + accessToken: `${this.authTokenPrefix}${encodeTokenPayload(identityTokenPayload)}`, + alias: '', + // 1 day expiration for shorter testing cycles + expiresAt: getFutureDate(1), + refreshToken: `${this.authTokenPrefix}${encodeTokenPayload(refreshTokenPayload)}`, + scopes, + userId: this.mockUserId, + }) } async exchangeAccessForApplicationTokens( - _identityToken: IdentityToken, + identityToken: IdentityToken, _scopes: ExchangeScopes, _store?: string, ): ExchangeAccessTokenResponse { - const actualToken = - 'mtkn_eyJ2YWxpZCI6dHJ1ZSwic2NvcGUiOiJodHRwczpcL1wvYXBpLnNob3BpZnkuY29tXC9hdXRoXC9vcmdhbml6YXRpb24uYXBwcy5tYW5hZ2UiLCJjbGllbnRfaWQiOiJlNTM4MGUwMi0zMTJhLTc0MDgtNTcxOC1lMDcwMTdlOWNmNTIiLCJ0b2tlbl90eXBlIjoiYmVhcmVyIiwiZXhwIjoxNzYzMDc1NTM2LCJpYXQiOjE3NjMwNjgzMzYsInN1YiI6IjA4OTc4NzM0LTMyNWUtNDRjZS1iYzY1LTM0ODIzYThkNTE4MCIsImF1ZCI6ImU5MjQ4MmNlYmI5YmZiOWZiNWEwMTk5Y2M3NzBmZGUzZGU2YzhkMTZiNzk4ZWU3M2UzNmM5ZDgxNWUwNzBlNTIiLCJpc3MiOiJodHRwczpcL1wvaWRlbnRpdHkuc2hvcC5kZXYiLCJzaWQiOiJkZjYzYzY1Yy0zNzMxLTQ4YWYtYTI4ZC03MmFiMTZhNjUyM2EiLCJhY3QiOnsic3ViIjoiZTUzODBlMDItMzEyYS03NDA4LTU3MTgtZTA3MDE3ZTljZjUyIiwiaXNzIjoiaHR0cHM6XC9cL2lkZW50aXR5LnNob3AuZGV2In0sImF1dGhfdGltZSI6MTc2MzA2ODMzMSwiYW1yIjpbInB3ZCIsImRldmljZS1hdXRoIl0sImRldmljZV91dWlkIjoiOGJhNjQ0YzgtN2QyZi00MjYwLTkzMTEtODZkZjA5MTk1ZWU4IiwiYXRsIjoxLjB9' - const fullScopeExchangeMockResult = { - accessToken: actualToken, - // accessToken: `${this.authTokenPrefix}CpUCCKv108gGEMut1MgGYoYCCAESEJ_CKyOGg00Jl0q4jUVIl2IaNWh0dHBzOi8vYXBpLnNob3BpZnkuY29tL2F1dGgvb3JnYW5pemF0aW9uLmFwcHMubWFuYWdlIAwoIDokMDg5Nzg3MzQtMzI1ZS00NGNlLWJjNjUtMzQ4MjNhOGQ1MTgwQgdBY2NvdW50ShAcrqYpOnFMYJbvMxKxqLvWUlB7InN1YiI6ImU1MzgwZTAyLTMxMmEtNzQwOC01NzE4LWUwNzAxN2U5Y2Y1MiIsImlzcyI6Imh0dHBzOi8vaWRlbnRpdHkuc2hvcC5kZXYifWIQZB-PM5BPSNC2Xn8OsL1zWGoQL8D3JzdaSaa8SUwlxxQuuxJAdBzL7VX5mXwP-8A5UZPUX-vvOa-CxX4fGZp50piE2bJDNSlGZa0SsKQRTHZqyu2plR4cUsglTLy1CxEYaIKbBQ`, - expiresAt: getFutureDate(), - scopes: allDefaultScopes(), - } + const now = getCurrentUnixTimestamp() + // 2 hours from now + const exp = now + 7200 - // Generate fresh timestamps - const now = Math.floor(Date.now() / 1000) - const exp = now + 7200 // 2 hours from now + outputDebug(`[LocalIdentityClient] Generating application tokens at ${new Date(now * 1000).toISOString()}`) + outputDebug(`[LocalIdentityClient] Token expiration: ${new Date(exp * 1000).toISOString()}`) - const generateAppToken = (appId: string, scopeArray: string[]) => { - const tokenInfo = { + const generateAppToken = (appId: string, scopeArray: string[]): ApplicationToken => { + const tokenPayload = { act: { iss: 'https://identity.shop.dev', sub: clientId(), @@ -307,19 +352,17 @@ export class LocalIdentityClient extends IdentityClient { iat: now, iss: 'https://identity.shop.dev', scope: scopeArray.join(' '), - token_type: 'bearer', - sub: '', // what is identityToken?? identityToken.userId, - sid: '', // identityToken.userId, // Or use a session ID + token_type: 'SLAT', // not sure it should be this type + sub: identityToken.userId, + sid: this.mockSessionId, + auth_time: now, + amr: ['pwd', 'device-auth'], + device_uuid: this.mockDeviceUuid, + atl: 1.0, } - const encoded = Buffer.from(JSON.stringify(tokenInfo)) - .toString('base64') - .replace(/[=]/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_') - return { - accessToken: `mtkn_${encoded}`, + accessToken: `${this.authTokenPrefix}${encodeTokenPayload(tokenPayload)}`, expiresAt: new Date(exp * 1000), scopes: scopeArray, } @@ -335,14 +378,44 @@ export class LocalIdentityClient extends IdentityClient { allDefaultScopes(), ), } + } - return { - [applicationId('app-management')]: fullScopeExchangeMockResult, - [applicationId('business-platform')]: fullScopeExchangeMockResult, - [applicationId('admin')]: fullScopeExchangeMockResult, - [applicationId('partners')]: fullScopeExchangeMockResult, - [applicationId('storefront-renderer')]: fullScopeExchangeMockResult, + async refreshAccessToken(currentToken: IdentityToken): Promise { + const now = getCurrentUnixTimestamp() + // 2 hours from now + const exp = now + 7200 + + outputDebug(`[LocalIdentityClient] Refreshing identity token at ${new Date(now * 1000).toISOString()}`) + outputDebug(`[LocalIdentityClient] Previous token userId: ${currentToken.userId}`) + + const identityTokenPayload = { + client_id: clientId(), + token_type: 'SLAT', + exp, + iat: now, + sub: currentToken.userId, + iss: 'https://identity.shop.dev', + sid: this.mockSessionId, + auth_time: now, + amr: ['pwd', 'device-auth'], + device_uuid: this.mockDeviceUuid, + scope: currentToken.scopes.join(' '), + atl: 1.0, } + + const refreshTokenPayload = { + ...identityTokenPayload, + token_use: 'refresh', + } + + return Promise.resolve({ + accessToken: `${this.authTokenPrefix}${encodeTokenPayload(identityTokenPayload)}`, + alias: currentToken.alias, + expiresAt: getFutureDate(1), + refreshToken: `${this.authTokenPrefix}${encodeTokenPayload(refreshTokenPayload)}`, + scopes: currentToken.scopes, + userId: currentToken.userId, + }) } } @@ -384,12 +457,24 @@ function buildAuthorizationParseErrorMessage(response: Response, responseText: s return `${errorMessage} If this issue persists, please contact support at https://help.shopify.com` } -function getFutureDate() { +function getFutureDate(daysInFuture = 100): Date { const futureDate = new Date() - futureDate.setDate(futureDate.getDate() + 100) + futureDate.setDate(futureDate.getDate() + daysInFuture) return futureDate } +function getCurrentUnixTimestamp(): number { + return Math.floor(Date.now() / 1000) +} + +function encodeTokenPayload(payload: object): string { + return Buffer.from(JSON.stringify(payload)) + .toString('base64') + .replace(/[=]/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_') +} + const ProdIC = new ProdIdentityClient() const LocalIC = new LocalIdentityClient() diff --git a/packages/cli-kit/src/public/node/api/utilities.ts b/packages/cli-kit/src/public/node/api/utilities.ts index 80770163c8c..8e32830d5fd 100644 --- a/packages/cli-kit/src/public/node/api/utilities.ts +++ b/packages/cli-kit/src/public/node/api/utilities.ts @@ -25,6 +25,6 @@ export const addCursorAndFiltersToAppLogsUrl = ( return url.toString() } -const FORCE_USE_RUNNING_EXTERNAL_SERVICES = true +const FORCE_USE_RUNNING_EXTERNAL_SERVICES = false const env = serviceEnvironment() export const USE_LOCAL_MOCKS = !FORCE_USE_RUNNING_EXTERNAL_SERVICES && env === 'local' From 476a6f4ad2a14059e628d9d6f1ef4c5c0ed0666a Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Mon, 17 Nov 2025 17:59:40 -0500 Subject: [PATCH 09/11] use is running util, not hardcoded flag for determining running services --- .../app-management-client.ts | 38 +++++++++++-------- .../src/public/node/api/business-platform.ts | 6 +-- .../src/public/node/api/identity-client.ts | 8 ++-- .../cli-kit/src/public/node/api/utilities.ts | 6 --- .../node/vendor/dev_server/dev-server-2024.ts | 9 +++++ .../node/vendor/dev_server/network/index.ts | 5 --- 6 files changed, 38 insertions(+), 34 deletions(-) diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 681ac4a8bd2..f57b8286eee 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -136,6 +136,7 @@ import { AppLogsSubscribeMutationVariables, } from '../../api/graphql/app-management/generated/app-logs-subscribe.js' import {SourceExtension} from '../../api/graphql/app-management/generated/types.js' +import {UserInfo} from '../../api/graphql/business-platform-destinations/generated/user-info.js' import {getPartnersToken} from '@shopify/cli-kit/node/environment' import {ensureAuthenticatedAppManagementAndBusinessPlatform, Session} from '@shopify/cli-kit/node/session' import {isUnitTest} from '@shopify/cli-kit/node/context/local' @@ -167,6 +168,7 @@ import {isPreReleaseVersion} from '@shopify/cli-kit/node/version' import {UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' import {Variables} from 'graphql-request' import {webhooksRequestDoc, WebhooksRequestOptions} from '@shopify/cli-kit/node/api/webhooks' +import {isRunning2024} from '@shopify/cli-kit/node/vendor/dev_server/dev-server-2024' const TEMPLATE_JSON_URL = 'https://cdn.shopify.com/static/cli/extensions/templates.json' @@ -277,23 +279,27 @@ export class AppManagementClient implements DeveloperPlatformClient { const {appManagementToken, businessPlatformToken, userId} = tokenResult // This one can't use the shared businessPlatformRequest because the token is not globally available yet. - // const userInfoResult = await businessPlatformRequestDoc({ - // query: UserInfo, - // cacheOptions: { - // cacheTTL: {hours: 6}, - // cacheExtraKey: userId, - // }, - // token: businessPlatformToken, - // unauthorizedHandler: this.createUnauthorizedHandler('businessPlatform'), - // }) - const userInfoResult = { - currentUserAccount: { - uuid: '08978734-325e-44ce-bc65-34823a8d5180', - email: 'dev@shopify.com', - organizations: { - nodes: [{name: 'My Test Org FROM CLI MOCK'}], + let userInfoResult + if (isRunning2024('business-platform')) { + userInfoResult = await businessPlatformRequestDoc({ + query: UserInfo, + cacheOptions: { + cacheTTL: {hours: 6}, + cacheExtraKey: userId, }, - }, + token: businessPlatformToken, + unauthorizedHandler: this.createUnauthorizedHandler('businessPlatform'), + }) + } else { + userInfoResult = { + currentUserAccount: { + uuid: '08978734-325e-44ce-bc65-34823a8d5180', + email: 'dev@shopify.com', + organizations: { + nodes: [{name: 'Test Business One'}], + }, + }, + } } if (getPartnersToken() && userInfoResult.currentUserAccount) { diff --git a/packages/cli-kit/src/public/node/api/business-platform.ts b/packages/cli-kit/src/public/node/api/business-platform.ts index 695903536d1..5e47805a45d 100644 --- a/packages/cli-kit/src/public/node/api/business-platform.ts +++ b/packages/cli-kit/src/public/node/api/business-platform.ts @@ -1,12 +1,12 @@ import {CacheOptions, GraphQLVariables, UnauthorizedHandler, graphqlRequest, graphqlRequestDoc} from './graphql.js' import {handleDeprecations} from './partners.js' -import {USE_LOCAL_MOCKS} from './utilities.js' import {businessPlatformFqdn} from '../context/fqdn.js' import {outputContent, outputDebug} from '../output.js' import { UserEmailQuery, UserEmailQueryString, } from '../../../private/node/api/graphql/business-platform-destinations/user-email.js' +import {isRunning2024} from '../vendor/dev_server/dev-server-2024.js' import {TypedDocumentNode} from '@graphql-typed-document-node/core' import {Variables} from 'graphql-request' @@ -17,7 +17,7 @@ import {Variables} from 'graphql-request' * @returns The user's email address or undefined if not found. */ async function fetchEmail(businessPlatformToken: string | undefined): Promise { - if (USE_LOCAL_MOCKS) { + if (!isRunning2024('identity')) { return LOCAL_OVERRIDES.fetchEmail() } if (!businessPlatformToken) return undefined @@ -106,7 +106,7 @@ async function businessPlatformRequestDoc /** * Sets up the request to the Business - * Platform Organizations API. + * Platform Organizations API. * * @param token - Business Platform token. * @param organizationId - Organization ID as a numeric (non-GID) value. diff --git a/packages/cli-kit/src/public/node/api/identity-client.ts b/packages/cli-kit/src/public/node/api/identity-client.ts index 505db251d52..86994578bbd 100644 --- a/packages/cli-kit/src/public/node/api/identity-client.ts +++ b/packages/cli-kit/src/public/node/api/identity-client.ts @@ -3,7 +3,6 @@ /* eslint-disable @nx/enforce-module-boundaries */ /* eslint-disable jsdoc/require-description */ -import {USE_LOCAL_MOCKS} from './utilities.js' import { buildIdentityToken, exchangeDeviceCodeForAccessToken, @@ -16,6 +15,7 @@ import {ApplicationToken} from '../../../private/node/session/schema.js' import {allDefaultScopes} from '../../../private/node/session/scopes.js' import {applicationId} from '../../../private/node/session/identity.js' import {Environment, serviceEnvironment} from '../../../private/node/context/service.js' +import {isRunning2024} from '../vendor/dev_server/dev-server-2024.js' import {identityFqdn} from '@shopify/cli-kit/node/context/fqdn' import {shopifyFetch} from '@shopify/cli-kit/node/http' import {AbortError, BugError} from '@shopify/cli-kit/node/error' @@ -62,11 +62,11 @@ type ExchangeAccessTokenResponse = Promise<{[x: string]: ApplicationToken}> export function clientId(): string { const environment = serviceEnvironment() if (environment === Environment.Local) { - return 'e5380e02-312a-7408-5718-e07017e9cf52' + return isRunning2024('identity') ? 'e5380e02-312a-7408-5718-e07017e9cf52' : 'shopify-cli-development' } else if (environment === Environment.Production) { return 'fbdb2649-e327-4907-8f67-908d24cfd7e3' } else { - return 'e5380e02-312a-7408-5718-e07017e9cf52' + return isRunning2024('identity') ? 'e5380e02-312a-7408-5718-e07017e9cf52' : 'shopify-cli-development' } } @@ -479,6 +479,6 @@ const ProdIC = new ProdIdentityClient() const LocalIC = new LocalIdentityClient() export function getIdentityClient(): IdentityClient { - const client = USE_LOCAL_MOCKS ? LocalIC : ProdIC + const client = isRunning2024('identity') ? ProdIC : LocalIC return client } diff --git a/packages/cli-kit/src/public/node/api/utilities.ts b/packages/cli-kit/src/public/node/api/utilities.ts index 8e32830d5fd..27f32c2430f 100644 --- a/packages/cli-kit/src/public/node/api/utilities.ts +++ b/packages/cli-kit/src/public/node/api/utilities.ts @@ -1,5 +1,3 @@ -import {serviceEnvironment} from '../../../private/node/context/service.js' - export const addCursorAndFiltersToAppLogsUrl = ( baseUrl: string, cursor?: string, @@ -24,7 +22,3 @@ export const addCursorAndFiltersToAppLogsUrl = ( return url.toString() } - -const FORCE_USE_RUNNING_EXTERNAL_SERVICES = false -const env = serviceEnvironment() -export const USE_LOCAL_MOCKS = !FORCE_USE_RUNNING_EXTERNAL_SERVICES && env === 'local' diff --git a/packages/cli-kit/src/public/node/vendor/dev_server/dev-server-2024.ts b/packages/cli-kit/src/public/node/vendor/dev_server/dev-server-2024.ts index 2882024d84b..79ce264c54f 100644 --- a/packages/cli-kit/src/public/node/vendor/dev_server/dev-server-2024.ts +++ b/packages/cli-kit/src/public/node/vendor/dev_server/dev-server-2024.ts @@ -46,6 +46,15 @@ function assertRunning2024(projectName: string): void { }) } +export function isRunning2024(projectName: string) { + try { + assertRunning2024(projectName) + return true + } catch (_) { + return false + } +} + function getBackendIp(projectName: string): string { try { const backendIp = resolveBackendHost(projectName) diff --git a/packages/cli-kit/src/public/node/vendor/dev_server/network/index.ts b/packages/cli-kit/src/public/node/vendor/dev_server/network/index.ts index 6bfcf79ea0e..e40a5135159 100644 --- a/packages/cli-kit/src/public/node/vendor/dev_server/network/index.ts +++ b/packages/cli-kit/src/public/node/vendor/dev_server/network/index.ts @@ -33,11 +33,6 @@ export function assertConnectable(options: ConnectionArguments): void { } } -// eslint-disable-next-line @typescript-eslint/naming-convention -export function TEST_testResetCheckPort(): void { - checkPort = getCheckPortHelper() -} - function getCheckPortHelper(): (addr: string, port: number, timeout: number) => boolean { return fallbackCheckPort } From b4ece019fdb88a32baa7d42a7ceb80a406ac8655 Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Mon, 17 Nov 2025 18:15:54 -0500 Subject: [PATCH 10/11] clean up client --- .../cli-kit/src/public/node/api/identity-client.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/cli-kit/src/public/node/api/identity-client.ts b/packages/cli-kit/src/public/node/api/identity-client.ts index 86994578bbd..297b5ae75ef 100644 --- a/packages/cli-kit/src/public/node/api/identity-client.ts +++ b/packages/cli-kit/src/public/node/api/identity-client.ts @@ -74,9 +74,6 @@ abstract class IdentityClient { authTokenPrefix: string constructor() { - // atkn_ - // atkn_mock_token_ - // mtkn_ this.authTokenPrefix = 'mtkn_' } @@ -235,9 +232,6 @@ export class ProdIdentityClient extends IdentityClient { store?: string, ): ExchangeAccessTokenResponse { const token = identityToken.accessToken - // 'MOCK_COMMENTED_TOKEN_PLACEHOLDER' - // scopes ex. 'https://api.shopify.com/auth/organization.apps.manage' - // debugger const [partners, storefront, businessPlatform, admin, appManagement] = await Promise.all([ requestAppToken('partners', token, scopes.partners), @@ -294,7 +288,7 @@ export class LocalIdentityClient extends IdentityClient { pollForDeviceAuthorization(_deviceAuth: DeviceAuthorizationResponse): Promise { const now = getCurrentUnixTimestamp() - const exp = now + 7200 // 2 hours from now + const exp = now + 7200 const scopes = allDefaultScopes() const identityTokenPayload = { @@ -320,7 +314,6 @@ export class LocalIdentityClient extends IdentityClient { return Promise.resolve({ accessToken: `${this.authTokenPrefix}${encodeTokenPayload(identityTokenPayload)}`, alias: '', - // 1 day expiration for shorter testing cycles expiresAt: getFutureDate(1), refreshToken: `${this.authTokenPrefix}${encodeTokenPayload(refreshTokenPayload)}`, scopes, @@ -334,7 +327,6 @@ export class LocalIdentityClient extends IdentityClient { _store?: string, ): ExchangeAccessTokenResponse { const now = getCurrentUnixTimestamp() - // 2 hours from now const exp = now + 7200 outputDebug(`[LocalIdentityClient] Generating application tokens at ${new Date(now * 1000).toISOString()}`) @@ -352,7 +344,7 @@ export class LocalIdentityClient extends IdentityClient { iat: now, iss: 'https://identity.shop.dev', scope: scopeArray.join(' '), - token_type: 'SLAT', // not sure it should be this type + token_type: 'SLAT', sub: identityToken.userId, sid: this.mockSessionId, auth_time: now, From 0423cd2d70f43a876a4cc7768b63ae9e5c890001 Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Tue, 18 Nov 2025 10:16:28 -0500 Subject: [PATCH 11/11] remove doc + fix non-async method --- identity_authentication_flow_analysis.md | 445 ------------------- packages/cli-kit/src/private/node/session.ts | 4 +- 2 files changed, 2 insertions(+), 447 deletions(-) delete mode 100644 identity_authentication_flow_analysis.md diff --git a/identity_authentication_flow_analysis.md b/identity_authentication_flow_analysis.md deleted file mode 100644 index d89c50e09a3..00000000000 --- a/identity_authentication_flow_analysis.md +++ /dev/null @@ -1,445 +0,0 @@ -# Shopify CLI Identity Authentication Flow Analysis - -## Overview - -This document traces the complete authentication flow in the Shopify CLI from entry point to completion, focusing specifically on interactions with the Identity service. Two scenarios are analyzed: first-time authentication (after `shopify auth logout`) and subsequent commands with cached sessions. - -## Scenario 1: First-Time Authentication Flow (After Logout) - -### Initial State -After running `shopify auth logout`: -- **File**: `packages/cli-kit/src/public/node/session.ts:281-283` -- **Action**: `logout()` calls `sessionStore.remove()` -- **File**: `packages/cli-kit/src/private/node/session/store.ts:39-42` -- **Result**: All session data and current session ID are cleared from local storage - -### Step-by-Step Flow for Any Command Requiring Authentication - -#### 1. Command Entry Point -**Example**: Any CLI command that needs Partners API access (e.g., `shopify app generate`) - -#### 2. Domain-Specific Authentication Request -**File**: `packages/cli-kit/src/public/node/session.ts:104-122` -**Function**: `ensureAuthenticatedPartners(scopes, env, options)` - -```typescript -export async function ensureAuthenticatedPartners( - scopes: PartnersAPIScope[] = [], - env = process.env, - options: EnsureAuthenticatedAdditionalOptions = {}, -): Promise<{token: string; userId: string}> -``` - -**Actions**: -1. Check for environment token (`getPartnersToken()`) - returns `undefined` after logout -2. Call `ensureAuthenticated({partnersApi: {scopes}}, env, options)` - -#### 3. Core Authentication Orchestration -**File**: `packages/cli-kit/src/private/node/session.ts:195-277` -**Function**: `ensureAuthenticated(applications, _env, options)` - -**Step 3.1**: Identity Service Discovery -```typescript -const fqdn = await identityFqdn() // e.g., "accounts.shopify.com" -``` - -**Step 3.2**: Session State Check -```typescript -const sessions = (await sessionStore.fetch()) ?? {} -let currentSessionId = getCurrentSessionId() -// Both return empty/undefined after logout -``` - -**Step 3.3**: Session Validation -```typescript -const validationResult = await validateSession(scopes, applications, currentSession) -// Returns 'needs_full_auth' since currentSession is undefined -``` - -**Step 3.4**: Full Authentication Flow Trigger -```typescript -if (validationResult === 'needs_full_auth') { - await throwOnNoPrompt(noPrompt) - outputDebug(outputContent`Initiating the full authentication flow...`) - newSession = await executeCompleteFlow(applications) -} -``` - -#### 4. Complete Authentication Flow Execution -**File**: `packages/cli-kit/src/private/node/session.ts:295-339` -**Function**: `executeCompleteFlow(applications)` - -**Step 4.1**: Scope Preparation -```typescript -const scopes = getFlattenScopes(applications) // ['openid', 'https://api.shopify.com/auth/partners.app.cli.access'] -const exchangeScopes = getExchangeScopes(applications) -const store = applications.adminApi?.storeFqdn -``` - -**Step 4.2**: Device Authorization Request -```typescript -const deviceAuth = await requestDeviceAuthorization(scopes) -``` - -#### 5. Device Authorization Flow (Identity Service Interaction #1) -**File**: `packages/cli-kit/src/private/node/session/device-authorization.ts:32-108` -**Function**: `requestDeviceAuthorization(scopes)` - -**Step 5.1**: Identity Service Endpoint Preparation -```typescript -const fqdn = await identityFqdn() // "accounts.shopify.com" -const identityClientId = clientId() // Environment-specific client ID -const queryParams = {client_id: identityClientId, scope: scopes.join(' ')} -const url = `https://${fqdn}/oauth/device_authorization` -``` - -**Step 5.2**: HTTP Request to Identity Service -```typescript -const response = await shopifyFetch(url, { - method: 'POST', - headers: {'Content-type': 'application/x-www-form-urlencoded'}, - body: convertRequestToParams(queryParams), -}) -``` - -**Identity Service Call**: `POST https://accounts.shopify.com/oauth/device_authorization` -**Payload**: `client_id=&scope=openid https://api.shopify.com/auth/partners.app.cli.access` - -**Step 5.3**: Response Processing -```typescript -let responseText = await response.text() -let jsonResult = JSON.parse(responseText) -``` - -**Step 5.4**: User Interaction Setup -```typescript -outputInfo('\nTo run this command, log in to Shopify.') -outputInfo(outputContent`User verification code: ${jsonResult.user_code}`) -// Opens browser or shows URL for user to authorize -``` - -**Returns**: `DeviceAuthorizationResponse` with `deviceCode`, `userCode`, `verificationUri`, etc. - -#### 6. Device Authorization Polling (Identity Service Interaction #2) -**File**: `packages/cli-kit/src/private/node/session/device-authorization.ts:121-159` -**Function**: `pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval)` - -**Step 6.1**: Polling Loop Setup -```typescript -let currentIntervalInSeconds = interval // Default 5 seconds -return new Promise((resolve, reject) => { - const onPoll = async () => { - const result = await exchangeDeviceCodeForAccessToken(code) - // Handle response states: 'authorization_pending', 'slow_down', success, errors - } -}) -``` - -**Step 6.2**: Token Exchange Request (per poll) -**File**: `packages/cli-kit/src/private/node/session/exchange.ts:141-158` -**Function**: `exchangeDeviceCodeForAccessToken(deviceCode)` - -```typescript -const clientId = await getIdentityClientId() -const params = { - grant_type: 'urn:ietf:params:oauth:grant-type:device_code', - device_code: deviceCode, - client_id: clientId, -} -const tokenResult = await tokenRequest(params) -``` - -**Step 6.3**: Core Token Request (Identity Service Interaction) -**File**: `packages/cli-kit/src/private/node/session/exchange.ts:226-238` -**Function**: `tokenRequest(params)` - -```typescript -const fqdn = await identityFqdn() -const url = new URL(`https://${fqdn}/oauth/token`) -url.search = new URLSearchParams(Object.entries(params)).toString() - -const res = await shopifyFetch(url.href, {method: 'POST'}) -const payload = await res.json() -``` - -**Identity Service Call**: `POST https://accounts.shopify.com/oauth/token` -**Payload**: `grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=&client_id=` - -**Polling Behavior**: -- Initial requests return `authorization_pending` until user completes browser authorization -- Once authorized, returns `IdentityToken` with `accessToken`, `refreshToken`, `expiresAt`, `scopes`, `userId` - -#### 7. Application Token Exchange (Identity Service Interactions #3-7) -**File**: `packages/cli-kit/src/private/node/session.ts:320` -**Function**: `exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store)` - -**File**: `packages/cli-kit/src/private/node/session/exchange.ts:31-53` - -**Step 7.1**: Parallel Token Requests -```typescript -const token = identityToken.accessToken - -const [partners, storefront, businessPlatform, admin, appManagement] = await Promise.all([ - requestAppToken('partners', token, scopes.partners), - requestAppToken('storefront-renderer', token, scopes.storefront), - requestAppToken('business-platform', token, scopes.businessPlatform), - store ? requestAppToken('admin', token, scopes.admin, store) : {}, - requestAppToken('app-management', token, scopes.appManagement), -]) -``` - -**Step 7.2**: Individual Application Token Request -**File**: `packages/cli-kit/src/private/node/session/exchange.ts:160-188` -**Function**: `requestAppToken(api, token, scopes, store)` - -```typescript -const appId = applicationId(api) -const clientId = await getIdentityClientId() - -const params = { - grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', - requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', - subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', - client_id: clientId, - audience: appId, - scope: scopes.join(' '), - subject_token: token, - ...(api === 'admin' && {destination: `https://${store}/admin`, store}), -} - -const tokenResult = await tokenRequest(params) -``` - -**Identity Service Calls** (one per API): -- `POST https://accounts.shopify.com/oauth/token` (Partners API) -- `POST https://accounts.shopify.com/oauth/token` (Storefront API) -- `POST https://accounts.shopify.com/oauth/token` (Business Platform API) -- `POST https://accounts.shopify.com/oauth/token` (App Management API) -- `POST https://accounts.shopify.com/oauth/token` (Admin API - if store specified) - -**Example Partners API Payload**: -``` -grant_type=urn:ietf:params:oauth:grant-type:token-exchange -&requested_token_type=urn:ietf:params:oauth:token-type:access_token -&subject_token_type=urn:ietf:params:oauth:token-type:access_token -&client_id= -&audience= -&scope=https://api.shopify.com/auth/partners.app.cli.access -&subject_token= -``` - -#### 8. Session Creation and Storage -**File**: `packages/cli-kit/src/private/node/session.ts:322-338` - -**Step 8.1**: Email Fetching (if Business Platform token available) -```typescript -const businessPlatformToken = result[applicationId('business-platform')]?.accessToken -const alias = (await fetchEmail(businessPlatformToken)) ?? identityToken.userId -``` - -**Step 8.2**: Session Object Creation -```typescript -const session: Session = { - identity: { - ...identityToken, - alias, - }, - applications: result, -} -``` - -**Step 8.3**: Session Persistence -**File**: `packages/cli-kit/src/private/node/session.ts:255-264` -```typescript -const completeSession = {...currentSession, ...newSession} as Session -const newSessionId = completeSession.identity.userId -const updatedSessions: Sessions = { - ...sessions, - [fqdn]: {...sessions[fqdn], [newSessionId]: completeSession}, -} - -await sessionStore.store(updatedSessions) -setCurrentSessionId(newSessionId) -``` - -#### 9. Token Return to Command -**File**: `packages/cli-kit/src/private/node/session.ts:265-276` -**Function**: `tokensFor(applications, completeSession)` - -**Returns**: `OAuthSession` object with domain-specific tokens: -```typescript -{ - admin?: AdminSession, - partners?: string, - storefront?: string, - businessPlatform?: string, - appManagement?: string, - userId: string -} -``` - -### Summary of Identity Service Interactions (First-Time Auth) -1. **Device Authorization Request**: `POST /oauth/device_authorization` -2. **Device Code Polling**: Multiple `POST /oauth/token` (device grant type) until authorized -3. **Partners Token Exchange**: `POST /oauth/token` (token exchange grant type) -4. **Storefront Token Exchange**: `POST /oauth/token` (token exchange grant type) -5. **Business Platform Token Exchange**: `POST /oauth/token` (token exchange grant type) -6. **App Management Token Exchange**: `POST /oauth/token` (token exchange grant type) -7. **Admin Token Exchange**: `POST /oauth/token` (token exchange grant type) - if store specified - -**Total Identity Service Calls**: 6-7 HTTP requests - ---- - -## Scenario 2: Subsequent Command with Cached Session - -### Initial State -- Valid session exists in local storage -- Current session ID is set -- All application tokens are within expiry threshold - -### Step-by-Step Flow - -#### 1. Command Entry Point -**Example**: Same command (`shopify app generate`) executed again - -#### 2. Domain-Specific Authentication Request -**File**: `packages/cli-kit/src/public/node/session.ts:104-122` -**Function**: `ensureAuthenticatedPartners(scopes, env, options)` - -Same entry point as Scenario 1. - -#### 3. Core Authentication Orchestration -**File**: `packages/cli-kit/src/private/node/session.ts:195-277` -**Function**: `ensureAuthenticated(applications, _env, options)` - -**Step 3.1**: Identity Service Discovery -```typescript -const fqdn = await identityFqdn() // "accounts.shopify.com" -``` - -**Step 3.2**: Session State Recovery -```typescript -const sessions = (await sessionStore.fetch()) ?? {} -let currentSessionId = getCurrentSessionId() // Returns cached user ID -const currentSession = sessions[fqdn]?.[currentSessionId] // Valid session object -``` - -**Step 3.3**: Session Validation -**File**: `packages/cli-kit/src/private/node/session/validate.ts:27-71` -**Function**: `validateSession(scopes, applications, currentSession)` - -```typescript -const scopesAreValid = validateScopes(scopes, session.identity) -let tokensAreExpired = isTokenExpired(session.identity) - -// Check each required application token -if (applications.partnersApi) { - const appId = applicationId('partners') - const token = session.applications[appId] - tokensAreExpired = tokensAreExpired || isTokenExpired(token) -} - -// Returns 'ok' if all tokens valid and not expired -// Returns 'needs_refresh' if expired but structure valid -// Returns 'needs_full_auth' if structure invalid -``` - -**Step 3.4**: Validation Result Handling -```typescript -// If validationResult === 'ok' -// Skip to Step 6 (Token Return) -``` - -#### 4. Token Refresh Flow (if tokens expired) -**If**: `validationResult === 'needs_refresh'` - -**File**: `packages/cli-kit/src/private/node/session.ts:235-250` -**Function**: `refreshTokens(currentSession, applications)` - -**Step 4.1**: Identity Token Refresh (Identity Service Interaction #1) -**File**: `packages/cli-kit/src/private/node/session/exchange.ts:57-68` -**Function**: `refreshAccessToken(currentToken)` - -```typescript -const clientId = getIdentityClientId() -const params = { - grant_type: 'refresh_token', - access_token: currentToken.accessToken, - refresh_token: currentToken.refreshToken, - client_id: clientId, -} -const tokenResult = await tokenRequest(params) -``` - -**Identity Service Call**: `POST https://accounts.shopify.com/oauth/token` -**Payload**: `grant_type=refresh_token&access_token=&refresh_token=&client_id=` - -**Step 4.2**: Application Token Re-exchange (Identity Service Interactions #2-6) -```typescript -const exchangeScopes = getExchangeScopes(applications) -const applicationTokens = await exchangeAccessForApplicationTokens( - identityToken, - exchangeScopes, - applications.adminApi?.storeFqdn, -) -``` - -Same parallel token exchange as Scenario 1, Step 7. - -#### 5. Session Update and Storage -```typescript -const updatedSession = { - identity: identityToken, - applications: applicationTokens, -} -await sessionStore.store(updatedSessions) -``` - -#### 6. Token Return to Command -**File**: `packages/cli-kit/src/private/node/session.ts:265-276` -**Function**: `tokensFor(applications, completeSession)` - -Returns cached or refreshed tokens to the calling command. - -### Summary of Identity Service Interactions (Cached Session) - -**If tokens are valid**: **0 HTTP requests** - all tokens returned from cache - -**If tokens need refresh**: **5-6 HTTP requests** -1. **Identity Token Refresh**: `POST /oauth/token` (refresh grant type) -2. **Partners Token Exchange**: `POST /oauth/token` (token exchange grant type) -3. **Storefront Token Exchange**: `POST /oauth/token` (token exchange grant type) -4. **Business Platform Token Exchange**: `POST /oauth/token` (token exchange grant type) -5. **App Management Token Exchange**: `POST /oauth/token` (token exchange grant type) -6. **Admin Token Exchange**: `POST /oauth/token` (token exchange grant type) - if store specified - ---- - -## Identity Service Interaction Patterns - -### HTTP Endpoints Used -All requests go to: `https://{identityFqdn}/oauth/token` and `https://{identityFqdn}/oauth/device_authorization` - -Where `identityFqdn` is typically `"accounts.shopify.com"` - -### Grant Types Used -1. **Device Authorization**: `POST /oauth/device_authorization` -2. **Device Code Exchange**: `grant_type=urn:ietf:params:oauth:grant-type:device_code` -3. **Token Exchange**: `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` -4. **Refresh Token**: `grant_type=refresh_token` - -### Request Patterns -- All token requests use the centralized `tokenRequest()` function -- All HTTP calls use `shopifyFetch()` wrapper -- All application token exchanges happen in parallel -- Error handling uses Result pattern with typed errors - -### Key Extraction Points for Client Architecture -1. **Device Authorization**: `requestDeviceAuthorization()` and `pollForDeviceAuthorization()` functions -2. **Token Exchange**: `tokenRequest()` function (centralized HTTP interface) -3. **Session Management**: Session validation, storage, and lifecycle management -4. **Error Handling**: Token request error handling and retry logic -5. **Configuration**: Client ID and application ID management per environment - -The current architecture shows clear separation between business logic and Identity service communication, with the `tokenRequest()` function serving as the primary HTTP interface to the Identity service. This function and the device authorization functions represent the core integration points that would be abstracted by an Identity service client. \ No newline at end of file diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index e22992c6413..8fbc4034544 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -238,7 +238,7 @@ ${outputToken.json(applications)} setCurrentSessionId(newSessionId) } - const tokens = await tokensFor(applications, completeSession) + const tokens = tokensFor(applications, completeSession) // Overwrite partners token if using a custom CLI Token const envToken = getPartnersToken() @@ -343,7 +343,7 @@ async function refreshTokens(session: Session, applications: OAuthApplications): * @param session - The current session. * @param fqdn - The identity FQDN. */ -async function tokensFor(applications: OAuthApplications, session: Session): Promise { +function tokensFor(applications: OAuthApplications, session: Session): OAuthSession { const tokens: OAuthSession = { userId: session.identity.userId, }