Skip to content

Commit

Permalink
Add oauth2 for webhook2.0 (#2334)
Browse files Browse the repository at this point in the history
* add oauth2 for webhook2.0

* fix types

* fix test functions

* handle auth failure

* test settings

* modify action extensible webhook

* fix settings

* update settings key value

* update extensible webhook

* update webhook2 action

* add type check

* fix yarn lock

* fix lint

* update yarn lock

* fix tests
  • Loading branch information
cyberlord29 authored Oct 8, 2024
1 parent 9098b20 commit d245e42
Show file tree
Hide file tree
Showing 9 changed files with 128 additions and 144 deletions.
26 changes: 26 additions & 0 deletions packages/core/src/__tests__/schema-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,4 +257,30 @@ describe('validateSchema', () => {
expect(validateSchema(less_than_min_payload, min_max_schema, { throwIfInvalid: false })).toBeFalsy()
expect(validateSchema(greater_than_max_payload, min_max_schema, { throwIfInvalid: false })).toBeFalsy()
})

it('should allow exempted properties', () => {
const payload = {
a: 'a',
b: {
anything: 'goes'
},
exemptKey: {
nested: 'nested'
}
}

validateSchema(payload, schema, { schemaKey: `testSchema`, exempt: ['exemptKey'] })
expect(payload).toHaveProperty('exemptKey')
expect(payload).toMatchInlineSnapshot(`
Object {
"a": "a",
"b": Object {
"anything": "goes",
},
"exemptKey": Object {
"nested": "nested",
},
}
`)
})
})
13 changes: 10 additions & 3 deletions packages/core/src/destination-kit/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,11 @@ export class Action<Settings, Payload extends JSONLikeObject, AudienceSettings =
// Validate the resolved payload against the schema
if (this.schema) {
const schemaKey = `${this.destinationName}:${this.definition.title}`
validateSchema(payload, this.schema, { schemaKey, statsContext: bundle.statsContext })
validateSchema(payload, this.schema, {
schemaKey,
statsContext: bundle.statsContext,
exempt: ['dynamicAuthSettings']
})
results.push({ output: 'Payload validated' })
}

Expand Down Expand Up @@ -392,7 +396,8 @@ export class Action<Settings, Payload extends JSONLikeObject, AudienceSettings =
const validationOptions = {
schemaKey: `${this.destinationName}:${this.definition.title}`,
throwIfInvalid: true,
statsContext: bundle.statsContext
statsContext: bundle.statsContext,
exempt: ['dynamicAuthSettings']
}

// Filter out invalid payloads before sending them to the action
Expand Down Expand Up @@ -652,7 +657,9 @@ export class Action<Settings, Payload extends JSONLikeObject, AudienceSettings =

if (this.hookSchemas?.[hookType]) {
const schema = this.hookSchemas[hookType]
validateSchema(data.hookInputs, schema)
validateSchema(data.hookInputs, schema, {
exempt: ['dynamicAuthSettings']
})
}

