Skip to content

Commit 7a6c83e

Browse files
committed
fix(webapp): honor RevokedApiKey grace window for public access tokens
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.
1 parent c69e939 commit 7a6c83e

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)