Skip to content

Commit 99dfee3

Browse files
authored
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
1 parent dac9c83 commit 99dfee3

3 files changed

Lines changed: 49 additions & 3 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
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.

apps/webapp/app/models/runtimeEnvironment.server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ export async function findEnvironmentByPublicApiKey(
109109

110110
export async function findEnvironmentById(
111111
id: string
112-
): Promise<(AuthenticatedEnvironment & { parentEnvironment: { apiKey: string } | null }) | null> {
112+
): Promise<
113+
| (AuthenticatedEnvironment & { parentEnvironment: { id: string; apiKey: string } | null })
114+
| null
115+
> {
113116
const environment = await $replica.runtimeEnvironment.findFirst({
114117
where: {
115118
id,
@@ -120,6 +123,7 @@ export async function findEnvironmentById(
120123
orgMember: true,
121124
parentEnvironment: {
122125
select: {
126+
id: true,
123127
apiKey: true,
124128
},
125129
},

apps/webapp/app/services/realtime/jwtAuth.server.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { json } from "@remix-run/server-runtime";
2-
import { validateJWT } from "@trigger.dev/core/v3/jwt";
2+
import { validateJWT, type ValidationResult } from "@trigger.dev/core/v3/jwt";
3+
import { $replica } from "~/db.server";
34
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
45
import { AuthenticatedEnvironment } from "../apiAuth.server";
56
import { logger } from "../logger.server";
@@ -34,11 +35,23 @@ export async function validatePublicJwtKey(token: string): Promise<ValidatePubli
3435
return { ok: false, error: "Invalid Public Access Token, environment not found." };
3536
}
3637

37-
const result = await validateJWT(
38+
let result = await validateJWT(
3839
token,
3940
environment.parentEnvironment?.apiKey ?? environment.apiKey
4041
);
4142

43+
// PATs are signed with the env's apiKey at mint time. If the env's apiKey
44+
// has since been rotated, signature verification fails against the current
45+
// key — fall back to any RevokedApiKey rows still in their grace window.
46+
// Only run this query on the failure path so the success path is unchanged.
47+
if (!result.ok) {
48+
result = await validateAgainstRevokedApiKeys(
49+
token,
50+
environment.parentEnvironment?.id ?? environment.id,
51+
result
52+
);
53+
}
54+
4255
if (!result.ok) {
4356
switch (result.code) {
4457
case "ERR_JWT_EXPIRED": {
@@ -71,6 +84,29 @@ export async function validatePublicJwtKey(token: string): Promise<ValidatePubli
7184
};
7285
}
7386

87+
async function validateAgainstRevokedApiKeys(
88+
token: string,
89+
signingEnvironmentId: string,
90+
primaryResult: ValidationResult
91+
): Promise<ValidationResult> {
92+
const revokedApiKeys = await $replica.revokedApiKey.findMany({
93+
where: {
94+
runtimeEnvironmentId: signingEnvironmentId,
95+
expiresAt: { gt: new Date() },
96+
},
97+
select: { apiKey: true },
98+
});
99+
100+
for (const { apiKey } of revokedApiKeys) {
101+
const fallbackResult = await validateJWT(token, apiKey);
102+
if (fallbackResult.ok) {
103+
return fallbackResult;
104+
}
105+
}
106+
107+
return primaryResult;
108+
}
109+
74110
export function isPublicJWT(token: string): boolean {
75111
// Split the token
76112
const parts = token.split(".");

0 commit comments

Comments
 (0)