Skip to content
Draft
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
30 changes: 30 additions & 0 deletions packages/cli-kit/src/private/node/conf-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,21 @@ interface Cache {
[rateLimitKey: RateLimitKey]: CacheValue<number[]>
}

export interface PendingDeviceAuth {
deviceCode: string
interval: number
expiresAt: number
verificationUriComplete: string
scopes: string[]
}

export interface ConfSchema {
sessionStore: string
currentSessionId?: string
devSessionStore?: string
currentDevSessionId?: string
cache?: Cache
pendingDeviceAuth?: PendingDeviceAuth
}

let _instance: LocalStorage<ConfSchema> | undefined
Expand Down Expand Up @@ -112,6 +121,27 @@ export function removeCurrentSessionId(config: LocalStorage<ConfSchema> = cliKit
config.delete(currentSessionIdKey())
}

/**
* Get pending device auth state (used for non-interactive login flow).
*/
export function getPendingDeviceAuth(config: LocalStorage<ConfSchema> = cliKitStore()): PendingDeviceAuth | undefined {
return config.get('pendingDeviceAuth')
}

/**
* Stash pending device auth state for later resumption.
*/
export function setPendingDeviceAuth(auth: PendingDeviceAuth, config: LocalStorage<ConfSchema> = cliKitStore()): void {
config.set('pendingDeviceAuth', auth)
}

/**
* Clear pending device auth state.
*/
export function clearPendingDeviceAuth(config: LocalStorage<ConfSchema> = cliKitStore()): void {
config.delete('pendingDeviceAuth')
}

type CacheValueForKey<TKey extends keyof Cache> = NonNullable<Cache[TKey]>['value']

/**
Expand Down
24 changes: 17 additions & 7 deletions packages/cli-kit/src/private/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,6 @@ The CLI is currently unable to prompt for reauthentication.`,
*/
async function executeCompleteFlow(applications: OAuthApplications): Promise<Session> {
const scopes = getFlattenScopes(applications)
const exchangeScopes = getExchangeScopes(applications)
const store = applications.adminApi?.storeFqdn
if (firstPartyDev()) {
outputDebug(outputContent`Authenticating as Shopify Employee...`)
scopes.push('employee')
Expand All @@ -314,6 +312,22 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise<Ses
identityToken = await pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval)
}

const session = await completeAuthFlow(identityToken, applications)
outputCompleted(`Logged in.`)
return session
}

/**
* Given an identity token, exchange it for application tokens and build a complete session.
* Shared between the interactive login flow and the --resume non-interactive flow.
*/
export async function completeAuthFlow(
identityToken: IdentityToken,
applications: OAuthApplications,
): Promise<Session> {
const exchangeScopes = getExchangeScopes(applications)
const store = applications.adminApi?.storeFqdn

// Exchange identity token for application tokens
outputDebug(outputContent`CLI token received. Exchanging it for application tokens...`)
const result = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store)
Expand All @@ -322,17 +336,13 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise<Ses
const businessPlatformToken = result[applicationId('business-platform')]?.accessToken
const alias = (await fetchEmail(businessPlatformToken)) ?? identityToken.userId

const session: Session = {
return {
identity: {
...identityToken,
alias,
},
applications: result,
}

outputCompleted(`Logged in.`)

return session
}

