Files
botpress--botpress/integrations/shopify-admin/src/oauth/wizard.ts
T
2026-07-13 13:34:48 +08:00

187 lines
7.1 KiB
TypeScript

import * as oauthWizard from '@botpress/common/src/oauth-wizard'
import * as sdk from '@botpress/sdk'
import { exchangeCodeForAccessToken } from '../auth'
import { verifyOAuthCallbackHmac } from './hmac'
import * as bp from '.botpress'
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
const SHOPIFY_OAUTH_SCOPES = ['read_products', 'read_orders', 'read_customers'].join(',')
const SHOP_NAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/i
export const oauthWizardHandler = async (props: bp.HandlerProps): Promise<sdk.Response> => {
const wizard = new oauthWizard.OAuthWizardBuilder(props)
.addStep({ id: 'start', handler: _startHandler })
.addStep({ id: 'get-shop', handler: _getShopHandler })
.addStep({ id: 'validate-shop', handler: _validateShopHandler })
.addStep({ id: 'authorize', handler: _authorizeHandler })
.addStep({ id: 'oauth-callback', handler: _oauthCallbackHandler })
.addStep({ id: 'end', handler: _endHandler })
.build()
return await wizard.handleRequest()
}
const _startHandler: WizardHandler = ({ responses }) =>
responses.displayButtons({
pageTitle: 'Connect Shopify',
htmlOrMarkdownPageContents:
'This wizard will connect your Shopify store to Botpress. If the integration was previously connected, the existing connection will be reset.\n\nDo you want to continue?',
buttons: [
{ action: 'navigate', label: 'Yes, continue', navigateToStep: 'get-shop', buttonType: 'primary' },
{ action: 'close', label: 'No, cancel', buttonType: 'secondary' },
],
})
const _getShopHandler: WizardHandler = ({ responses }) =>
responses.displayInput({
pageTitle: 'Enter Shopify Store',
htmlOrMarkdownPageContents:
'Enter the domain of your Shopify store. It looks like `your-store.myshopify.com` — you can find it in the Shopify admin URL.',
input: { label: 'e.g. your-store.myshopify.com', type: 'text' },
nextStepId: 'validate-shop',
})
const _validateShopHandler: WizardHandler = async ({ client, ctx, inputValue, responses }) => {
if (!inputValue) {
throw new sdk.RuntimeError('Shop domain cannot be empty')
}
const shopDomain = normalizeShopDomain(inputValue)
if (!SHOP_NAME_REGEX.test(shopDomain)) {
return responses.displayButtons({
pageTitle: 'Invalid Shop Domain',
htmlOrMarkdownPageContents: `"${inputValue}" doesn't look like a valid Shopify store domain. Please enter a domain like \`your-store.myshopify.com\`.`,
buttons: [
{ action: 'navigate', label: 'Try again', navigateToStep: 'get-shop', buttonType: 'primary' },
{ action: 'close', label: 'Cancel', buttonType: 'secondary' },
],
})
}
await _patchCredentialsState(client, ctx, { shopDomain, accessToken: undefined })
return responses.displayButtons({
pageTitle: 'Confirm Shopify Store',
htmlOrMarkdownPageContents: `Is <strong>${shopDomain}.myshopify.com</strong> your Shopify store?`,
buttons: [
{ action: 'navigate', label: 'Yes, connect', navigateToStep: 'authorize', buttonType: 'primary' },
{ action: 'navigate', label: 'No, go back', navigateToStep: 'get-shop', buttonType: 'secondary' },
],
})
}
const _authorizeHandler: WizardHandler = async ({ client, ctx, responses }) => {
const { shopDomain } = await _getCredentialsState(client, ctx)
if (!shopDomain) {
throw new sdk.RuntimeError('Shop domain missing from state; please restart the wizard')
}
const redirectUri = oauthWizard.getWizardStepUrl('oauth-callback').toString()
const authorizeUrl =
`https://${shopDomain}.myshopify.com/admin/oauth/authorize` +
`?client_id=${encodeURIComponent(bp.secrets.SHOPIFY_CLIENT_ID)}` +
`&scope=${encodeURIComponent(SHOPIFY_OAUTH_SCOPES)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&state=${encodeURIComponent(ctx.webhookId)}`
return responses.redirectToExternalUrl(authorizeUrl)
}
const _oauthCallbackHandler: WizardHandler = async ({ query, client, ctx, logger, responses }) => {
try {
const state = query.get('state')
if (state !== ctx.webhookId) {
return responses.endWizard({
success: false,
errorMessage: 'OAuth state mismatch — possible CSRF attempt. Please retry the connection.',
})
}
if (!verifyOAuthCallbackHmac(query, bp.secrets.SHOPIFY_CLIENT_SECRET)) {
return responses.endWizard({
success: false,
errorMessage: 'Shopify OAuth callback HMAC verification failed. Please retry the connection.',
})
}
const code = query.get('code')
const shopParam = query.get('shop')
if (!code || !shopParam) {
return responses.endWizard({
success: false,
errorMessage: 'Missing `code` or `shop` parameter on Shopify OAuth callback.',
})
}
const shopDomainFromCallback = shopParam.replace(/\.myshopify\.com$/i, '').toLowerCase()
const stored = await _getCredentialsState(client, ctx)
if (stored.shopDomain && stored.shopDomain.toLowerCase() !== shopDomainFromCallback) {
return responses.endWizard({
success: false,
errorMessage: `Shop mismatch: expected ${stored.shopDomain} but Shopify returned ${shopDomainFromCallback}.`,
})
}
const credentials = await exchangeCodeForAccessToken({ shop: shopDomainFromCallback, code })
await _patchCredentialsState(client, ctx, {
shopDomain: shopDomainFromCallback,
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
accessTokenExpiresAtSeconds: credentials.accessTokenExpiresAtSeconds,
refreshTokenExpiresAtSeconds: credentials.refreshTokenExpiresAtSeconds,
})
await client.configureIntegration({ identifier: shopDomainFromCallback })
return responses.redirectToStep('end')
} catch (e) {
logger.forBot().error({ err: e }, 'Shopify OAuth callback failed')
return responses.endWizard({
success: false,
errorMessage: e instanceof Error ? e.message : String(e),
})
}
}
const _endHandler: WizardHandler = ({ responses }) => responses.endWizard({ success: true })
export const normalizeShopDomain = (raw: string): string =>
raw
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/\/.*$/, '')
.replace(/\.myshopify\.com$/, '')
type CredentialsPatch = {
shopDomain?: string
accessToken?: string
refreshToken?: string
accessTokenExpiresAtSeconds?: number
refreshTokenExpiresAtSeconds?: number
webhookSubscriptionIds?: string[]
}
// `client.patchState` has known issues — merge manually via getState/setState
const _patchCredentialsState = async (client: bp.Client, ctx: bp.Context, patch: CredentialsPatch) => {
const current = await _getCredentialsState(client, ctx)
await client.setState({
type: 'integration',
name: 'credentials',
id: ctx.integrationId,
payload: { ...current, ...patch },
})
}
const _getCredentialsState = async (client: bp.Client, ctx: bp.Context): Promise<CredentialsPatch> => {
try {
const { state } = await client.getState({ type: 'integration', name: 'credentials', id: ctx.integrationId })
return (state?.payload as CredentialsPatch | undefined) ?? {}
} catch {
return {}
}
}