GeezerVison is showing github.com — feat(webapp,webhook-engine): keep the old webhook signing secret vali… · triggerdotdev/trigger.dev@08d1ddb
File treeExpand file treeCollapse file tree Open diff view settings _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam
internal-packages/webhook-engine/src packages/core/src/v3/schemas
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboard +5Lines changed: 5 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +--- | | 2 | +"@trigger.dev/core": patch | | 3 | +--- | | 4 | + | | 5 | +HMAC webhook verifier configs accept `additionalSignatureHeaders`: more headers that can carry a signature, matched as alternatives to `signatureHeader`. Providers that send a second signature during a secret rotation (Hookdeck's `x-hookdeck-signature-2`) keep verifying through the rotation. |
Collapse file Copy file name to clipboard +6Lines changed: 6 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +--- | | 2 | +area: webapp | | 3 | +type: feature | | 4 | +--- | | 5 | + | | 6 | +Rotating a webhook endpoint's signing secret can keep the old one valid for a while, so you can rotate without failing deliveries that are still signed with it. The rotate and regenerate dialogs let you choose how long the old secret stays valid (up to 7 days, 24 hours by default). The `rotate-secret` and `secret` endpoints accept an optional `previousSecretTtlSeconds` and return `previousSecretExpiresAt`. With no value, the old secret stops working immediately, as before. |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam/route.tsx +76-5Lines changed: 76 additions & 5 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -29,6 +29,7 @@ import { Label } from "~/components/primitives/Label"; | 29 | 29 | import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; | 30 | 30 | import { Paragraph } from "~/components/primitives/Paragraph"; | 31 | 31 | import * as Property from "~/components/primitives/PropertyTable"; | | 32 | +import { Select, SelectItem } from "~/components/primitives/Select"; | 32 | 33 | import { | 33 | 34 | ResizableHandle, | 34 | 35 | ResizablePanel, | @@ -68,6 +69,7 @@ import { docsPath, EnvironmentParamSchema, v3WebhooksPath } from "~/utils/pathBu | 68 | 69 | import { throwPermissionDenied } from "~/utils/permissionDenied"; | 69 | 70 | import { parseFiniteInt } from "~/utils/searchParams"; | 70 | 71 | import { webhookEngine, webhookVerifyTokenKey } from "~/v3/webhookEngine.server"; | | 72 | +import { PreviousSecretTtlSeconds } from "~/v3/webhookSigningSecret"; | 71 | 73 | import { | 72 | 74 | generateWebhookSigningSecret, | 73 | 75 | storeWebhookSigningSecret, | @@ -209,7 +211,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { | 209 | 211 | }); | 210 | 212 | }; | 211 | 213 |
| 212 | | -const SetSecretSchema = z.object({ | | 214 | +const PreviousSecretTtlFormField = z.object({ | | 215 | + previousSecretTtlSeconds: z.coerce.number().pipe(PreviousSecretTtlSeconds).optional(), | | 216 | +}); | | 217 | + | | 218 | +const SetSecretSchema = PreviousSecretTtlFormField.extend({ | 213 | 219 | intent: z.literal("set-secret"), | 214 | 220 | secret: z.string().trim().min(1, "A signing secret is required"), | 215 | 221 | }); | @@ -261,9 +267,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { | 261 | 267 |
| 262 | 268 | // Generate (integrator-supplied secret): the UI reveals it ONCE for pasting into the provider. | 263 | 269 | if (intent === "generate-secret") { | 264 | | - const result = await generateWebhookSigningSecret(endpoint); | | 270 | + const ttl = PreviousSecretTtlFormField.safeParse(Object.fromEntries(formData)); | | 271 | + if (!ttl.success) { | | 272 | + return { success: false as const, error: ttl.error.issues[0]?.message ?? "Invalid" }; | | 273 | + } | | 274 | + const result = await generateWebhookSigningSecret(endpoint, ttl.data); | 265 | 275 | return result.ok | 266 | | - ? { success: true as const, generatedSecret: result.secret } | | 276 | + ? { | | 277 | + success: true as const, | | 278 | + generatedSecret: result.secret, | | 279 | + previousSecretExpiresAt: result.previousSecretExpiresAt, | | 280 | + } | 267 | 281 | : { success: false as const, error: result.error }; | 268 | 282 | } | 269 | 283 |
| @@ -287,7 +301,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { | 287 | 301 | if (!submission.success) { | 288 | 302 | return { success: false as const, error: submission.error.issues[0]?.message ?? "Invalid" }; | 289 | 303 | } | 290 | | - await storeWebhookSigningSecret(endpoint, submission.data.secret); | | 304 | + await storeWebhookSigningSecret(endpoint, submission.data.secret, { | | 305 | + previousSecretTtlSeconds: submission.data.previousSecretTtlSeconds, | | 306 | + }); | 291 | 307 |
| 292 | 308 | return { success: true as const }; | 293 | 309 | }; | @@ -683,7 +699,12 @@ function ProviderSetup({ verifier, source }: { verifier: LoaderData["verifier"]; | 683 | 699 | <Property.Item> | 684 | 700 | <Property.Label>Signature header</Property.Label> | 685 | 701 | <Property.Value> | 686 | | - <span className="font-mono text-sm">{config.signatureHeader}</span> | | 702 | + <span className="font-mono text-sm"> | | 703 | + {[ | | 704 | + config.signatureHeader, | | 705 | + ...(config.scheme === "hmac" ? (config.additionalSignatureHeaders ?? []) : []), | | 706 | + ].join(", ")} | | 707 | + </span> | 687 | 708 | </Property.Value> | 688 | 709 | </Property.Item> | 689 | 710 | <Property.Item> | @@ -780,6 +801,39 @@ function describeTimestampSource(source: WebhookValueSource): string { | 780 | 801 | } | 781 | 802 | } | 782 | 803 |
| | 804 | +const PREVIOUS_SECRET_TTL_OPTIONS = [ | | 805 | + { value: "0", label: "Stop immediately" }, | | 806 | + { value: "3600", label: "1 hour" }, | | 807 | + { value: "86400", label: "24 hours" }, | | 808 | + { value: "604800", label: "7 days" }, | | 809 | +]; | | 810 | + | | 811 | +function PreviousSecretTtlField({ credentialNoun }: { credentialNoun: string }) { | | 812 | + return ( | | 813 | + <div className="flex flex-col gap-1"> | | 814 | + <Label>Keep accepting the old {credentialNoun} for</Label> | | 815 | + <Select | | 816 | + name="previousSecretTtlSeconds" | | 817 | + variant="tertiary/medium" | | 818 | + dropdownIcon | | 819 | + items={PREVIOUS_SECRET_TTL_OPTIONS} | | 820 | + defaultValue="86400" | | 821 | + text={(value) => PREVIOUS_SECRET_TTL_OPTIONS.find((o) => o.value === value)?.label} | | 822 | + > | | 823 | + {PREVIOUS_SECRET_TTL_OPTIONS.map((option) => ( | | 824 | + <SelectItem key={option.value} value={option.value}> | | 825 | + {option.label} | | 826 | + </SelectItem> | | 827 | + ))} | | 828 | + </Select> | | 829 | + <Hint> | | 830 | + Deliveries signed with the old {credentialNoun} still verify until then, so you can update | | 831 | + the provider without failing any. Stop immediately if the old one leaked. | | 832 | + </Hint> | | 833 | + </div> | | 834 | + ); | | 835 | +} | | 836 | + | 783 | 837 | function SetSecretDialog({ | 784 | 838 | hasSigningSecret, | 785 | 839 | credentialNoun, | @@ -832,6 +886,7 @@ function SetSecretDialog({ | 832 | 886 | : "Stored encrypted and never shown again. Deliveries are verified against this secret."} | 833 | 887 | </Hint> | 834 | 888 | </div> | | 889 | + {hasSigningSecret ? <PreviousSecretTtlField credentialNoun={credentialNoun} /> : null} | 835 | 890 | {fetcher.data && !fetcher.data.success ? ( | 836 | 891 | <Paragraph variant="small" className="text-error"> | 837 | 892 | {fetcher.data.error} | @@ -864,6 +919,10 @@ function GenerateSecretDialog({ hasSigningSecret }: { hasSigningSecret: boolean | 864 | 919 | const isSubmitting = fetcher.state !== "idle"; | 865 | 920 | const generated = | 866 | 921 | fetcher.data && "generatedSecret" in fetcher.data ? fetcher.data.generatedSecret : undefined; | | 922 | + const previousSecretExpiresAt = | | 923 | + fetcher.data && "previousSecretExpiresAt" in fetcher.data | | 924 | + ? fetcher.data.previousSecretExpiresAt | | 925 | + : undefined; | 867 | 926 |
| 868 | 927 | return ( | 869 | 928 | <Dialog open={open} onOpenChange={setOpen}> | @@ -883,6 +942,12 @@ function GenerateSecretDialog({ hasSigningSecret }: { hasSigningSecret: boolean | 883 | 942 | </Paragraph> | 884 | 943 | <ClipboardField value={generated} variant="secondary/medium" /> | 885 | 944 | <Hint>Paste this into your provider's webhook signing-secret field.</Hint> | | 945 | + {previousSecretExpiresAt ? ( | | 946 | + <Hint> | | 947 | + The previous secret keeps verifying deliveries until{" "} | | 948 | + <DateTime date={previousSecretExpiresAt} />. | | 949 | + </Hint> | | 950 | + ) : null} | 886 | 951 | <div className="flex justify-end"> | 887 | 952 | <Button type="button" variant="primary/small" onClick={() => setOpen(false)}> | 888 | 953 | Done | @@ -896,6 +961,12 @@ function GenerateSecretDialog({ hasSigningSecret }: { hasSigningSecret: boolean | 896 | 961 | Trigger.dev generates a strong signing secret, stores it encrypted, and shows it once | 897 | 962 | so you can paste it into your provider. | 898 | 963 | </Paragraph> | | 964 | + {hasSigningSecret ? <PreviousSecretTtlField credentialNoun="signing secret" /> : null} | | 965 | + {fetcher.data && !fetcher.data.success ? ( | | 966 | + <Paragraph variant="small" className="text-error"> | | 967 | + {fetcher.data.error} | | 968 | + </Paragraph> | | 969 | + ) : null} | 899 | 970 | <div className="flex justify-end gap-2"> | 900 | 971 | <Button | 901 | 972 | type="button" | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.rotate-secret.ts +21-3Lines changed: 21 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -3,10 +3,17 @@ import { z } from "zod"; | 3 | 3 | import { webhookPrisma } from "~/db.server"; | 4 | 4 | import { webhookEndpointLookup } from "~/v3/webhookEndpointLookup"; | 5 | 5 | import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; | | 6 | +import { safeJsonParse } from "~/utils/json"; | | 7 | +import { PreviousSecretTtlSeconds } from "~/v3/webhookSigningSecret"; | 6 | 8 | import { generateWebhookSigningSecret } from "~/v3/webhookSigningSecret.server"; | 7 | 9 |
| 8 | 10 | const ParamsSchema = z.object({ endpointId: z.string() }); | 9 | 11 |
| | 12 | +/** Optional: existing callers POST with no body, which keeps the replace-immediately behavior. */ | | 13 | +const RotateSecretRequestBody = z.object({ | | 14 | + previousSecretTtlSeconds: PreviousSecretTtlSeconds.optional(), | | 15 | +}); | | 16 | + | 10 | 17 | // POST /api/v1/webhooks/endpoints/:endpointId/rotate-secret — mint a new signing secret and return | 11 | 18 | // it ONCE. Only for schemes we generate (hmac / shared-secret); asymmetric endpoints set a public key. | 12 | 19 | const { action, loader } = createActionApiRoute( | @@ -17,17 +24,28 @@ const { action, loader } = createActionApiRoute( | 17 | 24 | corsStrategy: "all", | 18 | 25 | authorization: { action: "write", resource: () => ({ type: "webhooks" }) }, | 19 | 26 | }, | 20 | | - async ({ params, authentication }) => { | | 27 | + async ({ params, authentication, request }) => { | | 28 | + const rawBody = await request.text(); | | 29 | + const body = RotateSecretRequestBody.safeParse(rawBody ? safeJsonParse(rawBody) : {}); | | 30 | + if (!body.success) { | | 31 | + return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 }); | | 32 | + } | | 33 | + | 21 | 34 | const endpoint = await webhookPrisma.webhookEndpoint.findFirst({ | 22 | 35 | where: webhookEndpointLookup(authentication.environment.id, params.endpointId), | 23 | 36 | select: { id: true, friendlyId: true, verifierArtifact: true }, | 24 | 37 | }); | 25 | 38 | if (!endpoint) return json({ error: "Not found" }, { status: 404 }); | 26 | 39 |
| 27 | | - const result = await generateWebhookSigningSecret(endpoint); | | 40 | + const result = await generateWebhookSigningSecret(endpoint, body.data); | 28 | 41 | if (!result.ok) return json({ error: result.error }, { status: 400 }); | 29 | 42 |
| 30 | | - return json({ id: endpoint.friendlyId, secretSet: true as const, secret: result.secret }); | | 43 | + return json({ | | 44 | + id: endpoint.friendlyId, | | 45 | + secretSet: true as const, | | 46 | + secret: result.secret, | | 47 | + previousSecretExpiresAt: result.previousSecretExpiresAt, | | 48 | + }); | 31 | 49 | } | 32 | 50 | ); | 33 | 51 |
| |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/routes/api.v1.webhooks.endpoints.$endpointId.secret.ts +10-3Lines changed: 10 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -4,14 +4,19 @@ import { z } from "zod"; | 4 | 4 | import { webhookPrisma } from "~/db.server"; | 5 | 5 | import { webhookEndpointLookup } from "~/v3/webhookEndpointLookup"; | 6 | 6 | import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; | | 7 | +import { PreviousSecretTtlSeconds } from "~/v3/webhookSigningSecret"; | 7 | 8 | import { storeWebhookSigningSecret } from "~/v3/webhookSigningSecret.server"; | 8 | 9 |
| 9 | 10 | const ParamsSchema = z.object({ endpointId: z.string() }); | 10 | 11 |
| | 12 | +const SetSecretRequestBody = SetWebhookEndpointSecretRequestBody.extend({ | | 13 | + previousSecretTtlSeconds: PreviousSecretTtlSeconds.optional(), | | 14 | +}); | | 15 | + | 11 | 16 | const { action, loader } = createActionApiRoute( | 12 | 17 | { | 13 | 18 | params: ParamsSchema, | 14 | | - body: SetWebhookEndpointSecretRequestBody, | | 19 | + body: SetSecretRequestBody, | 15 | 20 | method: "PUT", | 16 | 21 | allowJWT: true, | 17 | 22 | corsStrategy: "all", | @@ -24,9 +29,11 @@ const { action, loader } = createActionApiRoute( | 24 | 29 | }); | 25 | 30 | if (!endpoint) return json({ error: "Not found" }, { status: 404 }); | 26 | 31 |
| 27 | | - await storeWebhookSigningSecret(endpoint, body.secret); | | 32 | + const { previousSecretExpiresAt } = await storeWebhookSigningSecret(endpoint, body.secret, { | | 33 | + previousSecretTtlSeconds: body.previousSecretTtlSeconds, | | 34 | + }); | 28 | 35 |
| 29 | | - return json({ id: endpoint.friendlyId, secretSet: true as const }); | | 36 | + return json({ id: endpoint.friendlyId, secretSet: true as const, previousSecretExpiresAt }); | 30 | 37 | } | 31 | 38 | ); | 32 | 39 |
| |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.webhooks.endpoints.$endpointParam.replay-source.ts +3Lines changed: 3 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -57,6 +57,9 @@ function signatureHeaderNames(artifact: unknown): Set<string> { | 57 | 57 | const config = parsed.data.config; | 58 | 58 | if (config.scheme === "hmac" || config.scheme === "asymmetric") { | 59 | 59 | names.add(config.signatureHeader.toLowerCase()); | | 60 | + if (config.scheme === "hmac") { | | 61 | + for (const header of config.additionalSignatureHeaders ?? []) names.add(header.toLowerCase()); | | 62 | + } | 60 | 63 | if (config.timestamp?.source.from === "header") { | 61 | 64 | const { name, fallback } = config.timestamp.source; | 62 | 65 | for (const header of [name, ...(fallback ?? [])]) names.add(header.toLowerCase()); | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/v3/webhookEngine.server.ts +4-6Lines changed: 4 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -21,6 +21,7 @@ import { engine as runEngine } from "./runEngine.server"; | 21 | 21 | import { ServiceValidationError } from "./services/common.server"; | 22 | 22 | import { TriggerTaskService } from "./services/triggerTask.server"; | 23 | 23 | import { resolveWebhookSession } from "./webhookSessionTarget.server"; | | 24 | +import { activeSigningSecrets, StoredSigningSecret } from "./webhookSigningSecret"; | 24 | 25 | import { webhookLimitsForEnvironment } from "./webhookLimits.server"; | 25 | 26 | import { webhookPortErrorMessage } from "./webhookPortErrors.server"; | 26 | 27 | import { webhookWaitpoints } from "./webhookWaitpoints.server"; | @@ -77,10 +78,6 @@ function webhookRedisConnection(): WebhookRedisOptions { | 77 | 78 | }; | 78 | 79 | } | 79 | 80 |
| 80 | | -// The plaintext signing secret is stored under the "DATABASE" SecretStore | 81 | | -// provider as { secret: string } (same shape as environment variables). | 82 | | -const SigningSecretSchema = z.object({ secret: z.string() }); | 83 | | - | 84 | 81 | /** The GET verification token (Meta hub.verify_token) an endpoint owner generated in the dashboard. */ | 85 | 82 | const VerifyTokenSchema = z.object({ token: z.string() }); | 86 | 83 |
| @@ -141,9 +138,10 @@ function createWebhookEngine() { | 141 | 138 | tracer, | 142 | 139 | meter, | 143 | 140 | resolveSigningSecret: async (key) => { | 144 | | - const value = await secretStore.getSecret(SigningSecretSchema, key); | | 141 | + const value = await secretStore.getSecret(StoredSigningSecret, key); | 145 | 142 | // Fail closed: an unset/empty secret returns undefined so ingest rejects. | 146 | | - return value?.secret || undefined; | | 143 | + const secrets = activeSigningSecrets(value, new Date()); | | 144 | + return secrets.length > 0 ? secrets : undefined; | 147 | 145 | }, | 148 | 146 | resolveVerifyToken: async (endpointId) => { | 149 | 147 | const value = await secretStore.getSecret( | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/v3/webhookSigningSecret.server.ts +23-7Lines changed: 23 additions & 7 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -2,9 +2,14 @@ import { randomBytes } from "node:crypto"; | 2 | 2 | import { WebhookVerifierArtifact } from "@trigger.dev/core/v3"; | 3 | 3 | import { prisma, webhookPrisma } from "~/db.server"; | 4 | 4 | import { getSecretStore } from "~/services/secrets/secretStore.server"; | | 5 | +import { replaceStoredSigningSecret, StoredSigningSecret } from "./webhookSigningSecret"; | 5 | 6 |
| 6 | 7 | type SecretEndpoint = { id: string; verifierArtifact: unknown }; | 7 | 8 |
| | 9 | +type StoreOptions = { previousSecretTtlSeconds?: number }; | | 10 | + | | 11 | +type StoreResult = { previousSecretExpiresAt?: string }; | | 12 | + | 8 | 13 | export function webhookSigningSecretKey(endpointId: string): string { | 9 | 14 | return `webhook:signing-secret:${endpointId}`; | 10 | 15 | } | @@ -16,28 +21,39 @@ function isAsymmetricWebhookEndpoint(endpoint: SecretEndpoint): boolean { | 16 | 21 |
| 17 | 22 | /** | 18 | 23 | * Stores a signing secret (or, for an asymmetric source, the provider's public key) in the shape the | 19 | | - * engine's `resolveSigningSecret` reads, and points the endpoint at it. | | 24 | + * engine's `resolveSigningSecret` reads, and points the endpoint at it. With | | 25 | + * `previousSecretTtlSeconds`, the secret it replaces keeps verifying deliveries for that long. | 20 | 26 | */ | 21 | | -export async function storeWebhookSigningSecret(endpoint: SecretEndpoint, secret: string) { | | 27 | +export async function storeWebhookSigningSecret( | | 28 | + endpoint: SecretEndpoint, | | 29 | + secret: string, | | 30 | + options: StoreOptions = {} | | 31 | +): Promise<StoreResult> { | 22 | 32 | const key = webhookSigningSecretKey(endpoint.id); | 23 | | - await getSecretStore("DATABASE", { prismaClient: prisma }).setSecret(key, { secret }); | | 33 | + const secretStore = getSecretStore("DATABASE", { prismaClient: prisma }); | | 34 | + const ttl = options.previousSecretTtlSeconds ?? 0; | | 35 | + const existing = ttl > 0 ? await secretStore.getSecret(StoredSigningSecret, key) : undefined; | | 36 | + const stored = replaceStoredSigningSecret(existing, secret, ttl, new Date()); | | 37 | + await secretStore.setSecret(key, stored); | 24 | 38 | await webhookPrisma.webhookEndpoint.update({ | 25 | 39 | where: { id: endpoint.id }, | 26 | 40 | data: { signingSecretKey: key }, | 27 | 41 | }); | | 42 | + return stored.previous ? { previousSecretExpiresAt: stored.previous.expiresAt } : {}; | 28 | 43 | } | 29 | 44 |
| 30 | 45 | /** Mints and stores a new shared secret, replacing any existing one. Refused for asymmetric sources. */ | 31 | 46 | export async function generateWebhookSigningSecret( | 32 | | - endpoint: SecretEndpoint | 33 | | -): Promise<{ ok: true; secret: string } | { ok: false; error: string }> { | | 47 | + endpoint: SecretEndpoint, | | 48 | + options: StoreOptions = {} | | 49 | +): Promise<({ ok: true; secret: string } & StoreResult) | { ok: false; error: string }> { | 34 | 50 | if (isAsymmetricWebhookEndpoint(endpoint)) { | 35 | 51 | return { | 36 | 52 | ok: false, | 37 | 53 | error: "Cannot generate a secret for an asymmetric endpoint; set its public key instead.", | 38 | 54 | }; | 39 | 55 | } | 40 | 56 | const secret = `whsec_${randomBytes(32).toString("hex")}`; | 41 | | - await storeWebhookSigningSecret(endpoint, secret); | 42 | | - return { ok: true, secret }; | | 57 | + const stored = await storeWebhookSigningSecret(endpoint, secret, options); | | 58 | + return { ok: true, secret, ...stored }; | 43 | 59 | } |
|