1import * as iam from "@distilled.cloud/aws/iam";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import { diffTags } from "../../Tags.ts";
7import { AWSEnvironment } from "../Environment.ts";
8import type { Providers } from "../Providers.ts";
9import { toTagRecord } from "./common.ts";
10
11export interface OpenIDConnectProviderProps {
12 /**
13 * The identity provider URL.
14 */
15 url: string;
16 /**
17 * Client IDs allowed for the provider. AWS rejects requests with an
18 * empty list, so the prop is typed as a non-empty tuple.
19 */
20 clientIDList?: [string, ...string[]];
21 /**
22 * Certificate thumbprints for the provider. AWS auto-manages thumbprints
23 * for well-known IdPs (e.g. GitHub Actions), so this is optional — but
24 * `iam.updateOpenIDConnectProviderThumbprint` rejects an empty list, so
25 * the prop is typed as a non-empty tuple when supplied.
26 */
27 thumbprintList?: [string, ...string[]];
28 /**
29 * User-defined tags to apply to the provider.
30 */
31 tags?: Record<string, string>;
32}
33
34export interface OpenIDConnectProvider extends Resource<
35 "AWS.IAM.OpenIDConnectProvider",
36 OpenIDConnectProviderProps,
37 {
38 /** The ARN of the OIDC provider. */
39 openIDConnectProviderArn: string;
40 /** The URL of the identity provider. */
41 url: string;
42 /** The client IDs (audiences) registered with the provider. */
43 clientIDList: string[];
44 /**
45 * Reflects the desired state — `undefined` when the user opted out of
46 * managing thumbprints (AWS auto-manages them for well-known IdPs).
47 */
48 thumbprintList: string[] | undefined;
49 /** The tags applied to the provider. */
50 tags: Record<string, string>;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * An IAM OpenID Connect provider for web identity federation.
58 *
59 * `OpenIDConnectProvider` registers an external OIDC issuer so IAM roles can be
60 * assumed through web identity federation flows such as GitHub Actions.
61 * ### Federating with OIDC
62 * **Example:** Create a GitHub Actions OIDC Provider
63 * ```typescript
64 * const oidc = yield* OpenIDConnectProvider("GithubOidc", {
65 * url: "https://token.actions.githubusercontent.com",
66 * clientIDList: ["sts.amazonaws.com"],
67 * thumbprintList: ["6938fd4d98bab03faadb97b34396831e3780aea1"],
68 * });
69 * ```
70 *
71 * @resource
72 */
73export const OpenIDConnectProvider = Resource<OpenIDConnectProvider>(
74 "AWS.IAM.OpenIDConnectProvider",
75);
76
77export const OpenIDConnectProviderProvider = () =>
78 Provider.effect(
79 OpenIDConnectProvider,
80 Effect.gen(function* () {
81 const oidcArnFromUrl = (url: string) =>
82 AWSEnvironment.current.pipe(
83 Effect.map(
84 ({ accountId }) =>
85 `arn:aws:iam::${accountId}:oidc-provider/${url.replace(/^https?:\/\//, "")}`,
86 ),
87 );
88
89 const readProvider = Effect.fn(function* (providerArn: string) {
90 const response = yield* iam
91 .getOpenIDConnectProvider({
92 OpenIDConnectProviderArn: providerArn,
93 })
94 .pipe(
95 Effect.catchTag("NoSuchEntityException", () =>
96 Effect.succeed(undefined),
97 ),
98 );
99 return response;
100 });
101
102 const hydrate = (providerArn: string) =>
103 Effect.gen(function* () {
104 const provider = yield* readProvider(providerArn);
105 if (!provider?.Url) {
106 return undefined;
107 }
108 const tags = yield* iam.listOpenIDConnectProviderTags({
109 OpenIDConnectProviderArn: providerArn,
110 });
111 return {
112 openIDConnectProviderArn: providerArn,
113 url: provider.Url,
114 clientIDList: provider.ClientIDList ?? [],
115 thumbprintList: provider.ThumbprintList ?? [],
116 tags: toTagRecord(tags.Tags),
117 };
118 }).pipe(
119 // A peer test may delete a provider between
120 // `listOpenIDConnectProviders` and hydrating its tags — skip the
121 // vanished entry rather than failing the whole enumeration.
122 Effect.catchTag("NoSuchEntityException", () =>
123 Effect.succeed(undefined),
124 ),
125 );
126
127 return {
128 stables: ["openIDConnectProviderArn"],
129 // IAM is global; `listOpenIDConnectProviders` returns ARNs only, so
130 // hydrate each into the full Attributes shape `read` produces.
131 list: () =>
132 Effect.gen(function* () {
133 const { OpenIDConnectProviderList } =
134 yield* iam.listOpenIDConnectProviders({});
135 const arns = (OpenIDConnectProviderList ?? [])
136 .map((entry) => entry.Arn)
137 .filter((arn): arn is string => arn != null);
138 const rows = yield* Effect.forEach(arns, hydrate, {
139 concurrency: 10,
140 });
141 const result: OpenIDConnectProvider["Attributes"][] = rows.filter(
142 (row): row is NonNullable<typeof row> => row !== undefined,
143 );
144 return result;
145 }),
146 diff: Effect.fn(function* ({ olds, news }) {
147 if (!isResolved(news)) return;
148 if (olds.url !== news.url) {
149 return { action: "replace" } as const;
150 }
151 }),
152 read: Effect.fn(function* ({ olds, output }) {
153 const providerArn =
154 output?.openIDConnectProviderArn ??
155 (olds?.url !== undefined
156 ? yield* oidcArnFromUrl(olds.url)
157 : undefined);
158 if (providerArn === undefined) {
159 // An Output-valued `url` doesn't survive a `creating`-state
160 // round-trip (it deserializes as `undefined`) — report "not
161 // found" so the engine re-drives the create.
162 return undefined;
163 }
164 const provider = yield* readProvider(providerArn);
165 if (!provider?.Url) {
166 return undefined;
167 }
168 const tags = yield* iam.listOpenIDConnectProviderTags({
169 OpenIDConnectProviderArn: providerArn,
170 });
171 return {
172 openIDConnectProviderArn: providerArn,
173 url: provider.Url,
174 clientIDList: provider.ClientIDList ?? [],
175 thumbprintList: provider.ThumbprintList ?? [],
176 tags: toTagRecord(tags.Tags),
177 };
178 }),
179 reconcile: Effect.fn(function* ({ news, output, session }) {
180 // The OIDC provider's ARN is deterministic from its URL, so we
181 // can observe with or without prior output.
182 const providerArn =
183 output?.openIDConnectProviderArn ??
184 (yield* oidcArnFromUrl(news.url));
185
186 // Observe — read the live provider; absent when missing.
187 let observed = yield* readProvider(providerArn);
188 let observedClientIds: string[] = [];
189 let observedThumbprints: string[] = [];
190 let observedTags: Record<string, string> = {};
191
192 if (observed?.Url) {
193 observedClientIds = observed.ClientIDList ?? [];
194 observedThumbprints = observed.ThumbprintList ?? [];
195 const tagsResp = yield* iam.listOpenIDConnectProviderTags({
196 OpenIDConnectProviderArn: providerArn,
197 });
198 observedTags = toTagRecord(tagsResp.Tags);
199 }
200
201 // Ensure — create the provider when it is missing. The API does
202 // not return idempotently for OIDC, but the deterministic ARN
203 // means a race manifests as `EntityAlreadyExistsException`.
204 if (!observed?.Url) {
205 yield* iam
206 .createOpenIDConnectProvider({
207 Url: news.url,
208 ClientIDList: news.clientIDList,
209 ThumbprintList: news.thumbprintList,
210 Tags: Object.entries(news.tags ?? {}).map(([Key, Value]) => ({
211 Key,
212 Value,
213 })),
214 })
215 .pipe(
216 Effect.catchTag(
217 "EntityAlreadyExistsException",
218 () => Effect.void,
219 ),
220 );
221 observed = yield* readProvider(providerArn);
222 observedClientIds = observed?.ClientIDList ?? [];
223 observedThumbprints = observed?.ThumbprintList ?? [];
224 const tagsResp = yield* iam.listOpenIDConnectProviderTags({
225 OpenIDConnectProviderArn: providerArn,
226 });
227 observedTags = toTagRecord(tagsResp.Tags);
228 }
229
230 // Sync client IDs against the observed list.
231 const desiredClientIds = news.clientIDList ?? [];
232 const observedClientSet = new Set(observedClientIds);
233 const desiredClientSet = new Set(desiredClientIds);
234 for (const clientId of desiredClientIds) {
235 if (!observedClientSet.has(clientId)) {
236 yield* iam.addClientIDToOpenIDConnectProvider({
237 OpenIDConnectProviderArn: providerArn,
238 ClientID: clientId,
239 });
240 }
241 }
242 for (const clientId of observedClientIds) {
243 if (!desiredClientSet.has(clientId)) {
244 yield* iam.removeClientIDFromOpenIDConnectProvider({
245 OpenIDConnectProviderArn: providerArn,
246 ClientID: clientId,
247 });
248 }
249 }
250
251 // Sync thumbprints — `updateOpenIDConnectProviderThumbprint`
252 // replaces the entire list, so call it whenever the set differs.
253 // When the user didn't request a list we leave the cloud-managed
254 // thumbprints alone (AWS auto-manages for well-known IdPs).
255 const desiredThumbprints = news.thumbprintList;
256 if (
257 desiredThumbprints &&
258 JSON.stringify([...observedThumbprints].sort()) !==
259 JSON.stringify([...desiredThumbprints].sort())
260 ) {
261 yield* iam.updateOpenIDConnectProviderThumbprint({
262 OpenIDConnectProviderArn: providerArn,
263 ThumbprintList: desiredThumbprints,
264 });
265 }
266
267 // Sync tags against the observed cloud tags.
268 const desiredTags = news.tags ?? {};
269 const { removed, upsert } = diffTags(observedTags, desiredTags);
270 if (upsert.length > 0) {
271 yield* iam.tagOpenIDConnectProvider({
272 OpenIDConnectProviderArn: providerArn,
273 Tags: upsert,
274 });
275 }
276 if (removed.length > 0) {
277 yield* iam.untagOpenIDConnectProvider({
278 OpenIDConnectProviderArn: providerArn,
279 TagKeys: removed,
280 });
281 }
282
283 yield* session.note(providerArn);
284 return {
285 openIDConnectProviderArn: providerArn,
286 url: news.url,
287 clientIDList: desiredClientIds,
288 thumbprintList: desiredThumbprints,
289 tags: desiredTags,
290 };
291 }),
292 delete: Effect.fn(function* ({ output }) {
293 yield* iam
294 .deleteOpenIDConnectProvider({
295 OpenIDConnectProviderArn: output.openIDConnectProviderArn,
296 })
297 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void));
298 }),
299 };
300 }),
301 );