187 lines
7.1 KiB
TypeScript
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 {}
|
|
}
|
|
}
|