diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index d733173cf51..c099a2a5a3a 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -7799,10 +7799,11 @@ "args": { }, "customPluginName": "@shopify/store", - "description": "Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup.", - "descriptionWithMarkdown": "Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup.", + "description": "Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup or stdin.", + "descriptionWithMarkdown": "Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup or stdin.", "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" ], "flags": { @@ -7833,12 +7834,12 @@ "type": "option" }, "signup": { - "description": "Provide JWT for the store.", + "description": "Provide JWT for the store. When omitted, the JWT is read from stdin.", "env": "SHOPIFY_FLAG_SIGNUP", "hasDynamicHelp": false, "multiple": false, "name": "signup", - "required": true, + "required": false, "type": "option" }, "store": { 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 df839923cf8..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 () => { @@ -309,7 +314,7 @@ describe('store auth service', () => { expect(presenter.success).toHaveBeenCalledWith(result) }) - test('authenticateStoreWithApp marks manual auth URL as sensitive when signup JWT is present', async () => { + 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(), @@ -321,63 +326,30 @@ describe('store auth service', () => { return 'abc123' }) - await expect( - 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, - }, - ), - ).rejects.toThrow() - - expect(presenter.manualAuthUrl).toHaveBeenCalledWith(expect.stringContaining('signup=signed.signup.jwt'), { - sensitive: true, - }) - }) - - test('authenticateStoreWithApp fails immediately instead of waiting for a callback that cannot arrive', async () => { - const openURL = vi.fn().mockResolvedValue(false) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } - const exchangeStoreAuthCodeForToken = vi.fn() - const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { - await options.onListening?.() - return 'abc123' - }) - - await expect( - authenticateStoreWithApp( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - signup: 'signed.signup.jwt', - }, - { - openURL, - waitForStoreAuthCode: waitForStoreAuthCodeMock, - exchangeStoreAuthCodeForToken, - presenter, - }, - ), - ).rejects.toThrow("Authentication can't continue without a browser.") + 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(exchangeStoreAuthCodeForToken).not.toHaveBeenCalled() - expect(presenter.success).not.toHaveBeenCalled() + 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 () => { diff --git a/packages/store/src/cli/services/store/auth/index.ts b/packages/store/src/cli/services/store/auth/index.ts index dcf179e40ac..dbf6443f345 100644 --- a/packages/store/src/cli/services/store/auth/index.ts +++ b/packages/store/src/cli/services/store/auth/index.ts @@ -76,14 +76,7 @@ export async function authenticateStoreWithApp( ...bootstrap.waitForAuthCodeOptions, onListening: async () => { const opened = await resolvedDependencies.openURL(authorizationUrl) - if (opened) return - - const sensitive = Boolean(input.signup) - resolvedDependencies.presenter.manualAuthUrl(authorizationUrl, {sensitive}) - - // A withheld URL never reaches the browser, so the callback this server is waiting for cannot - // arrive. Returning here would leave the command idle until the timeout elapses. - if (sensitive) throw new AbortError("Authentication can't continue without a browser.") + 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}), }