1import * as cip from "@distilled.cloud/aws/cognito-identity-provider";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { toWireMinutes } from "../../Util/Duration.ts";
11import type { Providers } from "../Providers.ts";
12
13/**
14 * The authentication flows an app client is allowed to use.
15 */
16export type ExplicitAuthFlow =
17 | "ALLOW_USER_PASSWORD_AUTH"
18 | "ALLOW_ADMIN_USER_PASSWORD_AUTH"
19 | "ALLOW_USER_SRP_AUTH"
20 | "ALLOW_USER_AUTH"
21 | "ALLOW_CUSTOM_AUTH"
22 | "ALLOW_REFRESH_TOKEN_AUTH";
23
24/** Units for the token validity durations. */
25export type TokenValidityUnit = "seconds" | "minutes" | "hours" | "days";
26
27export interface UserPoolClientProps {
28 /**
29 * The ID of the user pool the client belongs to. Changing this triggers a
30 * replacement.
31 */
32 userPoolId: string;
33 /**
34 * Name of the app client. If omitted, a deterministic physical name is
35 * generated from the app, stage, and logical ID.
36 */
37 clientName?: string;
38 /**
39 * Whether to generate a client secret. Confidential (server-side) clients
40 * should use a secret; public (browser/mobile) clients must not.
41 * Changing this triggers a replacement.
42 * @default false
43 */
44 generateSecret?: boolean;
45 /**
46 * The authentication flows this client may use, e.g.
47 * `ALLOW_USER_PASSWORD_AUTH` for direct username/password sign-in or
48 * `ALLOW_USER_SRP_AUTH` for the SRP protocol.
49 * @default ALLOW_USER_SRP_AUTH, ALLOW_CUSTOM_AUTH, ALLOW_REFRESH_TOKEN_AUTH
50 */
51 explicitAuthFlows?: ExplicitAuthFlow[];
52 /**
53 * Refresh token validity (in `tokenValidityUnits.refreshToken`, default days).
54 * @default 30 days
55 */
56 refreshTokenValidity?: number;
57 /**
58 * Access token validity (in `tokenValidityUnits.accessToken`, default hours).
59 * @default 1 hour
60 */
61 accessTokenValidity?: number;
62 /**
63 * ID token validity (in `tokenValidityUnits.idToken`, default hours).
64 * @default 1 hour
65 */
66 idTokenValidity?: number;
67 /**
68 * Units for the three token validity numbers.
69 */
70 tokenValidityUnits?: {
71 accessToken?: TokenValidityUnit;
72 idToken?: TokenValidityUnit;
73 refreshToken?: TokenValidityUnit;
74 };
75 /**
76 * User attributes this client may read.
77 */
78 readAttributes?: string[];
79 /**
80 * User attributes this client may write.
81 */
82 writeAttributes?: string[];
83 /**
84 * Identity providers this client supports, e.g. `COGNITO` or the names of
85 * configured `IdentityProvider`s.
86 */
87 supportedIdentityProviders?: string[];
88 /**
89 * Allowed OAuth callback (redirect) URLs.
90 */
91 callbackUrls?: string[];
92 /**
93 * Allowed OAuth sign-out URLs.
94 */
95 logoutUrls?: string[];
96 /**
97 * Default redirect URI; must be listed in `callbackUrls`.
98 */
99 defaultRedirectUri?: string;
100 /**
101 * Allowed OAuth flows (`code`, `implicit`, `client_credentials`).
102 */
103 allowedOAuthFlows?: ("code" | "implicit" | "client_credentials")[];
104 /**
105 * Allowed OAuth scopes, e.g. `openid`, `email`, or resource-server scopes.
106 */
107 allowedOAuthScopes?: string[];
108 /**
109 * Must be `true` for the OAuth settings above to take effect.
110 * @default false
111 */
112 allowedOAuthFlowsUserPoolClient?: boolean;
113 /**
114 * `ENABLED` returns a generic error for sign-in attempts against
115 * non-existent users (prevents user enumeration); `LEGACY` returns the
116 * original errors.
117 * @default "ENABLED"
118 */
119 preventUserExistenceErrors?: "ENABLED" | "LEGACY";
120 /**
121 * Whether issued tokens can be revoked with `RevokeToken`.
122 * @default true
123 */
124 enableTokenRevocation?: boolean;
125 /**
126 * Duration of the session token in auth challenge flows, e.g.
127 * `"5 minutes"` (3-15 minutes). Rounded to whole minutes on the wire.
128 * @default 3 minutes
129 */
130 authSessionValidity?: Duration.Input;
131}
132
133export interface UserPoolClient extends Resource<
134 "AWS.Cognito.UserPoolClient",
135 UserPoolClientProps,
136 {
137 /** The generated app client ID. */
138 clientId: string;
139 /** The client secret; defined only when `generateSecret` is true. */
140 clientSecret: Redacted.Redacted<string> | undefined;
141 /** The name of the app client. */
142 clientName: string;
143 /** The ID of the user pool the client belongs to. */
144 userPoolId: string;
145 },
146 never,
147 Providers
148> {}
149
150/**
151 * An app client of an Amazon Cognito user pool. Applications authenticate
152 * against the pool through a client, which controls the allowed auth flows,
153 * token lifetimes, and OAuth settings.
154 * ### Creating an App Client
155 * **Example:** Public Client with Password Auth
156 * ```typescript
157 * import * as Cognito from "alchemy/AWS/Cognito";
158 *
159 * const pool = yield* Cognito.UserPool("Users", {});
160 * const client = yield* Cognito.UserPoolClient("Web", {
161 * userPoolId: pool.userPoolId,
162 * explicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"],
163 * });
164 * ```
165 *
166 * **Example:** Confidential Client with a Secret
167 * ```typescript
168 * const server = yield* Cognito.UserPoolClient("Server", {
169 * userPoolId: pool.userPoolId,
170 * generateSecret: true,
171 * explicitAuthFlows: ["ALLOW_ADMIN_USER_PASSWORD_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"],
172 * });
173 * ```
174 *
175 * ### Token Configuration
176 * **Example:** Short-Lived Access Tokens
177 * ```typescript
178 * const client = yield* Cognito.UserPoolClient("Web", {
179 * userPoolId: pool.userPoolId,
180 * accessTokenValidity: 30,
181 * idTokenValidity: 30,
182 * refreshTokenValidity: 7,
183 * tokenValidityUnits: {
184 * accessToken: "minutes",
185 * idToken: "minutes",
186 * refreshToken: "days",
187 * },
188 * });
189 * ```
190 *
191 * ### OAuth
192 * **Example:** Authorization Code Flow
193 * ```typescript
194 * const client = yield* Cognito.UserPoolClient("Web", {
195 * userPoolId: pool.userPoolId,
196 * allowedOAuthFlowsUserPoolClient: true,
197 * allowedOAuthFlows: ["code"],
198 * allowedOAuthScopes: ["openid", "email"],
199 * callbackUrls: ["https://example.com/callback"],
200 * supportedIdentityProviders: ["COGNITO"],
201 * });
202 * ```
203 *
204 * @resource
205 */
206export const UserPoolClient = Resource<UserPoolClient>(
207 "AWS.Cognito.UserPoolClient",
208);
209
210const plain = (
211 value: string | Redacted.Redacted<string> | undefined,
212): string | undefined =>
213 value === undefined
214 ? undefined
215 : typeof value === "string"
216 ? value
217 : Redacted.value(value);
218
219/** The mutable desired state, in wire shape — used both as the update body
220 * and (against the observed client) for drift detection. */
221const desiredConfig = (news: UserPoolClientProps) => ({
222 ExplicitAuthFlows: news.explicitAuthFlows,
223 RefreshTokenValidity: news.refreshTokenValidity,
224 AccessTokenValidity: news.accessTokenValidity,
225 IdTokenValidity: news.idTokenValidity,
226 TokenValidityUnits:
227 news.tokenValidityUnits === undefined
228 ? undefined
229 : {
230 AccessToken: news.tokenValidityUnits.accessToken,
231 IdToken: news.tokenValidityUnits.idToken,
232 RefreshToken: news.tokenValidityUnits.refreshToken,
233 },
234 ReadAttributes: news.readAttributes,
235 WriteAttributes: news.writeAttributes,
236 SupportedIdentityProviders: news.supportedIdentityProviders,
237 CallbackURLs: news.callbackUrls,
238 LogoutURLs: news.logoutUrls,
239 DefaultRedirectURI: news.defaultRedirectUri,
240 AllowedOAuthFlows: news.allowedOAuthFlows,
241 AllowedOAuthScopes: news.allowedOAuthScopes,
242 AllowedOAuthFlowsUserPoolClient: news.allowedOAuthFlowsUserPoolClient,
243 PreventUserExistenceErrors: news.preventUserExistenceErrors,
244 EnableTokenRevocation: news.enableTokenRevocation,
245 // The Cognito wire unit for AuthSessionValidity is whole minutes.
246 AuthSessionValidity: toWireMinutes(news.authSessionValidity),
247});
248
249/** True when any prop the user specified differs from the observed client.
250 * Unspecified props are "don't care". */
251const hasDrift = (
252 news: UserPoolClientProps,
253 clientName: string,
254 observed: cip.UserPoolClientType,
255) => {
256 if (observed.ClientName !== clientName) return true;
257 const desired = desiredConfig(news);
258 const observedSubset: Record<string, unknown> = {
259 ExplicitAuthFlows: [...(observed.ExplicitAuthFlows ?? [])].sort(),
260 RefreshTokenValidity: observed.RefreshTokenValidity,
261 AccessTokenValidity: observed.AccessTokenValidity,
262 IdTokenValidity: observed.IdTokenValidity,
263 TokenValidityUnits: {
264 AccessToken: observed.TokenValidityUnits?.AccessToken,
265 IdToken: observed.TokenValidityUnits?.IdToken,
266 RefreshToken: observed.TokenValidityUnits?.RefreshToken,
267 },
268 ReadAttributes: [...(observed.ReadAttributes ?? [])].sort(),
269 WriteAttributes: [...(observed.WriteAttributes ?? [])].sort(),
270 SupportedIdentityProviders: [
271 ...(observed.SupportedIdentityProviders ?? []),
272 ].sort(),
273 CallbackURLs: [...(observed.CallbackURLs ?? [])].sort(),
274 LogoutURLs: [...(observed.LogoutURLs ?? [])].sort(),
275 DefaultRedirectURI: observed.DefaultRedirectURI,
276 AllowedOAuthFlows: [...(observed.AllowedOAuthFlows ?? [])].sort(),
277 AllowedOAuthScopes: [...(observed.AllowedOAuthScopes ?? [])].sort(),
278 AllowedOAuthFlowsUserPoolClient: observed.AllowedOAuthFlowsUserPoolClient,
279 PreventUserExistenceErrors: observed.PreventUserExistenceErrors,
280 EnableTokenRevocation: observed.EnableTokenRevocation,
281 AuthSessionValidity: observed.AuthSessionValidity,
282 };
283 for (const [key, desiredValue] of Object.entries(desired)) {
284 if (desiredValue === undefined) continue;
285 const observedValue = observedSubset[key];
286 const normalizedDesired = Array.isArray(desiredValue)
287 ? [...desiredValue].sort()
288 : desiredValue;
289 if (JSON.stringify(normalizedDesired) !== JSON.stringify(observedValue)) {
290 return true;
291 }
292 }
293 return false;
294};
295
296export const UserPoolClientProvider = () =>
297 Provider.effect(
298 UserPoolClient,
299 Effect.gen(function* () {
300 const createName = Effect.fn(function* (
301 id: string,
302 props: Pick<UserPoolClientProps, "clientName">,
303 ) {
304 return (
305 props.clientName ??
306 (yield* createPhysicalName({ id, maxLength: 128 }))
307 );
308 });
309
310 const describeClient = Effect.fn(function* (
311 userPoolId: string,
312 clientId: string,
313 ) {
314 return yield* cip
315 .describeUserPoolClient({
316 UserPoolId: userPoolId,
317 ClientId: clientId,
318 })
319 .pipe(
320 Effect.map((r) => r.UserPoolClient),
321 Effect.catchTag("ResourceNotFoundException", () =>
322 Effect.succeed(undefined),
323 ),
324 );
325 });
326
327 /** Find a client of the pool by exact name (used when state was lost).
328 * The physical name embeds app/stage/id, so a match is ours. */
329 const findClientByName = Effect.fn(function* (
330 userPoolId: string,
331 clientName: string,
332 ) {
333 const pages = yield* cip.listUserPoolClients
334 .pages({ UserPoolId: userPoolId, MaxResults: 60 })
335 .pipe(
336 Stream.runCollect,
337 Effect.catchTag("ResourceNotFoundException", () =>
338 Effect.succeed([] as cip.ListUserPoolClientsResponse[]),
339 ),
340 );
341 const match = Array.from(pages)
342 .flatMap((page) => page.UserPoolClients ?? [])
343 .find((client) => client.ClientName === clientName);
344 if (match?.ClientId === undefined) return undefined;
345 return yield* describeClient(userPoolId, plain(match.ClientId)!);
346 });
347
348 const attributesOf = (client: cip.UserPoolClientType) => {
349 const secret = plain(client.ClientSecret);
350 return {
351 clientId: plain(client.ClientId)!,
352 clientSecret:
353 secret === undefined ? undefined : Redacted.make(secret),
354 clientName: client.ClientName!,
355 userPoolId: client.UserPoolId!,
356 };
357 };
358
359 return UserPoolClient.Provider.of({
360 stables: ["clientId", "userPoolId"],
361
362 // Sub-resource keyed entirely by its user pool (userPoolId) with no
363 // global enumeration API of its own — nuke reaches it through the
364 // parent pool's deletion, so enumeration returns empty per the
365 // ProviderService doctrine.
366 list: () => Effect.succeed([]),
367
368 read: Effect.fn(function* ({ id, olds, output }) {
369 const userPoolId = output?.userPoolId ?? olds?.userPoolId;
370 if (userPoolId === undefined) return undefined;
371 const observed =
372 output?.clientId !== undefined
373 ? yield* describeClient(userPoolId, output.clientId)
374 : yield* findClientByName(
375 userPoolId,
376 yield* createName(id, olds ?? {}),
377 );
378 return observed === undefined ? undefined : attributesOf(observed);
379 }),
380
381 diff: Effect.fn(function* ({ id, news, olds }) {
382 if (!isResolved(news)) return undefined;
383 if (
384 (olds?.generateSecret ?? false) !== (news?.generateSecret ?? false)
385 ) {
386 return { action: "replace" } as const;
387 }
388 if (olds?.userPoolId !== news?.userPoolId) {
389 return { action: "replace" } as const;
390 }
391 const oldName = yield* createName(id, olds ?? {});
392 const newName = yield* createName(id, news ?? {});
393 if (oldName !== newName) {
394 // ClientName is mutable via UpdateUserPoolClient — fall through
395 // to the default update path.
396 return undefined;
397 }
398 }),
399
400 reconcile: Effect.fn(function* ({ id, news, output, session }) {
401 const clientName =
402 output?.clientName ?? (yield* createName(id, news));
403 const userPoolId = news.userPoolId;
404
405 // 1. OBSERVE — output.clientId is only a cache.
406 let observed =
407 output?.clientId !== undefined
408 ? yield* describeClient(userPoolId, output.clientId)
409 : undefined;
410 if (observed === undefined) {
411 observed = yield* findClientByName(userPoolId, clientName);
412 }
413
414 // 2. ENSURE — create when missing.
415 if (observed === undefined) {
416 observed = yield* cip
417 .createUserPoolClient({
418 UserPoolId: userPoolId,
419 ClientName: clientName,
420 GenerateSecret: news.generateSecret,
421 ...desiredConfig(news),
422 })
423 .pipe(Effect.map((r) => r.UserPoolClient!));
424 } else if (hasDrift(news, clientName, observed)) {
425 // 3. SYNC — UpdateUserPoolClient resets omitted fields to
426 // defaults, so the body is the full desired state; skip the
427 // call when nothing drifted.
428 observed = yield* cip
429 .updateUserPoolClient({
430 UserPoolId: userPoolId,
431 ClientId: plain(observed.ClientId)!,
432 ClientName: clientName,
433 ...desiredConfig(news),
434 })
435 .pipe(Effect.map((r) => r.UserPoolClient!));
436 }
437
438 const attrs = attributesOf(observed);
439 yield* session.note(attrs.clientId);
440 return attrs;
441 }),
442
443 delete: Effect.fn(function* ({ output }) {
444 yield* cip
445 .deleteUserPoolClient({
446 UserPoolId: output.userPoolId,
447 ClientId: output.clientId,
448 })
449 .pipe(
450 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
451 );
452 }),
453 });
454 }),
455 );