From 95f8fe34ae004373918569706d8ba1981954f3f0 Mon Sep 17 00:00:00 2001 From: Alex Montague Date: Fri, 21 Nov 2025 15:48:04 -0500 Subject: [PATCH] Refactor identity client code execution --- .../node/clients/identity/identity-client.ts | 23 ++- .../clients/identity/identity-mock-client.ts | 62 +++----- .../identity/identity-service-client.ts | 101 +------------ .../cli-kit/src/private/node/session.test.ts | 16 +- packages/cli-kit/src/private/node/session.ts | 7 +- .../node/session/device-authorization.test.ts | 108 +++++++++----- .../node/session/device-authorization.ts | 9 -- .../src/private/node/session/exchange.ts | 140 +++++++++--------- .../src/private/node/session/token-utils.ts | 87 +++++++++++ 9 files changed, 285 insertions(+), 268 deletions(-) create mode 100644 packages/cli-kit/src/private/node/session/token-utils.ts diff --git a/packages/cli-kit/src/private/node/clients/identity/identity-client.ts b/packages/cli-kit/src/private/node/clients/identity/identity-client.ts index 337976c406..553a2849ec 100644 --- a/packages/cli-kit/src/private/node/clients/identity/identity-client.ts +++ b/packages/cli-kit/src/private/node/clients/identity/identity-client.ts @@ -1,18 +1,31 @@ -import {IdentityToken} from '../../session/schema.js' -import {TokenRequestResult} from '../../session/exchange.js' import {API} from '../../api.js' import {Environment, serviceEnvironment} from '../../context/service.js' import {BugError} from '../../../../public/node/error.js' import {Result} from '../../../../public/node/result.js' -export abstract class IdentityClient { - abstract requestAccessToken(scopes: string[]): Promise +export interface TokenRequestResult { + access_token: string + expires_in: number + refresh_token: string + scope: string + id_token?: string +} +export interface DeviceAuthorizationResponse { + deviceCode: string + userCode: string + verificationUri: string + expiresIn: number + verificationUriComplete?: string + interval?: number +} + +export abstract class IdentityClient { abstract tokenRequest(params: { [key: string]: string }): Promise> - abstract refreshAccessToken(currentToken: IdentityToken): Promise + abstract requestDeviceAuthorization(scopes: string[]): Promise abstract clientId(): string diff --git a/packages/cli-kit/src/private/node/clients/identity/identity-mock-client.ts b/packages/cli-kit/src/private/node/clients/identity/identity-mock-client.ts index ea109f1df6..6bba30f50c 100644 --- a/packages/cli-kit/src/private/node/clients/identity/identity-mock-client.ts +++ b/packages/cli-kit/src/private/node/clients/identity/identity-mock-client.ts @@ -1,6 +1,4 @@ -import {IdentityClient} from './identity-client.js' -import {ApplicationToken, IdentityToken} from '../../session/schema.js' -import {ExchangeScopes, TokenRequestResult} from '../../session/exchange.js' +import {IdentityClient, type TokenRequestResult, type DeviceAuthorizationResponse} from './identity-client.js' import {ok, Result} from '../../../../public/node/result.js' import {allDefaultScopes} from '../../session/scopes.js' @@ -8,55 +6,34 @@ export class IdentityMockClient extends IdentityClient { private readonly mockUserId = '08978734-325e-44ce-bc65-34823a8d5180' private readonly authTokenPrefix = 'mtkn_' - async requestAccessToken(_scopes: string[]): Promise { - const tokens = this.generateTokens('identity') - + async requestDeviceAuthorization(_scopes: string[]): Promise { return Promise.resolve({ - accessToken: tokens.accessToken, - alias: '', - expiresAt: this.getFutureDate(1), - refreshToken: tokens.refreshToken, - scopes: allDefaultScopes(), - userId: this.mockUserId, + deviceCode: 'mock_device_code', + userCode: 'MOCK-CODE', + verificationUri: 'https://identity.shop.dev/device', + expiresIn: 600, + verificationUriComplete: 'https://identity.shop.dev/device?code=MOCK-CODE', + interval: 5, }) } - async exchangeAccessForApplicationTokens( - _identityToken: IdentityToken, - _scopes: ExchangeScopes, - _store?: string, - ): Promise<{[x: string]: ApplicationToken}> { - return { - [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')), - } - } - async tokenRequest(params: { [key: string]: string }): Promise> { const tokens = this.generateTokens(params?.audience ?? '') + const idTokenPayload = { + sub: this.mockUserId, + aud: params?.audience ?? 'identity', + iss: 'https://identity.shop.dev', + exp: this.getCurrentUnixTimestamp() + 7200, + iat: this.getCurrentUnixTimestamp(), + } return ok({ access_token: tokens.accessToken, expires_in: this.getFutureDate(1).getTime(), refresh_token: tokens.refreshToken, scope: allDefaultScopes().join(' '), - }) - } - - async refreshAccessToken(_currentToken: IdentityToken): Promise { - const tokens = this.generateTokens('identity') - - return Promise.resolve({ - accessToken: tokens.accessToken, - alias: 'dev@shopify.com', - expiresAt: this.getFutureDate(1), - refreshToken: tokens.refreshToken, - scopes: allDefaultScopes(), - userId: this.mockUserId, + id_token: this.generateMockJWT(idTokenPayload), }) } @@ -118,4 +95,11 @@ export class IdentityMockClient extends IdentityClient { .replace(/\+/g, '-') .replace(/\//g, '_') } + + private generateMockJWT(payload: object): string { + const header = {alg: 'none', typ: 'JWT'} + const encodedHeader = this.encodeTokenPayload(header) + const encodedPayload = this.encodeTokenPayload(payload) + return `${encodedHeader}.${encodedPayload}.` + } } diff --git a/packages/cli-kit/src/private/node/clients/identity/identity-service-client.ts b/packages/cli-kit/src/private/node/clients/identity/identity-service-client.ts index b49eb48f3e..be00c3b57c 100644 --- a/packages/cli-kit/src/private/node/clients/identity/identity-service-client.ts +++ b/packages/cli-kit/src/private/node/clients/identity/identity-service-client.ts @@ -1,11 +1,4 @@ -import {IdentityClient} from './identity-client.js' -import {IdentityToken} from '../../session/schema.js' -import { - buildIdentityToken, - exchangeDeviceCodeForAccessToken, - tokenRequestErrorHandler, - TokenRequestResult, -} from '../../session/exchange.js' +import {IdentityClient, type TokenRequestResult, type DeviceAuthorizationResponse} from './identity-client.js' import {outputContent, outputDebug, outputInfo, outputToken} from '../../../../public/node/output.js' import {AbortError, BugError} from '../../../../public/node/error.js' import {identityFqdn} from '../../../../public/node/context/fqdn.js' @@ -13,26 +6,11 @@ import {shopifyFetch} from '../../../../public/node/http.js' import {isCI, openURL} from '../../../../public/node/system.js' import {isCloudEnvironment} from '../../../../public/node/context/local.js' import {isTTY, keypress} from '../../../../public/node/ui.js' -import { - buildAuthorizationParseErrorMessage, - convertRequestToParams, - type DeviceAuthorizationResponse, -} from '../../session/device-authorization.js' +import {buildAuthorizationParseErrorMessage, convertRequestToParams} from '../../session/device-authorization.js' import {err, ok, Result} from '../../../../public/node/result.js' import {Environment, serviceEnvironment} from '../../context/service.js' export class IdentityServiceClient extends IdentityClient { - async requestAccessToken(scopes: string[]): Promise { - // Request a device code to authorize without a browser redirect. - outputDebug(outputContent`Requesting device authorization code...`) - const deviceAuth = await this.requestDeviceAuthorization(scopes) - - // Poll for the identity token - outputDebug(outputContent`Starting polling for the identity token...`) - const identityToken = await this.pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval) - return identityToken - } - async tokenRequest(params: { [key: string]: string }): Promise> { @@ -49,22 +27,6 @@ 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 { - const clientId = this.clientId() - const params = { - grant_type: 'refresh_token', - access_token: currentToken.accessToken, - refresh_token: currentToken.refreshToken, - client_id: clientId, - } - const tokenResult = await this.tokenRequest(params) - const value = tokenResult.mapError(tokenRequestErrorHandler).valueOrBug() - return buildIdentityToken(value, currentToken.userId, currentToken.alias) - } - clientId(): string { const environment = serviceEnvironment() if (environment === Environment.Local) { @@ -76,12 +38,6 @@ export class IdentityServiceClient extends IdentityClient { } } - /** - * ======================== - * Private Instance Methods - * ======================== - */ - /** * Initiate a device authorization flow. * This will return a DeviceAuthorizationResponse containing the URL where user @@ -92,7 +48,7 @@ export class IdentityServiceClient extends IdentityClient { * @param scopes - The scopes to request * @returns An object with the device authorization response. */ - private async requestDeviceAuthorization(scopes: string[]): Promise { + async requestDeviceAuthorization(scopes: string[]): Promise { const fqdn = await identityFqdn() const identityClientId = this.clientId() const queryParams = {client_id: identityClientId, scope: scopes.join(' ')} @@ -169,55 +125,4 @@ export class IdentityServiceClient extends IdentityClient { 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 - */ - private async 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() - }) - } } diff --git a/packages/cli-kit/src/private/node/session.test.ts b/packages/cli-kit/src/private/node/session.test.ts index e2c9e73ea9..f239c3f868 100644 --- a/packages/cli-kit/src/private/node/session.test.ts +++ b/packages/cli-kit/src/private/node/session.test.ts @@ -11,6 +11,7 @@ import { exchangeAccessForApplicationTokens, exchangeCustomPartnerToken, refreshAccessToken, + requestAccessToken, InvalidGrantError, } from './session/exchange.js' import {allDefaultScopes} from './session/scopes.js' @@ -127,6 +128,7 @@ beforeEach(() => { vi.spyOn(fqdnModule, 'identityFqdn').mockResolvedValue(fqdn) vi.mocked(exchangeAccessForApplicationTokens).mockResolvedValue(appTokens) vi.mocked(refreshAccessToken).mockResolvedValue(validIdentityToken) + vi.mocked(requestAccessToken).mockResolvedValue(validIdentityToken) vi.mocked(exchangeCustomPartnerToken).mockResolvedValue({ accessToken: partnersToken.accessToken, userId: validIdentityToken.userId, @@ -145,8 +147,6 @@ 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) }) describe('ensureAuthenticated when previous session is invalid', () => { @@ -340,7 +340,7 @@ describe('when existing session is valid', () => { const got = await ensureAuthenticated(defaultApplications, process.env, {forceRefresh: true}) // Then - expect(mockIdentityClient.refreshAccessToken).toBeCalled() + expect(refreshAccessToken).toBeCalled() expect(exchangeAccessForApplicationTokens).toBeCalled() expect(storeSessions).toBeCalledWith(validSessions) expect(got).toEqual(validTokens) @@ -360,7 +360,7 @@ describe('when existing session is expired', () => { const got = await ensureAuthenticated(defaultApplications) // Then - expect(mockIdentityClient.refreshAccessToken).toBeCalled() + expect(refreshAccessToken).toBeCalled() expect(exchangeAccessForApplicationTokens).toBeCalled() expect(storeSessions).toBeCalledWith(validSessions) expect(got).toEqual(validTokens) @@ -375,13 +375,13 @@ describe('when existing session is expired', () => { vi.mocked(validateSession).mockResolvedValueOnce('needs_refresh') vi.mocked(fetchSessions).mockResolvedValue(validSessions) - vi.spyOn(mockIdentityClient, 'refreshAccessToken').mockRejectedValueOnce(tokenResponseError) + vi.mocked(refreshAccessToken).mockRejectedValueOnce(tokenResponseError) // When const got = await ensureAuthenticated(defaultApplications) // Then - expect(mockIdentityClient.refreshAccessToken).toBeCalled() + expect(refreshAccessToken).toBeCalled() expect(exchangeAccessForApplicationTokens).toBeCalled() expect(businessPlatformRequest).toHaveBeenCalled() expect(storeSessions).toHaveBeenCalledOnce() @@ -665,8 +665,8 @@ describe('ensureAuthenticated email fetch functionality', () => { const tokenResponseError = new InvalidGrantError() vi.mocked(validateSession).mockResolvedValueOnce('needs_refresh') vi.mocked(fetchSessions).mockResolvedValue(validSessions) - vi.spyOn(mockIdentityClient, 'refreshAccessToken').mockRejectedValueOnce(tokenResponseError) - vi.spyOn(mockIdentityClient, 'requestAccessToken').mockResolvedValueOnce(validIdentityToken) + vi.mocked(refreshAccessToken).mockRejectedValueOnce(tokenResponseError) + vi.mocked(requestAccessToken).mockResolvedValueOnce(validIdentityToken) vi.mocked(businessPlatformRequest).mockResolvedValueOnce({ currentUserAccount: { email: 'dev@shopify.com', diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 8afb08f5ef..31efc2a63c 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -3,6 +3,8 @@ import {allDefaultScopes, apiScopes} from './session/scopes.js' import { exchangeAccessForApplicationTokens, exchangeCustomPartnerToken, + requestAccessToken, + refreshAccessToken, ExchangeScopes, InvalidGrantError, InvalidRequestError, @@ -288,7 +290,6 @@ The CLI is currently unable to prompt for reauthentication.`, * Execute the full authentication flow. * * @param applications - An object containing the applications we need to be authenticated with. - * @param alias - Optional alias to use for the session. */ async function executeCompleteFlow(applications: OAuthApplications): Promise { const scopes = getFlattenScopes(applications) @@ -305,7 +306,7 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise { // Refresh Identity Token - const identityToken = await getIdentityClient().refreshAccessToken(session.identity) + const identityToken = await refreshAccessToken(session.identity) // Exchange new identity token for application tokens const exchangeScopes = getExchangeScopes(applications) const applicationTokens = await exchangeAccessForApplicationTokens( 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 b2455f63f0..4a4fbdb4c4 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,9 @@ -import {DeviceAuthorizationResponse} from './device-authorization.js' +import {requestAccessToken} from './exchange.js' import {IdentityToken} from './schema.js' -import {exchangeDeviceCodeForAccessToken} from './exchange.js' +import {DeviceAuthorizationResponse} from '../clients/identity/identity-client.js' import {identityFqdn} from '../../../public/node/context/fqdn.js' import {shopifyFetch} from '../../../public/node/http.js' import {isTTY, keypress} from '../../../public/node/ui.js' -import {err, ok} from '../../../public/node/result.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' @@ -20,7 +19,6 @@ 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') @@ -63,18 +61,20 @@ describe('requestDeviceAuthorization', () => { 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)) + const tokenResponse = new Response( + JSON.stringify({ + access_token: 'access_token', + refresh_token: 'refresh_token', + expires_in: 3600, + scope: 'scope1 scope2', + id_token: 'eyJhbGciOiJub25lIn0.eyJzdWIiOiIxMjM0LTU2NzgiLCJhdWQiOiJpZGVudGl0eSJ9.', + }), + ) + vi.mocked(shopifyFetch).mockResolvedValue(tokenResponse) // When - const got = await mockIdentityClient.requestAccessToken(['scope1', 'scope2']) + vi.mocked(getIdentityClient).mockReturnValue(mockIdentityClient) + const got = await requestAccessToken(['scope1', 'scope2']) // Then expect(shopifyFetch).toHaveBeenCalledWith('https://fqdn.com/oauth/device_authorization', { @@ -82,7 +82,8 @@ describe('requestDeviceAuthorization', () => { headers: {'Content-type': 'application/x-www-form-urlencoded'}, body: 'client_id=fbdb2649-e327-4907-8f67-908d24cfd7e3&scope=scope1 scope2', }) - expect(got).toEqual(identityToken) + expect(got.accessToken).toEqual('access_token') + expect(got.userId).toEqual('1234-5678') }) test('when the response is not valid JSON, throw an error with context', async () => { @@ -94,7 +95,8 @@ describe('requestDeviceAuthorization', () => { vi.mocked(identityFqdn).mockResolvedValue('fqdn.com') // When/Then - await expect(mockIdentityClient.requestAccessToken(['scope1', 'scope2'])).rejects.toThrowError( + vi.mocked(getIdentityClient).mockReturnValue(mockIdentityClient) + await expect(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', ) }) @@ -108,7 +110,8 @@ describe('requestDeviceAuthorization', () => { vi.mocked(identityFqdn).mockResolvedValue('fqdn.com') // When/Then - await expect(mockIdentityClient.requestAccessToken(['scope1', 'scope2'])).rejects.toThrowError( + vi.mocked(getIdentityClient).mockReturnValue(mockIdentityClient) + await expect(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', ) }) @@ -123,7 +126,8 @@ describe('requestDeviceAuthorization', () => { vi.mocked(identityFqdn).mockResolvedValue('fqdn.com') // When/Then - await expect(mockIdentityClient.requestAccessToken(['scope1', 'scope2'])).rejects.toThrowError( + vi.mocked(getIdentityClient).mockReturnValue(mockIdentityClient) + await expect(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', ) }) @@ -137,7 +141,8 @@ describe('requestDeviceAuthorization', () => { vi.mocked(identityFqdn).mockResolvedValue('fqdn.com') // When/Then - await expect(mockIdentityClient.requestAccessToken(['scope1', 'scope2'])).rejects.toThrowError( + vi.mocked(getIdentityClient).mockReturnValue(mockIdentityClient) + await expect(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', ) }) @@ -153,7 +158,8 @@ describe('requestDeviceAuthorization', () => { vi.mocked(identityFqdn).mockResolvedValue('fqdn.com') // When/Then - await expect(mockIdentityClient.requestAccessToken(['scope1', 'scope2'])).rejects.toThrowError( + vi.mocked(getIdentityClient).mockReturnValue(mockIdentityClient) + await expect(requestAccessToken(['scope1', 'scope2'])).rejects.toThrowError( 'Failed to read response from authorization service (HTTP 200). Network or streaming error occurred.', ) }) @@ -184,17 +190,38 @@ describe('pollForDeviceAuthorization', () => { 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)) + vi.mocked(getIdentityClient).mockReturnValue(mockIdentityClient) + + // Mock pending responses, then success - set ok: false for error responses + const pendingResponse1 = new Response(JSON.stringify({error: 'authorization_pending'})) + Object.defineProperty(pendingResponse1, 'ok', {value: false}) + const pendingResponse2 = new Response(JSON.stringify({error: 'authorization_pending'})) + Object.defineProperty(pendingResponse2, 'ok', {value: false}) + const pendingResponse3 = new Response(JSON.stringify({error: 'authorization_pending'})) + Object.defineProperty(pendingResponse3, 'ok', {value: false}) + + vi.mocked(shopifyFetch).mockResolvedValueOnce(pendingResponse1) + vi.mocked(shopifyFetch).mockResolvedValueOnce(pendingResponse2) + vi.mocked(shopifyFetch).mockResolvedValueOnce(pendingResponse3) + + const successResponse = new Response( + JSON.stringify({ + access_token: identityToken.accessToken, + refresh_token: identityToken.refreshToken, + expires_in: 3600, + scope: 'scope scope2', + id_token: 'eyJhbGciOiJub25lIn0.eyJzdWIiOiIxMjM0LTU2NzgiLCJhdWQiOiJpZGVudGl0eSJ9.', + }), + ) + Object.defineProperty(successResponse, 'ok', {value: true}) + vi.mocked(shopifyFetch).mockResolvedValueOnce(successResponse) // When - const got = await mockIdentityClient.requestAccessToken(['scope1', 'scope2']) + const got = await requestAccessToken(['scope1', 'scope2']) - // Then - expect(exchangeDeviceCodeForAccessToken).toBeCalledTimes(4) - expect(got).toEqual(identityToken) + // Then - 1 device auth request + 4 polling requests + expect(shopifyFetch).toBeCalledTimes(5) + expect(got.accessToken).toEqual(identityToken.accessToken) }) test('when polling, if an error is received, stop polling and throw error', async () => { @@ -202,15 +229,24 @@ describe('pollForDeviceAuthorization', () => { 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')) + vi.mocked(getIdentityClient).mockReturnValue(mockIdentityClient) - // When - const got = mockIdentityClient.requestAccessToken(['scope1', 'scope2']) + // Mock pending responses, then error - set ok: false for all error responses + const pendingResponse1 = new Response(JSON.stringify({error: 'authorization_pending'})) + Object.defineProperty(pendingResponse1, 'ok', {value: false}) + const pendingResponse2 = new Response(JSON.stringify({error: 'authorization_pending'})) + Object.defineProperty(pendingResponse2, 'ok', {value: false}) - // Then - await expect(got).rejects.toThrow() - expect(exchangeDeviceCodeForAccessToken).toBeCalledTimes(3) + vi.mocked(shopifyFetch).mockResolvedValueOnce(pendingResponse1) + vi.mocked(shopifyFetch).mockResolvedValueOnce(pendingResponse2) + + const errorResponse = new Response(JSON.stringify({error: 'access_denied'})) + Object.defineProperty(errorResponse, 'ok', {value: false}) + vi.mocked(shopifyFetch).mockResolvedValueOnce(errorResponse) + + // When/Then + await expect(requestAccessToken(['scope1', 'scope2'])).rejects.toThrow() + // 1 device auth request + 3 polling requests + expect(shopifyFetch).toBeCalledTimes(4) }) }) 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 85ec5f8ec3..a0445cb2b2 100644 --- a/packages/cli-kit/src/private/node/session/device-authorization.ts +++ b/packages/cli-kit/src/private/node/session/device-authorization.ts @@ -1,14 +1,5 @@ import {Response} from 'node-fetch' -export interface DeviceAuthorizationResponse { - deviceCode: string - userCode: string - verificationUri: string - expiresIn: number - verificationUriComplete?: string - interval?: number -} - export function convertRequestToParams(queryParams: {client_id: string; scope: string}): string { return Object.entries(queryParams) .map(([key, value]) => value && `${key}=${value}`) diff --git a/packages/cli-kit/src/private/node/session/exchange.ts b/packages/cli-kit/src/private/node/session/exchange.ts index 395cc11f5d..fe8573b1e7 100644 --- a/packages/cli-kit/src/private/node/session/exchange.ts +++ b/packages/cli-kit/src/private/node/session/exchange.ts @@ -1,16 +1,15 @@ import {ApplicationToken, IdentityToken} from './schema.js' import {tokenExchangeScopes} from './scopes.js' +import {buildIdentityToken, buildApplicationToken, tokenRequestErrorHandler} from './token-utils.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 {AbortError} from '../../../public/node/error.js' import {setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../session.js' import {nonRandomUUID} from '../../../public/node/crypto.js' import {getIdentityClient} from '../clients/identity/instance.js' -import * as jose from 'jose' +import {outputContent, outputDebug} from '../../../public/node/output.js' -export class InvalidGrantError extends ExtendableError {} -export class InvalidRequestError extends ExtendableError {} -class InvalidTargetError extends AbortError {} +export {InvalidGrantError, InvalidRequestError} from './token-utils.js' export interface ExchangeScopes { admin: string[] @@ -20,6 +19,71 @@ export interface ExchangeScopes { appManagement: string[] } +/** + * Request an identity token using the device authorization flow. + * This initiates the full flow: request device code, show to user, and poll for completion. + * @param scopes - The scopes to request + * @returns An identity token + */ +export async function requestAccessToken(scopes: string[]): Promise { + const identityClient = getIdentityClient() + outputDebug(outputContent`Requesting device authorization code...`) + const deviceAuth = await identityClient.requestDeviceAuthorization(scopes) + + outputDebug(outputContent`Starting polling for the identity token...`) + const identityToken = await pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval) + return identityToken +} + +/** + * 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. + * + * @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 + */ +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() + }) +} + /** * Given an identity token, request an application token. * @param identityToken - access token obtained in a previous step @@ -133,7 +197,7 @@ type IdentityDeviceError = 'authorization_pending' | 'access_denied' | 'expired_ * @param scopes - The scopes to request * @returns An instance with the identity access tokens. */ -export async function exchangeDeviceCodeForAccessToken( +async function exchangeDeviceCodeForAccessToken( deviceCode: string, ): Promise> { const clientId = getIdentityClient().clientId() @@ -181,67 +245,3 @@ export async function requestAppToken( const appToken = buildApplicationToken(value) return {[identifier]: appToken} } - -export interface TokenRequestResult { - access_token: string - expires_in: number - refresh_token: string - scope: string - id_token?: 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}` : '.' - }` - - if (error === 'invalid_grant') { - // There's an scenario when Identity returns "invalid_grant" when trying to refresh the token - // using a valid refresh token. When that happens, we take the user through the authentication flow. - return new InvalidGrantError() - } - if (error === 'invalid_request') { - // There's an scenario when Identity returns "invalid_request" when exchanging an identity token. - // This means the token is invalid. We clear the session and throw an error to let the caller know. - return new InvalidRequestError() - } - if (error === 'invalid_target') { - return new InvalidTargetError(invalidTargetErrorMessage, '', [ - 'Ensure you have logged in to the store using the Shopify admin at least once.', - 'Ensure you are the store owner, or have a staff account if you are attempting to log in to a dev store.', - 'Ensure you are using the permanent store domain, not a vanity domain.', - ]) - } - // eslint-disable-next-line @shopify/cli/no-error-factory-functions - return new AbortError(error) -} - -export function buildIdentityToken( - result: TokenRequestResult, - existingUserId?: string, - existingAlias?: string, -): IdentityToken { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const userId = existingUserId ?? (result.id_token ? jose.decodeJwt(result.id_token).sub! : undefined) - - if (!userId) { - throw new BugError('Error setting userId for session. No id_token or pre-existing user ID provided.') - } - - return { - accessToken: result.access_token, - refreshToken: result.refresh_token, - expiresAt: new Date(Date.now() + result.expires_in * 1000), - scopes: result.scope.split(' '), - userId, - alias: existingAlias, - } -} - -function buildApplicationToken(result: TokenRequestResult): ApplicationToken { - return { - accessToken: result.access_token, - expiresAt: new Date(Date.now() + result.expires_in * 1000), - scopes: result.scope.split(' '), - } -} diff --git a/packages/cli-kit/src/private/node/session/token-utils.ts b/packages/cli-kit/src/private/node/session/token-utils.ts new file mode 100644 index 0000000000..ec9c2655f4 --- /dev/null +++ b/packages/cli-kit/src/private/node/session/token-utils.ts @@ -0,0 +1,87 @@ +import {ApplicationToken, IdentityToken} from './schema.js' +import {TokenRequestResult} from '../clients/identity/identity-client.js' +import {AbortError, BugError, ExtendableError} from '../../../public/node/error.js' +import * as jose from 'jose' + +export class InvalidGrantError extends ExtendableError {} +export class InvalidRequestError extends ExtendableError {} +class InvalidTargetError extends AbortError {} + +/** + * Handles errors returned from token requests to the identity service. + * Maps specific error codes to appropriate error types for proper error handling. + * + * @param error - The error code returned from the identity service + * @param store - Optional store name for contextual error messages + * @returns An appropriate error instance based on the error code + */ +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}` : '.' + }` + + if (error === 'invalid_grant') { + // There's a scenario when Identity returns "invalid_grant" when trying to refresh the token + // using a valid refresh token. When that happens, we take the user through the authentication flow. + return new InvalidGrantError() + } + if (error === 'invalid_request') { + // There's a scenario when Identity returns "invalid_request" when exchanging an identity token. + // This means the token is invalid. We clear the session and throw an error to let the caller know. + return new InvalidRequestError() + } + if (error === 'invalid_target') { + return new InvalidTargetError(invalidTargetErrorMessage, '', [ + 'Ensure you have logged in to the store using the Shopify admin at least once.', + 'Ensure you are the store owner, or have a staff account if you are attempting to log in to a dev store.', + 'Ensure you are using the permanent store domain, not a vanity domain.', + ]) + } + // eslint-disable-next-line @shopify/cli/no-error-factory-functions + return new AbortError(error) +} + +/** + * Builds an IdentityToken from a token request result. + * Extracts the user ID from the id_token JWT if not provided. + * + * @param result - The token request result from the identity service + * @param existingUserId - Optional existing user ID to preserve + * @param existingAlias - Optional existing alias to preserve + * @returns A complete IdentityToken with all required fields + */ +export function buildIdentityToken( + result: TokenRequestResult, + existingUserId?: string, + existingAlias?: string, +): IdentityToken { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const userId = existingUserId ?? (result.id_token ? jose.decodeJwt(result.id_token).sub! : undefined) + + if (!userId) { + throw new BugError('Error setting userId for session. No id_token or pre-existing user ID provided.') + } + + return { + accessToken: result.access_token, + refreshToken: result.refresh_token, + expiresAt: new Date(Date.now() + result.expires_in * 1000), + scopes: result.scope.split(' '), + userId, + alias: existingAlias, + } +} + +/** + * Builds an ApplicationToken from a token request result. + * + * @param result - The token request result from the identity service + * @returns An ApplicationToken with access token, expiration, and scopes + */ +export function buildApplicationToken(result: TokenRequestResult): ApplicationToken { + return { + accessToken: result.access_token, + expiresAt: new Date(Date.now() + result.expires_in * 1000), + scopes: result.scope.split(' '), + } +}