Skip to content

Commit cb605c4

Browse files
Merge pull request #8428 from Shopify/signup-stdin-loopback
Read store signup JWT from stdin
2 parents 3518185 + e4458fd commit cb605c4

10 files changed

Lines changed: 175 additions & 81 deletions

File tree

packages/cli/oclif.manifest.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7799,10 +7799,11 @@
77997799
"args": {
78007800
},
78017801
"customPluginName": "@shopify/store",
7802-
"description": "Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup.",
7803-
"descriptionWithMarkdown": "Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup.",
7802+
"description": "Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup or stdin.",
7803+
"descriptionWithMarkdown": "Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup or stdin.",
78047804
"examples": [
78057805
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup <signup-jwt>",
7806+
"printf %s <signup-jwt> | <%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products",
78067807
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup <signup-jwt> --json"
78077808
],
78087809
"flags": {
@@ -7833,12 +7834,12 @@
78337834
"type": "option"
78347835
},
78357836
"signup": {
7836-
"description": "Provide JWT for the store.",
7837+
"description": "Provide JWT for the store. When omitted, the JWT is read from stdin.",
78377838
"env": "SHOPIFY_FLAG_SIGNUP",
78387839
"hasDynamicHelp": false,
78397840
"multiple": false,
78407841
"name": "signup",
7841-
"required": true,
7842+
"required": false,
78427843
"type": "option"
78437844
},
78447845
"store": {

packages/store/src/cli/commands/store/stripe-auth.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import StoreStripeAuth from './stripe-auth.js'
1+
import StoreStripeAuth, {readSignupJwtFromStdin} from './stripe-auth.js'
22
import {authenticateStoreWithApp} from '../../services/store/auth/index.js'
33
import {createStoreAuthPresenter} from '../../services/store/auth/result.js'
44
import {describe, expect, test, vi} from 'vitest'
5+
import {Readable} from 'stream'
56

67
vi.mock('../../services/store/auth/index.js')
78
vi.mock('../../services/store/attribution.js')
@@ -57,9 +58,17 @@ describe('store stripe-auth command', () => {
5758
expect(StoreStripeAuth.flags.store).toBeDefined()
5859
expect(StoreStripeAuth.flags.scopes).toBeDefined()
5960
expect(StoreStripeAuth.flags.signup).toBeDefined()
60-
expect(StoreStripeAuth.flags.signup.required).toBe(true)
61+
expect(StoreStripeAuth.flags.signup.required).toBe(false)
6162
expect(StoreStripeAuth.flags.json).toBeDefined()
6263
expect('port' in StoreStripeAuth.flags).toBe(false)
6364
expect('client-secret-file' in StoreStripeAuth.flags).toBe(false)
6465
})
66+
67+
test('reads the signup JWT from stdin', async () => {
68+
await expect(readSignupJwtFromStdin(Readable.from([' signed.signup.jwt\n']))).resolves.toBe('signed.signup.jwt')
69+
})
70+
71+
test('rejects blank stdin signup JWTs', async () => {
72+
await expect(readSignupJwtFromStdin(Readable.from(['\n']))).rejects.toThrow('Missing signup JWT')
73+
})
6574
})

packages/store/src/cli/commands/store/stripe-auth.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,21 @@ import {createStoreAuthPresenter} from '../../services/store/auth/result.js'
33
import StoreCommand from '../../utilities/store-command.js'
44
import {storeFlags} from '../../flags.js'
55
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
6+
import {AbortError} from '@shopify/cli-kit/node/error'
67
import {Flags} from '@oclif/core'
78

89
export default class StoreStripeAuth extends StoreCommand {
910
static hidden = true
1011

1112
static summary = 'Authenticate for store commands.'
1213

13-
static descriptionWithMarkdown = `Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup.`
14+
static descriptionWithMarkdown = `Authenticates to a store then stores an online access token for later reuse. Pass the provided JWT to --signup or stdin.`
1415

1516
static description = this.descriptionWithoutMarkdown()
1617

1718
static examples = [
1819
'<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup <signup-jwt>',
20+
'printf %s <signup-jwt> | <%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products',
1921
'<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --signup <signup-jwt> --json',
2022
]
2123

@@ -29,24 +31,44 @@ export default class StoreStripeAuth extends StoreCommand {
2931
required: true,
3032
}),
3133
signup: Flags.string({
32-
description: 'Provide JWT for the store.',
34+
description: 'Provide JWT for the store. When omitted, the JWT is read from stdin.',
3335
env: 'SHOPIFY_FLAG_SIGNUP',
34-
required: true,
36+
required: false,
3537
}),
3638
}
3739

