Skip to content
Merged
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
29 changes: 19 additions & 10 deletions integrations/dropbox/definitions/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@ import { z } from '@botpress/sdk'
import * as sdk from '@botpress/sdk'

export const configuration = {
schema: z.object({
clientId: z.string().title('App Key').describe('Available in the App Console on Dropbox'),
clientSecret: z.string().title('App Secret').describe('Available in the App Console on Dropbox').secret(),
authorizationCode: z
.string()
.title('Access Code')
.describe('Obtained by navigating to the authorization URL')
.secret(),
}),
} as const satisfies sdk.IntegrationDefinitionProps['configuration']
identifier: { linkTemplateScript: 'linkTemplate.vrl', required: false },
schema: z.object({}),
} satisfies sdk.IntegrationDefinitionProps['configuration']

export const configurations = {
manual: {
title: 'Manual Configuration',
description: 'Configure the Dropbox integration manually using your own app.',
schema: z.object({
clientId: z.string().title('App Key').describe('Available in the App Console on Dropbox'),
clientSecret: z.string().title('App Secret').describe('Available in the App Console on Dropbox').secret(),
authorizationCode: z
.string()
.title('Access Code')
.describe('Obtained by navigating to the authorization URL')
.secret(),
}),
},
} satisfies sdk.IntegrationDefinitionProps['configurations']
2 changes: 2 additions & 0 deletions integrations/dropbox/definitions/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ import { sentry as sentryHelpers } from '@botpress/sdk-addons'

export const secrets = {
...sentryHelpers.COMMON_SECRET_NAMES,
APP_KEY: { description: 'Dropbox App Key' },
APP_SECRET: { description: 'Dropbox App Secret' },
} as const satisfies sdk.IntegrationDefinitionProps['secrets']
5 changes: 3 additions & 2 deletions integrations/dropbox/integration.definition.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import * as sdk from '@botpress/sdk'
import filesReadonly from './bp_modules/files-readonly'
import { actions, configuration, entities, secrets, states } from './definitions'
import { actions, configuration, configurations, entities, secrets, states } from './definitions'

