Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {ApplicationToken, IdentityToken} from '../../session/schema.js'
import {ExchangeScopes, TokenRequestResult} from '../../session/exchange.js'
import {ok, Result} from '../../../../public/node/result.js'
import {allDefaultScopes} from '../../session/scopes.js'
import {applicationId} from '../../session/identity.js'

export class IdentityMockClient extends IdentityClient {
private readonly mockUserId = '08978734-325e-44ce-bc65-34823a8d5180'
Expand All @@ -28,11 +27,11 @@ export class IdentityMockClient extends IdentityClient {
_store?: string,
): Promise<{[x: string]: ApplicationToken}> {
return {
[applicationId('app-management')]: this.generateTokens(applicationId('app-management')),
[applicationId('business-platform')]: this.generateTokens(applicationId('business-platform')),
[applicationId('admin')]: this.generateTokens(applicationId('admin')),
[applicationId('partners')]: this.generateTokens(applicationId('partners')),
[applicationId('storefront-renderer')]: this.generateTokens(applicationId('storefront-renderer')),
[this.applicationId('app-management')]: this.generateTokens(this.applicationId('app-management')),
[this.applicationId('business-platform')]: this.generateTokens(this.applicationId('business-platform')),
[this.applicationId('admin')]: this.generateTokens(this.applicationId('admin')),
[this.applicationId('partners')]: this.generateTokens(this.applicationId('partners')),
[this.applicationId('storefront-renderer')]: this.generateTokens(this.applicationId('storefront-renderer')),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export class IdentityServiceClient extends IdentityClient {
return err({error: payload.error, store: params.store})
}

/**
* Given an expired access token, refresh it to get a new one.
*/
async refreshAccessToken(currentToken: IdentityToken): Promise<IdentityToken> {
const clientId = this.clientId()
const params = {
Expand Down
13 changes: 1 addition & 12 deletions packages/cli-kit/src/private/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ import {allDefaultScopes} from './session/scopes.js'
import {store as storeSessions, fetch as fetchSessions, remove as secureRemove} from './session/store.js'
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 {getCurrentSessionId} from './conf-store.js'
import {getIdentityClient} from './clients/identity/instance.js'
import {IdentityMockClient} from './clients/identity/identity-mock-client.js'
Expand Down Expand Up @@ -129,7 +127,6 @@ beforeEach(() => {
vi.spyOn(fqdnModule, 'identityFqdn').mockResolvedValue(fqdn)
vi.mocked(exchangeAccessForApplicationTokens).mockResolvedValue(appTokens)
vi.mocked(refreshAccessToken).mockResolvedValue(validIdentityToken)
vi.mocked(applicationId).mockImplementation((app) => app)
vi.mocked(exchangeCustomPartnerToken).mockResolvedValue({
accessToken: partnersToken.accessToken,
userId: validIdentityToken.userId,
Expand All @@ -139,15 +136,6 @@ beforeEach(() => {
setLastSeenUserIdAfterAuth(undefined as any)
setLastSeenAuthMethod('none')

vi.mocked(requestDeviceAuthorization).mockResolvedValue({
deviceCode: 'device_code',
userCode: 'user_code',
verificationUri: 'verification_uri',
expiresIn: 3600,
verificationUriComplete: 'verification_uri_complete',
interval: 5,
})
vi.mocked(pollForDeviceAuthorization).mockResolvedValue(validIdentityToken)
vi.mocked(terminalSupportsPrompting).mockReturnValue(true)
vi.mocked(businessPlatformRequest).mockResolvedValue({
currentUserAccount: {
Expand All @@ -156,6 +144,7 @@ beforeEach(() => {
})

vi.mocked(getIdentityClient).mockImplementation(() => mockIdentityClient)
vi.spyOn(mockIdentityClient, 'applicationId').mockImplementation((app) => app)
vi.spyOn(mockIdentityClient, 'refreshAccessToken').mockResolvedValue(validIdentityToken)
vi.spyOn(mockIdentityClient, 'requestAccessToken').mockResolvedValue(validIdentityToken)
})
Expand Down
17 changes: 9 additions & 8 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {applicationId} from './session/identity.js'
import {validateSession} from './session/validate.js'
import {allDefaultScopes, apiScopes} from './session/scopes.js'
import {
Expand Down Expand Up @@ -299,21 +298,22 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise<Ses
outputDebug(outputContent`Authenticating as Shopify Employee...`)
scopes.push('employee')
}
const identityClient = getIdentityClient()

let identityToken: IdentityToken
const identityTokenInformation = getIdentityTokenInformation()
if (identityTokenInformation) {
identityToken = buildIdentityTokenFromEnv(scopes, identityTokenInformation)
} else {
identityToken = await getIdentityClient().requestAccessToken(scopes)
identityToken = await identityClient.requestAccessToken(scopes)
}

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

// Get the alias for the session (email or userId)
const businessPlatformToken = result[applicationId('business-platform')]?.accessToken
const businessPlatformToken = result[identityClient.applicationId('business-platform')]?.accessToken
const alias = (await fetchEmail(businessPlatformToken)) ?? identityToken.userId

const session: Session = {
Expand Down Expand Up @@ -362,9 +362,10 @@ async function tokensFor(applications: OAuthApplications, session: Session): Pro
const tokens: OAuthSession = {
userId: session.identity.userId,
}
const identityClient = getIdentityClient()

if (applications.adminApi) {
const appId = applicationId('admin')
const appId = identityClient.applicationId('admin')
const realAppId = `${applications.adminApi.storeFqdn}-${appId}`
const token = session.applications[realAppId]?.accessToken
if (token) {
Expand All @@ -373,22 +374,22 @@ async function tokensFor(applications: OAuthApplications, session: Session): Pro
}

if (applications.partnersApi) {
const appId = applicationId('partners')
const appId = identityClient.applicationId('partners')
tokens.partners = session.applications[appId]?.accessToken
}

if (applications.storefrontRendererApi) {
const appId = applicationId('storefront-renderer')
const appId = identityClient.applicationId('storefront-renderer')
tokens.storefront = session.applications[appId]?.accessToken
}

if (applications.businessPlatformApi) {
const appId = applicationId('business-platform')
const appId = identityClient.applicationId('business-platform')
tokens.businessPlatform = session.applications[appId]?.accessToken
}

if (applications.appManagementApi) {
const appId = applicationId('app-management')
const appId = identityClient.applicationId('app-management')
tokens.appManagement = session.applications[appId]?.accessToken
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,40 @@
import {
DeviceAuthorizationResponse,
pollForDeviceAuthorization,
requestDeviceAuthorization,
} from './device-authorization.js'
import {DeviceAuthorizationResponse} from './device-authorization.js'
import {IdentityToken} from './schema.js'
import {exchangeDeviceCodeForAccessToken} from './exchange.js'
import {identityFqdn} from '../../../public/node/context/fqdn.js'
import {shopifyFetch} from '../../../public/node/http.js'
import {isTTY} from '../../../public/node/ui.js'
import {isTTY, keypress} from '../../../public/node/ui.js'
import {err, ok} from '../../../public/node/result.js'
import {isCI} from '../../../public/node/system.js'
import {isCI, openURL} from '../../../public/node/system.js'
import {getIdentityClient} from '../clients/identity/instance.js'
import {IdentityServiceClient} from '../clients/identity/identity-service-client.js'
import {isCloudEnvironment} from '../../../public/node/context/local.js'
import {stringifyMessage} from '../../../public/node/output.js'
import {beforeEach, describe, expect, test, vi} from 'vitest'
import {Response} from 'node-fetch'

// use real client since we stub out network requests in this "integration" test
const mockIdentityClient = new IdentityServiceClient()

vi.mock('../../../public/node/context/fqdn.js')
vi.mock('./identity')
vi.mock('../../../public/node/http.js')
vi.mock('../../../public/node/ui.js')
vi.mock('./exchange.js')
vi.mock('../../../public/node/system.js')
vi.mock('../clients/identity/instance.js')
vi.mock('../../../public/node/output.js')
vi.mock('../../../public/node/context/local.js')

beforeEach(() => {
vi.mocked(isTTY).mockReturnValue(true)
vi.mocked(isCI).mockReturnValue(false)
vi.mocked(isCloudEnvironment).mockReturnValue(false)
vi.mocked(keypress).mockResolvedValue(undefined)
vi.mocked(openURL).mockResolvedValue(true)
vi.mocked(getIdentityClient).mockImplementation(() => mockIdentityClient)
// Mock stringifyMessage to pass through strings for error messages
vi.mocked(stringifyMessage).mockImplementation((msg) => (typeof msg === 'string' ? msg : String(msg)))
})

describe('requestDeviceAuthorization', () => {
Expand All @@ -32,7 +44,7 @@ describe('requestDeviceAuthorization', () => {
verification_uri: 'verification_uri',
expires_in: 3600,
verification_uri_complete: 'verification_uri_complete',
interval: 5,
interval: 0.05,
}

const dataExpected: DeviceAuthorizationResponse = {
Expand All @@ -46,20 +58,31 @@ describe('requestDeviceAuthorization', () => {

test('requests an authorization code to initiate the device auth', async () => {
// Given
const response = new Response(JSON.stringify(data))
vi.mocked(shopifyFetch).mockResolvedValue(response)
const deviceAuthResponse = new Response(JSON.stringify(data))
vi.mocked(shopifyFetch).mockResolvedValueOnce(deviceAuthResponse)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')

// Mock the token exchange to complete the flow
const identityToken: IdentityToken = {
accessToken: 'access_token',
refreshToken: 'refresh_token',
expiresAt: new Date(2022, 1, 1, 11),
scopes: ['scope1', 'scope2'],
userId: '1234-5678',
alias: '1234-5678',
}
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValue(ok(identityToken))

// When
const got = await requestDeviceAuthorization(['scope1', 'scope2'])
const got = await mockIdentityClient.requestAccessToken(['scope1', 'scope2'])

// Then
expect(shopifyFetch).toBeCalledWith('https://fqdn.com/oauth/device_authorization', {
expect(shopifyFetch).toHaveBeenCalledWith('https://fqdn.com/oauth/device_authorization', {
method: 'POST',
headers: {'Content-type': 'application/x-www-form-urlencoded'},
body: 'client_id=fbdb2649-e327-4907-8f67-908d24cfd7e3&scope=scope1 scope2',
})
expect(got).toEqual(dataExpected)
expect(got).toEqual(identityToken)
})

test('when the response is not valid JSON, throw an error with context', async () => {
Expand All @@ -71,7 +94,7 @@ describe('requestDeviceAuthorization', () => {
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')

// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
await expect(mockIdentityClient.requestAccessToken(['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 @@ -85,7 +108,7 @@ describe('requestDeviceAuthorization', () => {
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')

// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
await expect(mockIdentityClient.requestAccessToken(['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 @@ -100,7 +123,7 @@ describe('requestDeviceAuthorization', () => {
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')

// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
await expect(mockIdentityClient.requestAccessToken(['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 @@ -114,7 +137,7 @@ describe('requestDeviceAuthorization', () => {
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')

// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
await expect(mockIdentityClient.requestAccessToken(['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 @@ -130,13 +153,23 @@ describe('requestDeviceAuthorization', () => {
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')

// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
await expect(mockIdentityClient.requestAccessToken(['scope1', 'scope2'])).rejects.toThrowError(
'Failed to read response from authorization service (HTTP 200). Network or streaming error occurred.',
)
})
})

describe('pollForDeviceAuthorization', () => {
const data: any = {
device_code: 'device_code',
user_code: 'user_code',
verification_uri: 'verification_uri',
expires_in: 3600,
verification_uri_complete: 'verification_uri_complete',
// Short interval for testing
interval: 0.05,
}

const identityToken: IdentityToken = {
accessToken: 'access_token',
refreshToken: 'refresh_token',
Expand All @@ -148,13 +181,16 @@ describe('pollForDeviceAuthorization', () => {

test('poll until a valid token is received', async () => {
// Given
const deviceAuthResponse = new Response(JSON.stringify(data))
vi.mocked(shopifyFetch).mockResolvedValueOnce(deviceAuthResponse)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('authorization_pending'))
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('authorization_pending'))
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('authorization_pending'))
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(ok(identityToken))

// When
const got = await pollForDeviceAuthorization('device_code', 0.05)
const got = await mockIdentityClient.requestAccessToken(['scope1', 'scope2'])

// Then
expect(exchangeDeviceCodeForAccessToken).toBeCalledTimes(4)
Expand All @@ -163,12 +199,15 @@ describe('pollForDeviceAuthorization', () => {

test('when polling, if an error is received, stop polling and throw error', async () => {
// Given
const deviceAuthResponse = new Response(JSON.stringify(data))
vi.mocked(shopifyFetch).mockResolvedValueOnce(deviceAuthResponse)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('authorization_pending'))
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('authorization_pending'))
vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValueOnce(err('access_denied'))

// When
const got = pollForDeviceAuthorization('device_code', 0.05)
const got = mockIdentityClient.requestAccessToken(['scope1', 'scope2'])

// Then
await expect(got).rejects.toThrow()
Expand Down
Loading
Loading