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..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 @@ -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' @@ -137,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' @@ -168,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' @@ -278,15 +279,28 @@ 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'), - }) + 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) { const organizations = userInfoResult.currentUserAccount.organizations.nodes.map((org) => ({ diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 0f666a7411f..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"], + "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/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..8fbc4034544 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -1,20 +1,11 @@ import {applicationId} from './session/identity.js' import {validateSession} from './session/validate.js' import {allDefaultScopes, apiScopes} from './session/scopes.js' -import { - exchangeAccessForApplicationTokens, - exchangeCustomPartnerToken, - ExchangeScopes, - refreshAccessToken, - InvalidGrantError, - InvalidRequestError, -} 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 {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' 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' @@ -23,25 +14,9 @@ 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 {fetchEmail} from '../../public/node/api/business-platform.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 - } -} +import {getIdentityClient} from '../../public/node/api/identity-client.js' /** * A scope supported by the Shopify Admin API. @@ -227,12 +202,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) @@ -262,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() @@ -301,6 +277,7 @@ 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, @@ -365,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, } 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 af0ca3979c7..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,11 +1,7 @@ -import { - DeviceAuthorizationResponse, - pollForDeviceAuthorization, - requestDeviceAuthorization, -} from './device-authorization.js' -import {clientId} from './identity.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' @@ -20,6 +16,7 @@ vi.mock('../../../public/node/http.js') vi.mock('../../../public/node/ui.js') vi.mock('./exchange.js') vi.mock('../../../public/node/system.js') +vi.mock('../../../public/node/api/identity-client.js') beforeEach(() => { vi.mocked(isTTY).mockReturnValue(true) @@ -53,7 +50,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 +71,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 +86,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 +102,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 +117,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 +134,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 deleted file mode 100644 index 711269ec46e..00000000000 --- a/packages/cli-kit/src/private/node/session/device-authorization.ts +++ /dev/null @@ -1,197 +0,0 @@ -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 - verificationUri: string - expiresIn: number - verificationUriComplete?: string - 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('> { const fqdn = await identityFqdn() @@ -237,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/private/node/session/identity.ts b/packages/cli-kit/src/private/node/session/identity.ts index a155b8ff40a..cf9340fceac 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': { @@ -56,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 e35409f46d7..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,9 +1,37 @@ import {CacheOptions, GraphQLVariables, UnauthorizedHandler, graphqlRequest, graphqlRequestDoc} from './graphql.js' import {handleDeprecations} from './partners.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' +/** + * 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 (!isRunning2024('identity')) { + 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({ @@ -77,7 +105,8 @@ export async function businessPlatformRequestDoc. */ -export async function businessPlatformOrganizationsRequest( +async function businessPlatformOrganizationsRequest( options: BusinessPlatformOrganizationsRequestNonTypedOptions, ): Promise { return graphqlRequest({ @@ -130,7 +159,7 @@ export interface BusinessPlatformOrganizationsRequestOptions. */ -export async function businessPlatformOrganizationsRequestDoc( +async function businessPlatformOrganizationsRequestDoc( options: BusinessPlatformOrganizationsRequestOptions, ): Promise { return graphqlRequestDoc({ @@ -140,3 +169,20 @@ export async function businessPlatformOrganizationsRequestDoc { + if (typeof arg === 'string' || arg instanceof Date) return new Date(arg) + return null +}, zod.date()) + +/** + * 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 +} + +type ExchangeAccessTokenResponse = Promise<{[x: string]: ApplicationToken}> + +// +/** + * @returns Something. + */ +export function clientId(): string { + const environment = serviceEnvironment() + if (environment === Environment.Local) { + return isRunning2024('identity') ? 'e5380e02-312a-7408-5718-e07017e9cf52' : 'shopify-cli-development' + } else if (environment === Environment.Production) { + return 'fbdb2649-e327-4907-8f67-908d24cfd7e3' + } else { + return isRunning2024('identity') ? 'e5380e02-312a-7408-5718-e07017e9cf52' : 'shopify-cli-development' + } +} + +abstract class IdentityClient { + authTokenPrefix: string + + constructor() { + this.authTokenPrefix = 'mtkn_' + } + + abstract requestDeviceAuthorization(scopes: string[]): Promise + abstract pollForDeviceAuthorization(deviceAuth: DeviceAuthorizationResponse): Promise + abstract exchangeAccessForApplicationTokens( + identityToken: IdentityToken, + scopes: ExchangeScopes, + store?: string, + ): ExchangeAccessTokenResponse + + abstract refreshAccessToken(currentToken: IdentityToken): Promise +} + +export class ProdIdentityClient extends IdentityClient { + /** + * 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 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 { + ...partners, + ...storefront, + ...businessPlatform, + ...admin, + ...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: '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 { + const now = getCurrentUnixTimestamp() + const exp = now + 7200 + 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: '', + expiresAt: getFutureDate(1), + refreshToken: `${this.authTokenPrefix}${encodeTokenPayload(refreshTokenPayload)}`, + scopes, + userId: this.mockUserId, + }) + } + + async exchangeAccessForApplicationTokens( + identityToken: IdentityToken, + _scopes: ExchangeScopes, + _store?: string, + ): ExchangeAccessTokenResponse { + const now = getCurrentUnixTimestamp() + const exp = now + 7200 + + 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[]): ApplicationToken => { + const tokenPayload = { + 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: 'SLAT', + sub: identityToken.userId, + sid: this.mockSessionId, + auth_time: now, + amr: ['pwd', 'device-auth'], + device_uuid: this.mockDeviceUuid, + atl: 1.0, + } + + return { + accessToken: `${this.authTokenPrefix}${encodeTokenPayload(tokenPayload)}`, + 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(), + ), + } + } + + 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, + }) + } +} + +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 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 }