Skip to content

Commit 317d4ab

Browse files
committed
fix(gong): JWT verification, trigger UX, alignment script
- Optional RS256 verification when Gong JWT public key is configured (webhook_url + body_sha256 per Gong docs); URL secrecy when unset. - Document that Gong rules filter calls; payload has no event type; add eventType + callId outputs for discoverability. - Refactor Gong triggers to buildTriggerSubBlocks + shared JWT field; setup copy matches security model. - Add check-trigger-alignment.ts (Gong bundled; extend PROVIDER_CHECKS for others) and update add-trigger guidance paths. Made-with: Cursor
1 parent e9618d9 commit 317d4ab

File tree

8 files changed

+413
-104
lines changed

8 files changed

+413
-104
lines changed

.agents/skills/add-trigger/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,7 @@ if (foundWebhook.provider === '{service}') {
596596

597597
Run the alignment checker:
598598
```bash
599-
bunx scripts/check-trigger-alignment.ts {service}
599+
bun run apps/sim/scripts/check-trigger-alignment.ts {service}
600600
```
601601

602602
## Trigger Outputs
@@ -699,7 +699,7 @@ export const {service}WebhookTrigger: TriggerConfig = {
699699
### Webhook Input Formatting
700700
- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed)
701701
- [ ] Handler returns fields matching trigger `outputs` exactly
702-
- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify alignment
702+
- [ ] Run `bun run apps/sim/scripts/check-trigger-alignment.ts {service}` to verify alignment
703703
704704
### Testing
705705
- [ ] Run `bun run type-check` to verify no TypeScript errors

.claude/commands/add-trigger.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -708,9 +708,9 @@ export const {service}Handler: WebhookProviderHandler = {
708708

709709
### Verify Alignment
710710

711-
Run the alignment checker:
711+
Run the alignment checker (from the `sim` git root). Supported providers have a check in `apps/sim/scripts/check-trigger-alignment.ts` (`PROVIDER_CHECKS`); others exit 0 with a note to add a handler-only entry or verify manually.
712712
```bash
713-
bunx scripts/check-trigger-alignment.ts {service}
713+
bun run apps/sim/scripts/check-trigger-alignment.ts {service}
714714
```
715715

716716
## Trigger Outputs
@@ -820,7 +820,7 @@ export const {service}WebhookTrigger: TriggerConfig = {
820820

821821
### Testing
822822
- [ ] Run `bun run type-check` to verify no TypeScript errors
823-
- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify output alignment
823+
- [ ] Run `bun run apps/sim/scripts/check-trigger-alignment.ts {service}` to verify output alignment
824824
- [ ] Restart dev server to pick up new triggers
825825
- [ ] Test trigger UI shows correctly in the block
826826
- [ ] Test automatic webhook creation works (if applicable)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { createHash } from 'node:crypto'
2+
import * as jose from 'jose'
3+
import { NextRequest } from 'next/server'
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
GONG_JWT_PUBLIC_KEY_CONFIG_KEY,
7+
gongHandler,
8+
normalizeGongPublicKeyPem,
9+
verifyGongJwtAuth,
10+
} from '@/lib/webhooks/providers/gong'
11+
12+
describe('normalizeGongPublicKeyPem', () => {
13+
it('passes through PEM', () => {
14+
const pem = '-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----'
15+
expect(normalizeGongPublicKeyPem(pem)).toBe(pem)
16+
})
17+
18+
it('wraps raw base64', () => {
19+
const raw = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfj3'
20+
const out = normalizeGongPublicKeyPem(raw)
21+
expect(out).toContain('BEGIN PUBLIC KEY')
22+
expect(out).toContain('END PUBLIC KEY')
23+
expect(out).toContain('MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfj3')
24+
})
25+
26+
it('returns null for garbage', () => {
27+
expect(normalizeGongPublicKeyPem('not-base64!!!')).toBeNull()
28+
})
29+
})
30+
31+
describe('gongHandler verifyAuth (JWT)', () => {
32+
it('returns null when JWT public key is not configured', async () => {
33+
const request = new NextRequest('https://app.example.com/api/webhooks/trigger/abc', {
34+
method: 'POST',
35+
body: '{}',
36+
})
37+
const rawBody = '{}'
38+
const res = await verifyGongJwtAuth({
39+
webhook: {},
40+
workflow: {},
41+
request,
42+
rawBody,
43+
requestId: 't1',
44+
providerConfig: {},
45+
})
46+
expect(res).toBeNull()
47+
})
48+
49+
it('returns 401 when key is configured but Authorization is missing', async () => {
50+
const { publicKey } = await jose.generateKeyPair('RS256')
51+
const spki = await jose.exportSPKI(publicKey)
52+
const request = new NextRequest('https://app.example.com/api/webhooks/trigger/abc', {
53+
method: 'POST',
54+
body: '{}',
55+
})
56+
const res = await verifyGongJwtAuth({
57+
webhook: {},
58+
workflow: {},
59+
request,
60+
rawBody: '{}',
61+
requestId: 't2',
62+
providerConfig: { [GONG_JWT_PUBLIC_KEY_CONFIG_KEY]: spki },
63+
})
64+
expect(res?.status).toBe(401)
65+
})
66+
67+
it('accepts a valid Gong-style JWT', async () => {
68+
const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
69+
const spki = await jose.exportSPKI(publicKey)
70+
const url = 'https://app.example.com/api/webhooks/trigger/test-path'
71+
const rawBody = '{"callData":{}}'
72+
const bodySha = createHash('sha256').update(rawBody, 'utf8').digest('hex')
73+
74+
const jwt = await new jose.SignJWT({
75+
webhook_url: url,
76+
body_sha256: bodySha,
77+
})
78+
.setProtectedHeader({ alg: 'RS256' })
79+
.setExpirationTime('1h')
80+
.sign(privateKey)
81+
82+
const request = new NextRequest(url, {
83+
method: 'POST',
84+
body: rawBody,
85+
headers: { Authorization: `Bearer ${jwt}` },
86+
})
87+
88+
const res = await gongHandler.verifyAuth!({
89+
webhook: {},
90+
workflow: {},
91+
request,
92+
rawBody,
93+
requestId: 't3',
94+
providerConfig: { [GONG_JWT_PUBLIC_KEY_CONFIG_KEY]: spki },
95+
})
96+
expect(res).toBeNull()
97+
})
98+
99+
it('rejects JWT when body hash does not match', async () => {
100+
const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
101+
const spki = await jose.exportSPKI(publicKey)
102+
const url = 'https://app.example.com/api/webhooks/trigger/x'
103+
const rawBody = '{"a":1}'
104+
105+
const jwt = await new jose.SignJWT({
106+
webhook_url: url,
107+
body_sha256: 'deadbeef',
108+
})
109+
.setProtectedHeader({ alg: 'RS256' })
110+
.setExpirationTime('1h')
111+
.sign(privateKey)
112+
113+
const request = new NextRequest(url, {
114+
method: 'POST',
115+
body: rawBody,
116+
headers: { Authorization: jwt },
117+
})
118+
119+
const res = await verifyGongJwtAuth({
120+
webhook: {},
121+
workflow: {},
122+
request,
123+
rawBody,
124+
requestId: 't4',
125+
providerConfig: { [GONG_JWT_PUBLIC_KEY_CONFIG_KEY]: spki },
126+
})
127+
expect(res?.status).toBe(401)
128+
})
129+
})

apps/sim/lib/webhooks/providers/gong.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,137 @@
1+
import { createHash } from 'node:crypto'
2+
import { createLogger } from '@sim/logger'
3+
import * as jose from 'jose'
4+
import { NextResponse } from 'next/server'
15
import type {
6+
AuthContext,
27
FormatInputContext,
38
FormatInputResult,
49
WebhookProviderHandler,
510
} from '@/lib/webhooks/providers/types'
611

12+
const logger = createLogger('WebhookProvider:Gong')
13+
14+
/** providerConfig key: PEM or raw base64 RSA public key from Gong (Signed JWT header auth). */
15+
export const GONG_JWT_PUBLIC_KEY_CONFIG_KEY = 'gongJwtPublicKeyPem'
16+
17+
/**
18+
* Gong automation webhooks support either URL secrecy (token in path) or a signed JWT in
19+
* `Authorization` (see https://help.gong.io/docs/create-a-webhook-rule).
20+
* When {@link GONG_JWT_PUBLIC_KEY_CONFIG_KEY} is set, we verify RS256 per Gong's JWT guide.
21+
* When unset, only the unguessable Sim webhook path authenticates the request (same as before).
22+
*/
23+
export function normalizeGongPublicKeyPem(input: string): string | null {
24+
const trimmed = input.trim()
25+
if (!trimmed) return null
26+
if (trimmed.includes('BEGIN PUBLIC KEY')) {
27+
return trimmed
28+
}
29+
const b64 = trimmed.replace(/\s/g, '')
30+
if (!/^[A-Za-z0-9+/]+=*$/.test(b64)) {
31+
return null
32+
}
33+
const chunked = b64.match(/.{1,64}/g)?.join('\n') ?? b64
34+
return `-----BEGIN PUBLIC KEY-----\n${chunked}\n-----END PUBLIC KEY-----`
35+
}
36+
37+
function normalizeUrlForGongJwtClaim(url: string): string {
38+
try {
39+
const u = new URL(url)
40+
let path = u.pathname
41+
if (path.length > 1 && path.endsWith('/')) {
42+
path = path.slice(0, -1)
43+
}
44+
return `${u.protocol}//${u.host.toLowerCase()}${path}`
45+
} catch {
46+
return url.trim()
47+
}
48+
}
49+
50+
function parseAuthorizationJwt(authHeader: string | null): string | null {
51+
if (!authHeader) return null
52+
const trimmed = authHeader.trim()
53+
if (trimmed.toLowerCase().startsWith('bearer ')) {
54+
return trimmed.slice(7).trim() || null
55+
}
56+
return trimmed || null
57+
}
58+
59+
export async function verifyGongJwtAuth(ctx: AuthContext): Promise<NextResponse | null> {
60+
const { request, rawBody, requestId, providerConfig } = ctx
61+
const rawKey = providerConfig[GONG_JWT_PUBLIC_KEY_CONFIG_KEY]
62+
if (typeof rawKey !== 'string') {
63+
return null
64+
}
65+
66+
const pem = normalizeGongPublicKeyPem(rawKey)
67+
if (!pem) {
68+
logger.warn(`[${requestId}] Gong JWT public key configured but could not be normalized`)
69+
return new NextResponse('Unauthorized - Invalid Gong JWT public key configuration', {
70+
status: 401,
71+
})
72+
}
73+
74+
const token = parseAuthorizationJwt(request.headers.get('authorization'))
75+
if (!token) {
76+
logger.warn(`[${requestId}] Gong JWT verification enabled but Authorization header missing`)
77+
return new NextResponse('Unauthorized - Missing Gong JWT', { status: 401 })
78+
}
79+
80+
let payload: jose.JWTPayload
81+
try {
82+
const key = await jose.importSPKI(pem, 'RS256')
83+
const verified = await jose.jwtVerify(token, key, { algorithms: ['RS256'] })
84+
payload = verified.payload
85+
} catch (error) {
86+
logger.warn(`[${requestId}] Gong JWT verification failed`, {
87+
message: error instanceof Error ? error.message : String(error),
88+
})
89+
return new NextResponse('Unauthorized - Invalid Gong JWT', { status: 401 })
90+
}
91+
92+
const claimUrl = payload.webhook_url
93+
if (typeof claimUrl !== 'string' || !claimUrl) {
94+
logger.warn(`[${requestId}] Gong JWT missing webhook_url claim`)
95+
return new NextResponse('Unauthorized - Invalid Gong JWT claims', { status: 401 })
96+
}
97+
98+
const claimDigest = payload.body_sha256
99+
if (typeof claimDigest !== 'string' || !claimDigest) {
100+
logger.warn(`[${requestId}] Gong JWT missing body_sha256 claim`)
101+
return new NextResponse('Unauthorized - Invalid Gong JWT claims', { status: 401 })
102+
}
103+
104+
const expectedDigest = createHash('sha256').update(rawBody, 'utf8').digest('hex')
105+
if (claimDigest !== expectedDigest) {
106+
logger.warn(`[${requestId}] Gong JWT body_sha256 mismatch`)
107+
return new NextResponse('Unauthorized - Gong JWT body mismatch', { status: 401 })
108+
}
109+
110+
const receivedNorm = normalizeUrlForGongJwtClaim(request.url)
111+
const claimNorm = normalizeUrlForGongJwtClaim(claimUrl)
112+
if (receivedNorm !== claimNorm) {
113+
logger.warn(`[${requestId}] Gong JWT webhook_url mismatch`, {
114+
receivedNorm,
115+
claimNorm,
116+
})
117+
return new NextResponse('Unauthorized - Gong JWT URL mismatch', { status: 401 })
118+
}
119+
120+
return null
121+
}
122+
7123
export const gongHandler: WebhookProviderHandler = {
124+
verifyAuth: verifyGongJwtAuth,
125+
8126
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
9127
const b = body as Record<string, unknown>
10128
const callData = b.callData as Record<string, unknown> | undefined
11129
const metaData = (callData?.metaData as Record<string, unknown>) || {}
12130
const content = callData?.content as Record<string, unknown> | undefined
131+
const callId =
132+
typeof metaData.id === 'string' || typeof metaData.id === 'number'
133+
? String(metaData.id)
134+
: null
13135

14136
return {
15137
input: {
@@ -19,6 +141,8 @@ export const gongHandler: WebhookProviderHandler = {
19141
parties: (callData?.parties as unknown[]) || [],
20142
context: (callData?.context as unknown[]) || [],
21143
trackers: (content?.trackers as unknown[]) || [],
144+
eventType: 'gong.automation_rule',
145+
callId: callId ?? null,
22146
},
23147
}
24148
},

0 commit comments

Comments
 (0)