export default new sdk.IntegrationDefinition({
name: 'dropbox',
title: 'Dropbox',
version: '1.2.2',
version: '2.0.0',
description: 'Manage your files and folders effortlessly.',
readme: 'hub.md',
icon: 'icon.svg',
configuration,
configurations,
actions,
entities,
secrets,
Expand Down
4 changes: 4 additions & 0 deletions integrations/dropbox/linkTemplate.vrl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
webhookId = to_string!(.webhookId)
webhookUrl = to_string!(.webhookUrl)

"{{ webhookUrl }}/oauth/wizard/start-confirm?state={{ webhookId }}"
13 changes: 9 additions & 4 deletions integrations/dropbox/src/dropbox-api/dropbox-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Dropbox } from 'dropbox'
import { File as FileEntity, Folder as FolderEntity, Deleted as DeletedEntity } from '../../definitions'
import { handleErrorsDecorator as handleErrors } from './error-handling'
import { ActionInput, RequestMapping, ResponseMapping } from './mapping'
import { DropboxOAuthClient } from './oauth-client'
import { DropboxOAuthClient, getAuthorizationCode, getOAuthClientId, getOAuthClientSecret } from './oauth-client'
import * as bp from '.botpress'

type File = FileEntity.InferredType
Expand All @@ -27,8 +27,8 @@ export class DropboxClient {

return new DropboxClient({
accessToken,
clientId: ctx.configuration.clientId,
clientSecret: ctx.configuration.clientSecret,
clientId: getOAuthClientId({ ctx }),
clientSecret: getOAuthClientSecret({ ctx }),
accountId,
})
}
Expand All @@ -38,8 +38,13 @@ export class DropboxClient {
}

public static async processAuthorizationCode(props: { client: bp.Client; ctx: bp.Context }): Promise<void> {
const authorizationCode = getAuthorizationCode({ ctx: props.ctx })
if (!authorizationCode) {
throw new Error('Authorization code is required')
}
const oauthClient = new DropboxOAuthClient(props)
await oauthClient.processAuthorizationCode(props.ctx.configuration.authorizationCode)
// For manual configuration (no redirect_uri used), pass empty string
await oauthClient.processAuthorizationCode(authorizationCode, '')
}

@handleErrors('Failed to validate Dropbox authentication')
Expand Down
50 changes: 38 additions & 12 deletions integrations/dropbox/src/dropbox-api/oauth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ import { DropboxAuth } from 'dropbox'
import { handleErrorsDecorator as handleErrors } from './error-handling'
import * as bp from '.botpress'

export const getOAuthClientId = ({ ctx }: { ctx: bp.Context }) =>
ctx.configurationType === 'manual' ? ctx.configuration.clientId : bp.secrets.APP_KEY

export const getOAuthClientSecret = ({ ctx }: { ctx: bp.Context }) =>
ctx.configurationType === 'manual' ? ctx.configuration.clientSecret : bp.secrets.APP_SECRET

export const getAuthorizationCode = ({ ctx }: { ctx: bp.Context }): string | undefined => {
if (ctx.configurationType === 'manual') {
return ctx.configuration.authorizationCode
}
return undefined
}

export class DropboxOAuthClient {
private readonly _client: bp.Client
private readonly _ctx: bp.Context
Expand All @@ -13,8 +26,8 @@ export class DropboxOAuthClient {
this._ctx = ctx

this._dropboxAuth = new DropboxAuth({
clientId: ctx.configuration.clientId,
clientSecret: ctx.configuration.clientSecret,
clientId: getOAuthClientId({ ctx }),
clientSecret: getOAuthClientSecret({ ctx }),
})
}

Expand All @@ -26,8 +39,8 @@ export class DropboxOAuthClient {
}

@handleErrors('Failed to exchange authorization code. Please reconfigure the integration.')
public async processAuthorizationCode(authorizationCode: string): Promise<void> {
const result = await this._exchangeAuthorizationCodeForRefreshToken(authorizationCode)
public async processAuthorizationCode(authorizationCode: string, redirectUri: string): Promise<void> {
const result = await this._exchangeAuthorizationCodeForRefreshToken(authorizationCode, redirectUri)

await this._client.setState({
id: this._ctx.integrationId,
Expand All @@ -42,8 +55,8 @@ export class DropboxOAuthClient {
})
}

private async _exchangeAuthorizationCodeForRefreshToken(authorizationCode: string) {
const response = await this._dropboxAuth.getAccessTokenFromCode('', authorizationCode)
private async _exchangeAuthorizationCodeForRefreshToken(authorizationCode: string, redirectUri: string) {
const response = await this._dropboxAuth.getAccessTokenFromCode(redirectUri, authorizationCode)

// NOTE: DropboxAuth.getAccessTokenFromCode is not properly typed: the
// response is not an empty object, but an object with the following properties:
Expand All @@ -65,13 +78,26 @@ export class DropboxOAuthClient {

@handleErrors('Failed to get authorization state. Please reconfigure the integration.')
private async _getAuthState(): Promise<bp.states.authorization.Authorization['payload']> {
const { state } = await this._client.getState({
id: this._ctx.integrationId,
type: 'integration',
name: 'authorization',
})
try {
const result = await this._client.getState({
id: this._ctx.integrationId,
type: 'integration',
name: 'authorization',
})

return state.payload
if (!result?.state?.payload) {
throw new Error('Authorization state not found. Please complete the OAuth wizard to configure the integration.')
}

return result.state.payload
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
throw error
}
throw new Error(
'Failed to retrieve authorization state. Please complete the OAuth wizard to configure the integration.'
)
}
}

@handleErrors('Failed to exchange refresh token for access token')
Expand Down
100 changes: 85 additions & 15 deletions integrations/dropbox/src/setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as sdk from '@botpress/sdk'
import { DropboxClient } from './dropbox-api'
import { getAuthorizationCode } from './dropbox-api/oauth-client'
import * as bp from '.botpress'

type RegisterProps = Parameters<bp.IntegrationProps['register']>[0]
Expand All @@ -24,38 +25,107 @@ const _isAlreadyAuthenticatedWithSameCredentials = async (props: RegisterProps):
return false
}

if (props.ctx.configurationType !== 'manual') {
return true
}

// For manual configurations, compare the authorization code from context with the one in state
const { state } = await props.client.getState({
type: 'integration',
id: props.ctx.integrationId,
name: 'authorization',
})

return state.payload.authorizationCode === props.ctx.configuration.authorizationCode
const currentAuthorizationCode = getAuthorizationCode({ ctx: props.ctx })
return state.payload.authorizationCode === currentAuthorizationCode
} catch {}

return false
}

const _authenticate = async (props: RegisterProps): Promise<void> => {
let authenticationSucceeded = false
const _authenticateWithRefreshToken = async (props: RegisterProps): Promise<boolean> => {
const dropboxClient = await DropboxClient.create(props)
return dropboxClient.isProperlyAuthenticated()
}

try {
await DropboxClient.processAuthorizationCode(props)
const dropboxClient = await DropboxClient.create(props)
authenticationSucceeded = await dropboxClient.isProperlyAuthenticated()
} catch (thrown: unknown) {
console.error('Failed to authenticate with Dropbox', thrown)
authenticationSucceeded = false
const _authenticateWithAuthorizationCode = async (props: RegisterProps): Promise<boolean> => {
const { logger } = props
await DropboxClient.processAuthorizationCode(props)
const dropboxClient = await DropboxClient.create(props)
const authenticated = await dropboxClient.isProperlyAuthenticated()
if (authenticated) {
logger.forBot().info('Successfully created Dropbox client from authorization code')
}
return authenticated
}

if (!authenticationSucceeded) {
throw new sdk.RuntimeError(
const _authenticateManual = async (props: RegisterProps): Promise<boolean> => {
const { ctx, logger } = props
const authorizationCode = getAuthorizationCode({ ctx })

if (!authorizationCode) {
logger.forBot().info('No authorization code provided, using existing refresh token from state')
return _authenticateWithRefreshToken(props).catch((err) => {
logger.forBot().warn({ err }, 'Failed to authenticate with existing refresh token')
return false
})
}

logger.forBot().info('Using authorization code from context')
let isAuthenticated = false
isAuthenticated = await _authenticateWithAuthorizationCode(props).catch((err) => {
logger?.forBot().warn({ err }, 'Failed to create Dropbox client from authorization code; falling back')
return false
})
if (!isAuthenticated) {
isAuthenticated = await _authenticateWithAuthorizationCode(props).catch((err) => {
logger.forBot().error({ err }, 'Failed to authenticate with fallback')
return false
})
}
return isAuthenticated
}

const _authenticateOAuth = async (props: RegisterProps): Promise<boolean> => {
const { logger } = props
logger.forBot().info('Using refresh token from state')
return _authenticateWithRefreshToken(props).catch((err) => {
logger.forBot().warn({ err }, 'Failed to authenticate with existing refresh token')
return false
})
}

const _getAuthFailureMessage = (configurationType: string): string => {
if (configurationType === 'manual') {
return (
'Dropbox authentication failed. ' +
'Please note that the Access Code is only valid for a few minutes. ' +
'You may need to reauthorize your Dropbox application by navigating ' +
"to the authorization URL and update the integration's config accordingly."
'Please note that the Access Code is only valid for a few minutes. ' +
'You may need to reauthorize your Dropbox application by navigating ' +
"to the authorization URL and update the integration's config accordingly."
)
}
return (
'Dropbox authentication failed. ' +
'Please use the OAuth wizard to re-authenticate your Dropbox application. ' +
'You can access the wizard through the integration configuration page.'
)
}

const _authenticate = async (props: RegisterProps): Promise<void> => {
const { ctx, logger } = props

if (ctx.configurationType !== 'manual') {
const authenticationSucceeded = await _authenticateOAuth(props)
if (!authenticationSucceeded) {
logger.forBot().info('No existing OAuth credentials found. Please complete the OAuth wizard to authenticate.')
}
return
}

const authenticationSucceeded = await _authenticateManual(props)
if (!authenticationSucceeded) {
throw new sdk.RuntimeError(_getAuthFailureMessage(ctx.configurationType ?? ''))
}
}

const _saveRegistrationDate = async (props: RegisterProps): Promise<void> => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import * as sdk from '@botpress/sdk'
import * as crypto from 'crypto'
import { getOAuthClientSecret } from '../dropbox-api/oauth-client'
import { handleFileChangeEvent, isFileChangeNotification } from './handlers/file-change'
import { isWebhookVerificationRequest, handleWebhookVerificationRequest } from './handlers/webhook-verification'
import { oauthCallbackHandler } from './oauth'
import * as bp from '.botpress'

export const handler: bp.IntegrationProps['handler'] = async (props) => {
if (props.req.path.startsWith('/oauth')) {
return await oauthCallbackHandler(props)
}

if (isWebhookVerificationRequest(props)) {
return await handleWebhookVerificationRequest(props)
}
Expand All @@ -25,8 +31,9 @@ const _validatePayloadSignature = (props: bp.HandlerProps) => {
throw new sdk.RuntimeError('Missing Dropbox signature in request headers')
}

const clientSecret = getOAuthClientSecret({ ctx: props.ctx })
const bodySignatureFromBotpress = crypto
.createHmac('sha256', props.ctx.configuration.clientSecret)
.createHmac('sha256', clientSecret)
.update(props.req.body ?? '')
.digest('hex')

Expand Down
23 changes: 23 additions & 0 deletions integrations/dropbox/src/webhook-events/oauth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { generateRedirection } from '@botpress/common/src/html-dialogs'
import { isOAuthWizardUrl, getInterstitialUrl } from '@botpress/common/src/oauth-wizard'
import * as wizard from './wizard'
import * as bp from '.botpress'

export const oauthCallbackHandler: bp.IntegrationProps['handler'] = async (props) => {
const { req, logger } = props
if (!isOAuthWizardUrl(req.path)) {
return {
status: 404,
body: 'Invalid OAuth endpoint',
}
}

try {
return await wizard.handler(props)
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown : Error(String(thrown))
const errorMessage = 'OAuth registration Error: ' + error.message
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
Loading
Loading