diff --git a/packages/store/src/cli/commands/store/stripe-auth.test.ts b/packages/store/src/cli/commands/store/stripe-auth.test.ts index 9014cb76758..aef7e95485a 100644 --- a/packages/store/src/cli/commands/store/stripe-auth.test.ts +++ b/packages/store/src/cli/commands/store/stripe-auth.test.ts @@ -1,7 +1,8 @@ -import StoreStripeAuth from './stripe-auth.js' +import StoreStripeAuth, {readSignupJwtFromStdin} from './stripe-auth.js' import {authenticateStoreWithApp} from '../../services/store/auth/index.js' import {createStoreAuthPresenter} from '../../services/store/auth/result.js' import {describe, expect, test, vi} from 'vitest' +import {Readable} from 'stream' vi.mock('../../services/store/auth/index.js') vi.mock('../../services/store/attribution.js') @@ -57,9 +58,17 @@ describe('store stripe-auth command', () => { expect(StoreStripeAuth.flags.store).toBeDefined() expect(StoreStripeAuth.flags.scopes).toBeDefined() expect(StoreStripeAuth.flags.signup).toBeDefined() - expect(StoreStripeAuth.flags.signup.required).toBe(true) + expect(StoreStripeAuth.flags.signup.required).toBe(false) expect(StoreStripeAuth.flags.json).toBeDefined() expect('port' in StoreStripeAuth.flags).toBe(false) expect('client-secret-file' in StoreStripeAuth.flags).toBe(false) }) + + test('reads the signup JWT from stdin', async () => { + await expect(readSignupJwtFromStdin(Readable.from([' signed.signup.jwt\n']))).resolves.toBe('signed.signup.jwt') + }) + + test('rejects blank stdin signup JWTs', async () => { + await expect(readSignupJwtFromStdin(Readable.from(['\n']))).rejects.toThrow('Missing signup JWT') + }) }) diff --git a/packages/store/src/cli/commands/store/stripe-auth.ts b/packages/store/src/cli/commands/store/stripe-auth.ts index 2a17b5ee223..cf33523c17f 100644 --- a/packages/store/src/cli/commands/store/stripe-auth.ts +++ b/packages/store/src/cli/commands/store/stripe-auth.ts @@ -3,6 +3,7 @@ import {createStoreAuthPresenter} from '../../services/store/auth/result.js' import StoreCommand from '../../utilities/store-command.js' import {storeFlags} from '../../flags.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {AbortError} from '@shopify/cli-kit/node/error' import {Flags} from '@oclif/core' export default class StoreStripeAuth extends StoreCommand { @@ -10,12 +11,13 @@ export default class StoreStripeAuth extends StoreCommand { static summary = 'Authenticate for store commands.' - static descriptionWithMarkdown = `Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup.` + static descriptionWithMarkdown = `Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup or stdin.` static description = this.descriptionWithoutMarkdown() static examples = [ '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup ', + 'printf %s | <%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products', '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup --json', ] @@ -29,20 +31,21 @@ export default class StoreStripeAuth extends StoreCommand { required: true, }), signup: Flags.string({ - description: 'Provide JWT for the store.', + description: 'Provide JWT for the store. When omitted, the JWT is read from stdin.', env: 'SHOPIFY_FLAG_SIGNUP', - required: true, + required: false, }), } public async run(): Promise { const {flags} = await this.parse(StoreStripeAuth) + const signup = flags.signup ?? (await readSignupJwtFromStdin()) await authenticateStoreWithApp( { store: flags.store, scopes: flags.scopes, - signup: flags.signup, + signup, }, { presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'), @@ -50,3 +53,22 @@ export default class StoreStripeAuth extends StoreCommand { ) } } + +export async function readSignupJwtFromStdin( + stdin: NodeJS.ReadableStream & AsyncIterable = process.stdin, +): Promise { + const chunks: Buffer[] = [] + for await (const chunk of stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + + const signup = Buffer.concat(chunks).toString('utf8').trim() + if (!signup) { + throw new AbortError( + 'Missing signup JWT.', + 'Pass --signup , set SHOPIFY_FLAG_SIGNUP, or pipe the JWT to stdin.', + ) + } + + return signup +} diff --git a/packages/store/src/cli/services/store/auth/callback.test.ts b/packages/store/src/cli/services/store/auth/callback.test.ts index dc392774848..133caca5102 100644 --- a/packages/store/src/cli/services/store/auth/callback.test.ts +++ b/packages/store/src/cli/services/store/auth/callback.test.ts @@ -59,6 +59,39 @@ describe('store auth callback server', () => { ).resolves.toBe('abc123') }) + test('waitForStoreAuthCode redirects a valid authorization handoff without settling auth', async () => { + const port = await getAvailablePort() + const params = callbackParams() + const authorizationUrl = 'https://shop.myshopify.com/admin/oauth/authorize?signup=signed.signup.jwt' + const onListening = async () => { + const handoffResponse = await globalThis.fetch(`http://127.0.0.1:${port}/auth/handoff?nonce=nonce-123`, { + redirect: 'manual', + }) + expect(handoffResponse.status).toBe(302) + expect(handoffResponse.headers.get('Location')).toBe(authorizationUrl) + expect(handoffResponse.headers.get('Cache-Control')).toBe('no-store') + expect(handoffResponse.headers.get('Referrer-Policy')).toBe('no-referrer') + + const callbackResponse = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(callbackResponse.status).toBe(200) + await callbackResponse.text() + } + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + authorizationRedirect: { + nonce: 'nonce-123', + authorizationUrl, + }, + onListening, + }), + ).resolves.toBe('abc123') + }) + test('waitForStoreAuthCode rejects when callback state does not match', async () => { const port = await getAvailablePort() const params = callbackParams({state: 'wrong-state'}) diff --git a/packages/store/src/cli/services/store/auth/callback.ts b/packages/store/src/cli/services/store/auth/callback.ts index 4a3bfd50aba..eacf4b95de0 100644 --- a/packages/store/src/cli/services/store/auth/callback.ts +++ b/packages/store/src/cli/services/store/auth/callback.ts @@ -1,4 +1,4 @@ -import {STORE_AUTH_CALLBACK_PATH, maskToken} from './config.js' +import {STORE_AUTH_CALLBACK_PATH, STORE_AUTH_HANDOFF_PATH, maskToken} from './config.js' import {retryStoreAuthWithPermanentDomainError} from './recovery.js' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {AbortError} from '@shopify/cli-kit/node/error' @@ -12,6 +12,10 @@ export interface WaitForAuthCodeOptions { port: number timeoutMs?: number onListening?: () => void | Promise + authorizationRedirect?: { + nonce: string + authorizationUrl: string + } } function renderAuthCallbackPage(title: string, message: string): string { @@ -99,12 +103,14 @@ export async function waitForStoreAuthCode({ port, timeoutMs = 5 * 60 * 1000, onListening, + authorizationRedirect, }: WaitForAuthCodeOptions): Promise { const normalizedStore = normalizeStoreFqdn(store) return new Promise((resolve, reject) => { let settled = false let isListening = false + let authorizationRedirectUsed = false const timeout = setTimeout(() => { settleWithError(new AbortError('Timed out waiting for OAuth callback.')) @@ -113,6 +119,34 @@ export async function waitForStoreAuthCode({ const server = createServer((req, res) => { const requestUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`) + if (requestUrl.pathname === STORE_AUTH_HANDOFF_PATH && authorizationRedirect) { + const returnedNonce = requestUrl.searchParams.get('nonce') + if (!returnedNonce || !constantTimeEqual(returnedNonce, authorizationRedirect.nonce)) { + res.statusCode = 403 + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Connection', 'close') + res.end('Forbidden') + return + } + + if (authorizationRedirectUsed) { + res.statusCode = 410 + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Connection', 'close') + res.end('Authorization handoff already used') + return + } + + authorizationRedirectUsed = true + res.statusCode = 302 + res.setHeader('Location', authorizationRedirect.authorizationUrl) + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Referrer-Policy', 'no-referrer') + res.setHeader('Connection', 'close') + res.end() + return + } + if (requestUrl.pathname !== STORE_AUTH_CALLBACK_PATH) { res.statusCode = 404 res.end('Not found') diff --git a/packages/store/src/cli/services/store/auth/config.ts b/packages/store/src/cli/services/store/auth/config.ts index 5a4739cda7f..ad859344e0b 100644 --- a/packages/store/src/cli/services/store/auth/config.ts +++ b/packages/store/src/cli/services/store/auth/config.ts @@ -3,11 +3,16 @@ export {storeAuthSessionKey} from '@shopify/cli-kit/node/store-auth-session' export const DEFAULT_STORE_AUTH_PORT = 13387 export const STORE_AUTH_CALLBACK_PATH = '/auth/callback' +export const STORE_AUTH_HANDOFF_PATH = '/auth/handoff' export function storeAuthRedirectUri(port: number): string { return `http://127.0.0.1:${port}${STORE_AUTH_CALLBACK_PATH}` } +export function storeAuthHandoffUri(port: number, nonce: string): string { + return `http://127.0.0.1:${port}${STORE_AUTH_HANDOFF_PATH}?nonce=${encodeURIComponent(nonce)}` +} + export function maskToken(token: string): string { if (token.length <= 10) return '***' return `${token.slice(0, 10)}***` diff --git a/packages/store/src/cli/services/store/auth/index.test.ts b/packages/store/src/cli/services/store/auth/index.test.ts index 709515377e6..63dad5722a1 100644 --- a/packages/store/src/cli/services/store/auth/index.test.ts +++ b/packages/store/src/cli/services/store/auth/index.test.ts @@ -77,7 +77,7 @@ describe('store auth service', () => { }) }) - test('authenticateStoreWithApp includes signup JWT in the authorization URL when provided', async () => { + test('authenticateStoreWithApp opens a loopback handoff URL when a signup JWT is provided', async () => { const openURL = vi.fn().mockResolvedValue(true) const presenter = { openingBrowser: vi.fn(), @@ -110,7 +110,12 @@ describe('store auth service', () => { ) const authorizationUrl = new URL(openURL.mock.calls[0]![0]) - expect(authorizationUrl.searchParams.get('signup')).toBe('signed.signup.jwt') + expect(authorizationUrl.hostname).toBe('127.0.0.1') + expect(authorizationUrl.pathname).toBe('/auth/handoff') + expect(authorizationUrl.searchParams.get('signup')).toBeNull() + + const waitOptions = waitForStoreAuthCodeMock.mock.calls[0]![0] + expect(waitOptions.authorizationRedirect.authorizationUrl).toContain('signup=signed.signup.jwt') }) test('authenticateStoreWithApp uses remote scopes by default when available', async () => { @@ -304,10 +309,49 @@ describe('store auth service', () => { expect(presenter.openingBrowser).toHaveBeenCalledOnce() expect(presenter.manualAuthUrl).toHaveBeenCalledWith( expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'), + {sensitive: false}, ) expect(presenter.success).toHaveBeenCalledWith(result) }) + test('authenticateStoreWithApp prints the non-sensitive loopback handoff URL when signup JWT is present', async () => { + const openURL = vi.fn().mockResolvedValue(false) + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + signup: 'signed.signup.jwt', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter, + }, + ) + + expect(presenter.manualAuthUrl).toHaveBeenCalledWith( + expect.stringContaining('http://127.0.0.1:13387/auth/handoff?nonce='), + {sensitive: false}, + ) + expect(presenter.manualAuthUrl.mock.calls[0]![0]).not.toContain('signed.signup.jwt') + }) + test('authenticateStoreWithApp records fqdn metadata before resolving existing scopes', async () => { await expect( authenticateStoreWithApp( diff --git a/packages/store/src/cli/services/store/auth/index.ts b/packages/store/src/cli/services/store/auth/index.ts index 9a1dc4c9f8e..dbf6443f345 100644 --- a/packages/store/src/cli/services/store/auth/index.ts +++ b/packages/store/src/cli/services/store/auth/index.ts @@ -76,7 +76,7 @@ export async function authenticateStoreWithApp( ...bootstrap.waitForAuthCodeOptions, onListening: async () => { const opened = await resolvedDependencies.openURL(authorizationUrl) - if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl) + if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl, {sensitive: false}) }, }) const tokenResponse = await bootstrap.exchangeCodeForToken(code) diff --git a/packages/store/src/cli/services/store/auth/pkce.test.ts b/packages/store/src/cli/services/store/auth/pkce.test.ts index 22ddd130b17..bb126f25d59 100644 --- a/packages/store/src/cli/services/store/auth/pkce.test.ts +++ b/packages/store/src/cli/services/store/auth/pkce.test.ts @@ -1,5 +1,5 @@ import {STORE_AUTH_APP_CLIENT_ID} from './config.js' -import {buildStoreAuthUrl, computeCodeChallenge, generateCodeVerifier} from './pkce.js' +import {buildStoreAuthUrl, computeCodeChallenge, createPkceBootstrap, generateCodeVerifier} from './pkce.js' import {describe, expect, test} from 'vitest' describe('store auth PKCE helpers', () => { @@ -35,6 +35,23 @@ describe('store auth PKCE helpers', () => { expect(url.searchParams.get('signup')).toBe('signed.signup.jwt') }) + test('createPkceBootstrap uses a loopback handoff URL when signup is provided', () => { + const bootstrap = createPkceBootstrap({ + store: 'shop.myshopify.com', + scopes: ['read_products'], + signup: 'signed.signup.jwt', + exchangeCodeForToken: async () => ({access_token: 'token', scope: 'read_products'}), + }) + + const authorizationUrl = new URL(bootstrap.authorization.authorizationUrl) + expect(authorizationUrl.hostname).toBe('127.0.0.1') + expect(authorizationUrl.pathname).toBe('/auth/handoff') + expect(authorizationUrl.searchParams.get('signup')).toBeNull() + expect(bootstrap.waitForAuthCodeOptions.authorizationRedirect?.authorizationUrl).toContain( + 'signup=signed.signup.jwt', + ) + }) + test('buildStoreAuthUrl includes PKCE params and response_type=code', () => { const url = new URL( buildStoreAuthUrl({ diff --git a/packages/store/src/cli/services/store/auth/pkce.ts b/packages/store/src/cli/services/store/auth/pkce.ts index ff36d3deb25..be97728fdf7 100644 --- a/packages/store/src/cli/services/store/auth/pkce.ts +++ b/packages/store/src/cli/services/store/auth/pkce.ts @@ -1,4 +1,4 @@ -import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, storeAuthRedirectUri} from './config.js' +import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, storeAuthHandoffUri, storeAuthRedirectUri} from './config.js' import {randomUUID} from '@shopify/cli-kit/node/crypto' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {createHash, randomBytes} from 'crypto' @@ -68,7 +68,9 @@ export function createPkceBootstrap(options: { const redirectUri = storeAuthRedirectUri(port) const codeVerifier = generateCodeVerifier() const codeChallenge = computeCodeChallenge(codeVerifier) - const authorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge, signup}) + const sensitiveAuthorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge, signup}) + const handoffNonce = signup ? randomBytes(32).toString('base64url') : undefined + const authorizationUrl = handoffNonce ? storeAuthHandoffUri(port, handoffNonce) : sensitiveAuthorizationUrl outputDebug( outputContent`Starting PKCE auth for ${outputToken.raw(store)} with scopes ${outputToken.raw(scopes.join(','))} (redirect_uri=${outputToken.raw(redirectUri)})`, @@ -89,6 +91,12 @@ export function createPkceBootstrap(options: { store, state, port, + authorizationRedirect: handoffNonce + ? { + nonce: handoffNonce, + authorizationUrl: sensitiveAuthorizationUrl, + } + : undefined, }, exchangeCodeForToken: (code: string) => exchangeCodeForToken({store, code, codeVerifier, redirectUri}), } diff --git a/packages/store/src/cli/services/store/auth/result.test.ts b/packages/store/src/cli/services/store/auth/result.test.ts index 06f7ae1fc64..0e820ea1fcb 100644 --- a/packages/store/src/cli/services/store/auth/result.test.ts +++ b/packages/store/src/cli/services/store/auth/result.test.ts @@ -103,4 +103,22 @@ describe('store auth presenter', () => { expect(streams.stdout()).toContain('"store": "shop.myshopify.com"') expect(streams.stdout()).not.toContain('Authenticated') }) + + test('does not print manual auth URL output when marked sensitive', () => { + const output = mockAndCaptureOutput() + const presenter = createStoreAuthPresenter('text') + + presenter.manualAuthUrl('https://shop.myshopify.com/admin/oauth/authorize?client_id=test&secret=sensitive', { + sensitive: true, + }) + + expect(output.info()).toContain( + 'Browser did not open automatically. The manual authorization URL contains sensitive credentials and was not printed.', + ) + expect(output.info()).toContain( + 'Run this command again in an environment where Shopify CLI can open a browser automatically.', + ) + expect(output.info()).not.toContain('secret=sensitive') + expect(output.info()).not.toContain('https://shop.myshopify.com/admin/oauth/authorize') + }) }) diff --git a/packages/store/src/cli/services/store/auth/result.ts b/packages/store/src/cli/services/store/auth/result.ts index 58098a7c4f4..bfb6eebd9c7 100644 --- a/packages/store/src/cli/services/store/auth/result.ts +++ b/packages/store/src/cli/services/store/auth/result.ts @@ -19,9 +19,13 @@ export interface StoreAuthResult { type StoreAuthOutputFormat = 'text' | 'json' +interface ManualAuthUrlOptions { + sensitive?: boolean +} + export interface StoreAuthPresenter { openingBrowser: () => void - manualAuthUrl: (authorizationUrl: string) => void + manualAuthUrl: (authorizationUrl: string, options?: ManualAuthUrlOptions) => void success: (result: StoreAuthResult) => void } @@ -47,7 +51,14 @@ function displayStoreAuthOpeningBrowser(): void { outputInfo('') } -function displayStoreAuthManualAuthUrl(authorizationUrl: string): void { +function displayStoreAuthManualAuthUrl(authorizationUrl: string, options: ManualAuthUrlOptions = {}): void { + if (options.sensitive) { + outputInfo('Browser did not open automatically. The manual authorization URL contains sensitive credentials and was not printed.') + outputInfo('Run this command again in an environment where Shopify CLI can open a browser automatically.') + outputInfo('') + return + } + outputInfo('Browser did not open automatically. Open this URL manually:') outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) outputInfo('')