1import * as ci from "@distilled.cloud/aws/cognito-identity";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
9import { AWSEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11
12/** A Cognito user pool provider entry for an identity pool. */
13export interface IdentityPoolCognitoProvider {
14 /**
15 * The user pool provider name:
16 * `cognito-idp.<region>.amazonaws.com/<userPoolId>`.
17 */
18 providerName: string;
19 /** The app client ID allowed to exchange tokens. */
20 clientId: string;
21 /**
22 * Whether Cognito checks with the user pool that the token was not
23 * globally signed out.
24 * @default false
25 */
26 serverSideTokenCheck?: boolean;
27}
28
29export interface IdentityPoolProps {
30 /**
31 * Name of the identity pool. If omitted, a deterministic physical name is
32 * generated from the app, stage, and logical ID. Mutable in place.
33 */
34 identityPoolName?: string;
35 /**
36 * Whether the pool vends credentials to unauthenticated (guest)
37 * identities.
38 * @default false
39 */
40 allowUnauthenticatedIdentities?: boolean;
41 /**
42 * Enables the legacy basic (classic) credentials flow.
43 * @default false
44 */
45 allowClassicFlow?: boolean;
46 /**
47 * Social login providers keyed by their domain, e.g.
48 * `{ "accounts.google.com": "<client-id>" }`.
49 */
50 supportedLoginProviders?: Record<string, string>;
51 /**
52 * Domain for developer-authenticated identities. Letters, digits, periods,
53 * underscores, and dashes.
54 */
55 developerProviderName?: string;
56 /**
57 * ARNs of IAM OpenID Connect providers this pool trusts.
58 */
59 openIdConnectProviderArns?: string[];
60 /**
61 * Cognito user pools this identity pool federates.
62 */
63 cognitoIdentityProviders?: IdentityPoolCognitoProvider[];
64 /**
65 * ARNs of IAM SAML providers this pool trusts.
66 */
67 samlProviderArns?: string[];
68 /**
69 * Tags to apply to the identity pool. Merged with internal Alchemy tags.
70 */
71 tags?: Record<string, string>;
72}
73
74export interface IdentityPool extends Resource<
75 "AWS.Cognito.IdentityPool",
76 IdentityPoolProps,
77 {
78 /** The generated identity pool ID, e.g. `us-west-2:xxxx-...`. */
79 identityPoolId: string;
80 /** The ARN of the identity pool. */
81 identityPoolArn: string;
82 /** The name of the identity pool. */
83 identityPoolName: string;
84 },
85 never,
86 Providers
87> {}
88
89/**
90 * An Amazon Cognito identity pool (federated identities) — exchanges tokens
91 * from user pools, social providers, OIDC/SAML IdPs, or developer backends
92 * for temporary AWS credentials.
93 * ### Creating an Identity Pool
94 * **Example:** Identity Pool Federating a User Pool
95 * ```typescript
96 * import * as Cognito from "alchemy/AWS/Cognito";
97 * import * as Output from "alchemy/Output";
98 *
99 * const pool = yield* Cognito.UserPool("Users", {});
100 * const client = yield* Cognito.UserPoolClient("Web", {
101 * userPoolId: pool.userPoolId,
102 * });
103 * const identities = yield* Cognito.IdentityPool("Identities", {
104 * cognitoIdentityProviders: [
105 * {
106 * providerName: Output.interpolate`cognito-idp.us-west-2.amazonaws.com/${pool.userPoolId}`,
107 * clientId: client.clientId,
108 * },
109 * ],
110 * });
111 * ```
112 *
113 * **Example:** Guest (Unauthenticated) Access
114 * ```typescript
115 * const identities = yield* Cognito.IdentityPool("Identities", {
116 * allowUnauthenticatedIdentities: true,
117 * });
118 * ```
119 *
120 * ### Roles
121 * **Example:** Attach Authenticated/Unauthenticated Roles
122 * ```typescript
123 * yield* Cognito.IdentityPoolRoleAttachment("Roles", {
124 * identityPoolId: identities.identityPoolId,
125 * roles: { authenticated: role.roleArn },
126 * });
127 * ```
128 *
129 * @resource
130 */
131export const IdentityPool = Resource<IdentityPool>("AWS.Cognito.IdentityPool");
132
133const tagRecordOf = (
134 tags: { [key: string]: string | undefined } | undefined,
135): Record<string, string> =>
136 Object.fromEntries(
137 Object.entries(tags ?? {}).filter(
138 (entry): entry is [string, string] => entry[1] !== undefined,
139 ),
140 );
141
142const identityPoolArnOf = (region: string, accountId: string, id: string) =>
143 `arn:aws:cognito-identity:${region}:${accountId}:identitypool/${id}`;
144
145/** The full desired wire configuration (update resets omitted fields). */
146const desiredConfig = (name: string, news: IdentityPoolProps) => ({
147 IdentityPoolName: name,
148 AllowUnauthenticatedIdentities: news.allowUnauthenticatedIdentities ?? false,
149 AllowClassicFlow: news.allowClassicFlow,
150 SupportedLoginProviders: news.supportedLoginProviders,
151 DeveloperProviderName: news.developerProviderName,
152 OpenIdConnectProviderARNs: news.openIdConnectProviderArns,
153 CognitoIdentityProviders: news.cognitoIdentityProviders?.map((p) => ({
154 ProviderName: p.providerName,
155 ClientId: p.clientId,
156 ServerSideTokenCheck: p.serverSideTokenCheck,
157 })),
158 SamlProviderARNs: news.samlProviderArns,
159});
160
161const canonical = (value: unknown) =>
162 JSON.stringify(value, (_, v) =>
163 v !== null && typeof v === "object" && !Array.isArray(v)
164 ? Object.fromEntries(
165 Object.entries(v as Record<string, unknown>)
166 .filter(([, x]) => x !== undefined)
167 .sort(([a], [b]) => a.localeCompare(b)),
168 )
169 : v,
170 );
171
172const hasDrift = (
173 name: string,
174 news: IdentityPoolProps,
175 observed: ci.IdentityPool,
176) => {
177 const desired = desiredConfig(name, news);
178 const observedSubset = {
179 IdentityPoolName: observed.IdentityPoolName,
180 AllowUnauthenticatedIdentities: observed.AllowUnauthenticatedIdentities,
181 AllowClassicFlow:
182 desired.AllowClassicFlow === undefined
183 ? undefined
184 : (observed.AllowClassicFlow ?? false),
185 SupportedLoginProviders:
186 desired.SupportedLoginProviders === undefined
187 ? undefined
188 : tagRecordOf(observed.SupportedLoginProviders),
189 DeveloperProviderName:
190 desired.DeveloperProviderName === undefined
191 ? undefined
192 : observed.DeveloperProviderName,
193 OpenIdConnectProviderARNs:
194 desired.OpenIdConnectProviderARNs === undefined
195 ? undefined
196 : (observed.OpenIdConnectProviderARNs ?? []),
197 CognitoIdentityProviders:
198 desired.CognitoIdentityProviders === undefined
199 ? undefined
200 : (observed.CognitoIdentityProviders ?? []).map((p) => ({
201 ProviderName: p.ProviderName,
202 ClientId: p.ClientId,
203 ServerSideTokenCheck: p.ServerSideTokenCheck ?? undefined,
204 })),
205 SamlProviderARNs:
206 desired.SamlProviderARNs === undefined
207 ? undefined
208 : (observed.SamlProviderARNs ?? []),
209 };
210 const normalizedDesired = {
211 ...desired,
212 CognitoIdentityProviders: desired.CognitoIdentityProviders?.map((p) => ({
213 ...p,
214 ServerSideTokenCheck: p.ServerSideTokenCheck ?? false,
215 })),
216 };
217 const normalizedObserved = {
218 ...observedSubset,
219 CognitoIdentityProviders: observedSubset.CognitoIdentityProviders?.map(
220 (p) => ({
221 ...p,
222 ServerSideTokenCheck: p.ServerSideTokenCheck ?? false,
223 }),
224 ),
225 };
226 return canonical(normalizedDesired) !== canonical(normalizedObserved);
227};
228
229export const IdentityPoolProvider = () =>
230 Provider.effect(
231 IdentityPool,
232 Effect.gen(function* () {
233 const createName = Effect.fn(function* (
234 id: string,
235 props: Pick<IdentityPoolProps, "identityPoolName">,
236 ) {
237 return (
238 props.identityPoolName ??
239 (yield* createPhysicalName({ id, maxLength: 128 }))
240 );
241 });
242
243 const describePool = Effect.fn(function* (identityPoolId: string) {
244 return yield* ci
245 .describeIdentityPool({ IdentityPoolId: identityPoolId })
246 .pipe(
247 Effect.catchTag("ResourceNotFoundException", () =>
248 Effect.succeed(undefined),
249 ),
250 );
251 });
252
253 const findPoolsByName = Effect.fn(function* (name: string) {
254 const pages = yield* ci.listIdentityPools
255 .pages({ MaxResults: 60 })
256 .pipe(Stream.runCollect);
257 const candidates = Array.from(pages)
258 .flatMap((page) => page.IdentityPools ?? [])
259 .filter(
260 (pool) =>
261 pool.IdentityPoolName === name &&
262 pool.IdentityPoolId !== undefined,
263 );
264 return yield* Effect.forEach(
265 candidates,
266 (candidate) => describePool(candidate.IdentityPoolId!),
267 { concurrency: 3 },
268 ).pipe(Effect.map((pools) => pools.filter((p) => p !== undefined)));
269 });
270
271 return IdentityPool.Provider.of({
272 stables: ["identityPoolId", "identityPoolArn"],
273
274 list: () =>
275 Effect.gen(function* () {
276 const { accountId, region } = yield* AWSEnvironment.current;
277 const pages = yield* ci.listIdentityPools
278 .pages({ MaxResults: 60 })
279 .pipe(Stream.runCollect);
280 return Array.from(pages)
281 .flatMap((page) => page.IdentityPools ?? [])
282 .filter((pool) => pool.IdentityPoolId !== undefined)
283 .map((pool) => ({
284 identityPoolId: pool.IdentityPoolId!,
285 identityPoolArn: identityPoolArnOf(
286 region,
287 accountId,
288 pool.IdentityPoolId!,
289 ),
290 identityPoolName: pool.IdentityPoolName ?? "",
291 }));
292 }),
293
294 read: Effect.fn(function* ({ id, olds, output }) {
295 const { accountId, region } = yield* AWSEnvironment.current;
296 const attributesOf = (pool: ci.IdentityPool) => ({
297 identityPoolId: pool.IdentityPoolId,
298 identityPoolArn: identityPoolArnOf(
299 region,
300 accountId,
301 pool.IdentityPoolId,
302 ),
303 identityPoolName: pool.IdentityPoolName,
304 });
305 if (output?.identityPoolId !== undefined) {
306 const pool = yield* describePool(output.identityPoolId);
307 if (pool === undefined) return undefined;
308 const attrs = attributesOf(pool);
309 const tags = tagRecordOf(pool.IdentityPoolTags);
310 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
311 }
312 const name = yield* createName(id, olds ?? {});
313 const pools = yield* findPoolsByName(name);
314 if (pools.length === 0) return undefined;
315 for (const pool of pools) {
316 if (yield* hasAlchemyTags(id, tagRecordOf(pool.IdentityPoolTags))) {
317 return attributesOf(pool);
318 }
319 }
320 return Unowned(attributesOf(pools[0]!));
321 }),
322
323 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
324 const { accountId, region } = yield* AWSEnvironment.current;
325 const name = yield* createName(id, news);
326 const internalTags = yield* createInternalTags(id);
327 const desiredTags = { ...news.tags, ...internalTags };
328
329 // 1. OBSERVE — the id in output is only a cache.
330 let observed =
331 output?.identityPoolId !== undefined
332 ? yield* describePool(output.identityPoolId)
333 : undefined;
334 if (observed === undefined) {
335 const candidates = yield* findPoolsByName(name);
336 for (const candidate of candidates) {
337 if (
338 yield* hasAlchemyTags(
339 id,
340 tagRecordOf(candidate.IdentityPoolTags),
341 )
342 ) {
343 observed = candidate;
344 break;
345 }
346 }
347 }
348
349 // 2. ENSURE
350 if (observed === undefined) {
351 observed = yield* ci.createIdentityPool({
352 ...desiredConfig(name, news),
353 IdentityPoolTags: desiredTags,
354 });
355 } else if (hasDrift(name, news, observed)) {
356 // 3. SYNC — UpdateIdentityPool resets omitted fields to their
357 // defaults, so send the full desired configuration.
358 observed = yield* ci.updateIdentityPool({
359 IdentityPoolId: observed.IdentityPoolId,
360 ...desiredConfig(name, news),
361 });
362 }
363
364 const identityPoolId = observed.IdentityPoolId;
365 const identityPoolArn = identityPoolArnOf(
366 region,
367 accountId,
368 identityPoolId,
369 );
370
371 // 3b. SYNC TAGS against OBSERVED cloud tags.
372 const observedTags = yield* ci
373 .listTagsForResource({ ResourceArn: identityPoolArn })
374 .pipe(
375 Effect.map((r) => tagRecordOf(r.Tags)),
376 Effect.catchTag("ResourceNotFoundException", () =>
377 Effect.succeed({} as Record<string, string>),
378 ),
379 );
380 const { upsert, removed } = diffTags(observedTags, desiredTags);
381 if (upsert.length > 0) {
382 yield* ci.tagResource({
383 ResourceArn: identityPoolArn,
384 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
385 });
386 }
387 if (removed.length > 0) {
388 yield* ci.untagResource({
389 ResourceArn: identityPoolArn,
390 TagKeys: removed,
391 });
392 }
393
394 yield* session.note(identityPoolId);
395 return {
396 identityPoolId,
397 identityPoolArn,
398 identityPoolName: name,
399 };
400 }),
401
402 delete: Effect.fn(function* ({ output }) {
403 yield* ci
404 .deleteIdentityPool({ IdentityPoolId: output.identityPoolId })
405 .pipe(
406 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
407 );
408 }),
409 });
410 }),
411 );