Skip to content
1 change: 0 additions & 1 deletion packages/app/src/cli/services/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ interface AppFromIdOptions {

export const appFromIdentifiers = async (options: AppFromIdOptions): Promise<OrganizationApp> => {
const allClients = allDeveloperPlatformClients()

let app: OrganizationApp | undefined
for (const client of allClients) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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'

Expand Down Expand Up @@ -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: '[email protected]',
organizations: {
nodes: [{name: 'Test Business One'}],
},
},
}
}

if (getPartnersToken() && userInfoResult.currentUserAccount) {
const organizations = userInfoResult.currentUserAccount.organizations.nodes.map((org) => ({
Expand Down
2 changes: 1 addition & 1 deletion packages/app/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 4 additions & 2 deletions packages/cli-kit/src/private/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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',
Expand Down
50 changes: 14 additions & 36 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<string | undefined> {
if (!businessPlatformToken) return undefined

try {
const userEmailResult = await businessPlatformRequest<UserEmailQuery>(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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -301,23 +277,24 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise<Ses
scopes.push('employee')
}

const client = getIdentityClient()
let identityToken: IdentityToken
const identityTokenInformation = getIdentityTokenInformation()
if (identityTokenInformation) {
identityToken = buildIdentityTokenFromEnv(scopes, identityTokenInformation)
} else {
// Request a device code to authorize without a browser redirect.
outputDebug(outputContent`Requesting device authorization code...`)
const deviceAuth = await requestDeviceAuthorization(scopes)
const deviceAuth = await client.requestDeviceAuthorization(scopes)

// Poll for the identity token
outputDebug(outputContent`Starting polling for the identity token...`)
identityToken = await pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval)
identityToken = await client.pollForDeviceAuthorization(deviceAuth)
}

// Exchange identity token for application tokens
outputDebug(outputContent`CLI token received. Exchanging it for application tokens...`)
const result = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store)
const result = await client.exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store)

// Get the alias for the session (email or userId)
const businessPlatformToken = result[applicationId('business-platform')]?.accessToken
Expand All @@ -342,11 +319,12 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise<Ses
* @param session - The session to refresh.
*/
async function refreshTokens(session: Session, applications: OAuthApplications): Promise<Session> {
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,
Expand All @@ -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<OAuthSession> {
function tokensFor(applications: OAuthApplications, session: Session): OAuthSession {
const tokens: OAuthSession = {
userId: session.identity.userId,
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
Expand Down Expand Up @@ -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', {
Expand All @@ -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',
)
})
Expand All @@ -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',
)
})
Expand All @@ -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',
)
})
Expand All @@ -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',
)
})
Expand All @@ -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.',
)
})
Expand Down
Loading
Loading