Skip to content

Commit 65352bc

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat: external tokens for aws oidc
Add short-lived OIDC token requests and an optional AWS credential provider for deployed tasks, with environment-specific setup instructions. OIDC is available in private beta on Trigger.dev Cloud. Mono-RevId: 410d4ebdb8d8843fa8ec4f19592e658131a16b73
1 parent 02bebb7 commit 65352bc

34 files changed

Lines changed: 1597 additions & 26 deletions

‎.changeset/cloud-oidc-aws.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Add private-beta OIDC authentication for deployed Cloud tasks, with an AWS credential provider and environment-specific setup instructions. Install `@aws-sdk/client-sts` to use `@trigger.dev/sdk/oidc/aws`, upgrade your deployment CLI, and redeploy. Contact us to enable access for your organization.

‎apps/webapp/app/components/navigation/sideMenuSections.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
v3ErrorsPath,
3737
v3LogsPath,
3838
v3ModelsPath,
39+
v3OidcPath,
3940
v3ProjectAlertsPath,
4041
v3ProjectSettingsIntegrationsPath,
4142
v3PromptsPath,
@@ -96,6 +97,7 @@ export function buildSideMenuSections({
9697
hasQueryAccess?: boolean;
9798
hasLogsPageAccess?: boolean;
9899
alertsEnabled?: boolean;
100+
oidcEnabled?: boolean;
99101
};
100102
isManagedCloud: boolean;
101103
/** Side-menu-only extras on the Dashboards item; the customize modal has no use for them. */
@@ -285,6 +287,18 @@ export function buildSideMenuSections({
285287
to: v3ApiKeysPath(organization, project, environment),
286288
dataAction: "api keys",
287289
},
290+
...(isManagedCloud && featureFlags.oidcEnabled
291+
? [
292+
{
293+
id: "oidc",
294+
name: "OIDC",
295+
icon: KeyIcon,
296+
activeIconColor: "text-text-bright",
297+
to: v3OidcPath(organization, project, environment),
298+
dataAction: "oidc",
299+
} satisfies SideMenuItemConfig,
300+
]
301+
: []),
288302
...(alertsInObservability ? [] : [alertsItem]),
289303
...(isManagedCloud
290304
? [

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2792,6 +2792,8 @@ const EnvironmentSchema = z
27922792
PRIVATE_CONNECTIONS_ENABLED: z.string().optional(),
27932793
PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS: z.string().optional(),
27942794

2795+
REQUIRE_PLUGINS: z.string().optional(),
2796+
27952797
// Force RBAC to not use the plugin
27962798
RBAC_FORCE_FALLBACK: BoolEnv.default(false),
27972799

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { type UIMatch } from "@remix-run/react";
2-
import { useOptionalOrganization } from "./useOrganizations";
2+
import { useOptionalOrganization, type MatchedOrganization } from "./useOrganizations";
33

44
/**
55
* Hook to access organization-level feature flags.
66
* Returns the feature flags from the current organization, or an empty object if no organization is found.
77
*/
8-
export function useFeatureFlags(matches?: UIMatch[]) {
8+
export function useFeatureFlags(matches?: UIMatch[]): Partial<MatchedOrganization["featureFlags"]> {
99
const org = useOptionalOrganization(matches);
1010
return org?.featureFlags ?? {};
1111
}

‎apps/webapp/app/presenters/OrganizationsPresenter.server.ts‎

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { redirect } from "remix-typedjson";
33
import { prisma } from "~/db.server";
44
import { getSupportAccessOrgSlugs } from "~/services/impersonation.server";
55
import { logger } from "~/services/logger.server";
6+
import { isExternalOidcEnabled } from "~/services/oidc/externalOidc.server";
67
import { type UserFromSession } from "~/services/session.server";
78
import { newOrganizationPath, newProjectPath } from "~/utils/pathBuilder";
89
import { SelectBestEnvironmentPresenter } from "./SelectBestEnvironmentPresenter.server";
@@ -166,25 +167,30 @@ export class OrganizationsPresenter {
166167

167168
const globalFlags = await globalFeatureFlags();
168169

169-
return orgs.map((org) => {
170-
const combinedFlags = mergeOrgFeatureFlags(globalFlags, org.featureFlags);
170+
return Promise.all(
171+
orgs.map(async (org) => {
172+
const combinedFlags = mergeOrgFeatureFlags(globalFlags, org.featureFlags);
171173

172-
return {
173-
id: org.id,
174-
slug: org.slug,
175-
title: org.title,
176-
avatar: parseAvatar(org.avatar, defaultAvatar),
177-
featureFlags: combinedFlags,
178-
supportAccessMode: org.supportAccessMode,
179-
projects: org.projects.map((project) => ({
180-
id: project.id,
181-
slug: project.slug,
182-
name: project.name,
183-
updatedAt: project.updatedAt,
184-
externalRef: project.externalRef,
185-
})),
186-
};
187-
});
174+
return {
175+
id: org.id,
176+
slug: org.slug,
177+
title: org.title,
178+
avatar: parseAvatar(org.avatar, defaultAvatar),
179+
featureFlags: {
180+
...combinedFlags,
181+
oidcEnabled: await isExternalOidcEnabled(org.featureFlags),
182+
},
183+
supportAccessMode: org.supportAccessMode,
184+
projects: org.projects.map((project) => ({
185+
id: project.id,
186+
slug: project.slug,
187+
name: project.name,
188+
updatedAt: project.updatedAt,
189+
externalRef: project.externalRef,
190+
})),
191+
};
192+
})
193+
);
188194
}
189195

190196
#getEnvironment({
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { useState } from "react";
2+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
3+
import { CodeBlock } from "~/components/code/CodeBlock";
4+
import {
5+
MainHorizontallyCenteredContainer,
6+
PageBody,
7+
PageContainer,
8+
} from "~/components/layout/AppLayout";
9+
import { LinkButton } from "~/components/primitives/Buttons";
10+
import { Callout } from "~/components/primitives/Callout";
11+
import { ClipboardField } from "~/components/primitives/ClipboardField";
12+
import { Header2 } from "~/components/primitives/Headers";
13+
import { Input } from "~/components/primitives/Input";
14+
import { Label } from "~/components/primitives/Label";
15+
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
16+
import { Paragraph } from "~/components/primitives/Paragraph";
17+
import { TextLink } from "~/components/primitives/TextLink";
18+
import { findProjectBySlug } from "~/models/project.server";
19+
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
20+
import {
21+
getExternalOidcController,
22+
isExternalOidcEnabled,
23+
} from "~/services/oidc/externalOidc.server";
24+
import { resolveProjectAuthScope } from "~/services/projectAuthScope.server";
25+
import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
26+
import { AWS_S3_READ_PERMISSIONS, awsOidcTaskExample, awsOidcTrustPolicy } from "~/utils/oidcSetup";
27+
import { pageMeta } from "~/utils/pageTitle";
28+
import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder";
29+
30+
export const meta = pageMeta("OpenID Connect (OIDC)");
31+
32+
export const loader = dashboardLoader(
33+
{
34+
params: EnvironmentParamSchema,
35+
context: (params) => resolveProjectAuthScope(params.organizationSlug, params.projectParam),
36+
},
37+
async ({ params, user }) => {
38+
const project = await findProjectBySlug(params.organizationSlug, params.projectParam, user.id);
39+
if (!project) throw new Response("Project not found", { status: 404 });
40+
41+
const environment = await findEnvironmentBySlug(project.id, params.envParam, user.id);
42+
if (!environment) throw new Response("Environment not found", { status: 404 });
43+
44+
const controller = await getExternalOidcController();
45+
const orgFeatureFlags = environment.organization.featureFlags;
46+
if (!controller || !(await isExternalOidcEnabled(orgFeatureFlags))) {
47+
throw new Response("Not found", { status: 404 });
48+
}
49+
50+
if (environment.type === "DEVELOPMENT") {
51+
return typedjson({ state: "development" as const, setup: null });
52+
}
53+
54+
return typedjson({
55+
state: "available" as const,
56+
setup: {
57+
...controller.getSetup({
58+
id: environment.id,
59+
slug: environment.slug,
60+
type: environment.type,
61+
organizationId: environment.organizationId,
62+
projectId: project.id,
63+
projectRef: project.externalRef,
64+
orgFeatureFlags,
65+
}),
66+
},
67+
});
68+
}
69+
);
70+
71+
const AWS_ACCOUNT_ID_PLACEHOLDER = "YOUR_AWS_ACCOUNT_ID";
72+
const AWS_ACCOUNT_ID_PATTERN = /^\d{12}$/;
73+
74+
export default function OidcSetupPage() {
75+
const { state, setup } = useTypedLoaderData<typeof loader>();
76+
const [accountId, setAccountId] = useState("");
77+
const validAccountId = AWS_ACCOUNT_ID_PATTERN.test(accountId);
78+
const exampleAccountId = validAccountId ? accountId : AWS_ACCOUNT_ID_PLACEHOLDER;
79+
80+
return (
81+
<PageContainer>
82+
<NavBar>
83+
<PageTitle title="OpenID Connect (OIDC)" />
84+
<PageAccessories>
85+
<LinkButton to={docsPath("oidc")} variant="secondary/small" target="_blank">
86+
OIDC documentation
87+
</LinkButton>
88+
</PageAccessories>
89+
</NavBar>
90+
<PageBody>
91+
<MainHorizontallyCenteredContainer className="mb-12 max-w-3xl space-y-6">
92+
<Paragraph>Access AWS from deployed tasks using short-lived credentials.</Paragraph>
93+
94+
{state === "development" ? (
95+
<Callout variant="info">
96+
OIDC requires a deployed environment. Select Production, Staging or Preview. For local
97+
development, use your own AWS credentials.
98+
</Callout>
99+
) : setup ? (
100+
<>
101+
<section className="space-y-3">
102+
<Header2>1. Add an IAM identity provider</Header2>
103+
<Paragraph>
104+
In AWS IAM, choose Identity providers → Add provider → OpenID Connect. Use these
105+
values, or reuse an existing provider.
106+
</Paragraph>
107+
<div className="space-y-1">
108+
<Header2 className="text-sm">Issuer URL</Header2>
109+
<ClipboardField value={setup.issuer} variant="secondary/medium" />
110+
</div>
111+
<div className="space-y-1">
112+
<Header2 className="text-sm">Audience</Header2>
113+
<ClipboardField value={setup.audience} variant="secondary/medium" />
114+
</div>
115+
<Paragraph>Or create the provider with the AWS CLI:</Paragraph>
116+
<CodeBlock
117+
code={`aws iam create-open-id-connect-provider --url "${setup.issuer}" --client-id-list "${setup.audience}"`}
118+
language="bash"
119+
showChrome
120+
showLineNumbers={false}
121+
/>
122+
</section>
123+
124+
<section className="space-y-3">
125+
<Header2>2. Create an IAM role</Header2>
126+
<Paragraph>
127+
Enter your AWS account ID, then create a role with this trust policy.
128+
</Paragraph>
129+
<div className="space-y-1">
130+
<Header2 className="text-sm">Subject</Header2>
131+
<ClipboardField value={setup.subject} variant="secondary/medium" />
132+
</div>
133+
<div className="max-w-sm space-y-2">
134+
<Label htmlFor="aws-account-id">AWS account ID</Label>
135+
<Input
136+
id="aws-account-id"
137+
value={accountId}
138+
onChange={(event) => setAccountId(event.target.value.trim())}
139+
placeholder="12-digit AWS account ID"
140+
inputMode="numeric"
141+
maxLength={12}
142+
pattern="[0-9]{12}"
143+
aria-describedby="aws-account-id-hint"
144+
aria-invalid={accountId.length > 0 && !validAccountId}
145+
/>
146+
<Paragraph id="aws-account-id-hint" variant="small">
147+
Updates the examples below.
148+
</Paragraph>
149+
</div>
150+
<CodeBlock
151+
code={awsOidcTrustPolicy(setup, exampleAccountId)}
152+
language="json"
153+
showChrome
154+
showLineNumbers={false}
155+
showCopyButton
156+
/>
157+
<Paragraph>
158+
To share a role across Preview branches, see{" "}
159+
<TextLink href={`${docsPath("oidc")}#set-up-aws`} target="_blank">
160+
Preview trust policies
161+
</TextLink>
162+
.
163+
</Paragraph>
164+
</section>
165+
166+
<section className="space-y-3">
167+
<Header2>3. Attach permissions to the role</Header2>
168+
<Paragraph>
169+
Attach permissions for the AWS services your task needs. This example allows
170+
reading an S3 prefix.
171+
</Paragraph>
172+
<CodeBlock
173+
code={AWS_S3_READ_PERMISSIONS}
174+
language="json"
175+
showChrome
176+
showLineNumbers={false}
177+
/>
178+
</section>
179+
180+
<section className="space-y-3">
181+
<Header2>4. Use the SDK</Header2>
182+
<Paragraph>
183+
Install the SDK and AWS clients, then use the credential provider in your task.
184+
</Paragraph>
185+
<CodeBlock
186+
code="npm install @trigger.dev/sdk@latest @aws-sdk/client-sts @aws-sdk/client-s3"
187+
language="bash"
188+
showChrome
189+
showLineNumbers={false}
190+
/>
191+
<CodeBlock
192+
code={awsOidcTaskExample(exampleAccountId)}
193+
language="typescript"
194+
showChrome
195+
showLineNumbers={false}
196+
/>
197+
</section>
198+
199+
<section className="space-y-3">
200+
<Header2>5. Redeploy</Header2>
201+
<Paragraph>
202+
Use SDK and CLI 4.8.3 or later, including in CI. Redeploy and start a new run.
203+
</Paragraph>
204+
</section>
205+
</>
206+
) : null}
207+
</MainHorizontallyCenteredContainer>
208+
</PageBody>
209+
</PageContainer>
210+
);
211+
}

‎apps/webapp/app/routes/account._index/route.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
resendEmailChange,
7676
} from "~/services/emailChange.server";
7777
import { logger } from "~/services/logger.server";
78+
import { isExternalOidcEnabled } from "~/services/oidc/externalOidc.server";
7879
import { type EmailOwnership } from "~/services/ssoManagedIdentity.server";
7980
import {
8081
updateContrastPreference,
@@ -251,7 +252,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
251252
// Resolved here, not from route matches: /account sits outside an org, so
252253
// `useFeatureFlags` would see none and the dialog would offer a shorter
253254
// section list than the side menu - and saving it drops the rest.
254-
featureFlags: Awaited<ReturnType<typeof resolveOrganizationFeatureFlags>>;
255+
featureFlags: Awaited<ReturnType<typeof resolveOrganizationFeatureFlags>> & {
256+
oidcEnabled: boolean;
257+
};
255258
} | null = null;
256259
try {
257260
const { organization, project, environment } = await new SelectBestEnvironmentPresenter().call({
@@ -261,7 +264,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
261264
organization: { slug: organization.slug },
262265
project: { slug: project.slug },
263266
environment: { slug: environment.slug },
264-
featureFlags: await resolveOrganizationFeatureFlags(organization.featureFlags),
267+
featureFlags: {
268+
...(await resolveOrganizationFeatureFlags(organization.featureFlags)),
269+
oidcEnabled: await isExternalOidcEnabled(organization.featureFlags),
270+
},
265271
};
266272
} catch (error) {
267273
logger.debug("Account page: no sidebar context for this user", {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { externalOidcTokenAction as action } from "~/services/oidc/externalOidcTokenAction.server";

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { prisma } from "~/db.server";
22
import type { LoaderFunction } from "@remix-run/node";
33
import { env } from "~/env.server";
44
import { rbac } from "~/services/rbac.server";
5+
import { getExternalOidcController } from "~/services/oidc/externalOidc.server";
56
import { isOtlpWorkerPoolHealthy } from "~/v3/otlpWorkerPool.server";
67

78
export const loader: LoaderFunction = async ({ request }) => {
@@ -13,6 +14,7 @@ export const loader: LoaderFunction = async ({ request }) => {
1314
// HEALTHCHECK_DATABASE_DISABLED=1 — REQUIRE_PLUGINS protection must
1415
// not be silently bypassed by the DB-disabled flag.
1516
await rbac.isUsingPlugin();
17+
await getExternalOidcController();
1618

1719
if (env.OTEL_TRANSFORM_WORKER_POOL_ENABLED && !isOtlpWorkerPoolHealthy()) {
1820
console.log("healthcheck ❌ otlp worker pool has no alive workers");

‎apps/webapp/app/services/apiRateLimit.server.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
164164
"/api/v1/timezones",
165165
"/api/v1/usage/ingest",
166166
"/api/v1/auth/jwt/claims",
167+
// Dedicated per-run/environment limits prevent a throttled run from draining the API budget.
168+
"/api/v1/auth/oidc/token",
167169
/^\/api\/v1\/runs\/[^/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
168170
/^\/api\/v1\/waitpoints\/tokens\/[^/]+\/callback\/[^/]+$/, // /api/v1/waitpoints/tokens/$waitpointFriendlyId/callback/$hash
169171
...deploymentApiPaths, // rate limited separately by deploymentRateLimiter

0 commit comments

Comments
 (0)