Skip to content
Open
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
78 changes: 78 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <signup-jwt>",
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup <signup-jwt> --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": [
],
Expand Down
1 change: 1 addition & 0 deletions packages/store/src/cli/commands/store/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
65 changes: 65 additions & 0 deletions packages/store/src/cli/commands/store/stripe-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
52 changes: 52 additions & 0 deletions packages/store/src/cli/commands/store/stripe-auth.ts
Original file line number Diff line number Diff line change
@@ -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 <signup-jwt>',
'<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup <signup-jwt> --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<void> {
const {flags} = await this.parse(StoreStripeAuth)

await authenticateStoreWithApp(
{
store: flags.store,
scopes: flags.scopes,
signup: flags.signup,
},
{
presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'),
},
)
}
}
3 changes: 2 additions & 1 deletion packages/store/src/cli/services/store/auth/config.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
36 changes: 36 additions & 0 deletions packages/store/src/cli/services/store/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 2 additions & 0 deletions packages/store/src/cli/services/store/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {listStoredStoreAuthSummaries, type StoredStoreAuthSummary} from './store
interface StoreAuthInput {
store: string
scopes: string
signup?: string
}

interface StoreAuthDependencies {
Expand Down Expand Up @@ -57,6 +58,7 @@ export async function authenticateStoreWithApp(
const bootstrap = createPkceBootstrap({
store,
scopes,
signup: input.signup,
exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken,
})
const {
Expand Down
16 changes: 16 additions & 0 deletions packages/store/src/cli/services/store/auth/pkce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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()
})
})
9 changes: 7 additions & 2 deletions packages/store/src/cli/services/store/auth/pkce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface StoreAuthorizationContext {
redirectUri: string
authorizationUrl: string
codeVerifier: string
signup?: string
}

interface StoreAuthBootstrap {
Expand All @@ -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)
Expand All @@ -44,27 +46,29 @@ 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()}`
}

export function createPkceBootstrap(options: {
store: string
scopes: string[]
signup?: string
exchangeCodeForToken: (options: {
store: string
code: string
codeVerifier: string
redirectUri: string
}) => Promise<StoreTokenResponse>
}): 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)})`,
Expand All @@ -79,6 +83,7 @@ export function createPkceBootstrap(options: {
redirectUri,
authorizationUrl,
codeVerifier,
signup,
},
waitForAuthCodeOptions: {
store,
Expand Down
2 changes: 2 additions & 0 deletions packages/store/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand Down
Loading