3840
public async run(): Promise<void> {
3941
const {flags} = await this.parse(StoreStripeAuth)
42+
const signup = flags.signup ?? (await readSignupJwtFromStdin())
4043

4144
await authenticateStoreWithApp(
4245
{
4346
store: flags.store,
4447
scopes: flags.scopes,
45-
signup: flags.signup,
48+
signup,
4649
},
4750
{
4851
presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'),
4952
},
5053
)
5154
}
5255
}
56+
57+
export async function readSignupJwtFromStdin(
58+
stdin: NodeJS.ReadableStream & AsyncIterable<Buffer | string> = process.stdin,
59+
): Promise<string> {
60+
const chunks: Buffer[] = []
61+
for await (const chunk of stdin) {
62+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
63+
}
64+
65+
const signup = Buffer.concat(chunks).toString('utf8').trim()
66+
if (!signup) {
67+
throw new AbortError(
68+
'Missing signup JWT.',
69+
'Pass --signup <jwt>, set SHOPIFY_FLAG_SIGNUP, or pipe the JWT to stdin.',
70+
)
71+
}
72+
73+
return signup
74+
}

packages/store/src/cli/services/store/auth/callback.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,39 @@ describe('store auth callback server', () => {
5959
).resolves.toBe('abc123')
6060
})
6161

