Skip to content
Merged
1 change: 1 addition & 0 deletions packages/monday/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ module.exports = {
],
},
moduleNameMapper: {
'^corsair/core$': '<rootDir>/../corsair/core.ts',
'^corsair/http$': '<rootDir>/../corsair/http.ts',
'^(\\.\\.?/.*)\\.js$': '$1',
},
Expand Down
18 changes: 9 additions & 9 deletions packages/monday/webhooks/column-value-changed.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { logEventFromContext } from 'corsair/core';
import type { MondayWebhooks } from '../index';
import { createMondayMatch } from './types';
import { createMondayMatch, verifyMondayWebhookSignature } from './types';

export const columnValueChanged: MondayWebhooks['columnValueChanged'] = {
match: createMondayMatch('change_column_value'),

handler: async (ctx, request) => {
// const verification = verifyMondayWebhookSignature(request, ctx.key);
// if (!verification.valid) {
// return {
// success: false,
// statusCode: 401,
// error: verification.error || 'Signature verification failed',
// };
// }
const verification = verifyMondayWebhookSignature(request, ctx.key);
if (!verification.valid) {
return {
success: false,
statusCode: 401,
error: verification.error || 'Signature verification failed',
};
}

const event = request.payload.event;

Expand Down
18 changes: 9 additions & 9 deletions packages/monday/webhooks/item-created.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { logEventFromContext } from 'corsair/core';
import type { MondayWebhooks } from '../index';
import { createMondayMatch } from './types';
import { createMondayMatch, verifyMondayWebhookSignature } from './types';

export const itemCreated: MondayWebhooks['itemCreated'] = {
match: createMondayMatch('create_pulse'),

handler: async (ctx, request) => {
// const verification = verifyMondayWebhookSignature(request, ctx.key);
// if (!verification.valid) {
// return {
// success: false,
// statusCode: 401,
// error: verification.error || 'Signature verification failed',
// };
// }
const verification = verifyMondayWebhookSignature(request, ctx.key);
if (!verification.valid) {
return {
success: false,
statusCode: 401,
error: verification.error || 'Signature verification failed',
};
}

const event = request.payload.event;

Expand Down
18 changes: 9 additions & 9 deletions packages/monday/webhooks/status-changed.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { logEventFromContext } from 'corsair/core';
import type { MondayWebhooks } from '../index';
import { createMondayMatch } from './types';
import { createMondayMatch, verifyMondayWebhookSignature } from './types';

export const statusChanged: MondayWebhooks['statusChanged'] = {
match: createMondayMatch('change_status_column_value'),

handler: async (ctx, request) => {
// const verification = verifyMondayWebhookSignature(request, ctx.key);
// if (!verification.valid) {
// return {
// success: false,
// statusCode: 401,
// error: verification.error || 'Signature verification failed',
// };
// }
const verification = verifyMondayWebhookSignature(request, ctx.key);
if (!verification.valid) {
return {
success: false,
statusCode: 401,
error: verification.error || 'Signature verification failed',
};
}

const event = request.payload.event;

Expand Down
5 changes: 2 additions & 3 deletions packages/monday/webhooks/tenant-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ function readJwtPayload(
const authorization = getHeader(request.headers, 'authorization');
if (!authorization) return null;

const token = authorization.startsWith('Bearer ')
? authorization.slice('Bearer '.length)
: authorization;
// RFC 9110: auth scheme token is case-insensitive ("Bearer" / "bearer" / …).
const token = authorization.replace(/^Bearer\s+/i, '');
const parts = token.split('.');
const payloadSegment = parts[1];
if (!payloadSegment) return null;
Expand Down
99 changes: 85 additions & 14 deletions packages/monday/webhooks/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as crypto from 'node:crypto';
import type {
CorsairWebhookMatcher,
RawWebhookRequest,
WebhookRequest,
} from 'corsair/core';
import { verifyHmacSignature } from 'corsair/http';
import { z } from 'zod';

// ── Shared Sub-Schemas ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -122,20 +122,91 @@ export function createMondayMatch(eventType: string): CorsairWebhookMatcher {

// ── Signature Verification ────────────────────────────────────────────────────

export function verifyMondayWebhookSignature(
request: WebhookRequest<unknown>,
// Monday board / integration webhooks put an HS256 JWT in Authorization,
// signed with the app Signing Secret (or Client Secret for lifecycle events).
// See https://developer.monday.com/apps/docs/authorization-header
// This is NOT a body HMAC — treating the JWT as an HMAC digest 401s real traffic.

function base64UrlEncode(buf: Buffer): string {
return buf
.toString('base64')
.replace(/=+$/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}

function base64UrlDecode(input: string): Buffer {
const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(
normalized.length + ((4 - (normalized.length % 4)) % 4),
'=',
);
return Buffer.from(padded, 'base64');
}

function timingSafeEqualString(a: string, b: string): boolean {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
if (aBuf.length !== bBuf.length) return false;
return crypto.timingSafeEqual(aBuf, bBuf);
}

function verifyMondayJwt(
token: string,
secret: string,
): { valid: boolean; error?: string } {
if (!secret) {
return { valid: true };
const parts = token.split('.');
if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) {
return { valid: false, error: 'Invalid Authorization JWT' };
}

const rawBody = request.rawBody;
if (!rawBody) {
return {
valid: false,
error: 'Missing raw body for signature verification',
const [headerB64, payloadB64, signatureB64] = parts;

try {
const header = JSON.parse(base64UrlDecode(headerB64).toString('utf8')) as {
alg?: string;
};
if (header.alg !== 'HS256') {
return { valid: false, error: 'Unsupported JWT algorithm' };
}
} catch {
return { valid: false, error: 'Invalid Authorization JWT' };
}

const expectedSig = base64UrlEncode(
crypto
.createHmac('sha256', secret)
.update(`${headerB64}.${payloadB64}`)
.digest(),
);

if (!timingSafeEqualString(expectedSig, signatureB64)) {
return { valid: false, error: 'Invalid signature' };
}

try {
const payload = JSON.parse(
base64UrlDecode(payloadB64).toString('utf8'),
) as { exp?: number };
if (
typeof payload.exp === 'number' &&
Math.floor(Date.now() / 1000) >= payload.exp
) {
return { valid: false, error: 'Token expired' };
}
} catch {
return { valid: false, error: 'Invalid Authorization JWT' };
}

return { valid: true };
}

export function verifyMondayWebhookSignature(
request: WebhookRequest<unknown>,
secret: string,
): { valid: boolean; error?: string } {
if (!secret) {
return { valid: false, error: 'Missing webhook secret' };
}

const headers = request.headers;
Expand All @@ -147,8 +218,8 @@ export function verifyMondayWebhookSignature(
return { valid: false, error: 'Missing Authorization header' };
}

const isValid = verifyHmacSignature(rawBody, secret, authHeader, 'sha256');
return isValid
? { valid: true }
: { valid: false, error: 'Invalid signature' };
// RFC 9110: auth scheme token is case-insensitive ("Bearer" / "bearer" / …).
const token = authHeader.replace(/^Bearer\s+/i, '');

return verifyMondayJwt(token, secret);
}
Loading
Loading