From 84ea2cb27f38b19e702bef942ba07182fa766a55 Mon Sep 17 00:00:00 2001 From: Jeffrey Deng Date: Thu, 25 Jun 2026 19:55:00 -0700 Subject: [PATCH] Add authentication command for stripe that accepts a JWT --- packages/cli/oclif.manifest.json | 78 +++++++++++++++++++ .../store/src/cli/commands/store/auth.test.ts | 1 + .../cli/commands/store/stripe-auth.test.ts | 65 ++++++++++++++++ .../src/cli/commands/store/stripe-auth.ts | 52 +++++++++++++ .../src/cli/services/store/auth/config.ts | 3 +- .../src/cli/services/store/auth/index.test.ts | 36 +++++++++ .../src/cli/services/store/auth/index.ts | 2 + .../src/cli/services/store/auth/pkce.test.ts | 16 ++++ .../store/src/cli/services/store/auth/pkce.ts | 9 ++- packages/store/src/index.ts | 2 + 10 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 packages/store/src/cli/commands/store/stripe-auth.test.ts create mode 100644 packages/store/src/cli/commands/store/stripe-auth.ts diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index ad60fffaed5..fd9f6499cd2 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6577,6 +6577,84 @@ "strict": true, "summary": "List stores in a Shopify organization." }, + "store:stripe-auth": { + "aliases": [ + ], + "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.", + "examples": [ + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup ", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup --json" + ], + "flags": { + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "scopes": { + "description": "Comma-separated Admin API scopes to request for the app.", + "env": "SHOPIFY_FLAG_SCOPES", + "hasDynamicHelp": false, + "multiple": false, + "name": "scopes", + "required": true, + "type": "option" + }, + "signup": { + "description": "Provide JWT for the store.", + "env": "SHOPIFY_FLAG_SIGNUP", + "hasDynamicHelp": false, + "multiple": false, + "name": "signup", + "required": true, + "type": "option" + }, + "store": { + "char": "s", + "description": "The myshopify.com domain of the store.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "required": true, + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "store:stripe-auth", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Authenticate for store commands." + }, "theme:check": { "aliases": [ ], diff --git a/packages/store/src/cli/commands/store/auth.test.ts b/packages/store/src/cli/commands/store/auth.test.ts index 2b3e0efa856..1a6560ee490 100644 --- a/packages/store/src/cli/commands/store/auth.test.ts +++ b/packages/store/src/cli/commands/store/auth.test.ts @@ -40,6 +40,7 @@ describe('store auth command', () => { expect(StoreAuth.flags.store).toBeDefined() expect(StoreAuth.flags.scopes).toBeDefined() expect(StoreAuth.flags.json).toBeDefined() + expect('signup' in StoreAuth.flags).toBe(false) expect('port' in StoreAuth.flags).toBe(false) expect('client-secret-file' in StoreAuth.flags).toBe(false) }) diff --git a/packages/store/src/cli/commands/store/stripe-auth.test.ts b/packages/store/src/cli/commands/store/stripe-auth.test.ts new file mode 100644 index 00000000000..9014cb76758 --- /dev/null +++ b/packages/store/src/cli/commands/store/stripe-auth.test.ts @@ -0,0 +1,65 @@ +import StoreStripeAuth 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' + +vi.mock('../../services/store/auth/index.js') +vi.mock('../../services/store/attribution.js') +vi.mock('../../services/store/auth/result.js', () => ({ + createStoreAuthPresenter: vi.fn((format: 'text' | 'json') => ({format})), +})) + +describe('store stripe-auth command', () => { + test('passes signup JWT through to the auth service', async () => { + await StoreStripeAuth.run([ + '--store', + 'shop.myshopify.com', + '--scopes', + 'read_products', + '--signup', + 'signed.signup.jwt', + ]) + + expect(createStoreAuthPresenter).toHaveBeenCalledWith('text') + expect(authenticateStoreWithApp).toHaveBeenCalledWith( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + signup: 'signed.signup.jwt', + }, + {presenter: {format: 'text'}}, + ) + }) + + test('passes a json presenter when --json is provided', async () => { + await StoreStripeAuth.run([ + '--store', + 'shop.myshopify.com', + '--scopes', + 'read_products', + '--signup', + 'signed.signup.jwt', + '--json', + ]) + + expect(createStoreAuthPresenter).toHaveBeenCalledWith('json') + expect(authenticateStoreWithApp).toHaveBeenCalledWith( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + signup: 'signed.signup.jwt', + }, + {presenter: {format: 'json'}}, + ) + }) + + test('defines the expected flags', () => { + 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.json).toBeDefined() + expect('port' in StoreStripeAuth.flags).toBe(false) + expect('client-secret-file' in StoreStripeAuth.flags).toBe(false) + }) +}) diff --git a/packages/store/src/cli/commands/store/stripe-auth.ts b/packages/store/src/cli/commands/store/stripe-auth.ts new file mode 100644 index 00000000000..2a17b5ee223 --- /dev/null +++ b/packages/store/src/cli/commands/store/stripe-auth.ts @@ -0,0 +1,52 @@ +import {authenticateStoreWithApp} from '../../services/store/auth/index.js' +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 {Flags} from '@oclif/core' + +export default class StoreStripeAuth extends StoreCommand { + static hidden = true + + 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 description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup ', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup --json', + ] + + static flags = { + ...globalFlags, + ...jsonFlag, + store: storeFlags.store, + scopes: Flags.string({ + description: 'Comma-separated Admin API scopes to request for the app.', + env: 'SHOPIFY_FLAG_SCOPES', + required: true, + }), + signup: Flags.string({ + description: 'Provide JWT for the store.', + env: 'SHOPIFY_FLAG_SIGNUP', + required: true, + }), + } + + public async run(): Promise { + const {flags} = await this.parse(StoreStripeAuth) + + await authenticateStoreWithApp( + { + store: flags.store, + scopes: flags.scopes, + signup: flags.signup, + }, + { + presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'), + }, + ) + } +} diff --git a/packages/store/src/cli/services/store/auth/config.ts b/packages/store/src/cli/services/store/auth/config.ts index 46b72e6a7da..cf5d05d6b23 100644 --- a/packages/store/src/cli/services/store/auth/config.ts +++ b/packages/store/src/cli/services/store/auth/config.ts @@ -1,4 +1,5 @@ -export const STORE_AUTH_APP_CLIENT_ID = '7e9cb568cfd431c538f36d1ad3f2b4f6' +export const STORE_AUTH_APP_CLIENT_ID = + process.env.SHOPIFY_STORE_AUTH_APP_CLIENT_ID ?? '7e9cb568cfd431c538f36d1ad3f2b4f6' export const DEFAULT_STORE_AUTH_PORT = 13387 export const STORE_AUTH_CALLBACK_PATH = '/auth/callback' 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 a02c10ce5ac..03700557f65 100644 --- a/packages/store/src/cli/services/store/auth/index.test.ts +++ b/packages/store/src/cli/services/store/auth/index.test.ts @@ -77,6 +77,42 @@ describe('store auth service', () => { }) }) + test('authenticateStoreWithApp includes signup JWT in the authorization URL when provided', async () => { + const openURL = vi.fn().mockResolvedValue(true) + 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, + refresh_token: 'refresh-token', + associated_user: {id: 42, email: 'test@example.com'}, + }), + presenter, + }, + ) + + const authorizationUrl = new URL(openURL.mock.calls[0]![0]) + expect(authorizationUrl.searchParams.get('signup')).toBe('signed.signup.jwt') + }) + test('authenticateStoreWithApp uses remote scopes by default when available', async () => { const openURL = vi.fn().mockResolvedValue(true) const presenter = { diff --git a/packages/store/src/cli/services/store/auth/index.ts b/packages/store/src/cli/services/store/auth/index.ts index 6cd398dd7f8..cb1ecd6b54e 100644 --- a/packages/store/src/cli/services/store/auth/index.ts +++ b/packages/store/src/cli/services/store/auth/index.ts @@ -18,6 +18,7 @@ export {listStoredStoreAuthSummaries, type StoredStoreAuthSummary} from './store interface StoreAuthInput { store: string scopes: string + signup?: string } interface StoreAuthDependencies { @@ -57,6 +58,7 @@ export async function authenticateStoreWithApp( const bootstrap = createPkceBootstrap({ store, scopes, + signup: input.signup, exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken, }) const { 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 d46c844b3a2..22ddd130b17 100644 --- a/packages/store/src/cli/services/store/auth/pkce.test.ts +++ b/packages/store/src/cli/services/store/auth/pkce.test.ts @@ -20,6 +20,21 @@ describe('store auth PKCE helpers', () => { expect(computeCodeChallenge(verifier)).toBe(expected) }) + test('buildStoreAuthUrl includes signup JWT when provided', () => { + const url = new URL( + buildStoreAuthUrl({ + store: 'shop.myshopify.com', + scopes: ['read_products'], + state: 'state-123', + redirectUri: 'http://127.0.0.1:13387/auth/callback', + codeChallenge: 'test-challenge-value', + signup: 'signed.signup.jwt', + }), + ) + + expect(url.searchParams.get('signup')).toBe('signed.signup.jwt') + }) + test('buildStoreAuthUrl includes PKCE params and response_type=code', () => { const url = new URL( buildStoreAuthUrl({ @@ -40,6 +55,7 @@ describe('store auth PKCE helpers', () => { expect(url.searchParams.get('response_type')).toBe('code') expect(url.searchParams.get('code_challenge')).toBe('test-challenge-value') expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('signup')).toBeNull() expect(url.searchParams.get('grant_options[]')).toBeNull() }) }) diff --git a/packages/store/src/cli/services/store/auth/pkce.ts b/packages/store/src/cli/services/store/auth/pkce.ts index e3ba9fd9562..ff36d3deb25 100644 --- a/packages/store/src/cli/services/store/auth/pkce.ts +++ b/packages/store/src/cli/services/store/auth/pkce.ts @@ -13,6 +13,7 @@ interface StoreAuthorizationContext { redirectUri: string authorizationUrl: string codeVerifier: string + signup?: string } interface StoreAuthBootstrap { @@ -35,6 +36,7 @@ export function buildStoreAuthUrl(options: { state: string redirectUri: string codeChallenge: string + signup?: string }): string { const params = new URLSearchParams() params.set('client_id', STORE_AUTH_APP_CLIENT_ID) @@ -44,6 +46,7 @@ export function buildStoreAuthUrl(options: { params.set('response_type', 'code') params.set('code_challenge', options.codeChallenge) params.set('code_challenge_method', 'S256') + if (options.signup) params.set('signup', options.signup) return `https://${options.store}/admin/oauth/authorize?${params.toString()}` } @@ -51,6 +54,7 @@ export function buildStoreAuthUrl(options: { export function createPkceBootstrap(options: { store: string scopes: string[] + signup?: string exchangeCodeForToken: (options: { store: string code: string @@ -58,13 +62,13 @@ export function createPkceBootstrap(options: { redirectUri: string }) => Promise }): StoreAuthBootstrap { - const {store, scopes, exchangeCodeForToken} = options + const {store, scopes, signup, exchangeCodeForToken} = options const port = DEFAULT_STORE_AUTH_PORT const state = randomUUID() const redirectUri = storeAuthRedirectUri(port) const codeVerifier = generateCodeVerifier() const codeChallenge = computeCodeChallenge(codeVerifier) - const authorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge}) + const authorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge, signup}) outputDebug( outputContent`Starting PKCE auth for ${outputToken.raw(store)} with scopes ${outputToken.raw(scopes.join(','))} (redirect_uri=${outputToken.raw(redirectUri)})`, @@ -79,6 +83,7 @@ export function createPkceBootstrap(options: { redirectUri, authorizationUrl, codeVerifier, + signup, }, waitForAuthCodeOptions: { store, diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 7a0951b8ff1..fd50a8de117 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -3,6 +3,7 @@ import StoreAuth from './cli/commands/store/auth.js' import StoreBulkCancel from './cli/commands/store/bulk/cancel.js' import StoreBulkExecute from './cli/commands/store/bulk/execute.js' import StoreBulkStatus from './cli/commands/store/bulk/status.js' +import StoreStripeAuth from './cli/commands/store/stripe-auth.js' import StoreCreateDev from './cli/commands/store/create/dev.js' import StoreCreatePreview from './cli/commands/store/create/preview.js' import StoreExecute from './cli/commands/store/execute.js' @@ -15,6 +16,7 @@ const COMMANDS = { 'store:bulk:cancel': StoreBulkCancel, 'store:bulk:execute': StoreBulkExecute, 'store:bulk:status': StoreBulkStatus, + 'store:stripe-auth': StoreStripeAuth, 'store:create:dev': StoreCreateDev, 'store:create:preview': StoreCreatePreview, 'store:execute': StoreExecute,