Skip to content

Commit 9629749

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat(webapp): add OIDC signing keys and discovery
Add an opt-in OIDC issuer with discovery and public JWKS endpoints, encrypted RS256 signing keys, and an administrator interface for generating, activating, rotating, and disabling signing keys. Staged keys are published before activation, and retired public keys remain available for existing tokens. Mono-RevId: a38dedebf3ccbda2aa15fa098131d1073e4c6bd1
1 parent d5dc484 commit 9629749

21 files changed

Lines changed: 1802 additions & 60 deletions

‎.env.schema‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ REMIX_APP_PORT=3030
2727
APP_ENV=development
2828
# @sensitive=false @type=url
2929
APP_ORIGIN=http://localhost:${REMIX_APP_PORT}
30+
# OIDC is disabled unless an HTTPS issuer origin is configured.
31+
# @sensitive=false @type=url
32+
OIDC_ISSUER_URL=
3033
# @sensitive=false @type=url
3134
ELECTRIC_ORIGIN=http://localhost:3060
3235
# @sensitive=false

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
webhookDeliveryRetentionClass,
55
} from "@trigger.dev/core/v3/isomorphic";
66
import { BoolEnv } from "./utils/boolEnv";
7+
import { OidcIssuerSchema } from "./utils/oidcIssuer";
78
import { isValidDatabaseUrl } from "./utils/db";
89
import { parseDeployBaseImages } from "~/v3/deployBaseImages.server";
910
import { parseRunOpsShards, validateShardListAgainstNewUrl } from "~/v3/runOpsShards.server";
@@ -384,6 +385,9 @@ const EnvironmentSchema = z
384385
"ENCRYPTION_KEY must be exactly 32 bytes"
385386
)
386387
.refine(isNotInsecureSecret, INSECURE_SECRET_MESSAGE),
388+
// Enables the AWS_KMS secret store provider. Callers opt in per secret; everything else
389+
// stays encrypted with ENCRYPTION_KEY.
390+
SECRET_STORE_AWS_KMS_KEY_ID: z.string().optional(),
387391
WHITELISTED_EMAILS: z
388392
.string()
389393
.refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.")
@@ -403,6 +407,10 @@ const EnvironmentSchema = z
403407
MAGIC_LINK_SAME_BROWSER_REQUIRED: BoolEnv.default(true),
404408
APP_ORIGIN: z.string().default("http://localhost:3030"),
405409
PUBLIC_APP_ORIGIN: z.url().optional(),
410+
OIDC_ISSUER_URL: z.preprocess(
411+
(value) => (value === "" ? undefined : value),
412+
OidcIssuerSchema.optional()
413+
),
406414
// Extra exact origins (comma separated) added to the document `img-src` CSP,
407415
// e.g. an SSO host serving profile images. Wildcards are refused.
408416
CSP_IMG_SRC_ALLOWLIST: z.string().optional(),
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { env } from "~/env.server";
3+
import { logger } from "~/services/logger.server";
4+
import { oidcSigningKeyService } from "~/services/oidc/instance.server";
5+
import { OIDC_JWKS_HTTP_MAX_AGE_SECONDS } from "~/services/oidc/constants";
6+
7+
export async function loader({ request }: LoaderFunctionArgs) {
8+
if (request.method !== "GET" && request.method !== "HEAD") {
9+
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
10+
}
11+
if (!env.OIDC_ISSUER_URL) {
12+
return new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
13+
}
14+
15+
try {
16+
const jwks = await oidcSigningKeyService.getPublicJwks();
17+
return json(jwks, {
18+
headers: {
19+
"Cache-Control": `public, max-age=${OIDC_JWKS_HTTP_MAX_AGE_SECONDS}`,
20+
"Access-Control-Allow-Origin": "*",
21+
},
22+
});
23+
} catch {
24+
logger.error("Unable to load OIDC public keys");
25+
return json(
26+
{ error: "OIDC public keys unavailable" },
27+
{
28+
status: 503,
29+
headers: { "Cache-Control": "no-store" },
30+
}
31+
);
32+
}
33+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { env } from "~/env.server";
3+
import { OIDC_JWKS_HTTP_MAX_AGE_SECONDS } from "~/services/oidc/constants";
4+
import { oidcDiscovery } from "~/utils/oidcIssuer";
5+
6+
export function loader({ request }: LoaderFunctionArgs) {
7+
if (request.method !== "GET" && request.method !== "HEAD") {
8+
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
9+
}
10+
if (!env.OIDC_ISSUER_URL) {
11+
return new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
12+
}
13+
14+
return json(oidcDiscovery(env.OIDC_ISSUER_URL), {
15+
headers: {
16+
"Cache-Control": `public, max-age=${OIDC_JWKS_HTTP_MAX_AGE_SECONDS}`,
17+
"Access-Control-Allow-Origin": "*",
18+
},
19+
});
20+
}
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { useFetcher, useRevalidator } from "@remix-run/react";
2+
import { useState } from "react";
3+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
4+
import { z } from "zod";
5+
import { Button } from "~/components/primitives/Buttons";
6+
import { Callout } from "~/components/primitives/Callout";
7+
import { Paragraph } from "~/components/primitives/Paragraph";
8+
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
9+
import {
10+
Table,
11+
TableBlankRow,
12+
TableBody,
13+
TableCell,
14+
TableHeader,
15+
TableHeaderCell,
16+
TableRow,
17+
} from "~/components/primitives/Table";
18+
import { env } from "~/env.server";
19+
import { logger } from "~/services/logger.server";
20+
import { oidcSigningKeyService } from "~/services/oidc/instance.server";
21+
import { OidcSigningKeyError } from "~/services/oidc/signingKeyService.server";
22+
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
23+
import { requestUrl } from "~/utils/requestUrl.server";
24+
25+
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") }),
29+
]);
30+
31+
type MutationResult = { success?: boolean; error?: string };
32+
33+
export const loader = dashboardLoader({ authorization: { requireSuper: true } }, async () => {
34+
const status = await oidcSigningKeyService.getSigningStatus();
35+
return typedjson(
36+
{ issuer: env.OIDC_ISSUER_URL, status, now: Date.now() },
37+
{
38+
headers: { "Cache-Control": "no-store" },
39+
}
40+
);
41+
});
42+
43+
export const action = dashboardAction(
44+
{ authorization: { requireSuper: true } },
45+
async ({ request, user }) => {
46+
if (request.method !== "POST") {
47+
return typedjson(
48+
{ error: "Method not allowed" },
49+
{ status: 405, headers: { Allow: "POST" } }
50+
);
51+
}
52+
// SameSite cookies alone do not protect against other origins on the same site.
53+
if (request.headers.get("Origin") !== requestUrl(request).origin) {
54+
return typedjson({ error: "Invalid request origin" }, { status: 403 });
55+
}
56+
if (!env.OIDC_ISSUER_URL) {
57+
return typedjson(
58+
{ error: "Configure OIDC_ISSUER_URL before managing keys" },
59+
{ status: 400 }
60+
);
61+
}
62+
63+
const result = MutationSchema.safeParse(Object.fromEntries(await request.formData()));
64+
if (!result.success) {
65+
return typedjson({ error: "Invalid key operation" }, { status: 400 });
66+
}
67+
68+
try {
69+
switch (result.data.intent) {
70+
case "generate":
71+
await oidcSigningKeyService.generateStagedKey(user.id);
72+
break;
73+
case "activate":
74+
await oidcSigningKeyService.activateKey(result.data.keyId, user.id);
75+
break;
76+
case "disable":
77+
await oidcSigningKeyService.disableSigning(user.id);
78+
break;
79+
}
80+
return typedjson({ success: true });
81+
} catch (error) {
82+
if (error instanceof OidcSigningKeyError) {
83+
return typedjson({ error: error.message }, { status: 409 });
84+
}
85+
logger.error("OIDC signing key operation failed", {
86+
operation: result.data.intent,
87+
userId: user.id,
88+
});
89+
return typedjson(
90+
{
91+
error: "Key operation failed. Refresh the page and check the key status before retrying.",
92+
},
93+
{ status: 409 }
94+
);
95+
}
96+
}
97+
);
98+
99+
export default function AdminOidcRoute() {
100+
const { issuer, status, now } = useTypedLoaderData<typeof loader>();
101+
const generateFetcher = useFetcher<MutationResult>();
102+
const revalidator = useRevalidator();
103+
const keys = [
104+
...(status.activeKey ? [status.activeKey] : []),
105+
...(status.stagedKey ? [status.stagedKey] : []),
106+
...status.retiredKeys,
107+
];
108+
109+
return (
110+
<main className="flex h-full flex-col gap-4 overflow-y-auto px-4 pb-4">
111+
<div className="flex items-center justify-between gap-4">
112+
<div className="space-y-1">
113+
<Paragraph className="text-text-bright">OIDC signing keys</Paragraph>
114+
<Paragraph variant="small">
115+
{issuer
116+
? `Issuer: ${issuer}`
117+
: "OIDC is disabled. Configure OIDC_ISSUER_URL to enable it."}
118+
</Paragraph>
119+
</div>
120+
<div className="flex gap-2">
121+
<Button
122+
variant="tertiary/small"
123+
onClick={() => revalidator.revalidate()}
124+
disabled={revalidator.state !== "idle"}
125+
>
126+
Refresh
127+
</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>
139+
</div>
140+
141+
{generateFetcher.data?.error && (
142+
<Callout variant="error">{generateFetcher.data.error}</Callout>
143+
)}
144+
<Callout variant={issuer && status.activeKey ? "info" : "warning"}>
145+
{!issuer
146+
? "Token signing is disabled because OIDC_ISSUER_URL is not configured. Stored signing keys are unchanged."
147+
: status.activeKey
148+
? "One key signs new tokens. Staged keys are published before activation, and retired public keys remain available until existing tokens expire."
149+
: "No active signing key. Token signing is disabled. Retained public keys can still verify existing tokens."}
150+
</Callout>
151+
{issuer && (
152+
<div className="flex gap-4 text-sm text-text-link">
153+
<a href={`${issuer}/.well-known/openid-configuration`} target="_blank" rel="noreferrer">
154+
Discovery document
155+
</a>
156+
<a href={`${issuer}/.well-known/jwks.json`} target="_blank" rel="noreferrer">
157+
Public JWKS
158+
</a>
159+
</div>
160+
)}
161+
<Table>
162+
<TableHeader>
163+
<TableRow>
164+
<TableHeaderCell>Key ID</TableHeaderCell>
165+
<TableHeaderCell>Status</TableHeaderCell>
166+
<TableHeaderCell>Generated</TableHeaderCell>
167+
<TableHeaderCell>Activation ready</TableHeaderCell>
168+
<TableHeaderCell>Public until</TableHeaderCell>
169+
<TableHeaderCell>Actions</TableHeaderCell>
170+
</TableRow>
171+
</TableHeader>
172+
<TableBody>
173+
{keys.length === 0 ? (
174+
<TableBlankRow colSpan={6}>
175+
<Paragraph>No signing keys configured</Paragraph>
176+
</TableBlankRow>
177+
) : (
178+
keys.map((key) => (
179+
<TableRow key={key.id}>
180+
<TableCell>
181+
<span className="font-mono text-xs">{key.kid}</span>
182+
</TableCell>
183+
<TableCell>{key.status}</TableCell>
184+
<TableCell>{key.createdAt.toLocaleString()}</TableCell>
185+
<TableCell>
186+
{key.status === "STAGED" ? key.readyAt.toLocaleString() : "Not applicable"}
187+
</TableCell>
188+
<TableCell>
189+
{key.publicUntil ? key.publicUntil.toLocaleString() : "While staged or active"}
190+
</TableCell>
191+
<TableCell>
192+
{key.status === "STAGED" && (
193+
<KeyMutation
194+
intent="activate"
195+
keyId={key.id}
196+
disabled={!issuer || key.readyAt.getTime() > now}
197+
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."
199+
/>
200+
)}
201+
{key.status === "ACTIVE" && (
202+
<KeyMutation
203+
intent="disable"
204+
disabled={!issuer}
205+
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."
207+
/>
208+
)}
209+
</TableCell>
210+
</TableRow>
211+
))
212+
)}
213+
</TableBody>
214+
</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>
220+
);
221+
}
222+
223+
function KeyMutation({
224+
intent,
225+
keyId,
226+
disabled,
227+
label,
228+
confirmation,
229+
}: {
230+
intent: "activate" | "disable";
231+
keyId?: string;
232+
disabled: boolean;
233+
label: string;
234+
confirmation: string;
235+
}) {
236+
const [open, setOpen] = useState(false);
237+
const fetcher = useFetcher<MutationResult>();
238+
const pending = fetcher.state !== "idle";
239+
240+
return (
241+
<div className="space-y-2">
242+
<Popover open={open} onOpenChange={setOpen}>
243+
<PopoverTrigger asChild>
244+
<Button
245+
variant={intent === "disable" ? "danger/small" : "secondary/small"}
246+
disabled={disabled || pending}
247+
>
248+
{label}
249+
</Button>
250+
</PopoverTrigger>
251+
<PopoverContent align="end" className="w-80 space-y-3">
252+
<Paragraph variant="small">{confirmation}</Paragraph>
253+
<div className="flex justify-end gap-2">
254+
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
255+
Cancel
256+
</Button>
257+
<fetcher.Form method="post" onSubmit={() => setOpen(false)}>
258+
<input type="hidden" name="intent" value={intent} />
259+
{keyId && <input type="hidden" name="keyId" value={keyId} />}
260+
<Button
261+
type="submit"
262+
variant={intent === "disable" ? "danger/small" : "primary/small"}
263+
disabled={pending}
264+
>
265+
{label}
266+
</Button>
267+
</fetcher.Form>
268+
</div>
269+
</PopoverContent>
270+
</Popover>
271+
{fetcher.data?.error && (
272+
<Paragraph variant="small" className="text-error">
273+
{fetcher.data.error}
274+
</Paragraph>
275+
)}
276+
</div>
277+
);
278+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export default function Page() {
5151
label: "Data Stores",
5252
to: "/admin/data-stores",
5353
},
54+
{
55+
label: "OIDC",
56+
to: "/admin/oidc",
57+
},
5458
]}
5559
layoutId={"admin"}
5660
/>

0 commit comments

Comments
 (0)