Skip to content

Commit 6fa83de

Browse files
prototype identity client segmentation
1 parent c356b4e commit 6fa83de

File tree

10 files changed

+331
-236
lines changed

10 files changed

+331
-236
lines changed

packages/app/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"extends": "../../configurations/tsconfig.json",
3-
"include": ["./src/**/*.ts", "./src/**/*.js", "./src/**/*.tsx"],
3+
"include": ["./src/**/*.ts", "./src/**/*.js", "./src/**/*.tsx", "../cli-kit/src/public/node/api/identity-client.ts"],
44
"exclude": ["./dist", "./src/templates/**/*"],
55
"compilerOptions": {
66
"outDir": "dist",

packages/cli-kit/src/private/node/session.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {store as storeSessions, fetch as fetchSessions, remove as secureRemove}
1818
import {ApplicationToken, IdentityToken, Sessions} from './session/schema.js'
1919
import {validateSession} from './session/validate.js'
2020
import {applicationId} from './session/identity.js'
21-
import {pollForDeviceAuthorization, requestDeviceAuthorization} from './session/device-authorization.js'
21+
import {pollForDeviceAuthorization} from './session/device-authorization.js'
2222
import {getCurrentSessionId} from './conf-store.js'
2323
import * as fqdnModule from '../../public/node/context/fqdn.js'
2424
import {themeToken} from '../../public/node/context/local.js'
@@ -27,6 +27,7 @@ import {businessPlatformRequest} from '../../public/node/api/business-platform.j
2727
import {getPartnersToken} from '../../public/node/environment.js'
2828
import {nonRandomUUID} from '../../public/node/crypto.js'
2929
import {terminalSupportsPrompting} from '../../public/node/system.js'
30+
import {ProdIC} from '../../public/node/api/identity-client.js'
3031
import {vi, describe, expect, test, beforeEach} from 'vitest'
3132

3233
const futureDate = new Date(2022, 1, 1, 11)
@@ -119,6 +120,7 @@ vi.mock('../../public/node/environment.js')
119120
vi.mock('./session/device-authorization')
120121
vi.mock('./conf-store')
121122
vi.mock('../../public/node/system.js')
123+
vi.mock('../../public/node/api/identity-client.js')
122124

123125
beforeEach(() => {
124126
vi.spyOn(fqdnModule, 'identityFqdn').mockResolvedValue(fqdn)
@@ -134,7 +136,7 @@ beforeEach(() => {
134136
setLastSeenUserIdAfterAuth(undefined as any)
135137
setLastSeenAuthMethod('none')
136138

137-
vi.mocked(requestDeviceAuthorization).mockResolvedValue({
139+
vi.mocked(ProdIC.requestDeviceAuthorization).mockResolvedValue({
138140
deviceCode: 'device_code',
139141
userCode: 'user_code',
140142
verificationUri: 'verification_uri',

packages/cli-kit/src/private/node/session.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
} from './session/exchange.js'
1212
import {IdentityToken, Session, Sessions} from './session/schema.js'
1313
import * as sessionStore from './session/store.js'
14-
import {pollForDeviceAuthorization, requestDeviceAuthorization} from './session/device-authorization.js'
1514
import {isThemeAccessSession} from './api/rest.js'
1615
import {getCurrentSessionId, setCurrentSessionId} from './conf-store.js'
1716
import {UserEmailQueryString, UserEmailQuery} from './api/graphql/business-platform-destinations/user-email.js'
@@ -25,6 +24,8 @@ import {nonRandomUUID} from '../../public/node/crypto.js'
2524
import {isEmpty} from '../../public/common/object.js'
2625
import {businessPlatformRequest} from '../../public/node/api/business-platform.js'
2726

27+
import {getIdentityClient} from '../../public/node/api/identity-client.js'
28+
2829
/**
2930
* Fetches the user's email from the Business Platform API
3031
* @param businessPlatformToken - The business platform token
@@ -308,11 +309,12 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise<Ses
308309
} else {
309310
// Request a device code to authorize without a browser redirect.
310311
outputDebug(outputContent`Requesting device authorization code...`)
311-
const deviceAuth = await requestDeviceAuthorization(scopes)
312+
const client = getIdentityClient()
313+
const deviceAuth = await client.requestDeviceAuthorization(scopes)
312314

313315
// Poll for the identity token
314316
outputDebug(outputContent`Starting polling for the identity token...`)
315-
identityToken = await pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval)
317+
identityToken = await client.pollForDeviceAuthorization(deviceAuth)
316318
}
317319

318320
// Exchange identity token for application tokens

packages/cli-kit/src/private/node/session/device-authorization.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import {
22
DeviceAuthorizationResponse,
33
pollForDeviceAuthorization,
4-
requestDeviceAuthorization,
54
} from './device-authorization.js'
6-
import {clientId} from './identity.js'
5+
import {clientId, ProdIC} from '../../../public/node/api/identity-client.js'
76
import {IdentityToken} from './schema.js'
87
import {exchangeDeviceCodeForAccessToken} from './exchange.js'
98
import {identityFqdn} from '../../../public/node/context/fqdn.js'
@@ -20,6 +19,7 @@ vi.mock('../../../public/node/http.js')
2019
vi.mock('../../../public/node/ui.js')
2120
vi.mock('./exchange.js')
2221
vi.mock('../../../public/node/system.js')
22+
vi.mock('../../../public/node/api/identity-client.js')
2323

2424
beforeEach(() => {
2525
vi.mocked(isTTY).mockReturnValue(true)
@@ -53,7 +53,7 @@ describe('requestDeviceAuthorization', () => {
5353
vi.mocked(clientId).mockReturnValue('clientId')
5454

5555
// When
56-
const got = await requestDeviceAuthorization(['scope1', 'scope2'])
56+
const got = await ProdIC.requestDeviceAuthorization(['scope1', 'scope2'])
5757

5858
// Then
5959
expect(shopifyFetch).toBeCalledWith('https://fqdn.com/oauth/device_authorization', {
@@ -74,7 +74,7 @@ describe('requestDeviceAuthorization', () => {
7474
vi.mocked(clientId).mockReturnValue('clientId')
7575

7676
// When/Then
77-
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
77+
await expect(ProdIC.requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
7878
'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',
7979
)
8080
})
@@ -89,7 +89,7 @@ describe('requestDeviceAuthorization', () => {
8989
vi.mocked(clientId).mockReturnValue('clientId')
9090

9191
// When/Then
92-
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
92+
await expect(ProdIC.requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
9393
'Received invalid response from authorization service (HTTP 200). Received empty response body. If this issue persists, please contact support at https://help.shopify.com',
9494
)
9595
})
@@ -105,7 +105,7 @@ describe('requestDeviceAuthorization', () => {
105105
vi.mocked(clientId).mockReturnValue('clientId')
106106

107107
// When/Then
108-
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
108+
await expect(ProdIC.requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
109109
'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',
110110
)
111111
})
@@ -120,7 +120,7 @@ describe('requestDeviceAuthorization', () => {
120120
vi.mocked(clientId).mockReturnValue('clientId')
121121

122122
// When/Then
123-
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
123+
await expect(ProdIC.requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
124124
'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',
125125
)
126126
})
@@ -137,7 +137,7 @@ describe('requestDeviceAuthorization', () => {
137137
vi.mocked(clientId).mockReturnValue('clientId')
138138

139139
// When/Then
140-
await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
140+
await expect(ProdIC.requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrowError(
141141
'Failed to read response from authorization service (HTTP 200). Network or streaming error occurred.',
142142
)
143143
})
Lines changed: 1 addition & 188 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,3 @@
1-
import {clientId} from './identity.js'
2-
import {exchangeDeviceCodeForAccessToken} from './exchange.js'
3-
import {IdentityToken} from './schema.js'
4-
import {identityFqdn} from '../../../public/node/context/fqdn.js'
5-
import {shopifyFetch} from '../../../public/node/http.js'
6-
import {outputContent, outputDebug, outputInfo, outputToken} from '../../../public/node/output.js'
7-
import {AbortError, BugError} from '../../../public/node/error.js'
8-
import {isCloudEnvironment} from '../../../public/node/context/local.js'
9-
import {isCI, openURL} from '../../../public/node/system.js'
10-
import {isTTY, keypress} from '../../../public/node/ui.js'
11-
import {Response} from 'node-fetch'
12-
131
export interface DeviceAuthorizationResponse {
142
deviceCode: string
153
userCode: string
@@ -19,179 +7,4 @@ export interface DeviceAuthorizationResponse {
197
interval?: number
208
}
219

22-
/**
23-
* Initiate a device authorization flow.
24-
* This will return a DeviceAuthorizationResponse containing the URL where user
25-
* should go to authorize the device without the need of a callback to the CLI.
26-
*
27-
* Also returns a `deviceCode` used for polling the token endpoint in the next step.
28-
*
29-
* @param scopes - The scopes to request
30-
* @returns An object with the device authorization response.
31-
*/
32-
export async function requestDeviceAuthorization(scopes: string[]): Promise<DeviceAuthorizationResponse> {
33-
const fqdn = await identityFqdn()
34-
const identityClientId = clientId()
35-
const queryParams = {client_id: identityClientId, scope: scopes.join(' ')}
36-
const url = `https://${fqdn}/oauth/device_authorization`
37-
38-
const response = await shopifyFetch(url, {
39-
method: 'POST',
40-
headers: {'Content-type': 'application/x-www-form-urlencoded'},
41-
body: convertRequestToParams(queryParams),
42-
})
43-
44-
// First read the response body as text so we have it for debugging
45-
let responseText: string
46-
try {
47-
responseText = await response.text()
48-
} catch (error) {
49-
throw new BugError(
50-
`Failed to read response from authorization service (HTTP ${response.status}). Network or streaming error occurred.`,
51-
'Check your network connection and try again.',
52-
)
53-
}
54-
55-
// Now try to parse the text as JSON
56-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
57-
let jsonResult: any
58-
try {
59-
jsonResult = JSON.parse(responseText)
60-
} catch {
61-
// JSON.parse failed, handle the parsing error
62-
const errorMessage = buildAuthorizationParseErrorMessage(response, responseText)
63-
throw new BugError(errorMessage)
64-
}
65-
66-
outputDebug(outputContent`Received device authorization code: ${outputToken.json(jsonResult)}`)
67-
if (!jsonResult.device_code || !jsonResult.verification_uri_complete) {
68-
throw new BugError('Failed to start authorization process')
69-
}
70-
71-
outputInfo('\nTo run this command, log in to Shopify.')
72-
73-
if (isCI()) {
74-
throw new AbortError(
75-
'Authorization is required to continue, but the current environment does not support interactive prompts.',
76-
'To resolve this, specify credentials in your environment, or run the command in an interactive environment such as your local terminal.',
77-
)
78-
}
79-
80-
outputInfo(outputContent`User verification code: ${jsonResult.user_code}`)
81-
const linkToken = outputToken.link(jsonResult.verification_uri_complete)
82-
83-
const cloudMessage = () => {
84-
outputInfo(outputContent`👉 Open this link to start the auth process: ${linkToken}`)
85-
}
86-
87-
if (isCloudEnvironment() || !isTTY()) {
88-
cloudMessage()
89-
} else {
90-
outputInfo('👉 Press any key to open the login page on your browser')
91-
await keypress()
92-
const opened = await openURL(jsonResult.verification_uri_complete)
93-
if (opened) {
94-
outputInfo(outputContent`Opened link to start the auth process: ${linkToken}`)
95-
} else {
96-
cloudMessage()
97-
}
98-
}
99-
100-
return {
101-
deviceCode: jsonResult.device_code,
102-
userCode: jsonResult.user_code,
103-
verificationUri: jsonResult.verification_uri,
104-
expiresIn: jsonResult.expires_in,
105-
verificationUriComplete: jsonResult.verification_uri_complete,
106-
interval: jsonResult.interval,
107-
}
108-
}
109-
110-
/**
111-
* Poll the Oauth token endpoint with the device code obtained from a DeviceAuthorizationResponse.
112-
* The endpoint will return `authorization_pending` until the user completes the auth flow in the browser.
113-
* Once the user completes the auth flow, the endpoint will return the identity token.
114-
*
115-
* Timeout for the polling is defined by the server and is around 600 seconds.
116-
*
117-
* @param code - The device code obtained after starting a device identity flow
118-
* @param interval - The interval to poll the token endpoint
119-
* @returns The identity token
120-
*/
121-
export async function pollForDeviceAuthorization(code: string, interval = 5): Promise<IdentityToken> {
122-
let currentIntervalInSeconds = interval
123-
124-
return new Promise<IdentityToken>((resolve, reject) => {
125-
const onPoll = async () => {
126-
const result = await exchangeDeviceCodeForAccessToken(code)
127-
if (!result.isErr()) {
128-
resolve(result.value)
129-
return
130-
}
131-
132-
const error = result.error ?? 'unknown_failure'
133-
134-
outputDebug(outputContent`Polling for device authorization... status: ${error}`)
135-
switch (error) {
136-
case 'authorization_pending': {
137-
startPolling()
138-
return
139-
}
140-
case 'slow_down':
141-
currentIntervalInSeconds += 5
142-
startPolling()
143-
return
144-
case 'access_denied':
145-
case 'expired_token':
146-
case 'unknown_failure': {
147-
reject(new Error(`Device authorization failed: ${error}`))
148-
}
149-
}
150-
}
151-
152-
const startPolling = () => {
153-
// eslint-disable-next-line @typescript-eslint/no-misused-promises
154-
setTimeout(onPoll, currentIntervalInSeconds * 1000)
155-
}
156-
157-
startPolling()
158-
})
159-
}
160-
161-
function convertRequestToParams(queryParams: {client_id: string; scope: string}): string {
162-
return Object.entries(queryParams)
163-
.map(([key, value]) => value && `${key}=${value}`)
164-
.filter((hasValue) => Boolean(hasValue))
165-
.join('&')
166-
}
167-
168-
/**
169-
* Build a detailed error message for JSON parsing failures from the authorization service.
170-
* Provides context-specific error messages based on response status and content.
171-
*
172-
* @param response - The HTTP response object
173-
* @param responseText - The raw response body text
174-
* @returns Detailed error message about the failure
175-
*/
176-
function buildAuthorizationParseErrorMessage(response: Response, responseText: string): string {
177-
// Build helpful error message based on response status and content
178-
let errorMessage = `Received invalid response from authorization service (HTTP ${response.status}).`
179-
180-
// Add status-based context
181-
if (response.status >= 500) {
182-
errorMessage += ' The service may be experiencing issues.'
183-
} else if (response.status >= 400) {
184-
errorMessage += ' The request may be malformed or unauthorized.'
185-
}
186-
187-
// Add content-based context (check these regardless of status)
188-
if (responseText.trim().startsWith('<!DOCTYPE') || responseText.trim().startsWith('<html')) {
189-
errorMessage += ' Received HTML instead of JSON - the service endpoint may have changed.'
190-
} else if (responseText.trim() === '') {
191-
errorMessage += ' Received empty response body.'
192-
} else {
193-
errorMessage += ' Response could not be parsed as valid JSON.'
194-
}
195-
196-
return `${errorMessage} If this issue persists, please contact support at https://help.shopify.com`
197-
}
10+
// export async function pollForDeviceAuthorization(code: string, interval = 5): Promise<IdentityToken> {}

packages/cli-kit/src/private/node/session/exchange.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
refreshAccessToken,
99
requestAppToken,
1010
} from './exchange.js'
11-
import {applicationId, clientId} from './identity.js'
11+
import {applicationId} from './identity.js'
12+
import {clientId} from '../../../public/node/api/identity-client.js'
1213
import {IdentityToken} from './schema.js'
1314
import {shopifyFetch} from '../../../public/node/http.js'
1415
import {identityFqdn} from '../../../public/node/context/fqdn.js'

0 commit comments

Comments
 (0)