return (await this.performRequest(hookFn, data)) as ActionHookResponse<any>
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/destination-kit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,10 @@ export class Destination<Settings = JSONObject, AudienceSettings = JSONObject> {
validateSettings(settings: Settings): void {
if (this.settingsSchema) {
try {
validateSchema(settings, this.settingsSchema, { schemaKey: `${this.name}:settings` })
validateSchema(settings, this.settingsSchema, {
schemaKey: `${this.name}:settings`,
exempt: ['dynamicAuthSettings']
})
} catch (err) {
const error = err as ResponseError
if (error.name === 'AggregateAjvError' || error.name === 'ValidationError') {
Expand All @@ -458,7 +461,9 @@ export class Destination<Settings = JSONObject, AudienceSettings = JSONObject> {
}
//validate audienceField Input
if (createAudienceInput.audienceSettings && Object.keys(createAudienceInput.audienceSettings).length > 0) {
validateSchema(createAudienceInput.audienceSettings, fieldsToJsonSchema(audienceFields))
validateSchema(createAudienceInput.audienceSettings, fieldsToJsonSchema(audienceFields), {
exempt: ['dynamicAuthSettings']
})
}
const destinationSettings = this.getDestinationSettings(settings)
const run = async () => {
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/schema-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,23 @@ interface ValidationOptions {
schemaKey?: string
throwIfInvalid?: boolean
statsContext?: StatsContext
exempt?: string[]
}

/**
* Validates an object against a json schema
* and caches the schema for subsequent validations when a key is provided
*/
export function validateSchema(obj: unknown, schema: JSONSchema4, options?: ValidationOptions) {
const { schemaKey, throwIfInvalid = true, statsContext } = options ?? {}
const { schemaKey, throwIfInvalid = true, statsContext, exempt = [] } = options ?? {}
let validate: ValidateFunction
const exemptedFields: Record<string, unknown> = {}

// save exempted fields
const objCopy = { ...(obj as Record<string, unknown>) }
exempt.forEach((prop) => {
exemptedFields[prop] = objCopy[prop]
})

if (schemaKey) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Expand All @@ -64,6 +72,13 @@ export function validateSchema(obj: unknown, schema: JSONSchema4, options?: Vali
arrifyFields(obj, schema)
const isValid = validate(obj)

// add exempted fields back
exempt.forEach((prop) => {
if (objCopy[prop] !== undefined) {
;(obj as Record<string, unknown>)[prop] = exemptedFields[prop]
}
})

if (throwIfInvalid && !isValid && validate.errors) {
statsContext?.statsClient?.incr('ajv.discard', 1, statsContext.tags)
throw new AggregateAjvError(validate.errors)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import {
DestinationDefinition
} from '@segment/actions-core'
import Webhook from '../index'
import { createHmac, timingSafeEqual } from 'crypto'
import { SegmentEvent } from '@segment/actions-core'

// Exported so we can re-use to test webhook-audiences
export const baseWebhookTests = (def: DestinationDefinition<any>) => {
Expand Down Expand Up @@ -57,102 +55,6 @@ export const baseWebhookTests = (def: DestinationDefinition<any>) => {
expect(responses[0].status).toBe(200)
})

it('supports request signing', async () => {
const url = 'https://example.com'
const event = createTestEvent({
properties: { cool: true }
})
const payload = JSON.stringify(event.properties)
const sharedSecret = 'abc123'

nock(url)
.post('/', payload)
.reply(async function (_uri, body) {
// Normally you should use the raw body but nock automatically
// deserializes it (and doesn't allow us to access the raw request
// body) so we re-serialize the body here so that we can demonstrate
// signture validation.
const bodyString = JSON.stringify(body)

// Validate the signature
const expectSignature = this.req.headers['x-signature'][0]
const actualSignature = createHmac('sha1', sharedSecret).update(bodyString).digest('hex')

// Use constant-time comparison to avoid timing attacks
if (
expectSignature.length !== actualSignature.length ||
!timingSafeEqual(Buffer.from(actualSignature, 'hex'), Buffer.from(expectSignature, 'hex'))
) {
return [400, 'Invalid signature']
}

return [200, 'OK']
})

const responses = await testDestination.testAction('send', {
event,
mapping: {
url,
data: { '@path': '$.properties' }
},
settings: { sharedSecret },
useDefaultMappings: true
})

expect(responses.length).toBe(1)
expect(responses[0].status).toBe(200)
})

it('supports request signing with batched events', async () => {
const url = 'https://example.com'

const events: SegmentEvent[] = [
createTestEvent({
properties: { cool: false }
}),
createTestEvent({
properties: { cool: true }
})
]

const payload = JSON.stringify(events.map(({ properties }) => properties))
const sharedSecret = 'abc123'
nock(url)
.post('/', payload)
.reply(async function (_uri, body: any) {
// Normally you should use the raw body but nock automatically
// deserializes it (and doesn't allow us to access the raw request
// body) so we re-serialize the body here so that we can demonstrate
// signture validation

// Validate the signature
const expectSignature = this.req.headers['x-signature'][0]
const actualSignature = createHmac('sha1', sharedSecret).update(JSON.stringify(body[0])).digest('hex')

// Use constant-time comparison to avoid timing attacks
if (
expectSignature.length !== actualSignature.length ||
!timingSafeEqual(Buffer.from(actualSignature, 'hex'), Buffer.from(expectSignature, 'hex'))
) {
return [400, 'Invalid signature123']
}

return [200, 'OK']
})

const responses = await testDestination.testBatchAction('send', {
events,
mapping: {
url,
data: { '@path': '$.properties' }
},
settings: { sharedSecret },
useDefaultMappings: true
})
expect(responses.length).toBe(1)
expect(responses[0].status).toBe(200)
})

it('should throw an error when header value is invalid', async () => {
const url = 'https://example.build'
const event = createTestEvent()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,47 @@
import type { DestinationDefinition } from '@segment/actions-core'
import type { Settings } from './generated-types'
import { createHmac } from 'crypto'
import { RefreshTokenResponse } from './types'

import send from './send'

const destination: DestinationDefinition<Settings> = {
type SettingsWithDynamicAuth = Settings & {
dynamicAuthSettings: any
}
const destination: DestinationDefinition<SettingsWithDynamicAuth> = {
name: 'Extensible Webhook',
slug: 'actions-webhook-extensible',
mode: 'cloud',
authentication: {
scheme: 'custom',
scheme: 'oauth2',
fields: {
sharedSecret: {
type: 'string',
label: 'Shared Secret',
description:
'If set, Segment will sign requests with an HMAC in the "X-Signature" request header. The HMAC is a hex-encoded SHA1 hash generated using this shared secret and the request body.'
}
},
refreshAccessToken: async (request, { settings, auth }) => {
const res = await request<RefreshTokenResponse>(settings.dynamicAuthSettings.oauth.refreshTokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(auth.clientId + ':' + auth.clientSecret).toString('base64')}`
},
body: '{"grant_type":"client_credentials"}'
})

return { accessToken: res.data.access_token }
}
},
extendRequest: ({ settings, payload }) => {
const payloadData = payload.length ? payload[0]['data'] : payload['data']
if (settings.sharedSecret && payloadData) {
const digest = createHmac('sha1', settings.sharedSecret).update(JSON.stringify(payloadData), 'utf8').digest('hex')
return { headers: { 'X-Signature': digest } }
extendRequest: ({ settings }) => {
const { dynamicAuthSettings } = settings
const accessToken = dynamicAuthSettings?.oauth?.access?.access_token
return {
headers: {
authorization: `Bearer ${accessToken}`
}
}
return {}
},
actions: {
send
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,21 @@ const action: ActionDefinition<Settings, Payload> = {
},
perform: (request, { payload }) => {
try {
let body
let contentType = 'application/json'

if (payload.headers) {
contentType = (payload.headers['Content-Type'] as string) || (payload.headers['content-type'] as string)
}

if (payload.data) {
body = encodeBody(payload.data, contentType)
}

return request(payload.url, {
method: payload.method as RequestMethod,
headers: payload.headers as Record<string, string>,
json: payload.data
...body
})
} catch (error) {
if (error instanceof TypeError) throw new PayloadValidationError(error.message)
Expand All @@ -61,12 +72,21 @@ const action: ActionDefinition<Settings, Payload> = {
},
performBatch: (request, { payload }) => {
// Expect these to be the same across the payloads
const { url, method, headers } = payload[0]
try {
const { url, method, headers } = payload[0]
return request(url, {
method: method as RequestMethod,
headers: headers as Record<string, string>,
json: payload.map(({ data }) => data)
json: payload.map(({ data }) => {
let contentType = 'application/json'

if (headers) {
contentType = (headers['Content-Type'] as string) || (headers['content-type'] as string)
}
if (data) return encodeBody(data, contentType)

return data
})
})
} catch (error) {
if (error instanceof TypeError) throw new PayloadValidationError(error.message)
Expand All @@ -75,4 +95,16 @@ const action: ActionDefinition<Settings, Payload> = {
}
}

const encodeBody = (payload: Record<string, any>, contentType: string) => {
if (contentType === 'application/json') {
return { json: payload }
} else if (contentType === 'application/x-www-form-urlencoded') {
const formUrlEncoded = new URLSearchParams(payload as Record<string, string>).toString()
return { body: formUrlEncoded }
} else {
// Handle other content types or default case
return { json: payload }
}
}

export default action
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface RefreshTokenResponse {
access_token: string
scope: string
expires_in: number
token_type: string
}
Loading

0 comments on commit d245e42

Please sign in to comment.