1import * as cip from "@distilled.cloud/aws/cognito-identity-provider";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10/** The kind of third-party identity provider. */
11export type IdentityProviderType =
12 | "SAML"
13 | "OIDC"
14 | "Facebook"
15 | "Google"
16 | "LoginWithAmazon"
17 | "SignInWithApple";
18
19export interface IdentityProviderProps {
20 /**
21 * The ID of the user pool the IdP is attached to. Changing this triggers
22 * a replacement.
23 */
24 userPoolId: string;
25 /**
26 * The kind of identity provider. Changing this triggers a replacement.
27 */
28 providerType: IdentityProviderType;
29 /**
30 * Name of the identity provider (1-32 chars, no spaces). For social
31 * providers the name must match the provider type (e.g. `Google`). If
32 * omitted, a deterministic name is generated from the app, stage, and
33 * logical ID. Changing this triggers a replacement.
34 */
35 providerName?: string;
36 /**
37 * Provider configuration. For OIDC: `client_id`, `client_secret`,
38 * `authorize_scopes`, `oidc_issuer`, `attributes_request_method`. For
39 * SAML: `MetadataURL` or `MetadataFile`. For social providers:
40 * `client_id`, `client_secret`, `authorize_scopes`. Wrap secret values
41 * (e.g. `client_secret`) with `Redacted.make(...)` so they never leak
42 * into logs or state output.
43 */
44 providerDetails: Record<string, string | Redacted.Redacted<string>>;
45 /**
46 * Maps IdP claims to user pool attributes, e.g. `{ email: "email" }`.
47 */
48 attributeMapping?: Record<string, string>;
49 /**
50 * Identifiers (up to 50) that direct sign-in requests to this IdP.
51 */
52 idpIdentifiers?: string[];
53}
54
55export interface IdentityProvider extends Resource<
56 "AWS.Cognito.IdentityProvider",
57 IdentityProviderProps,
58 {
59 /** The name of the identity provider. */
60 providerName: string;
61 /** The ID of the user pool the IdP is attached to. */
62 userPoolId: string;
63 /** The kind of identity provider. */
64 providerType: IdentityProviderType;
65 },
66 never,
67 Providers
68> {}
69
70/**
71 * A third-party identity provider (SAML, OIDC, or social) attached to an
72 * Amazon Cognito user pool, enabling federated sign-in through managed
73 * login.
74 * ### Creating Identity Providers
75 * **Example:** OIDC Provider
76 * ```typescript
77 * import * as Cognito from "alchemy/AWS/Cognito";
78 *
79 * const pool = yield* Cognito.UserPool("Users", {});
80 * const oidc = yield* Cognito.IdentityProvider("Corporate", {
81 * userPoolId: pool.userPoolId,
82 * providerType: "OIDC",
83 * providerDetails: {
84 * client_id: "my-client-id",
85 * client_secret: Redacted.make("my-client-secret"),
86 * authorize_scopes: "openid email",
87 * oidc_issuer: "https://accounts.google.com",
88 * attributes_request_method: "GET",
89 * },
90 * attributeMapping: { email: "email", username: "sub" },
91 * });
92 * ```
93 *
94 * **Example:** Wire the IdP to an App Client
95 * ```typescript
96 * const client = yield* Cognito.UserPoolClient("Web", {
97 * userPoolId: pool.userPoolId,
98 * supportedIdentityProviders: ["COGNITO", oidc.providerName],
99 * });
100 * ```
101 *
102 * @resource
103 */
104export const IdentityProvider = Resource<IdentityProvider>(
105 "AWS.Cognito.IdentityProvider",
106);
107
108/** Unwrap any `Redacted` values (e.g. `client_secret`) into the plain
109 * string record the Cognito wire API expects. */
110const plainDetails = (
111 details: Record<string, string | Redacted.Redacted<string>>,
112): Record<string, string> =>
113 Object.fromEntries(
114 Object.entries(details).map(([key, value]) => [
115 key,
116 typeof value === "string" ? value : Redacted.value(value),
117 ]),
118 );
119
120const definedRecord = (
121 record: { [key: string]: string | undefined } | undefined,
122): Record<string, string> =>
123 Object.fromEntries(
124 Object.entries(record ?? {}).filter(
125 (entry): entry is [string, string] => entry[1] !== undefined,
126 ),
127 );
128
129const canonicalRecord = (record: Record<string, string>) =>
130 JSON.stringify(
131 Object.fromEntries(
132 Object.entries(record).sort(([a], [b]) => a.localeCompare(b)),
133 ),
134 );
135
136export const IdentityProviderProvider = () =>
137 Provider.effect(
138 IdentityProvider,
139 Effect.gen(function* () {
140 const createName = Effect.fn(function* (
141 id: string,
142 props: Pick<IdentityProviderProps, "providerName" | "providerType">,
143 ) {
144 if (props.providerName) return props.providerName;
145 // social providers require the name to equal the type
146 if (
147 props.providerType !== undefined &&
148 props.providerType !== "SAML" &&
149 props.providerType !== "OIDC"
150 ) {
151 return props.providerType;
152 }
153 return yield* createPhysicalName({ id, maxLength: 32 });
154 });
155
156 const describeProvider = Effect.fn(function* (
157 userPoolId: string,
158 providerName: string,
159 ) {
160 return yield* cip
161 .describeIdentityProvider({
162 UserPoolId: userPoolId,
163 ProviderName: providerName,
164 })
165 .pipe(
166 Effect.map((r) => r.IdentityProvider),
167 Effect.catchTag("ResourceNotFoundException", () =>
168 Effect.succeed(undefined),
169 ),
170 );
171 });
172
173 const attributesOf = (
174 provider: cip.IdentityProviderType,
175 providerName: string,
176 userPoolId: string,
177 ) => ({
178 providerName,
179 userPoolId,
180 providerType: (provider.ProviderType ?? "OIDC") as IdentityProviderType,
181 });
182
183 return IdentityProvider.Provider.of({
184 stables: ["providerName", "userPoolId", "providerType"],
185
186 // Sub-resource keyed entirely by its user pool (userPoolId) with no global
187 // enumeration API of its own — nuke reaches it through the parent's
188 // deletion, so enumeration returns empty per the ProviderService
189 // doctrine.
190 list: () => Effect.succeed([]),
191
192 read: Effect.fn(function* ({ id, olds, output }) {
193 const userPoolId = output?.userPoolId ?? olds?.userPoolId;
194 if (userPoolId === undefined) return undefined;
195 const providerName =
196 output?.providerName ??
197 (yield* createName(
198 id,
199 olds ?? { providerType: "OIDC" as const, providerDetails: {} },
200 ));
201 const observed = yield* describeProvider(userPoolId, providerName);
202 return observed === undefined
203 ? undefined
204 : attributesOf(observed, providerName, userPoolId);
205 }),
206
207 diff: Effect.fn(function* ({ id, news, olds }) {
208 if (!isResolved(news)) return undefined;
209 const oldName =
210 olds === undefined ? undefined : yield* createName(id, olds);
211 const newName =
212 news === undefined ? undefined : yield* createName(id, news);
213 if (
214 oldName !== newName ||
215 olds?.providerType !== news?.providerType ||
216 olds?.userPoolId !== news?.userPoolId
217 ) {
218 return { action: "replace" } as const;
219 }
220 }),
221
222 reconcile: Effect.fn(function* ({ id, news, output, session }) {
223 const providerName =
224 output?.providerName ?? (yield* createName(id, news));
225 const userPoolId = news.userPoolId;
226 const desiredDetails = plainDetails(news.providerDetails);
227
228 // 1. OBSERVE
229 let observed = yield* describeProvider(userPoolId, providerName);
230
231 // 2. ENSURE — tolerate the create race.
232 if (observed === undefined) {
233 observed = yield* cip
234 .createIdentityProvider({
235 UserPoolId: userPoolId,
236 ProviderName: providerName,
237 ProviderType: news.providerType,
238 ProviderDetails: desiredDetails,
239 AttributeMapping: news.attributeMapping,
240 IdpIdentifiers: news.idpIdentifiers,
241 })
242 .pipe(
243 Effect.map((r) => r.IdentityProvider),
244 Effect.catchTag("DuplicateProviderException", () =>
245 describeProvider(userPoolId, providerName).pipe(
246 Effect.map((provider) => provider!),
247 ),
248 ),
249 );
250 } else {
251 // 3. SYNC — details, mapping, and identifiers are mutable.
252 // Cognito augments ProviderDetails with derived keys (e.g.
253 // attributes_url), so compare desired keys only.
254 const observedDetails = definedRecord(observed.ProviderDetails);
255 const detailsDrift = Object.entries(desiredDetails).some(
256 ([key, value]) => observedDetails[key] !== value,
257 );
258 const mappingDrift =
259 news.attributeMapping !== undefined &&
260 canonicalRecord(definedRecord(observed.AttributeMapping)) !==
261 canonicalRecord(news.attributeMapping);
262 const identifiersDrift =
263 news.idpIdentifiers !== undefined &&
264 [...news.idpIdentifiers].sort().join(",") !==
265 [...(observed.IdpIdentifiers ?? [])].sort().join(",");
266 if (detailsDrift || mappingDrift || identifiersDrift) {
267 observed = yield* cip
268 .updateIdentityProvider({
269 UserPoolId: userPoolId,
270 ProviderName: providerName,
271 ProviderDetails: desiredDetails,
272 AttributeMapping: news.attributeMapping,
273 IdpIdentifiers: news.idpIdentifiers,
274 })
275 .pipe(Effect.map((r) => r.IdentityProvider));
276 }
277 }
278
279 yield* session.note(providerName);
280 return attributesOf(observed!, providerName, userPoolId);
281 }),
282
283 delete: Effect.fn(function* ({ output }) {
284 yield* cip
285 .deleteIdentityProvider({
286 UserPoolId: output.userPoolId,
287 ProviderName: output.providerName,
288 })
289 .pipe(
290 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
291 );
292 }),
293 });
294 }),
295 );