From 7a6c83e102ca1bd4c0eee62873905e5c61a55902 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 28 Apr 2026 18:01:29 +0100 Subject: [PATCH] fix(webapp): honor RevokedApiKey grace window for public access tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATs (public access tokens) are signed with the env's apiKey at mint time. The original grace-period PR (#3420) updated findEnvironmentByApiKey to fall back to RevokedApiKey rows for raw secret-key auth, and updated JWT minting to use the env's current canonical key — but left validatePublicJwtKey only checking the env's current apiKey. Result: PATs minted before rotation 401'd immediately on the realtime stream endpoints, even though the rotation flow advertises a 24h overlap. Fall back to non-expired RevokedApiKey rows for the signing env (parent env when the request is against a child) only when the primary signature check fails. Uses $replica. --- .../api-key-rotation-pat-grace-period.md | 6 +++ .../app/models/runtimeEnvironment.server.ts | 6 ++- .../app/services/realtime/jwtAuth.server.ts | 40 ++++++++++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 .server-changes/api-key-rotation-pat-grace-period.md diff --git a/.server-changes/api-key-rotation-pat-grace-period.md b/.server-changes/api-key-rotation-pat-grace-period.md new file mode 100644 index 00000000000..c2163d968b8 --- /dev/null +++ b/.server-changes/api-key-rotation-pat-grace-period.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Public Access Tokens (PATs) minted before an API key rotation now keep working during the 24h grace window. `validatePublicJwtKey` falls back to any non-expired `RevokedApiKey` rows for the signing environment when the primary signature check against the env's current `apiKey` fails. The fallback query only runs on the failure path, so the hot success path is unchanged. diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index c919fe4a618..9db3bb3133b 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -109,7 +109,10 @@ export async function findEnvironmentByPublicApiKey( export async function findEnvironmentById( id: string -): Promise<(AuthenticatedEnvironment & { parentEnvironment: { apiKey: string } | null }) | null> { +): Promise< + | (AuthenticatedEnvironment & { parentEnvironment: { id: string; apiKey: string } | null }) + | null +> { const environment = await $replica.runtimeEnvironment.findFirst({ where: { id, @@ -120,6 +123,7 @@ export async function findEnvironmentById( orgMember: true, parentEnvironment: { select: { + id: true, apiKey: true, }, }, diff --git a/apps/webapp/app/services/realtime/jwtAuth.server.ts b/apps/webapp/app/services/realtime/jwtAuth.server.ts index 1bac76b8f3a..00266075d0f 100644 --- a/apps/webapp/app/services/realtime/jwtAuth.server.ts +++ b/apps/webapp/app/services/realtime/jwtAuth.server.ts @@ -1,5 +1,6 @@ import { json } from "@remix-run/server-runtime"; -import { validateJWT } from "@trigger.dev/core/v3/jwt"; +import { validateJWT, type ValidationResult } from "@trigger.dev/core/v3/jwt"; +import { $replica } from "~/db.server"; import { findEnvironmentById } from "~/models/runtimeEnvironment.server"; import { AuthenticatedEnvironment } from "../apiAuth.server"; import { logger } from "../logger.server"; @@ -34,11 +35,23 @@ export async function validatePublicJwtKey(token: string): Promise { + const revokedApiKeys = await $replica.revokedApiKey.findMany({ + where: { + runtimeEnvironmentId: signingEnvironmentId, + expiresAt: { gt: new Date() }, + }, + select: { apiKey: true }, + }); + + for (const { apiKey } of revokedApiKeys) { + const fallbackResult = await validateJWT(token, apiKey); + if (fallbackResult.ok) { + return fallbackResult; + } + } + + return primaryResult; +} + export function isPublicJWT(token: string): boolean { // Split the token const parts = token.split(".");