Skip to content

Commit 176cf3b

Browse files
committed
Split auth/index
1 parent ead0c31 commit 176cf3b

File tree

10 files changed

+832
-807
lines changed

10 files changed

+832
-807
lines changed
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import {createServer} from 'http'
2+
import {describe, expect, test} from 'vitest'
3+
import {waitForStoreAuthCode} from './callback.js'
4+
5+
async function getAvailablePort(): Promise<number> {
6+
return await new Promise<number>((resolve, reject) => {
7+
const server = createServer()
8+
9+
server.on('error', reject)
10+
server.listen(0, '127.0.0.1', () => {
11+
const address = server.address()
12+
if (!address || typeof address === 'string') {
13+
reject(new Error('Expected an ephemeral port.'))
14+
return
15+
}
16+
17+
server.close((error) => {
18+
if (error) {
19+
reject(error)
20+
return
21+
}
22+
23+
resolve(address.port)
24+
})
25+
})
26+
})
27+
}
28+
29+
function callbackParams(options?: {
30+
code?: string
31+
shop?: string
32+
state?: string
33+
error?: string
34+
}): URLSearchParams {
35+
const params = new URLSearchParams()
36+
params.set('shop', options?.shop ?? 'shop.myshopify.com')
37+
params.set('state', options?.state ?? 'state-123')
38+
39+
if (options?.code) params.set('code', options.code)
40+
if (options?.error) params.set('error', options.error)
41+
if (!options?.code && !options?.error) params.set('code', 'abc123')
42+
43+
return params
44+
}
45+
46+
describe('store auth callback server', () => {
47+
test('waitForStoreAuthCode resolves after a valid callback', async () => {
48+
const port = await getAvailablePort()
49+
const params = callbackParams()
50+
const onListening = async () => {
51+
const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`)
52+
expect(response.status).toBe(200)
53+
await response.text()
54+
}
55+
56+
await expect(
57+
waitForStoreAuthCode({
58+
store: 'shop.myshopify.com',
59+
state: 'state-123',
60+
port,
61+
timeoutMs: 1000,
62+
onListening,
63+
}),
64+
).resolves.toBe('abc123')
65+
})
66+
67+
test('waitForStoreAuthCode rejects when callback state does not match', async () => {
68+
const port = await getAvailablePort()
69+
const params = callbackParams({state: 'wrong-state'})
70+
71+
await expect(
72+
waitForStoreAuthCode({
73+
store: 'shop.myshopify.com',
74+
state: 'state-123',
75+
port,
76+
timeoutMs: 1000,
77+
onListening: async () => {
78+
const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`)
79+
expect(response.status).toBe(400)
80+
await response.text()
81+
},
82+
}),
83+
).rejects.toThrow('OAuth callback state does not match the original request.')
84+
})
85+
86+
test('waitForStoreAuthCode rejects when callback store does not match and suggests the returned permanent domain', async () => {
87+
const port = await getAvailablePort()
88+
const params = callbackParams({shop: 'other-shop.myshopify.com'})
89+
90+
await expect(
91+
waitForStoreAuthCode({
92+
store: 'shop.myshopify.com',
93+
state: 'state-123',
94+
port,
95+
timeoutMs: 1000,
96+
onListening: async () => {
97+
const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`)
98+
expect(response.status).toBe(400)
99+
await response.text()
100+
},
101+
}),
102+
).rejects.toMatchObject({
103+
message: 'OAuth callback store does not match the requested store.',
104+
tryMessage: 'Shopify returned other-shop.myshopify.com during authentication. Re-run using the permanent store domain:',
105+
nextSteps: [[{command: 'shopify store auth --store other-shop.myshopify.com --scopes <comma-separated-scopes>'}]],
106+
})
107+
})
108+
109+
test('waitForStoreAuthCode rejects when Shopify returns an OAuth error', async () => {
110+
const port = await getAvailablePort()
111+
const params = callbackParams({error: 'access_denied'})
112+
113+
await expect(
114+
waitForStoreAuthCode({
115+
store: 'shop.myshopify.com',
116+
state: 'state-123',
117+
port,
118+
timeoutMs: 1000,
119+
onListening: async () => {
120+
const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`)
121+
expect(response.status).toBe(400)
122+
await response.text()
123+
},
124+
}),
125+
).rejects.toThrow('Shopify returned an OAuth error: access_denied')
126+
})
127+
128+
test('waitForStoreAuthCode rejects when callback does not include an authorization code', async () => {
129+
const port = await getAvailablePort()
130+
const params = callbackParams()
131+
params.delete('code')
132+
133+
await expect(
134+
waitForStoreAuthCode({
135+
store: 'shop.myshopify.com',
136+
state: 'state-123',
137+
port,
138+
timeoutMs: 1000,
139+
onListening: async () => {
140+
const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`)
141+
expect(response.status).toBe(400)
142+
await response.text()
143+
},
144+
}),
145+
).rejects.toThrow('OAuth callback did not include an authorization code.')
146+
})
147+
148+
test('waitForStoreAuthCode rejects when the port is already in use', async () => {
149+
const port = await getAvailablePort()
150+
const server = createServer()
151+
await new Promise<void>((resolve, reject) => {
152+
server.on('error', reject)
153+
server.listen(port, '127.0.0.1', () => resolve())
154+
})
155+
156+
await expect(
157+
waitForStoreAuthCode({
158+
store: 'shop.myshopify.com',
159+
state: 'state-123',
160+
port,
161+
timeoutMs: 1000,
162+
}),
163+
).rejects.toThrow(`Port ${port} is already in use.`)
164+
165+
await new Promise<void>((resolve, reject) => {
166+
server.close((error) => {
167+
if (error) {
168+
reject(error)
169+
return
170+
}
171+
172+
resolve()
173+
})
174+
})
175+
})
176+
177+
test('waitForStoreAuthCode rejects on timeout', async () => {
178+
const port = await getAvailablePort()
179+
180+
await expect(
181+
waitForStoreAuthCode({
182+
store: 'shop.myshopify.com',
183+
state: 'state-123',
184+
port,
185+
timeoutMs: 25,
186+
}),
187+
).rejects.toThrow('Timed out waiting for OAuth callback.')
188+
})
189+
})
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
2+
import {AbortError} from '@shopify/cli-kit/node/error'
3+
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
4+
import {timingSafeEqual} from 'crypto'
5+
import {createServer} from 'http'
6+
import {STORE_AUTH_CALLBACK_PATH, maskToken} from './config.js'
7+
import {retryStoreAuthWithPermanentDomainError} from './recovery.js'
8+
9+
export interface WaitForAuthCodeOptions {
10+
store: string
11+
state: string
12+
port: number
13+
timeoutMs?: number
14+
onListening?: () => void | Promise<void>
15+
}
16+
17+
function renderAuthCallbackPage(title: string, message: string): string {
18+
const safeTitle = title
19+
.replace(/&/g, '&amp;')
20+
.replace(/</g, '&lt;')
21+
.replace(/>/g, '&gt;')
22+
.replace(/"/g, '&quot;')
23+
const safeMessage = message
24+
.replace(/&/g, '&amp;')
25+
.replace(/</g, '&lt;')
26+
.replace(/>/g, '&gt;')
27+
.replace(/"/g, '&quot;')
28+
29+
return `<!doctype html>
30+
<html lang="en">
31+
<head>
32+
<meta charset="utf-8" />
33+
<meta name="viewport" content="width=device-width, initial-scale=1" />
34+
<title>${safeTitle}</title>
35+
</head>
36+
<body style="margin:0;background:#f6f6f7;color:#202223;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
37+
<main style="max-width:32rem;margin:12vh auto;padding:0 1rem;">
38+
<section style="background:#fff;border:1px solid #e1e3e5;border-radius:12px;padding:1.5rem 1.25rem;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
39+
<h1 style="margin:0 0 0.75rem 0;font-size:1.375rem;line-height:1.2;">${safeTitle}</h1>
40+
<p style="margin:0;font-size:1rem;line-height:1.5;">${safeMessage}</p>
41+
</section>
42+
</main>
43+
</body>
44+
</html>`
45+
}
46+
47+
export async function waitForStoreAuthCode({
48+
store,
49+
state,
50+
port,
51+
timeoutMs = 5 * 60 * 1000,
52+
onListening,
53+
}: WaitForAuthCodeOptions): Promise<string> {
54+
const normalizedStore = normalizeStoreFqdn(store)
55+
56+
return new Promise<string>((resolve, reject) => {
57+
let settled = false
58+
let isListening = false
59+
60+
const timeout = setTimeout(() => {
61+
settleWithError(new AbortError('Timed out waiting for OAuth callback.'))
62+
}, timeoutMs)
63+
64+
const server = createServer((req, res) => {
65+
const requestUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`)
66+
67+
if (requestUrl.pathname !== STORE_AUTH_CALLBACK_PATH) {
68+
res.statusCode = 404
69+
res.end('Not found')
70+
return
71+
}
72+
73+
const {searchParams} = requestUrl
74+
75+
const fail = (error: AbortError | string, tryMessage?: string) => {
76+
const abortError = typeof error === 'string' ? new AbortError(error, tryMessage) : error
77+
78+
res.statusCode = 400
79+
res.setHeader('Content-Type', 'text/html')
80+
res.setHeader('Connection', 'close')
81+
res.once('finish', () => settleWithError(abortError))
82+
res.end(renderAuthCallbackPage('Authentication failed', abortError.message))
83+
}
84+
85+
const returnedStore = searchParams.get('shop')
86+
outputDebug(outputContent`Received OAuth callback for shop ${outputToken.raw(returnedStore ?? 'unknown')}`)
87+
88+
if (!returnedStore) {
89+
fail('OAuth callback store does not match the requested store.')
90+
return
91+
}
92+
93+
const normalizedReturnedStore = normalizeStoreFqdn(returnedStore)
94+
if (normalizedReturnedStore !== normalizedStore) {
95+
fail(retryStoreAuthWithPermanentDomainError(normalizedReturnedStore))
96+
return
97+
}
98+
99+
const returnedState = searchParams.get('state')
100+
if (!returnedState || !constantTimeEqual(returnedState, state)) {
101+
fail('OAuth callback state does not match the original request.')
102+
return
103+
}
104+
105+
const error = searchParams.get('error')
106+
if (error) {
107+
fail(`Shopify returned an OAuth error: ${error}`)
108+
return
109+
}
110+
111+
const code = searchParams.get('code')
112+
if (!code) {
113+
fail('OAuth callback did not include an authorization code.')
114+
return
115+
}
116+
117+
outputDebug(outputContent`Received authorization code ${outputToken.raw(maskToken(code))}`)
118+
119+
res.statusCode = 200
120+
res.setHeader('Content-Type', 'text/html')
121+
res.setHeader('Connection', 'close')
122+
res.once('finish', () => settle(() => resolve(code)))
123+
res.end(renderAuthCallbackPage('Authentication succeeded', 'You can close this window and return to the terminal.'))
124+
})
125+
126+
const settle = (callback: () => void) => {
127+
if (settled) return
128+
settled = true
129+
clearTimeout(timeout)
130+
131+
const finalize = () => {
132+
callback()
133+
}
134+
135+
if (!isListening) {
136+
finalize()
137+
return
138+
}
139+
140+
server.close(() => {
141+
isListening = false
142+
finalize()
143+
})
144+
server.closeIdleConnections?.()
145+
}
146+
147+
const settleWithError = (error: Error) => {
148+
settle(() => reject(error))
149+
}
150+
151+
server.on('error', (error: NodeJS.ErrnoException) => {
152+
if (error.code === 'EADDRINUSE') {
153+
settleWithError(
154+
new AbortError(
155+
`Port ${port} is already in use.`,
156+
`Free port ${port} and re-run ${outputToken.genericShellCommand(`shopify store auth --store ${store} --scopes <comma-separated-scopes>`).value}. Ensure that redirect URI is allowed in the app configuration.`,
157+
),
158+
)
159+
return
160+
}
161+
162+
settleWithError(error)
163+
})
164+
165+
server.listen(port, '127.0.0.1', async () => {
166+
isListening = true
167+
outputDebug(
168+
outputContent`PKCE callback server listening on http://127.0.0.1:${outputToken.raw(String(port))}${outputToken.raw(STORE_AUTH_CALLBACK_PATH)}`,
169+
)
170+
171+
if (!onListening) return
172+
173+
try {
174+
await onListening()
175+
} catch (error) {
176+
settleWithError(error instanceof Error ? error : new Error(String(error)))
177+
}
178+
})
179+
})
180+
}
181+
182+
function constantTimeEqual(a: string, b: string): boolean {
183+
if (a.length !== b.length) return false
184+
return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'))
185+
}

0 commit comments

Comments
 (0)