Skip to content

Commit 95b7888

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat(webapp): inject a signed per-attempt run token into deployed runs
Deployed runs can receive a signed per-attempt workload token, `TRIGGER_RUN_TOKEN`. It is signed with a dedicated internal key set and issuer (`RUN_TOKEN_ISSUER_URL`), separate from the public OIDC signing keys, and is off by default behind the `runTokenInjection` feature flag. Mono-RevId: 13979b6b7591e6f76ea3c28a9131559b8b424c18
1 parent b1998e4 commit 95b7888

18 files changed

Lines changed: 476 additions & 96 deletions

File tree

‎.env.schema‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ APP_ORIGIN=http://localhost:${REMIX_APP_PORT}
3030
# OIDC is disabled unless an HTTPS issuer origin is configured.
3131
# @sensitive=false @type=url
3232
OIDC_ISSUER_URL=
33+
# Run tokens are disabled unless a separate HTTPS issuer origin is configured.
34+
# @sensitive=false @type=url
35+
RUN_TOKEN_ISSUER_URL=
3336
# @sensitive=false @type=url
3437
ELECTRIC_ORIGIN=http://localhost:3060
3538
# @sensitive=false

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,12 @@ const EnvironmentSchema = z
442442
(value) => (value === "" ? undefined : value),
443443
OidcIssuerSchema.optional()
444444
),
445+
// Issuer for per-attempt run tokens, signed with a separate internal key set. Nothing is served
446+
// at this URL; it only has to differ from OIDC_ISSUER_URL.
447+
RUN_TOKEN_ISSUER_URL: z.preprocess(
448+
(value) => (value === "" ? undefined : value),
449+
OidcIssuerSchema.optional()
450+
),
445451
// Extra exact origins (comma separated) added to the document `img-src` CSP,
446452
// e.g. an SSO host serving profile images. Wildcards are refused.
447453
CSP_IMG_SRC_ALLOWLIST: z.string().optional(),
@@ -2797,6 +2803,13 @@ const EnvironmentSchema = z
27972803
.and(GithubAppEnvSchema)
27982804
.and(S2EnvSchema)
27992805
.superRefine((env, ctx) => {
2806+
if (env.RUN_TOKEN_ISSUER_URL && env.RUN_TOKEN_ISSUER_URL === env.OIDC_ISSUER_URL) {
2807+
ctx.addIssue({
2808+
code: z.ZodIssueCode.custom,
2809+
path: ["RUN_TOKEN_ISSUER_URL"],
2810+
message: "RUN_TOKEN_ISSUER_URL must differ from OIDC_ISSUER_URL",
2811+
});
2812+
}
28002813
if (!validateShardListAgainstNewUrl(env.RUN_OPS_SHARDS, env.RUN_OPS_DATABASE_URL)) {
28012814
ctx.addIssue({
28022815
code: z.ZodIssueCode.custom,

‎apps/webapp/app/routes/[.well-known].jwks[.]json.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
22
import { env } from "~/env.server";
33
import { logger } from "~/services/logger.server";
4-
import { oidcSigningKeyService } from "~/services/oidc/instance.server";
4+
import { externalOidcSigningService } from "~/services/oidc/instance.server";
55
import { OIDC_JWKS_HTTP_MAX_AGE_SECONDS } from "~/services/oidc/constants";
66

77
export async function loader({ request }: LoaderFunctionArgs) {
@@ -13,7 +13,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
1313
}
1414

1515
try {
16-
const jwks = await oidcSigningKeyService.getPublicJwks();
16+
const jwks = await externalOidcSigningService.getPublicJwks();
1717
return json(jwks, {
1818
headers: {
1919
"Cache-Control": `public, max-age=${OIDC_JWKS_HTTP_MAX_AGE_SECONDS}`,

‎apps/webapp/app/routes/admin.oidc.tsx‎

Lines changed: 118 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,47 @@ import {
1717
} from "~/components/primitives/Table";
1818
import { env } from "~/env.server";
1919
import { logger } from "~/services/logger.server";
20-
import { oidcSigningKeyService } from "~/services/oidc/instance.server";
21-
import { OidcSigningKeyError } from "~/services/oidc/signingKeyService.server";
20+
import {
21+
externalOidcSigningService,
22+
internalRunSigningService,
23+
signingServiceFor,
24+
} from "~/services/oidc/instance.server";
25+
import {
26+
OidcSigningKeyError,
27+
type OidcSigningStatus,
28+
} from "~/services/oidc/signingKeyService.server";
2229
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
2330
import { requestUrl } from "~/utils/requestUrl.server";
2431

32+
const PurposeSchema = z.enum(["EXTERNAL_OIDC", "INTERNAL_RUN"]);
33+
type Purpose = z.infer<typeof PurposeSchema>;
34+
2535
const MutationSchema = z.discriminatedUnion("intent", [
26-
z.object({ intent: z.literal("generate") }),
27-
z.object({ intent: z.literal("activate"), keyId: z.string().min(1) }),
28-
z.object({ intent: z.literal("disable") }),
36+
z.object({ intent: z.literal("generate"), purpose: PurposeSchema }),
37+
z.object({ intent: z.literal("activate"), purpose: PurposeSchema, keyId: z.string().min(1) }),
38+
z.object({ intent: z.literal("disable"), purpose: PurposeSchema }),
2939
]);
3040

3141
type MutationResult = { success?: boolean; error?: string };
3242

43+
const ISSUER_SETTING: Record<Purpose, string> = {
44+
EXTERNAL_OIDC: "OIDC_ISSUER_URL",
45+
INTERNAL_RUN: "RUN_TOKEN_ISSUER_URL",
46+
};
47+
3348
export const loader = dashboardLoader({ authorization: { requireSuper: true } }, async () => {
34-
const status = await oidcSigningKeyService.getSigningStatus();
49+
const [external, internal] = await Promise.all([
50+
externalOidcSigningService.getSigningStatus(),
51+
internalRunSigningService.getSigningStatus(),
52+
]);
3553
return typedjson(
36-
{ issuer: env.OIDC_ISSUER_URL, status, now: Date.now() },
54+
{
55+
keySets: [
56+
{ purpose: "EXTERNAL_OIDC" as const, issuer: env.OIDC_ISSUER_URL, status: external },
57+
{ purpose: "INTERNAL_RUN" as const, issuer: env.RUN_TOKEN_ISSUER_URL, status: internal },
58+
],
59+
now: Date.now(),
60+
},
3761
{
3862
headers: { "Cache-Control": "no-store" },
3963
}
@@ -53,28 +77,29 @@ export const action = dashboardAction(
5377
if (request.headers.get("Origin") !== requestUrl(request).origin) {
5478
return typedjson({ error: "Invalid request origin" }, { status: 403 });
5579
}
56-
if (!env.OIDC_ISSUER_URL) {
57-
return typedjson(
58-
{ error: "Configure OIDC_ISSUER_URL before managing keys" },
59-
{ status: 400 }
60-
);
61-
}
62-
6380
const result = MutationSchema.safeParse(Object.fromEntries(await request.formData()));
6481
if (!result.success) {
6582
return typedjson({ error: "Invalid key operation" }, { status: 400 });
6683
}
6784

85+
const service = signingServiceFor(result.data.purpose);
86+
if (!service.issuer) {
87+
return typedjson(
88+
{ error: `Configure ${ISSUER_SETTING[result.data.purpose]} before managing these keys` },
89+
{ status: 400 }
90+
);
91+
}
92+
6893
try {
6994
switch (result.data.intent) {
7095
case "generate":
71-
await oidcSigningKeyService.generateStagedKey(user.id);
96+
await service.generateStagedKey(user.id);
7297
break;
7398
case "activate":
74-
await oidcSigningKeyService.activateKey(result.data.keyId, user.id);
99+
await service.activateKey(result.data.keyId, user.id);
75100
break;
76101
case "disable":
77-
await oidcSigningKeyService.disableSigning(user.id);
102+
await service.disableSigning(user.id);
78103
break;
79104
}
80105
return typedjson({ success: true });
@@ -84,6 +109,7 @@ export const action = dashboardAction(
84109
}
85110
logger.error("OIDC signing key operation failed", {
86111
operation: result.data.intent,
112+
purpose: result.data.purpose,
87113
userId: user.id,
88114
});
89115
return typedjson(
@@ -97,58 +123,100 @@ export const action = dashboardAction(
97123
);
98124

99125
export default function AdminOidcRoute() {
100-
const { issuer, status, now } = useTypedLoaderData<typeof loader>();
101-
const generateFetcher = useFetcher<MutationResult>();
126+
const { keySets, now } = useTypedLoaderData<typeof loader>();
102127
const revalidator = useRevalidator();
128+
129+
return (
130+
<main className="flex h-full flex-col gap-6 overflow-y-auto px-4 pb-4">
131+
<div className="flex items-center justify-between gap-4">
132+
<Paragraph className="text-text-bright">OIDC signing keys</Paragraph>
133+
<Button
134+
variant="tertiary/small"
135+
onClick={() => revalidator.revalidate()}
136+
disabled={revalidator.state !== "idle"}
137+
>
138+
Refresh
139+
</Button>
140+
</div>
141+
{keySets.map((keySet) => (
142+
<KeySet key={keySet.purpose} {...keySet} now={now} />
143+
))}
144+
<Paragraph variant="small">
145+
Keys use RS256. Private keys are encrypted and are never displayed. Each key set rotates
146+
independently. Refresh after the activation-ready time to enable activation.
147+
</Paragraph>
148+
</main>
149+
);
150+
}
151+
152+
const KEY_SET_COPY: Record<Purpose, { title: string; description: string }> = {
153+
EXTERNAL_OIDC: {
154+
title: "External OIDC",
155+
description:
156+
"Signs tokens for external relying parties such as cloud providers. Public keys are published at the issuer's JWKS URL.",
157+
},
158+
INTERNAL_RUN: {
159+
title: "Internal run tokens",
160+
description:
161+
"Signs per-attempt run tokens accepted by the Trigger API. Public keys are never published.",
162+
},
163+
};
164+
165+
function KeySet({
166+
purpose,
167+
issuer,
168+
status,
169+
now,
170+
}: {
171+
purpose: Purpose;
172+
issuer: string | undefined;
173+
status: OidcSigningStatus;
174+
now: number;
175+
}) {
176+
const generateFetcher = useFetcher<MutationResult>();
177+
const copy = KEY_SET_COPY[purpose];
103178
const keys = [
104179
...(status.activeKey ? [status.activeKey] : []),
105180
...(status.stagedKey ? [status.stagedKey] : []),
106181
...status.retiredKeys,
107182
];
108183

109184
return (
110-
<main className="flex h-full flex-col gap-4 overflow-y-auto px-4 pb-4">
185+
<section className="flex flex-col gap-4">
111186
<div className="flex items-center justify-between gap-4">
112187
<div className="space-y-1">
113-
<Paragraph className="text-text-bright">OIDC signing keys</Paragraph>
188+
<Paragraph className="text-text-bright">{copy.title}</Paragraph>
189+
<Paragraph variant="small">{copy.description}</Paragraph>
114190
<Paragraph variant="small">
115191
{issuer
116192
? `Issuer: ${issuer}`
117-
: "OIDC is disabled. Configure OIDC_ISSUER_URL to enable it."}
193+
: `Disabled. Configure ${ISSUER_SETTING[purpose]} to enable it.`}
118194
</Paragraph>
119195
</div>
120-
<div className="flex gap-2">
196+
<generateFetcher.Form method="post">
197+
<input type="hidden" name="intent" value="generate" />
198+
<input type="hidden" name="purpose" value={purpose} />
121199
<Button
122-
variant="tertiary/small"
123-
onClick={() => revalidator.revalidate()}
124-
disabled={revalidator.state !== "idle"}
200+
type="submit"
201+
variant="primary/small"
202+
disabled={!issuer || !!status.stagedKey || generateFetcher.state !== "idle"}
125203
>
126-
Refresh
204+
Generate key
127205
</Button>
128-
<generateFetcher.Form method="post">
129-
<input type="hidden" name="intent" value="generate" />
130-
<Button
131-
type="submit"
132-
variant="primary/small"
133-
disabled={!issuer || !!status.stagedKey || generateFetcher.state !== "idle"}
134-
>
135-
Generate key
136-
</Button>
137-
</generateFetcher.Form>
138-
</div>
206+
</generateFetcher.Form>
139207
</div>
140208

141209
{generateFetcher.data?.error && (
142210
<Callout variant="error">{generateFetcher.data.error}</Callout>
143211
)}
144212
<Callout variant={issuer && status.activeKey ? "info" : "warning"}>
145213
{!issuer
146-
? "Token signing is disabled because OIDC_ISSUER_URL is not configured. Stored signing keys are unchanged."
214+
? `Token signing is disabled because ${ISSUER_SETTING[purpose]} is not configured. Stored signing keys are unchanged.`
147215
: status.activeKey
148-
? "One key signs new tokens. Staged keys are published before activation, and retired public keys remain available until existing tokens expire."
216+
? "One key signs new tokens. Staged keys are trusted before activation, and retired public keys remain trusted until existing tokens expire."
149217
: "No active signing key. Token signing is disabled. Retained public keys can still verify existing tokens."}
150218
</Callout>
151-
{issuer && (
219+
{issuer && purpose === "EXTERNAL_OIDC" && (
152220
<div className="flex gap-4 text-sm text-text-link">
153221
<a href={`${issuer}/.well-known/openid-configuration`} target="_blank" rel="noreferrer">
154222
Discovery document
@@ -165,7 +233,7 @@ export default function AdminOidcRoute() {
165233
<TableHeaderCell>Status</TableHeaderCell>
166234
<TableHeaderCell>Generated</TableHeaderCell>
167235
<TableHeaderCell>Activation ready</TableHeaderCell>
168-
<TableHeaderCell>Public until</TableHeaderCell>
236+
<TableHeaderCell>Trusted until</TableHeaderCell>
169237
<TableHeaderCell>Actions</TableHeaderCell>
170238
</TableRow>
171239
</TableHeader>
@@ -192,18 +260,20 @@ export default function AdminOidcRoute() {
192260
{key.status === "STAGED" && (
193261
<KeyMutation
194262
intent="activate"
263+
purpose={purpose}
195264
keyId={key.id}
196265
disabled={!issuer || key.readyAt.getTime() > now}
197266
label="Activate"
198-
confirmation="Activate this key? It will sign new tokens. Any current signing key will be retired, but its public key will remain available for existing tokens."
267+
confirmation="Activate this key? It will sign new tokens. Any current signing key in this set will be retired, but its public key will remain trusted for existing tokens."
199268
/>
200269
)}
201270
{key.status === "ACTIVE" && (
202271
<KeyMutation
203272
intent="disable"
273+
purpose={purpose}
204274
disabled={!issuer}
205275
label="Disable signing"
206-
confirmation="Disable token signing? No new tokens can be signed until another key is activated. Existing tokens remain valid until expiry."
276+
confirmation="Disable token signing for this key set? No new tokens can be signed until another key is activated. Existing tokens remain valid until expiry."
207277
/>
208278
)}
209279
</TableCell>
@@ -212,22 +282,20 @@ export default function AdminOidcRoute() {
212282
)}
213283
</TableBody>
214284
</Table>
215-
<Paragraph variant="small">
216-
Keys use RS256. Private keys are encrypted and are never displayed. Refresh after the
217-
activation-ready time to enable activation.
218-
</Paragraph>
219-
</main>
285+
</section>
220286
);
221287
}
222288

223289
function KeyMutation({
224290
intent,
291+
purpose,
225292
keyId,
226293
disabled,
227294
label,
228295
confirmation,
229296
}: {
230297
intent: "activate" | "disable";
298+
purpose: Purpose;
231299
keyId?: string;
232300
disabled: boolean;
233301
label: string;
@@ -256,6 +324,7 @@ function KeyMutation({
256324
</Button>
257325
<fetcher.Form method="post" onSubmit={() => setOpen(false)}>
258326
<input type="hidden" name="intent" value={intent} />
327+
<input type="hidden" name="purpose" value={purpose} />
259328
{keyId && <input type="hidden" name="keyId" value={keyId} />}
260329
<Button
261330
type="submit"

‎apps/webapp/app/services/oidc/crypto.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export async function signRs256Jwt({
105105
audience,
106106
subject,
107107
expiresInSeconds,
108+
typ = "JWT",
108109
now = new Date(),
109110
}: {
110111
payload: JWTPayload;
@@ -114,6 +115,7 @@ export async function signRs256Jwt({
114115
audience: string | string[];
115116
subject: string;
116117
expiresInSeconds: number;
118+
typ?: string;
117119
now?: Date;
118120
}): Promise<string> {
119121
if (
@@ -129,7 +131,7 @@ export async function signRs256Jwt({
129131
const issuedAt = Math.floor(now.getTime() / 1000);
130132

131133
return new SignJWT(payload)
132-
.setProtectedHeader({ alg: OIDC_SIGNING_ALGORITHM, kid, typ: "JWT" })
134+
.setProtectedHeader({ alg: OIDC_SIGNING_ALGORITHM, kid, typ })
133135
.setIssuer(issuer)
134136
.setAudience(audience)
135137
.setSubject(subject)
@@ -143,18 +145,21 @@ export async function verifyRs256Jwt<Payload extends JWTPayload = JWTPayload>({
143145
jwks,
144146
issuer,
145147
audience,
148+
typ,
146149
now = new Date(),
147150
}: {
148151
token: string;
149152
jwks: PublicJwks;
150153
issuer: string;
151154
audience: string | string[];
155+
typ?: string;
152156
now?: Date;
153157
}): Promise<JWTVerifyResult<Payload>> {
154158
return jwtVerify<Payload>(token, createLocalJWKSet(jwks), {
155159
algorithms: [OIDC_SIGNING_ALGORITHM],
156160
issuer,
157161
audience,
162+
typ,
158163
requiredClaims: ["exp", "iat", "iss", "aud", "sub"],
159164
maxTokenAge: OIDC_MAX_SUPPORTED_JWT_TTL_SECONDS,
160165
clockTolerance: OIDC_CLOCK_TOLERANCE_SECONDS,

0 commit comments

Comments
 (0)