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:
@@ -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(".");
|
||||
|
||||
Reference in New Issue
Block a user