/**
Expand Down
47 changes: 25 additions & 22 deletions packages/cli-kit/src/private/node/session/device-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,13 @@ export interface DeviceAuthorizationResponse {
* Also returns a `deviceCode` used for polling the token endpoint in the next step.
*
* @param scopes - The scopes to request
* @param options - Optional settings. Pass `noPrompt: true` to print the URL without waiting for keypress or opening a browser.
* @returns An object with the device authorization response.
*/
export async function requestDeviceAuthorization(scopes: string[]): Promise<DeviceAuthorizationResponse> {
export async function requestDeviceAuthorization(
scopes: string[],
{noPrompt = false}: {noPrompt?: boolean} = {},
): Promise<DeviceAuthorizationResponse> {
const fqdn = await identityFqdn()
const identityClientId = clientId()
const queryParams = {client_id: identityClientId, scope: scopes.join(' ')}
Expand Down Expand Up @@ -69,32 +73,31 @@ export async function requestDeviceAuthorization(scopes: string[]): Promise<Devi
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 = () => {
if (noPrompt) {
outputInfo(outputContent`\nUser verification code: ${jsonResult.user_code}`)
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}`)
outputInfo('\nTo run this command, log in to Shopify.')
outputInfo(outputContent`User verification code: ${jsonResult.user_code}`)

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.',
)
} else if (isCloudEnvironment() || !isTTY()) {
outputInfo(outputContent`👉 Open this link to start the auth process: ${linkToken}`)
} else {
cloudMessage()
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 {
outputInfo(outputContent`👉 Open this link to start the auth process: ${linkToken}`)
}
}
}

Expand Down
111 changes: 111 additions & 0 deletions packages/cli-kit/src/public/node/session.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {shopifyFetch} from './http.js'
import {nonRandomUUID} from './crypto.js'
import {getPartnersToken} from './environment.js'
import {identityFqdn} from './context/fqdn.js'
import {AbortError, BugError} from './error.js'
import {outputContent, outputToken, outputDebug} from './output.js'
import * as sessionStore from '../../private/node/session/store.js'
import {getCurrentSessionId, setCurrentSessionId} from '../../private/node/conf-store.js'
import {
exchangeCustomPartnerToken,
exchangeCliTokenForAppManagementAccessToken,
Expand Down Expand Up @@ -274,6 +276,27 @@ ${outputToken.json(scopes)}
return tokens.businessPlatform
}

/**
* Returns info about the currently logged-in user, or undefined if not logged in.
* Does not trigger any authentication flow.
*
* @returns The current user's alias, or undefined if not logged in.
*/
export async function getCurrentUserInfo(): Promise<{alias: string} | undefined> {
const currentSessionId = getCurrentSessionId()
if (!currentSessionId) return undefined

const sessions = await sessionStore.fetch()
if (!sessions) return undefined

const fqdn = await identityFqdn()
const session = sessions[fqdn]?.[currentSessionId]
if (!session) return undefined

const alias = session.identity.alias ?? currentSessionId
return {alias}
}

/**
* Logout from Shopify.
*
Expand All @@ -283,6 +306,94 @@ export function logout(): Promise<void> {
return sessionStore.remove()
}

/**
* Start the device authorization flow without polling.
* Stashes the device code for later resumption via `resumeDeviceAuth`.
*
* @returns The verification URL the user must visit to authorize.
*/
export async function startDeviceAuthNoPolling(): Promise<{verificationUriComplete: string}> {
const {requestDeviceAuthorization} = await import('../../private/node/session/device-authorization.js')
const {allDefaultScopes} = await import('../../private/node/session/scopes.js')
const {setPendingDeviceAuth} = await import('../../private/node/conf-store.js')

const scopes = allDefaultScopes()
const deviceAuth = await requestDeviceAuthorization(scopes, {noPrompt: true})

setPendingDeviceAuth({
deviceCode: deviceAuth.deviceCode,
interval: deviceAuth.interval ?? 5,
expiresAt: Date.now() + deviceAuth.expiresIn * 1000,
verificationUriComplete: deviceAuth.verificationUriComplete ?? deviceAuth.verificationUri,
scopes,
})

return {verificationUriComplete: deviceAuth.verificationUriComplete ?? deviceAuth.verificationUri}
}

export type ResumeDeviceAuthResult =
| {status: 'success'; alias: string}
| {status: 'pending'; verificationUriComplete: string}
| {status: 'expired'; message: string}
| {status: 'denied'; message: string}
| {status: 'no_pending'; message: string}

/**
* Resume a previously started device authorization flow.
* Exchanges the stashed device code for tokens and stores the session.
*
* @returns The result of the resume attempt.
*/
export async function resumeDeviceAuth(): Promise<ResumeDeviceAuthResult> {
const {exchangeDeviceCodeForAccessToken} = await import('../../private/node/session/exchange.js')
const {getPendingDeviceAuth, clearPendingDeviceAuth} = await import('../../private/node/conf-store.js')
const {completeAuthFlow} = await import('../../private/node/session.js')

const pending = getPendingDeviceAuth()
if (!pending) {
return {status: 'no_pending', message: 'No pending login flow. Run `shopify auth login --no-polling` first.'}
}

if (Date.now() > pending.expiresAt) {
clearPendingDeviceAuth()
return {status: 'expired', message: 'The login flow has expired. Run `shopify auth login --no-polling` again.'}
}

const result = await exchangeDeviceCodeForAccessToken(pending.deviceCode)

if (result.isErr()) {
const error = result.error
if (error === 'authorization_pending') {
return {status: 'pending', verificationUriComplete: pending.verificationUriComplete}
}
if (error === 'expired_token') {
clearPendingDeviceAuth()
return {status: 'expired', message: 'The login flow has expired. Run `shopify auth login --no-polling` again.'}
}
// access_denied or unknown
clearPendingDeviceAuth()
return {status: 'denied', message: `Authorization failed: ${error}`}
}

// Successfully got an identity token — complete the flow
const identityToken = result.value
const session = await completeAuthFlow(identityToken, {})
const fqdn = await identityFqdn()

// Store the session
const existingSessions = (await sessionStore.fetch()) ?? {}
const newSessionId = session.identity.userId
const updatedSessions = {
...existingSessions,
[fqdn]: {...existingSessions[fqdn], [newSessionId]: session},
}
await sessionStore.store(updatedSessions)
setCurrentSessionId(newSessionId)

clearPendingDeviceAuth()
return {status: 'success', alias: session.identity.alias ?? newSessionId}
}

/**
* Ensure that we have a valid Admin session for the given store, with access on behalf of the app.
*
Expand Down
18 changes: 17 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
* [`shopify app webhook trigger`](#shopify-app-webhook-trigger)
* [`shopify auth login`](#shopify-auth-login)
* [`shopify auth logout`](#shopify-auth-logout)
* [`shopify auth whoami`](#shopify-auth-whoami)
* [`shopify commands`](#shopify-commands)
* [`shopify config autocorrect off`](#shopify-config-autocorrect-off)
* [`shopify config autocorrect on`](#shopify-config-autocorrect-on)
Expand Down Expand Up @@ -1053,10 +1054,13 @@ Logs you in to your Shopify account.

```
USAGE
$ shopify auth login [--alias <value>]
$ shopify auth login [--alias <value>] [--no-polling] [--resume]

FLAGS
--alias=<value> [env: SHOPIFY_FLAG_AUTH_ALIAS] Alias of the session you want to login to.
--no-polling [env: SHOPIFY_FLAG_AUTH_NO_POLLING] Start the login flow without polling. Prints the auth URL and
exits immediately.
--resume [env: SHOPIFY_FLAG_AUTH_RESUME] Resume a previously started login flow.

DESCRIPTION
Logs you in to your Shopify account.
Expand All @@ -1074,6 +1078,18 @@ DESCRIPTION
Logs you out of the Shopify account or Partner account and store.
```

## `shopify auth whoami`

Displays the currently logged-in Shopify account.

```
USAGE
$ shopify auth whoami

DESCRIPTION
Displays the currently logged-in Shopify account.
```

## `shopify commands`

List all shopify commands.
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3168,6 +3168,20 @@
"multiple": false,
"name": "alias",
"type": "option"
},
"no-polling": {
"allowNo": false,
"description": "Start the login flow without polling. Prints the auth URL and exits immediately.",
"env": "SHOPIFY_FLAG_AUTH_NO_POLLING",
"name": "no-polling",
"type": "boolean"
},
"resume": {
"allowNo": false,
"description": "Resume a previously started login flow.",
"env": "SHOPIFY_FLAG_AUTH_RESUME",
"name": "resume",
"type": "boolean"
}
},
"hasDynamicHelp": false,
Expand Down Expand Up @@ -3197,6 +3211,24 @@
"pluginType": "core",
"strict": true
},
"auth:whoami": {
"aliases": [
],
"args": {
},
"description": "Displays the currently logged-in Shopify account.",
"enableJsonFlag": false,
"flags": {
},
"hasDynamicHelp": false,
"hiddenAliases": [
],
"id": "auth:whoami",
"pluginAlias": "@shopify/cli",
"pluginName": "@shopify/cli",
"pluginType": "core",
"strict": true
},
"cache:clear": {
"aliases": [
],
Expand Down
Loading
Loading