62+
test('waitForStoreAuthCode redirects a valid authorization handoff without settling auth', async () => {
63+
const port = await getAvailablePort()
64+
const params = callbackParams()
65+
const authorizationUrl = 'https://shop.myshopify.com/admin/oauth/authorize?signup=signed.signup.jwt'
66+
const onListening = async () => {
67+
const handoffResponse = await globalThis.fetch(`http://127.0.0.1:${port}/auth/handoff?nonce=nonce-123`, {
68+
redirect: 'manual',
69+
})
70+
expect(handoffResponse.status).toBe(302)
71+
expect(handoffResponse.headers.get('Location')).toBe(authorizationUrl)
72+
expect(handoffResponse.headers.get('Cache-Control')).toBe('no-store')
73+
expect(handoffResponse.headers.get('Referrer-Policy')).toBe('no-referrer')
74+
75+
const callbackResponse = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`)
76+
expect(callbackResponse.status).toBe(200)
77+
await callbackResponse.text()
78+
}
79+
80+
await expect(
81+
waitForStoreAuthCode({
82+
store: 'shop.myshopify.com',
83+
state: 'state-123',
84+
port,
85+
timeoutMs: 1000,
86+
authorizationRedirect: {
87+
nonce: 'nonce-123',
88+
authorizationUrl,
89+
},
90+
onListening,
91+
}),
92+
).resolves.toBe('abc123')
93+
})
94+
6295
test('waitForStoreAuthCode rejects when callback state does not match', async () => {
6396
const port = await getAvailablePort()
6497
const params = callbackParams({state: 'wrong-state'})

packages/store/src/cli/services/store/auth/callback.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {STORE_AUTH_CALLBACK_PATH, maskToken} from './config.js'
1+
import {STORE_AUTH_CALLBACK_PATH, STORE_AUTH_HANDOFF_PATH, maskToken} from './config.js'
22
import {retryStoreAuthWithPermanentDomainError} from './recovery.js'
33
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
44
import {AbortError} from '@shopify/cli-kit/node/error'
@@ -12,6 +12,10 @@ export interface WaitForAuthCodeOptions {
1212
port: number
1313
timeoutMs?: number
1414
onListening?: () => void | Promise<void>
15+
authorizationRedirect?: {
16+
nonce: string
17+
authorizationUrl: string
18+
}
1519
}
1620

1721
function renderAuthCallbackPage(title: string, message: string): string {
@@ -99,12 +103,14 @@ export async function waitForStoreAuthCode({
99103
port,
100104
timeoutMs = 5 * 60 * 1000,
101105
onListening,
106+
authorizationRedirect,
102107
}: WaitForAuthCodeOptions): Promise<string> {
103108
const normalizedStore = normalizeStoreFqdn(store)
104109

105110
return new Promise<string>((resolve, reject) => {
106111
let settled = false
107112
let isListening = false
113+
let authorizationRedirectUsed = false
108114

109115
const timeout = setTimeout(() => {
110116
settleWithError(new AbortError('Timed out waiting for OAuth callback.'))
@@ -113,6 +119,34 @@ export async function waitForStoreAuthCode({
113119
const server = createServer((req, res) => {
114120
const requestUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`)
115121

122+
if (requestUrl.pathname === STORE_AUTH_HANDOFF_PATH && authorizationRedirect) {
123+
const returnedNonce = requestUrl.searchParams.get('nonce')
124+
if (!returnedNonce || !constantTimeEqual(returnedNonce, authorizationRedirect.nonce)) {
125+
res.statusCode = 403
126+
res.setHeader('Cache-Control', 'no-store')
127+
res.setHeader('Connection', 'close')
128+
res.end('Forbidden')
129+
return
130+
}
131+
132+
if (authorizationRedirectUsed) {
133+
res.statusCode = 410
134+
res.setHeader('Cache-Control', 'no-store')
135+
res.setHeader('Connection', 'close')
136+
res.end('Authorization handoff already used')
137+
return
138+
}
139+
140+
authorizationRedirectUsed = true
141+
res.statusCode = 302
142+
res.setHeader('Location', authorizationRedirect.authorizationUrl)
143+
res.setHeader('Cache-Control', 'no-store')
144+
res.setHeader('Referrer-Policy', 'no-referrer')
145+
res.setHeader('Connection', 'close')
146+
res.end()
147+
return
148+
}
149+
116150
if (requestUrl.pathname !== STORE_AUTH_CALLBACK_PATH) {
117151
res.statusCode = 404
118152
res.end('Not found')

packages/store/src/cli/services/store/auth/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@ export {storeAuthSessionKey} from '@shopify/cli-kit/node/store-auth-session'
33

44
export const DEFAULT_STORE_AUTH_PORT = 13387
55
export const STORE_AUTH_CALLBACK_PATH = '/auth/callback'
6+
export const STORE_AUTH_HANDOFF_PATH = '/auth/handoff'
67

78
export function storeAuthRedirectUri(port: number): string {
89
return `http://127.0.0.1:${port}${STORE_AUTH_CALLBACK_PATH}`
910
}
1011

12+
export function storeAuthHandoffUri(port: number, nonce: string): string {
13+
return `http://127.0.0.1:${port}${STORE_AUTH_HANDOFF_PATH}?nonce=${encodeURIComponent(nonce)}`
14+
}
15+
1116
export function maskToken(token: string): string {
1217
if (token.length <= 10) return '***'
1318
return `${token.slice(0, 10)}***`

packages/store/src/cli/services/store/auth/index.test.ts

Lines changed: 31 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe('store auth service', () => {
7777
})
7878
})
7979

80-
test('authenticateStoreWithApp includes signup JWT in the authorization URL when provided', async () => {
80+
test('authenticateStoreWithApp opens a loopback handoff URL when a signup JWT is provided', async () => {
8181
const openURL = vi.fn().mockResolvedValue(true)
8282
const presenter = {
8383
openingBrowser: vi.fn(),
@@ -110,7 +110,12 @@ describe('store auth service', () => {
110110
)
111111

112112
const authorizationUrl = new URL(openURL.mock.calls[0]![0])
113-
expect(authorizationUrl.searchParams.get('signup')).toBe('signed.signup.jwt')
113+
expect(authorizationUrl.hostname).toBe('127.0.0.1')
114+
expect(authorizationUrl.pathname).toBe('/auth/handoff')
115+
expect(authorizationUrl.searchParams.get('signup')).toBeNull()
116+
117+
const waitOptions = waitForStoreAuthCodeMock.mock.calls[0]![0]
118+
expect(waitOptions.authorizationRedirect.authorizationUrl).toContain('signup=signed.signup.jwt')
114119
})
115120

116121
test('authenticateStoreWithApp uses remote scopes by default when available', async () => {
@@ -309,7 +314,7 @@ describe('store auth service', () => {
309314
expect(presenter.success).toHaveBeenCalledWith(result)
310315
})
311316

312-
test('authenticateStoreWithApp marks manual auth URL as sensitive when signup JWT is present', async () => {
317+
test('authenticateStoreWithApp prints the non-sensitive loopback handoff URL when signup JWT is present', async () => {
313318
const openURL = vi.fn().mockResolvedValue(false)
314319
const presenter = {
315320
openingBrowser: vi.fn(),
@@ -321,63 +326,30 @@ describe('store auth service', () => {
321326
return 'abc123'
322327
})
323328

324-
await expect(
325-
authenticateStoreWithApp(
326-
{
327-
store: 'shop.myshopify.com',
328-
scopes: 'read_products',
329-
signup: 'signed.signup.jwt',
330-
},
331-
{
332-
openURL,
333-
waitForStoreAuthCode: waitForStoreAuthCodeMock,
334-
exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({
335-
access_token: 'token',
336-
scope: 'read_products',
337-
expires_in: 86400,
338-
associated_user: {id: 42, email: 'test@example.com'},
339-
}),
340-
presenter,
341-
},
342-
),
343-
).rejects.toThrow()
344-
345-
expect(presenter.manualAuthUrl).toHaveBeenCalledWith(expect.stringContaining('signup=signed.signup.jwt'), {
346-
sensitive: true,
347-
})
348-
})
349-
350-
test('authenticateStoreWithApp fails immediately instead of waiting for a callback that cannot arrive', async () => {
351-
const openURL = vi.fn().mockResolvedValue(false)
352-
const presenter = {
353-
openingBrowser: vi.fn(),
354-
manualAuthUrl: vi.fn(),
355-
success: vi.fn(),
356-
}
357-
const exchangeStoreAuthCodeForToken = vi.fn()
358-
const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => {
359-
await options.onListening?.()
360-
return 'abc123'
361-
})
362-
363-
await expect(
364-
authenticateStoreWithApp(
365-
{
366-
store: 'shop.myshopify.com',
367-
scopes: 'read_products',
368-
signup: 'signed.signup.jwt',
369-
},
370-
{
371-
openURL,
372-
waitForStoreAuthCode: waitForStoreAuthCodeMock,
373-
exchangeStoreAuthCodeForToken,
374-
presenter,
375-
},
376-
),
377-
).rejects.toThrow("Authentication can't continue without a browser.")
329+
await authenticateStoreWithApp(
330+
{
331+
store: 'shop.myshopify.com',
332+
scopes: 'read_products',
333+
signup: 'signed.signup.jwt',
334+
},
335+
{
336+
openURL,
337+
waitForStoreAuthCode: waitForStoreAuthCodeMock,
338+
exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({
339+
access_token: 'token',
340+
scope: 'read_products',
341+
expires_in: 86400,
342+
associated_user: {id: 42, email: 'test@example.com'},
343+
}),
344+
presenter,
345+
},
346+
)
378347

379-
expect(exchangeStoreAuthCodeForToken).not.toHaveBeenCalled()
380-
expect(presenter.success).not.toHaveBeenCalled()
348+
expect(presenter.manualAuthUrl).toHaveBeenCalledWith(
349+
expect.stringContaining('http://127.0.0.1:13387/auth/handoff?nonce='),
350+
{sensitive: false},
351+
)
352+
expect(presenter.manualAuthUrl.mock.calls[0]![0]).not.toContain('signed.signup.jwt')
381353
})
382354

383355
test('authenticateStoreWithApp records fqdn metadata before resolving existing scopes', async () => {

packages/store/src/cli/services/store/auth/index.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,7 @@ export async function authenticateStoreWithApp(
7676
...bootstrap.waitForAuthCodeOptions,
7777
onListening: async () => {
7878
const opened = await resolvedDependencies.openURL(authorizationUrl)
79-
if (opened) return
80-
81-
const sensitive = Boolean(input.signup)
82-
resolvedDependencies.presenter.manualAuthUrl(authorizationUrl, {sensitive})
83-
84-
// A withheld URL never reaches the browser, so the callback this server is waiting for cannot
85-
// arrive. Returning here would leave the command idle until the timeout elapses.
86-
if (sensitive) throw new AbortError("Authentication can't continue without a browser.")
79+
if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl, {sensitive: false})
8780
},
8881
})
8982
const tokenResponse = await bootstrap.exchangeCodeForToken(code)

0 commit comments

Comments
 (0)