Skip to content

Commit d4354dd

Browse files
nicktrnTrigger.dev RepoOps
authored andcommitted
feat(webapp): accept previous worker auth secrets during rotation
Mono-RevId: 5147714aeb571087890f5bb0b5c4a7a3a592bb39
1 parent 41ad199 commit d4354dd

9 files changed

Lines changed: 388 additions & 38 deletions

File tree

‎apps/webapp/app/env.server.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,10 @@ const EnvironmentSchema = z
10341034
EVENTS_LOAD_SHEDDING_ENABLED: z.string().default("1"),
10351035

10361036
MANAGED_WORKER_SECRET: z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
1037+
MANAGED_WORKER_SECRET_PREVIOUS: z.preprocess(
1038+
(v) => (v === "" ? undefined : v),
1039+
z.string().min(1).refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE).optional()
1040+
),
10371041

10381042
// Allow booting with a known-insecure published default secret. Temporary
10391043
// bridge for deployments that can't rotate yet; rotate as soon as possible.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { tryCatch } from "@trigger.dev/core/utils";
3+
import { z } from "zod";
4+
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
5+
import { WorkerGroupTokenService } from "~/v3/services/worker/workerGroupTokenService.server";
6+
7+
const DEFAULT_GRACE_SECONDS = 48 * 60 * 60;
8+
9+
const ParamsSchema = z.object({
10+
workerGroupId: z.string(),
11+
});
12+
13+
const RequestBodySchema = z.discriminatedUnion("action", [
14+
z.object({
15+
action: z.literal("rotate"),
16+
graceSeconds: z.number().int().positive().default(DEFAULT_GRACE_SECONDS),
17+
}),
18+
z.object({
19+
action: z.literal("revoke-previous"),
20+
}),
21+
]);
22+
23+
export async function action({ request, params }: ActionFunctionArgs) {
24+
await requireAdminApiRequest(request);
25+
26+
if (request.method !== "POST") {
27+
return json({ error: "method not allowed" }, { status: 405 });
28+
}
29+
30+
const { workerGroupId } = ParamsSchema.parse(params);
31+
32+
const [jsonError, rawBody] = await tryCatch(request.json());
33+
34+
if (jsonError) {
35+
return json({ error: "invalid request body" }, { status: 400 });
36+
}
37+
38+
const parsedBody = RequestBodySchema.safeParse(rawBody ?? {});
39+
40+
if (!parsedBody.success) {
41+
return json(
42+
{ error: "invalid request body", issues: parsedBody.error.issues },
43+
{ status: 400 }
44+
);
45+
}
46+
47+
const service = new WorkerGroupTokenService();
48+
49+
switch (parsedBody.data.action) {
50+
case "rotate": {
51+
const result = await service.rotateToken({
52+
workerGroupId,
53+
graceMs: parsedBody.data.graceSeconds * 1000,
54+
});
55+
56+
switch (result.outcome) {
57+
case "not_found":
58+
return json({ error: "worker group not found" }, { status: 404 });
59+
case "conflict":
60+
return json({ error: "worker group token changed concurrently" }, { status: 409 });
61+
case "grace_active":
62+
return json(
63+
{
64+
error:
65+
"previous worker group token is still within its grace period; revoke it first",
66+
previousTokenValidUntil: result.previousTokenValidUntil,
67+
},
68+
{ status: 409 }
69+
);
70+
case "rotated":
71+
return json({
72+
outcome: "rotated worker group token",
73+
token: result.plaintext,
74+
previousTokenValidUntil: result.previousTokenValidUntil,
75+
});
76+
}
77+
}
78+
case "revoke-previous": {
79+
const result = await service.revokePreviousToken({ workerGroupId });
80+
81+
if (!result) {
82+
return json({ error: "worker group not found" }, { status: 404 });
83+
}
84+
85+
return json({ outcome: "revoked previous worker group token" });
86+
}
87+
}
88+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { timingSafeEqual } from "node:crypto";
2+
3+
export function matchesAnyManagedWorkerSecret(
4+
presented: string,
5+
accepted: ReadonlyArray<string | undefined>
6+
): boolean {
7+
const encoder = new TextEncoder();
8+
const a = encoder.encode(presented);
9+
10+
let matched = false;
11+
12+
for (const candidate of accepted) {
13+
if (!candidate) {
14+
continue;
15+
}
16+
17+
const b = encoder.encode(candidate);
18+
19+
if (a.byteLength === b.byteLength && timingSafeEqual(a, b)) {
20+
matched = true;
21+
}
22+
}
23+
24+
return matched;
25+
}

‎apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts‎

Lines changed: 92 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ import {
2828
type WorkerInstanceGroupType,
2929
} from "@trigger.dev/database";
3030
import { acceptsWorkerStamp } from "./workerGroupAccess";
31+
import { matchesAnyManagedWorkerSecret } from "./managedWorkerSecret.server";
32+
import { sanitizeWorkerHeaders } from "./sanitizeWorkerHeaders";
3133
import { json } from "@remix-run/server-runtime";
32-
import { createHash, timingSafeEqual } from "crypto";
34+
import { createHash } from "crypto";
3335
import { customAlphabet } from "nanoid";
3436
import { z } from "zod";
3537
import { env } from "~/env.server";
@@ -129,51 +131,108 @@ export class WorkerGroupTokenService extends WithRunEngine {
129131
const workerGroup = await this._prisma.workerInstanceGroup.findFirst({
130132
where: {
131133
token: {
132-
tokenHash,
134+
OR: [
135+
{ tokenHash },
136+
{ previousTokenHash: tokenHash, previousTokenValidUntil: { gt: new Date() } },
137+
],
133138
},
134139
},
140+
include: {
141+
token: { select: { tokenHash: true, previousTokenValidUntil: true } },
142+
},
135143
});
136144

137145
if (!workerGroup) {
138-
logger.warn("[WorkerGroupTokenService] No matching worker group found", { token });
146+
logger.warn("[WorkerGroupTokenService] No matching worker group found");
139147
return null;
140148
}
141149

142-
return workerGroup;
150+
const { token: matchedToken, ...group } = workerGroup;
151+
const tokenValidUntil =
152+
matchedToken.tokenHash === tokenHash
153+
? undefined
154+
: (matchedToken.previousTokenValidUntil ?? undefined);
155+
156+
return { ...group, tokenValidUntil };
143157
}
144158

145-
async rotateToken({ workerGroupId }: { workerGroupId: string }) {
159+
async rotateToken({ workerGroupId, graceMs }: { workerGroupId: string; graceMs: number }) {
146160
const workerGroup = await this._prisma.workerInstanceGroup.findFirst({
147161
where: {
148162
id: workerGroupId,
149163
},
164+
include: {
165+
token: true,
166+
},
150167
});
151168

152169
if (!workerGroup) {
153170
logger.error("[WorkerGroupTokenService] WorkerGroup not found", { workerGroupId });
154-
return;
171+
return { outcome: "not_found" as const };
172+
}
173+
174+
const activeGraceUntil = workerGroup.token.previousTokenValidUntil;
175+
if (activeGraceUntil && activeGraceUntil.getTime() > Date.now()) {
176+
logger.error("[WorkerGroupTokenService] Previous token still within grace", {
177+
workerGroupId,
178+
previousTokenValidUntil: activeGraceUntil,
179+
});
180+
return { outcome: "grace_active" as const, previousTokenValidUntil: activeGraceUntil };
155181
}
156182

157183
const rawToken = await this.generateToken();
184+
const previousTokenValidUntil = new Date(Date.now() + graceMs);
158185

159-
const workerGroupToken = await this._prisma.workerGroupToken.update({
186+
const updated = await this._prisma.workerGroupToken.updateMany({
160187
where: {
161188
id: workerGroup.tokenId,
189+
tokenHash: workerGroup.token.tokenHash,
162190
},
163191
data: {
164192
tokenHash: rawToken.hash,
193+
previousTokenHash: workerGroup.token.tokenHash,
194+
previousTokenValidUntil,
195+
},
196+
});
197+
198+
if (updated.count === 0) {
199+
logger.error("[WorkerGroupTokenService] Concurrent token rotation detected", {
200+
workerGroupId,
201+
});
202+
return { outcome: "conflict" as const };
203+
}
204+
205+
return {
206+
outcome: "rotated" as const,
207+
plaintext: rawToken.plaintext,
208+
previousTokenValidUntil,
209+
};
210+
}
211+
212+
async revokePreviousToken({ workerGroupId }: { workerGroupId: string }) {
213+
const workerGroup = await this._prisma.workerInstanceGroup.findFirst({
214+
where: {
215+
id: workerGroupId,
165216
},
166217
});
167218

168-
if (!workerGroupToken) {
169-
logger.error("[WorkerGroupTokenService] WorkerGroupToken not found", { workerGroupId });
219+
if (!workerGroup) {
220+
logger.error("[WorkerGroupTokenService] WorkerGroup not found", { workerGroupId });
170221
return;
171222
}
172223

224+
const workerGroupToken = await this._prisma.workerGroupToken.update({
225+
where: {
226+
id: workerGroup.tokenId,
227+
},
228+
data: {
229+
previousTokenHash: null,
230+
previousTokenValidUntil: null,
231+
},
232+
});
233+
173234
return {
174235
id: workerGroupToken.id,
175-
tokenHash: workerGroupToken.tokenHash,
176-
plaintext: rawToken.plaintext,
177236
};
178237
}
179238

@@ -203,7 +262,6 @@ export class WorkerGroupTokenService extends WithRunEngine {
203262

204263
if (!token.startsWith(this.tokenPrefix)) {
205264
logger.error("[WorkerGroupTokenService] Token does not start with expected prefix", {
206-
token,
207265
prefix: this.tokenPrefix,
208266
});
209267
return;
@@ -227,22 +285,13 @@ export class WorkerGroupTokenService extends WithRunEngine {
227285
return;
228286
}
229287

230-
const encoder = new TextEncoder();
231-
232-
const a = encoder.encode(managedWorkerSecret);
233-
const b = encoder.encode(env.MANAGED_WORKER_SECRET);
234-
235-
if (a.byteLength !== b.byteLength) {
236-
logger.error("[WorkerGroupTokenService] Managed secret length mismatch", {
237-
managedWorkerSecret,
238-
headers: this.sanitizeHeaders(request),
239-
});
240-
return;
241-
}
242-
243-
if (!timingSafeEqual(a, b)) {
288+
if (
289+
!matchesAnyManagedWorkerSecret(managedWorkerSecret, [
290+
env.MANAGED_WORKER_SECRET,
291+
env.MANAGED_WORKER_SECRET_PREVIOUS,
292+
])
293+
) {
244294
logger.error("[WorkerGroupTokenService] Managed secret mismatch", {
245-
managedWorkerSecret,
246295
headers: this.sanitizeHeaders(request),
247296
});
248297
return;
@@ -256,7 +305,7 @@ export class WorkerGroupTokenService extends WithRunEngine {
256305
const workerGroup = await this.findWorkerGroup({ token });
257306

258307
if (!workerGroup) {
259-
logger.warn("[WorkerGroupTokenService] Worker group not found", { token });
308+
logger.warn("[WorkerGroupTokenService] Worker group not found", { instanceName });
260309
return;
261310
}
262311

@@ -285,6 +334,7 @@ export class WorkerGroupTokenService extends WithRunEngine {
285334
workloadType: workerGroup.workloadType,
286335
allowedSubscriptions: env.RUN_ENGINE_WORKER_QUEUE_SUBSCRIPTIONS[workerGroup.id] ?? [],
287336
mixedRuntime: env.RUN_ENGINE_MIXED_RUNTIME_WORKER_GROUPS.includes(workerGroup.id),
337+
tokenValidUntil: workerGroup.tokenValidUntil,
288338
});
289339
}
290340
);
@@ -296,6 +346,15 @@ export class WorkerGroupTokenService extends WithRunEngine {
296346
return;
297347
}
298348

349+
if (result.val?.tokenValidUntil && result.val.tokenValidUntil.getTime() <= Date.now()) {
350+
await authenticatedWorkerInstanceCache.authenticatedWorkerInstance.remove(cacheKey.join("-"));
351+
logger.warn("[WorkerGroupTokenService] Previous worker group token has expired", {
352+
instanceName,
353+
workerGroupId: result.val.workerGroupId,
354+
});
355+
return;
356+
}
357+
299358
return result.val;
300359
}
301360

@@ -368,16 +427,8 @@ export class WorkerGroupTokenService extends WithRunEngine {
368427
}
369428
}
370429

371-
private sanitizeHeaders(request: Request, skipHeaders = ["authorization"]) {
372-
const sanitizedHeaders: Partial<Record<string, string>> = {};
373-
374-
for (const [key, value] of request.headers.entries()) {
375-
if (!skipHeaders.includes(key.toLowerCase())) {
376-
sanitizedHeaders[key] = value;
377-
}
378-
}
379-
380-
return sanitizedHeaders;
430+
private sanitizeHeaders(request: Request) {
431+
return sanitizeWorkerHeaders(request.headers);
381432
}
382433
}
383434

@@ -391,6 +442,7 @@ export type AuthenticatedWorkerInstanceOptions = WithRunEngineOptions<
391442
WorkerQueueConsumerOptions & {
392443
type: WorkerInstanceGroupType;
393444
name: string;
445+
tokenValidUntil?: Date;
394446
}
395447
>;
396448

@@ -400,6 +452,7 @@ export class AuthenticatedWorkerInstance extends WithRunEngine {
400452
readonly workerGroupId: string;
401453
readonly workerInstanceId: string;
402454
readonly masterQueue: string;
455+
readonly tokenValidUntil?: Date;
403456
private readonly queueConsumer: WorkerQueueConsumer;
404457

405458
// FIXME: Required for unmanaged workers
@@ -413,6 +466,7 @@ export class AuthenticatedWorkerInstance extends WithRunEngine {
413466
this.workerGroupId = opts.workerGroupId;
414467
this.workerInstanceId = opts.workerInstanceId;
415468
this.masterQueue = opts.masterQueue;
469+
this.tokenValidUntil = opts.tokenValidUntil;
416470
this.queueConsumer = createWorkerQueueConsumer(opts);
417471

418472
if (refusedSubscriptions(opts).length > 0 && !warnedRefusedGroups.has(opts.workerGroupId)) {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from "vitest";
2+
import { matchesAnyManagedWorkerSecret } from "~/v3/services/worker/managedWorkerSecret.server";
3+
4+
describe("matchesAnyManagedWorkerSecret", () => {
5+
it("accepts the current secret", () => {
6+
expect(matchesAnyManagedWorkerSecret("current", ["current", undefined])).toBe(true);
7+
});
8+
9+
it("accepts the previous secret while one is configured", () => {
10+
expect(matchesAnyManagedWorkerSecret("previous", ["current", "previous"])).toBe(true); // Catches: old supervisors 401 mid-rotation
11+
});
12+
13+
it("rejects a secret that matches neither", () => {
14+
expect(matchesAnyManagedWorkerSecret("other", ["current", "previous"])).toBe(false);
15+
});
16+
17+
it("rejects a length-mismatched secret without throwing", () => {
18+
expect(matchesAnyManagedWorkerSecret("curr", ["current"])).toBe(false); // Catches: timingSafeEqual length throw
19+
});
20+
21+
it("rejects an empty presented secret even when no candidate is set", () => {
22+
expect(matchesAnyManagedWorkerSecret("", [undefined, undefined])).toBe(false); // Catches: unset env matching empty header
23+
});
24+
25+
it("never treats an empty candidate as a match", () => {
26+
expect(matchesAnyManagedWorkerSecret("", ["", "current"])).toBe(false); // Catches: empty PREVIOUS accepting empty header
27+
});
28+
});

0 commit comments

Comments
 (0)