Skip to content
Draft
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
13 changes: 11 additions & 2 deletions packages/store/src/cli/commands/store/stripe-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand Down Expand Up @@ -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')
})
})
30 changes: 26 additions & 4 deletions packages/store/src/cli/commands/store/stripe-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@ 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 {
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 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 <signup-jwt>',
'printf %s <signup-jwt> | <%= 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 <signup-jwt> --json',
]

Expand All @@ -29,24 +31,44 @@ 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<void> {
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'),
},
)
}
}

export async function readSignupJwtFromStdin(
stdin: NodeJS.ReadableStream & AsyncIterable<Buffer | string> = process.stdin,
): Promise<string> {
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 <jwt>, set SHOPIFY_FLAG_SIGNUP, or pipe the JWT to stdin.',
)
}

return signup
}
33 changes: 33 additions & 0 deletions packages/store/src/cli/services/store/auth/callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'})
Expand Down
36 changes: 35 additions & 1 deletion packages/store/src/cli/services/store/auth/callback.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,6 +12,10 @@ export interface WaitForAuthCodeOptions {
port: number
timeoutMs?: number
onListening?: () => void | Promise<void>
authorizationRedirect?: {
nonce: string
authorizationUrl: string
}
}

function renderAuthCallbackPage(title: string, message: string): string {
Expand Down Expand Up @@ -99,12 +103,14 @@ export async function waitForStoreAuthCode({
port,
timeoutMs = 5 * 60 * 1000,
onListening,
authorizationRedirect,
}: WaitForAuthCodeOptions): Promise<string> {
const normalizedStore = normalizeStoreFqdn(store)

return new Promise<string>((resolve, reject) => {
let settled = false
let isListening = false
let authorizationRedirectUsed = false

const timeout = setTimeout(() => {
settleWithError(new AbortError('Timed out waiting for OAuth callback.'))
Expand All @@ -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')
Expand Down
5 changes: 5 additions & 0 deletions packages/store/src/cli/services/store/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}***`
Expand Down
48 changes: 46 additions & 2 deletions packages/store/src/cli/services/store/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion packages/store/src/cli/services/store/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 18 additions & 1 deletion packages/store/src/cli/services/store/auth/pkce.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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({
Expand Down
12 changes: 10 additions & 2 deletions packages/store/src/cli/services/store/auth/pkce.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)})`,
Expand All @@ -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}),
}
Expand Down
18 changes: 18 additions & 0 deletions packages/store/src/cli/services/store/auth/result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
Loading
Loading