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 f1252eb8bf..337976c406 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,23 +1,73 @@ -import {ApplicationToken, IdentityToken} from '../../session/schema.js' -import {ExchangeScopes} from '../../session/exchange.js' +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 - abstract exchangeAccessForApplicationTokens( - identityToken: IdentityToken, - scopes: ExchangeScopes, - store?: string, - ): Promise<{[x: string]: ApplicationToken}> + abstract tokenRequest(params: { + [key: string]: string + }): Promise> abstract refreshAccessToken(currentToken: IdentityToken): Promise - clientId(): string { - return '' - } + abstract clientId(): string - applicationId(_api: API): string { - return '' + applicationId(api: API): string { + switch (api) { + case 'admin': { + const environment = serviceEnvironment() + if (environment === Environment.Local) { + return 'e92482cebb9bfb9fb5a0199cc770fde3de6c8d16b798ee73e36c9d815e070e52' + } else if (environment === Environment.Production) { + return '7ee65a63608843c577db8b23c4d7316ea0a01bd2f7594f8a9c06ea668c1b775c' + } else { + return 'e92482cebb9bfb9fb5a0199cc770fde3de6c8d16b798ee73e36c9d815e070e52' + } + } + case 'partners': { + const environment = serviceEnvironment() + if (environment === Environment.Local) { + return 'df89d73339ac3c6c5f0a98d9ca93260763e384d51d6038da129889c308973978' + } else if (environment === Environment.Production) { + return '271e16d403dfa18082ffb3d197bd2b5f4479c3fc32736d69296829cbb28d41a6' + } else { + return 'df89d73339ac3c6c5f0a98d9ca93260763e384d51d6038da129889c308973978' + } + } + case 'storefront-renderer': { + const environment = serviceEnvironment() + if (environment === Environment.Local) { + return '46f603de-894f-488d-9471-5b721280ff49' + } else if (environment === Environment.Production) { + return 'ee139b3d-5861-4d45-b387-1bc3ada7811c' + } else { + return '46f603de-894f-488d-9471-5b721280ff49' + } + } + case 'business-platform': { + const environment = serviceEnvironment() + if (environment === Environment.Local) { + return 'ace6dc89-b526-456d-a942-4b8ef6acda4b' + } else if (environment === Environment.Production) { + return '32ff8ee5-82b8-4d93-9f8a-c6997cefb7dc' + } else { + return 'ace6dc89-b526-456d-a942-4b8ef6acda4b' + } + } + case 'app-management': { + const environment = serviceEnvironment() + if (environment === Environment.Production) { + return '7ee65a63608843c577db8b23c4d7316ea0a01bd2f7594f8a9c06ea668c1b775c' + } else { + return 'e92482cebb9bfb9fb5a0199cc770fde3de6c8d16b798ee73e36c9d815e070e52' + } + } + default: + throw new BugError(`Application id for API of type: ${api}`) + } } } 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 29a8c40f82..59ff688919 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,10 +1,25 @@ import {IdentityClient} from './identity-client.js' import {ApplicationToken, IdentityToken} from '../../session/schema.js' -import {ExchangeScopes} from '../../session/exchange.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' + private readonly authTokenPrefix = 'mtkn_' + async requestAccessToken(_scopes: string[]): Promise { - return {} as IdentityToken + const tokens = this.generateTokens('identity') + + return Promise.resolve({ + accessToken: tokens.accessToken, + alias: '', + expiresAt: this.getFutureDate(1), + refreshToken: tokens.refreshToken, + scopes: allDefaultScopes(), + userId: this.mockUserId, + }) } async exchangeAccessForApplicationTokens( @@ -12,10 +27,96 @@ export class IdentityMockClient extends IdentityClient { _scopes: ExchangeScopes, _store?: string, ): Promise<{[x: string]: ApplicationToken}> { - return {} + 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')), + } + } + + async tokenRequest(params: { + [key: string]: string + }): Promise> { + const tokens = this.generateTokens(params?.audience ?? '') + return ok({ + access_token: tokens.accessToken, + expires_in: this.getFutureDate(1).getTime(), + refresh_token: tokens.refreshToken, + scope: allDefaultScopes().join(' '), + }) } async refreshAccessToken(_currentToken: IdentityToken): Promise { - return {} as IdentityToken + 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, + }) + } + + clientId(): string { + return 'shopify-cli-development' + } + + private readonly generateTokens = (appId: string) => { + const now = this.getCurrentUnixTimestamp() + const exp = now + 7200 + + const tokenPayload = { + act: { + iss: 'https://identity.shop.dev', + sub: this.clientId(), + }, + aud: appId, + client_id: this.clientId(), + token_type: 'SLAT', + exp, + iat: now, + iss: 'https://identity.shop.dev', + scope: allDefaultScopes().join(' '), + sub: this.mockUserId, + sid: 'df63c65c-3731-48af-a28d-72ab16a6523a', + auth_time: now, + amr: ['pwd', 'device-auth'], + device_uuid: '8ba644c8-7d2f-4260-9311-86df09195ee8', + atl: 1.0, + } + + const refreshTokenPayload = { + ...tokenPayload, + token_use: 'refresh', + } + + return { + accessToken: `${this.authTokenPrefix}${this.encodeTokenPayload(tokenPayload)}`, + refreshToken: `${this.authTokenPrefix}${this.encodeTokenPayload(refreshTokenPayload)}`, + expiresAt: new Date(exp * 1000), + scopes: allDefaultScopes(), + } + } + + private getFutureDate(daysInFuture = 100): Date { + const futureDate = new Date() + futureDate.setDate(futureDate.getDate() + daysInFuture) + return futureDate + } + + private getCurrentUnixTimestamp(): number { + return Math.floor(Date.now() / 1000) + } + + private encodeTokenPayload(payload: object): string { + return Buffer.from(JSON.stringify(payload)) + .toString('base64') + .replace(/[=]/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_') } } 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 309ea41a8c..88d1a52078 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,21 +1,220 @@ import {IdentityClient} from './identity-client.js' -import {ApplicationToken, IdentityToken} from '../../session/schema.js' -import {ExchangeScopes} from '../../session/exchange.js' +import {IdentityToken} from '../../session/schema.js' +import { + buildIdentityToken, + exchangeDeviceCodeForAccessToken, + tokenRequestErrorHandler, + TokenRequestResult, +} from '../../session/exchange.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' +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 {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 { - return {} as IdentityToken + 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 exchangeAccessForApplicationTokens( - _identityToken: IdentityToken, - _scopes: ExchangeScopes, - _store?: string, - ): Promise<{[x: string]: ApplicationToken}> { - return {} + async tokenRequest(params: { + [key: string]: string + }): Promise> { + const fqdn = await identityFqdn() + const url = new URL(`https://${fqdn}/oauth/token`) + url.search = new URLSearchParams(Object.entries(params)).toString() + + const res = await shopifyFetch(url.href, {method: 'POST'}) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const payload: any = await res.json() + + if (res.ok) return ok(payload) + + return err({error: payload.error, store: params.store}) + } + + 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) { + return 'e5380e02-312a-7408-5718-e07017e9cf52' + } else if (environment === Environment.Production) { + return 'fbdb2649-e327-4907-8f67-908d24cfd7e3' + } else { + return 'e5380e02-312a-7408-5718-e07017e9cf52' + } + } + + /** + * ======================== + * Private Instance Methods + * ======================== + */ + + /** + * 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. + */ + private async requestDeviceAuthorization(scopes: string[]): Promise { + const fqdn = await identityFqdn() + const identityClientId = this.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, + } } - async refreshAccessToken(_currentToken: IdentityToken): Promise { - return {} as 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. + * + * 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/device-authorization.ts b/packages/cli-kit/src/private/node/session/device-authorization.ts index 711269ec46..aa6c247ddb 100644 --- a/packages/cli-kit/src/private/node/session/device-authorization.ts +++ b/packages/cli-kit/src/private/node/session/device-authorization.ts @@ -158,7 +158,7 @@ export async function pollForDeviceAuthorization(code: string, interval = 5): Pr }) } -function convertRequestToParams(queryParams: {client_id: string; scope: string}): string { +export function convertRequestToParams(queryParams: {client_id: string; scope: string}): string { return Object.entries(queryParams) .map(([key, value]) => value && `${key}=${value}`) .filter((hasValue) => Boolean(hasValue)) @@ -173,7 +173,7 @@ function convertRequestToParams(queryParams: {client_id: string; scope: string}) * @param responseText - The raw response body text * @returns Detailed error message about the failure */ -function buildAuthorizationParseErrorMessage(response: Response, responseText: string): string { +export 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}).` diff --git a/packages/cli-kit/src/private/node/session/exchange.ts b/packages/cli-kit/src/private/node/session/exchange.ts index 1fcb3024ee..fb5479c909 100644 --- a/packages/cli-kit/src/private/node/session/exchange.ts +++ b/packages/cli-kit/src/private/node/session/exchange.ts @@ -187,7 +187,7 @@ export async function requestAppToken( return {[identifier]: appToken} } -interface TokenRequestResult { +export interface TokenRequestResult { access_token: string expires_in: number refresh_token: string @@ -195,7 +195,7 @@ interface TokenRequestResult { id_token?: string } -function tokenRequestErrorHandler({error, store}: {error: string; store?: 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}` : '.' }` @@ -237,7 +237,7 @@ async function tokenRequest(params: { return err({error: payload.error, store: params.store}) } -function buildIdentityToken( +export function buildIdentityToken( result: TokenRequestResult, existingUserId?: string, existingAlias?: string,