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
68 changes: 39 additions & 29 deletions packages/cli-kit/src/private/node/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ 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'
import * as fqdnModule from '../../public/node/context/fqdn.js'
import {themeToken} from '../../public/node/context/local.js'
import {partnersRequest} from '../../public/node/api/partners.js'
Expand All @@ -31,7 +33,7 @@ import {vi, describe, expect, test, beforeEach} from 'vitest'

const futureDate = new Date(2022, 1, 1, 11)

const userId = '1234-5678'
const mockUserId = '08978734-325e-44ce-bc65-34823a8d5180'

const defaultApplications: OAuthApplications = {
adminApi: {storeFqdn: 'mystore', scopes: []},
Expand All @@ -44,15 +46,15 @@ const validIdentityToken: IdentityToken = {
refreshToken: 'refresh_token',
expiresAt: futureDate,
scopes: ['scope', 'scope2'],
userId,
alias: userId,
userId: mockUserId,
alias: mockUserId,
}

const validTokens: OAuthSession = {
admin: {token: 'admin_token', storeFqdn: 'mystore.myshopify.com'},
storefront: 'storefront_token',
partners: 'partners_token',
userId,
userId: mockUserId,
}

const appTokens: {[x: string]: ApplicationToken} = {
Expand Down Expand Up @@ -89,7 +91,7 @@ const fqdn = 'fqdn.com'

const validSessions: Sessions = {
[fqdn]: {
[userId]: {
[mockUserId]: {
identity: validIdentityToken,
applications: appTokens,
},
Expand All @@ -98,13 +100,15 @@ const validSessions: Sessions = {

const invalidSessions: Sessions = {
[fqdn]: {
[userId]: {
[mockUserId]: {
identity: validIdentityToken,
applications: {},
},
},
}

const mockIdentityClient = new IdentityMockClient()

vi.mock('../../public/node/context/local.js')
vi.mock('./session/identity')
vi.mock('./session/authorize')
Expand All @@ -119,6 +123,7 @@ vi.mock('../../public/node/environment.js')
vi.mock('./session/device-authorization')
vi.mock('./conf-store')
vi.mock('../../public/node/system.js')
vi.mock('./clients/identity/instance.js')

beforeEach(() => {
vi.spyOn(fqdnModule, 'identityFqdn').mockResolvedValue(fqdn)
Expand Down Expand Up @@ -149,6 +154,10 @@ beforeEach(() => {
email: 'user@example.com',
},
})

vi.mocked(getIdentityClient).mockImplementation(() => mockIdentityClient)
vi.spyOn(mockIdentityClient, 'refreshAccessToken').mockResolvedValue(validIdentityToken)
vi.spyOn(mockIdentityClient, 'requestAccessToken').mockResolvedValue(validIdentityToken)
})

describe('ensureAuthenticated when previous session is invalid', () => {
Expand All @@ -169,10 +178,10 @@ describe('ensureAuthenticated when previous session is invalid', () => {

// Verify the session was stored with email as alias
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
expect(storedSession[fqdn]![userId]!.identity.alias).toBe('user@example.com')
expect(storedSession[fqdn]![mockUserId]!.identity.alias).toBe('user@example.com')

// The userID is cached in memory and the secureStore is not accessed again
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678')
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(mockUserId)
await expect(getLastSeenAuthMethod()).resolves.toEqual('device_auth')
expect(fetchSessions).toHaveBeenCalledOnce()
})
Expand Down Expand Up @@ -205,7 +214,7 @@ The CLI is currently unable to prompt for reauthentication.`,
const expectedSessions = {
...invalidSessions,
[fqdn]: {
[userId]: {
[mockUserId]: {
identity: {
...validIdentityToken,
alias: 'user@example.com',
Expand All @@ -223,7 +232,7 @@ The CLI is currently unable to prompt for reauthentication.`,
expect(refreshAccessToken).not.toBeCalled()
expect(storeSessions).toBeCalledWith(expectedSessions)
expect(got).toEqual(validTokens)
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678')
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(mockUserId)
await expect(getLastSeenAuthMethod()).resolves.toEqual('device_auth')
expect(fetchSessions).toHaveBeenCalledOnce()
})
Expand All @@ -244,7 +253,7 @@ The CLI is currently unable to prompt for reauthentication.`,

// Verify the session was stored with userId as alias (fallback)
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
expect(storedSession[fqdn]![userId]!.identity.alias).toBe(userId)
expect(storedSession[fqdn]![mockUserId]!.identity.alias).toBe(mockUserId)

expect(got).toEqual(validTokens)
})
Expand All @@ -268,7 +277,7 @@ The CLI is currently unable to prompt for reauthentication.`,

// Verify the session was stored with userId as alias (fallback)
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
expect(storedSession[fqdn]![userId]!.identity.alias).toBe(userId)
expect(storedSession[fqdn]![mockUserId]!.identity.alias).toBe(mockUserId)
})

test('executes complete auth flow if requesting additional scopes', async () => {
Expand All @@ -287,10 +296,10 @@ The CLI is currently unable to prompt for reauthentication.`,

// Verify the session was stored with email as alias
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
expect(storedSession[fqdn]![userId]!.identity.alias).toBe('user@example.com')
expect(storedSession[fqdn]![mockUserId]!.identity.alias).toBe('user@example.com')

expect(got).toEqual(validTokens)
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678')
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(mockUserId)
await expect(getLastSeenAuthMethod()).resolves.toEqual('device_auth')
expect(fetchSessions).toHaveBeenCalledOnce()
})
Expand All @@ -309,7 +318,7 @@ describe('when existing session is valid', () => {
expect(exchangeAccessForApplicationTokens).not.toBeCalled()
expect(refreshAccessToken).not.toBeCalled()
expect(got).toEqual(validTokens)
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678')
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(mockUserId)
await expect(getLastSeenAuthMethod()).resolves.toEqual('device_auth')
expect(fetchSessions).toHaveBeenCalledOnce()
})
Expand All @@ -328,7 +337,7 @@ describe('when existing session is valid', () => {
expect(exchangeAccessForApplicationTokens).not.toBeCalled()
expect(refreshAccessToken).not.toBeCalled()
expect(got).toEqual(expected)
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678')
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(mockUserId)
await expect(getLastSeenAuthMethod()).resolves.toEqual('partners_token')
expect(fetchSessions).toHaveBeenCalledOnce()
})
Expand All @@ -342,11 +351,11 @@ describe('when existing session is valid', () => {
const got = await ensureAuthenticated(defaultApplications, process.env, {forceRefresh: true})

// Then
expect(refreshAccessToken).toBeCalled()
expect(mockIdentityClient.refreshAccessToken).toBeCalled()
expect(exchangeAccessForApplicationTokens).toBeCalled()
expect(storeSessions).toBeCalledWith(validSessions)
expect(got).toEqual(validTokens)
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678')
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(mockUserId)
await expect(getLastSeenAuthMethod()).resolves.toEqual('device_auth')
expect(fetchSessions).toHaveBeenCalledOnce()
})
Expand All @@ -362,11 +371,11 @@ describe('when existing session is expired', () => {
const got = await ensureAuthenticated(defaultApplications)

// Then
expect(refreshAccessToken).toBeCalled()
expect(mockIdentityClient.refreshAccessToken).toBeCalled()
expect(exchangeAccessForApplicationTokens).toBeCalled()
expect(storeSessions).toBeCalledWith(validSessions)
expect(got).toEqual(validTokens)
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678')
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(mockUserId)
await expect(getLastSeenAuthMethod()).resolves.toEqual('device_auth')
expect(fetchSessions).toHaveBeenCalledOnce()
})
Expand All @@ -377,23 +386,23 @@ describe('when existing session is expired', () => {

vi.mocked(validateSession).mockResolvedValueOnce('needs_refresh')
vi.mocked(fetchSessions).mockResolvedValue(validSessions)
vi.mocked(refreshAccessToken).mockRejectedValueOnce(tokenResponseError)
vi.spyOn(mockIdentityClient, 'refreshAccessToken').mockRejectedValueOnce(tokenResponseError)

// When
const got = await ensureAuthenticated(defaultApplications)

// Then
expect(refreshAccessToken).toBeCalled()
expect(mockIdentityClient.refreshAccessToken).toBeCalled()
expect(exchangeAccessForApplicationTokens).toBeCalled()
expect(businessPlatformRequest).toHaveBeenCalled()
expect(storeSessions).toHaveBeenCalledOnce()

// Verify the session was stored with email as alias
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
expect(storedSession[fqdn]![userId]!.identity.alias).toBe('user@example.com')
expect(storedSession[fqdn]![mockUserId]!.identity.alias).toBe('user@example.com')

expect(got).toEqual(validTokens)
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('1234-5678')
await expect(getLastSeenUserIdAfterAuth()).resolves.toBe(mockUserId)
await expect(getLastSeenAuthMethod()).resolves.toEqual('device_auth')
expect(fetchSessions).toHaveBeenCalledOnce()
})
Expand Down Expand Up @@ -630,7 +639,7 @@ describe('ensureAuthenticated email fetch functionality', () => {

// Then
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
expect(storedSession[fqdn]![userId]!.identity.alias).toBe('work@example.com')
expect(storedSession[fqdn]![mockUserId]!.identity.alias).toBe('work@example.com')
expect(got).toEqual(validTokens)
})

Expand Down Expand Up @@ -667,10 +676,11 @@ describe('ensureAuthenticated email fetch functionality', () => {
const tokenResponseError = new InvalidGrantError()
vi.mocked(validateSession).mockResolvedValueOnce('needs_refresh')
vi.mocked(fetchSessions).mockResolvedValue(validSessions)
vi.mocked(refreshAccessToken).mockRejectedValueOnce(tokenResponseError)
vi.spyOn(mockIdentityClient, 'refreshAccessToken').mockRejectedValueOnce(tokenResponseError)
vi.spyOn(mockIdentityClient, 'requestAccessToken').mockResolvedValueOnce(validIdentityToken)
vi.mocked(businessPlatformRequest).mockResolvedValueOnce({
currentUserAccount: {
email: 'fallback@example.com',
email: 'dev@shopify.com',
},
})

Expand All @@ -679,7 +689,7 @@ describe('ensureAuthenticated email fetch functionality', () => {

// Then
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
expect(storedSession[fqdn]![userId]!.identity.alias).toBe('fallback@example.com')
expect(storedSession[fqdn]![mockUserId]!.identity.alias).toBe('dev@shopify.com')
expect(got).toEqual(validTokens)
})

Expand All @@ -698,7 +708,7 @@ describe('ensureAuthenticated email fetch functionality', () => {

// Then
const storedSession = vi.mocked(storeSessions).mock.calls[0]![0]
expect(storedSession[fqdn]![userId]!.identity.alias).toBe(userId)
expect(storedSession[fqdn]![mockUserId]!.identity.alias).toBe(mockUserId)
expect(got).toEqual(validTokens)
})
})
13 changes: 3 additions & 10 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@ import {
exchangeAccessForApplicationTokens,
exchangeCustomPartnerToken,
ExchangeScopes,
refreshAccessToken,
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 {getIdentityClient} from './clients/identity/instance.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 Down Expand Up @@ -306,13 +305,7 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise<Ses
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)

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

// Exchange identity token for application tokens
Expand Down Expand Up @@ -343,7 +336,7 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise<Ses
*/
async function refreshTokens(session: Session, applications: OAuthApplications): Promise<Session> {
// Refresh Identity Token
const identityToken = await refreshAccessToken(session.identity)
const identityToken = await getIdentityClient().refreshAccessToken(session.identity)
// Exchange new identity token for application tokens
const exchangeScopes = getExchangeScopes(applications)
const applicationTokens = await exchangeAccessForApplicationTokens(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
pollForDeviceAuthorization,
requestDeviceAuthorization,
} from './device-authorization.js'
import {clientId} from './identity.js'
import {IdentityToken} from './schema.js'
import {exchangeDeviceCodeForAccessToken} from './exchange.js'
import {identityFqdn} from '../../../public/node/context/fqdn.js'
Expand Down Expand Up @@ -50,7 +49,6 @@ describe('requestDeviceAuthorization', () => {
const response = new Response(JSON.stringify(data))
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')

// When
const got = await requestDeviceAuthorization(['scope1', 'scope2'])
Expand All @@ -59,7 +57,7 @@ describe('requestDeviceAuthorization', () => {
expect(shopifyFetch).toBeCalledWith('https://fqdn.com/oauth/device_authorization', {
method: 'POST',
headers: {'Content-type': 'application/x-www-form-urlencoded'},
body: 'client_id=clientId&scope=scope1 scope2',
body: 'client_id=fbdb2649-e327-4907-8f67-908d24cfd7e3&scope=scope1 scope2',
})
expect(got).toEqual(dataExpected)
})
Expand All @@ -71,7 +69,6 @@ describe('requestDeviceAuthorization', () => {
Object.defineProperty(response, 'statusText', {value: 'OK'})
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')

// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
Expand All @@ -86,7 +83,6 @@ describe('requestDeviceAuthorization', () => {
Object.defineProperty(response, 'statusText', {value: 'OK'})
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')

// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
Expand All @@ -102,7 +98,6 @@ describe('requestDeviceAuthorization', () => {
Object.defineProperty(response, 'statusText', {value: 'Not Found'})
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')

// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
Expand All @@ -117,7 +112,6 @@ describe('requestDeviceAuthorization', () => {
Object.defineProperty(response, 'statusText', {value: 'Internal Server Error'})
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')

// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
Expand All @@ -134,7 +128,6 @@ describe('requestDeviceAuthorization', () => {
response.text = vi.fn().mockRejectedValue(new Error('Network error'))
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')

// When/Then
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +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'
Expand All @@ -8,6 +7,7 @@ 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 {getIdentityClient} from '../clients/identity/instance.js'
import {Response} from 'node-fetch'

export interface DeviceAuthorizationResponse {
Expand All @@ -31,7 +31,7 @@ export interface DeviceAuthorizationResponse {
*/
export async function requestDeviceAuthorization(scopes: string[]): Promise<DeviceAuthorizationResponse> {
const fqdn = await identityFqdn()
const identityClientId = clientId()
const identityClientId = getIdentityClient().clientId()
const queryParams = {client_id: identityClientId, scope: scopes.join(' ')}
const url = `https://${fqdn}/oauth/device_authorization`

Expand Down
Loading