-
Notifications
You must be signed in to change notification settings - Fork 220
Refactor identity client code execution #6654
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 11-20-add_option_to_bypass_running_service_check_if_configured
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<IdentityToken> | ||
| 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<Result<TokenRequestResult, {error: string; store?: string}>> | ||
|
|
||
| abstract refreshAccessToken(currentToken: IdentityToken): Promise<IdentityToken> | ||
| abstract requestDeviceAuthorization(scopes: string[]): Promise<DeviceAuthorizationResponse> | ||
|
|
||
| abstract clientId(): string | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,38 +1,16 @@ | ||
| 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' | ||
| 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<IdentityToken> { | ||
| // 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<Result<TokenRequestResult, {error: string; store?: string}>> { | ||
|
|
@@ -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<IdentityToken> { | ||
| 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<DeviceAuthorizationResponse> { | ||
| async requestDeviceAuthorization(scopes: string[]): Promise<DeviceAuthorizationResponse> { | ||
| 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<IdentityToken> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This feels like a loss to me - this code is directly related to the service client and the encapsulation was nice IMO |
||
| let currentIntervalInSeconds = interval | ||
|
|
||
| return new Promise<IdentityToken>((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() | ||
| }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've moved only flows pertaining to network requests specifically to the client to keep it super lightweight. Shared modules in
session/*are then pulled in to existing flows as needed, with the client only being used for the token exchanges/fetchesThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO thinning out the client here weakens the abstraction:
Light weight isn't necessarily good in this context - I think the "right weight" (ahaha rhyming) is what we want, where the implementation code that "belongs" behind the interface lives behind the interface.
Having an abstraction just over the network part is better than having none (we do need the local dev solution!), but my gut here is that the circular dependency issue led us to making other changes that weakened the rest of the architecture you had established.
I strongly suspect there would be another solution to the circular dependency issue that would still allow us to keep the higher level interface with more encapsulated code behind the interface. My guess is there would be even more things we could pull behind the interface as a way to fix the circular dependency but I can't be sure without reviewing what the circular relationship actually was (is this written up somewhere I can 👀 ?)