diff --git a/packages/cli-kit/src/private/node/conf-store.ts b/packages/cli-kit/src/private/node/conf-store.ts index 1c585eae41a..b3c122ec6d5 100644 --- a/packages/cli-kit/src/private/node/conf-store.ts +++ b/packages/cli-kit/src/private/node/conf-store.ts @@ -26,12 +26,21 @@ interface Cache { [rateLimitKey: RateLimitKey]: CacheValue } +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 | undefined @@ -112,6 +121,27 @@ export function removeCurrentSessionId(config: LocalStorage = cliKit config.delete(currentSessionIdKey()) } +/** + * Get pending device auth state (used for non-interactive login flow). + */ +export function getPendingDeviceAuth(config: LocalStorage = cliKitStore()): PendingDeviceAuth | undefined { + return config.get('pendingDeviceAuth') +} + +/** + * Stash pending device auth state for later resumption. + */ +export function setPendingDeviceAuth(auth: PendingDeviceAuth, config: LocalStorage = cliKitStore()): void { + config.set('pendingDeviceAuth', auth) +} + +/** + * Clear pending device auth state. + */ +export function clearPendingDeviceAuth(config: LocalStorage = cliKitStore()): void { + config.delete('pendingDeviceAuth') +} + type CacheValueForKey = NonNullable['value'] /** diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 6f87d00c66d..e7d6afa0b19 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -293,8 +293,6 @@ The CLI is currently unable to prompt for reauthentication.`, */ async function executeCompleteFlow(applications: OAuthApplications): Promise { const scopes = getFlattenScopes(applications) - const exchangeScopes = getExchangeScopes(applications) - const store = applications.adminApi?.storeFqdn if (firstPartyDev()) { outputDebug(outputContent`Authenticating as Shopify Employee...`) scopes.push('employee') @@ -314,6 +312,22 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise { + 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) @@ -322,17 +336,13 @@ async function executeCompleteFlow(applications: OAuthApplications): Promise { +export async function requestDeviceAuthorization( + scopes: string[], + {noPrompt = false}: {noPrompt?: boolean} = {}, +): Promise { const fqdn = await identityFqdn() const identityClientId = clientId() const queryParams = {client_id: identityClientId, scope: scopes.join(' ')} @@ -69,32 +73,31 @@ export async function requestDeviceAuthorization(scopes: string[]): Promise { + 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}`) + } } } diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index c1ddcf2038f..8bba5340912 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -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, @@ -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. * @@ -283,6 +306,94 @@ export function logout(): Promise { 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 { + 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. * diff --git a/packages/cli/README.md b/packages/cli/README.md index 97c66738629..9a19a126c00 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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) @@ -1053,10 +1054,13 @@ Logs you in to your Shopify account. ``` USAGE - $ shopify auth login [--alias ] + $ shopify auth login [--alias ] [--no-polling] [--resume] FLAGS --alias= [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. @@ -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. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 1c6912ece8d..3e1200d2700 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -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, @@ -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": [ ], diff --git a/packages/cli/src/cli/commands/auth/login.test.ts b/packages/cli/src/cli/commands/auth/login.test.ts index 29401713c58..845945d0ee2 100644 --- a/packages/cli/src/cli/commands/auth/login.test.ts +++ b/packages/cli/src/cli/commands/auth/login.test.ts @@ -1,9 +1,11 @@ import Login from './login.js' import {describe, expect, vi, test} from 'vitest' import {promptSessionSelect} from '@shopify/cli-kit/node/session-prompt' +import {startDeviceAuthNoPolling, resumeDeviceAuth} from '@shopify/cli-kit/node/session' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('@shopify/cli-kit/node/session-prompt') +vi.mock('@shopify/cli-kit/node/session') describe('Login command', () => { test('runs login without alias flag', async () => { @@ -40,5 +42,81 @@ describe('Login command', () => { expect(flags.alias).toBeDefined() expect(flags.alias.description).toBe('Alias of the session you want to login to.') expect(flags.alias.env).toBe('SHOPIFY_FLAG_AUTH_ALIAS') + expect(flags['no-polling']).toBeDefined() + expect(flags.resume).toBeDefined() + }) + + test('--no-polling starts device auth and prints URL', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(startDeviceAuthNoPolling).mockResolvedValue({ + verificationUriComplete: 'https://accounts.shopify.com/activate?user_code=ABCD-EFGH', + }) + + // When + await Login.run(['--no-polling']) + + // Then + expect(startDeviceAuthNoPolling).toHaveBeenCalledOnce() + expect(outputMock.info()).toMatch('shopify auth login --resume') + expect(promptSessionSelect).not.toHaveBeenCalled() + }) + + test('--resume succeeds and outputs alias', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(resumeDeviceAuth).mockResolvedValue({status: 'success', alias: 'user@example.com'}) + + // When + await Login.run(['--resume']) + + // Then + expect(resumeDeviceAuth).toHaveBeenCalledOnce() + expect(outputMock.output()).toMatch('Logged in as: user@example.com') + }) + + test('--resume exits with error when authorization is still pending', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(resumeDeviceAuth).mockResolvedValue({ + status: 'pending', + verificationUriComplete: 'https://accounts.shopify.com/activate?user_code=ABCD-EFGH', + }) + + // When + await expect(Login.run(['--resume'])).rejects.toThrow() + + // Then + expect(outputMock.error()).toMatch('Authorization is still pending.') + }) + + test('--resume exits with error when no pending auth exists', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(resumeDeviceAuth).mockResolvedValue({ + status: 'no_pending', + message: 'No pending login flow. Run `shopify auth login --no-polling` first.', + }) + + // When + await expect(Login.run(['--resume'])).rejects.toThrow() + + // Then + expect(outputMock.error()).toMatch('No pending login flow.') + }) + + test('--resume exits with error when auth has expired', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(resumeDeviceAuth).mockResolvedValue({ + status: 'expired', + message: 'The login flow has expired. Run `shopify auth login --no-polling` again.', + }) + + // When + await expect(Login.run(['--resume'])).rejects.toThrow() + + // Then + expect(outputMock.error()).toMatch('The login flow has expired.') }) }) diff --git a/packages/cli/src/cli/commands/auth/login.ts b/packages/cli/src/cli/commands/auth/login.ts index 992b311dd26..2a70790c613 100644 --- a/packages/cli/src/cli/commands/auth/login.ts +++ b/packages/cli/src/cli/commands/auth/login.ts @@ -1,7 +1,9 @@ import Command from '@shopify/cli-kit/node/base-command' import {promptSessionSelect} from '@shopify/cli-kit/node/session-prompt' +import {startDeviceAuthNoPolling, resumeDeviceAuth} from '@shopify/cli-kit/node/session' import {Flags} from '@oclif/core' -import {outputCompleted} from '@shopify/cli-kit/node/output' +import {outputCompleted, outputInfo} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' export default class Login extends Command { static description = 'Logs you in to your Shopify account.' @@ -11,10 +13,46 @@ export default class Login extends Command { description: 'Alias of the session you want to login to.', env: 'SHOPIFY_FLAG_AUTH_ALIAS', }), + 'no-polling': Flags.boolean({ + description: 'Start the login flow without polling. Prints the auth URL and exits immediately.', + default: false, + env: 'SHOPIFY_FLAG_AUTH_NO_POLLING', + }), + resume: Flags.boolean({ + description: 'Resume a previously started login flow.', + default: false, + env: 'SHOPIFY_FLAG_AUTH_RESUME', + }), } async run(): Promise { const {flags} = await this.parse(Login) + + if (flags['no-polling']) { + await startDeviceAuthNoPolling() + outputInfo('Run `shopify auth login --resume` to complete login after authorizing.') + return + } + + if (flags.resume) { + const result = await resumeDeviceAuth() + switch (result.status) { + case 'success': + outputCompleted(`Logged in as: ${result.alias}`) + return + case 'pending': + throw new AbortError( + 'Authorization is still pending.', + `Open ${result.verificationUriComplete} to authorize, then run \`shopify auth login --resume\` again.`, + ) + case 'expired': + case 'denied': + case 'no_pending': + throw new AbortError(result.message) + } + } + + // Default: interactive flow const result = await promptSessionSelect(flags.alias) outputCompleted(`Current account: ${result}.`) } diff --git a/packages/cli/src/cli/commands/auth/whoami.test.ts b/packages/cli/src/cli/commands/auth/whoami.test.ts new file mode 100644 index 00000000000..e87c2b32098 --- /dev/null +++ b/packages/cli/src/cli/commands/auth/whoami.test.ts @@ -0,0 +1,32 @@ +import Whoami from './whoami.js' +import {describe, expect, vi, test} from 'vitest' +import {getCurrentUserInfo} from '@shopify/cli-kit/node/session' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('@shopify/cli-kit/node/session') + +describe('Whoami command', () => { + test('outputs account alias when logged in', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(getCurrentUserInfo).mockResolvedValue({alias: 'user@example.com'}) + + // When + await Whoami.run([]) + + // Then + expect(outputMock.info()).toMatch('Logged in as: user@example.com') + }) + + test('exits with error when not logged in', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(getCurrentUserInfo).mockResolvedValue(undefined) + + // When + await expect(Whoami.run([])).rejects.toThrow() + + // Then + expect(outputMock.error()).toMatch('Not logged in.') + }) +}) diff --git a/packages/cli/src/cli/commands/auth/whoami.ts b/packages/cli/src/cli/commands/auth/whoami.ts new file mode 100644 index 00000000000..d2e035aa008 --- /dev/null +++ b/packages/cli/src/cli/commands/auth/whoami.ts @@ -0,0 +1,17 @@ +import Command from '@shopify/cli-kit/node/base-command' +import {getCurrentUserInfo} from '@shopify/cli-kit/node/session' +import {outputInfo} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' + +export default class Whoami extends Command { + static description = 'Displays the currently logged-in Shopify account.' + + async run(): Promise { + const userInfo = await getCurrentUserInfo() + if (userInfo) { + outputInfo(`Logged in as: ${userInfo.alias}`) + } else { + throw new AbortError('Not logged in.', 'Run `shopify auth login` to log in.') + } + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4e762b6067d..3ec298714a4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,6 +4,7 @@ import Search from './cli/commands/search.js' import Upgrade from './cli/commands/upgrade.js' import Logout from './cli/commands/auth/logout.js' import Login from './cli/commands/auth/login.js' +import Whoami from './cli/commands/auth/whoami.js' import CommandFlags from './cli/commands/debug/command-flags.js' import KitchenSinkAsync from './cli/commands/kitchen-sink/async.js' import KitchenSinkPrompts from './cli/commands/kitchen-sink/prompts.js' @@ -140,6 +141,7 @@ export const COMMANDS: any = { help: HelpCommand, 'auth:logout': Logout, 'auth:login': Login, + 'auth:whoami': Whoami, 'debug:command-flags': CommandFlags, 'kitchen-sink': KitchenSink, 'kitchen-sink:async': KitchenSinkAsync, diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 1cf767ee0d3..6eacde1f842 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -38,7 +38,8 @@ │ └─ trigger ├─ auth │ ├─ login -│ └─ logout +│ ├─ logout +│ └─ whoami ├─ commands ├─ config │ └─ autocorrect