fix(webapp): honor RevokedApiKey grace window for public access tokens (#3464)

## Summary

Follow-up to #3420. PATs (public access tokens) minted before an API key
rotation 401'd immediately on the realtime stream endpoints, even though
the rotation flow advertises a 24h overlap. This fixes the gap.

## Root cause

PATs are JWTs signed with the env's `apiKey` at mint time. When that
secret is rotated, `validatePublicJwtKey`
(`apps/webapp/app/services/realtime/jwtAuth.server.ts`) only verifies
the signature against `environment.parentEnvironment?.apiKey ??
environment.apiKey` — i.e. the env's *current* canonical key. Any PAT in
the wild signed with the previous key fails signature verification →
401, even within the grace window.

#3420 wired up the grace-window fallback in two places —
`findEnvironmentByApiKey` (raw secret-key auth) and `api.v1.auth.jwt.ts`
(signs new JWTs with the canonical key when minting from an old one) —
but the *verify* path for already-issued PATs was never updated.

In a typical app, `POST /api/v1/tasks/.../trigger` (Bearer secret) keeps
working through rotation because that path has the fallback, but `GET
/realtime/v1/streams/run_*/...` and `POST
/realtime/v1/streams/run_*/input/...` 401 for runs that were already in
flight when the rotation happened.

## Fix

After the primary `validateJWT` against the env's current `apiKey`, fall
back to non-expired `RevokedApiKey` rows for the signing env (parent env
when the request is against a child) — but **only on the failure path**,
so the hot success path is unchanged. Uses `$replica` to match the rest
of the auth path.

Symmetrical to the `findEnvironmentByApiKey` two-step from #3420.

## Changes

- `apps/webapp/app/services/realtime/jwtAuth.server.ts` —
`validateAgainstRevokedApiKeys` helper invoked only on `!result.ok`
- `apps/webapp/app/models/runtimeEnvironment.server.ts` —
`findEnvironmentById` also selects `parentEnvironment.id` so we can
scope the revoked-keys lookup to the correct env

## Test plan

E2E verified locally via curl against `GET /realtime/v1/runs/{runId}`
(PAT-authenticated):

- [x] Pre-rotation, PAT signed with K1 → **200** with run body
- [x] Simulate rotation (insert `RevokedApiKey` row + flip env `apiKey`
to K2 in a single transaction, mirroring `regenerateApiKey`)
- [x] Same PAT (K1) within grace window → **200** with run body —
fallback hits
- [x] Fresh PAT signed with K2 → **200** — current key still works
- [x] Set `RevokedApiKey.expiresAt` to past → **401** — fallback finds
no live row
- [x] Bogus signature (no rotation) → **401**
- [x] Cleanup verified: env `apiKey` restored, `RevokedApiKey` row
deleted
- [x] `pnpm run typecheck --filter webapp` passes
This commit is contained in:
Eric Allam
2026-04-29 10:00:33 +01:00
committed by GitHub
parent dac9c83bdc
commit 99dfee3a57
3 changed files with 49 additions and 3 deletions
@@ -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.
@@ -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,
},
},
@@ -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<ValidatePubli
return { ok: false, error: "Invalid Public Access Token, environment not found." };
}
const result = await validateJWT(
let result = await validateJWT(
token,
environment.parentEnvironment?.apiKey ?? environment.apiKey
);
// PATs are signed with the env's apiKey at mint time. If the env's apiKey
// has since been rotated, signature verification fails against the current
// key — fall back to any RevokedApiKey rows still in their grace window.
// Only run this query on the failure path so the success path is unchanged.
if (!result.ok) {
result = await validateAgainstRevokedApiKeys(
token,
environment.parentEnvironment?.id ?? environment.id,
result
);
}
if (!result.ok) {
switch (result.code) {
case "ERR_JWT_EXPIRED": {
@@ -71,6 +84,29 @@ export async function validatePublicJwtKey(token: string): Promise<ValidatePubli
};
}
async function validateAgainstRevokedApiKeys(
token: string,
signingEnvironmentId: string,
primaryResult: ValidationResult
): Promise<ValidationResult> {
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(".");