1import * as avp from "@distilled.cloud/aws/verifiedpermissions";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10const unwrap = (v: string | Redacted.Redacted<string> | undefined) =>
11 v === undefined ? undefined : Redacted.isRedacted(v) ? Redacted.value(v) : v;
12
13const unwrapAll = (
14 vs: readonly (string | Redacted.Redacted<string>)[] | undefined,
15) => (vs === undefined ? undefined : vs.map((v) => unwrap(v)!));
16
17/** Sorted copy, with empty/absent arrays collapsing to `undefined`. */
18const sortedOrUndefined = (vs: readonly string[] | undefined) =>
19 vs === undefined || vs.length === 0 ? undefined : [...vs].sort();
20
21/**
22 * Identity-source configuration backed by an Amazon Cognito user pool.
23 */
24export interface CognitoUserPoolConfiguration {
25 /**
26 * ARN of the Cognito user pool whose identities should be usable as
27 * principals in authorization requests.
28 */
29 userPoolArn: string;
30 /**
31 * The user pool app client IDs to accept tokens from. When omitted, tokens
32 * from any of the pool's app clients are accepted.
33 */
34 clientIds?: string[];
35 /**
36 * The Cedar entity type to map Cognito groups to (e.g.
37 * `PhotoApp::UserGroup`). Enables group claims in policies.
38 */
39 groupEntityType?: string;
40}
41
42/**
43 * Identity-source configuration backed by a generic OpenID Connect (OIDC)
44 * identity provider.
45 */
46export interface OpenIdConnectConfiguration {
47 /**
48 * The issuer URL of the OIDC provider, e.g. `https://accounts.google.com`.
49 * Verified Permissions fetches `/.well-known/openid-configuration` from
50 * this URL at creation time.
51 */
52 issuer: string;
53 /**
54 * A prefix prepended to the user ID taken from the token, producing entity
55 * IDs like `MyOIDCProvider|user-id`.
56 */
57 entityIdPrefix?: string;
58 /**
59 * Map a token group claim onto a Cedar entity type so policies can match
60 * on group membership.
61 */
62 groupConfiguration?: {
63 /** The token claim that lists the user's groups, e.g. `groups`. */
64 groupClaim: string;
65 /** The Cedar entity type groups map to, e.g. `PhotoApp::UserGroup`. */
66 groupEntityType: string;
67 };
68 /**
69 * Which token type the identity source consumes — exactly one of
70 * `accessTokenOnly` or `identityTokenOnly`.
71 */
72 tokenSelection:
73 | {
74 /** Consume OIDC access tokens. */
75 accessTokenOnly: {
76 /** The claim to derive the principal entity ID from. @default "sub" */
77 principalIdClaim?: string;
78 /** The `aud` values to accept tokens for. */
79 audiences?: string[];
80 };
81 identityTokenOnly?: never;
82 }
83 | {
84 accessTokenOnly?: never;
85 /** Consume OIDC identity (ID) tokens. */
86 identityTokenOnly: {
87 /** The claim to derive the principal entity ID from. @default "sub" */
88 principalIdClaim?: string;
89 /** The client IDs (`aud`) to accept tokens for. */
90 clientIds?: string[];
91 };
92 };
93}
94
95/**
96 * Exactly one of a Cognito user pool or an OIDC provider configuration.
97 */
98export type IdentitySourceConfiguration =
99 | {
100 /** Use an Amazon Cognito user pool as the identity provider. */
101 cognito: CognitoUserPoolConfiguration;
102 openIdConnect?: never;
103 }
104 | {
105 cognito?: never;
106 /** Use a generic OpenID Connect provider as the identity provider. */
107 openIdConnect: OpenIdConnectConfiguration;
108 };
109
110export type IdentitySourceProps = IdentitySourceConfiguration & {
111 /**
112 * The ID of the policy store the identity source belongs to. Changing the
113 * store replaces the identity source.
114 */
115 policyStoreId: string;
116 /**
117 * The Cedar entity type that tokens from this identity source map to, e.g.
118 * `PhotoApp::User`. Mutable via `UpdateIdentitySource`.
119 */
120 principalEntityType?: string;
121};
122
123export interface IdentitySource extends Resource<
124 "AWS.VerifiedPermissions.IdentitySource",
125 IdentitySourceProps,
126 {
127 /**
128 * ID of the policy store the identity source belongs to.
129 */
130 policyStoreId: string;
131 /**
132 * Service-assigned unique ID of the identity source within the store.
133 */
134 identitySourceId: string;
135 },
136 {},
137 Providers
138> {}
139
140/**
141 * An identity source connects a Verified Permissions policy store to an
142 * identity provider — an Amazon Cognito user pool or any OpenID Connect
143 * (OIDC) IdP — so that `IsAuthorizedWithToken` and
144 * `BatchIsAuthorizedWithToken` can derive the principal directly from a JWT.
145 * ### Connecting an Identity Provider
146 * **Example:** Cognito User Pool
147 * ```typescript
148 * import * as AWS from "alchemy/AWS";
149 *
150 * const store = yield* AWS.VerifiedPermissions.PolicyStore("Store", {});
151 *
152 * yield* AWS.VerifiedPermissions.IdentitySource("Users", {
153 * policyStoreId: store.policyStoreId,
154 * principalEntityType: "PhotoApp::User",
155 * cognito: {
156 * userPoolArn: userPool.userPoolArn,
157 * },
158 * });
159 * ```
160 *
161 * **Example:** OpenID Connect Provider
162 * ```typescript
163 * yield* AWS.VerifiedPermissions.IdentitySource("Oidc", {
164 * policyStoreId: store.policyStoreId,
165 * principalEntityType: "PhotoApp::User",
166 * openIdConnect: {
167 * issuer: "https://accounts.google.com",
168 * tokenSelection: {
169 * identityTokenOnly: { clientIds: ["my-oauth-client-id"] },
170 * },
171 * },
172 * });
173 * ```
174 *
175 * @resource
176 */
177export const IdentitySource = Resource<IdentitySource>(
178 "AWS.VerifiedPermissions.IdentitySource",
179);
180
181/** Desired props → the wire `Configuration` union. */
182const toConfiguration = (news: IdentitySourceProps): avp.Configuration =>
183 news.cognito !== undefined
184 ? {
185 cognitoUserPoolConfiguration: {
186 userPoolArn: news.cognito.userPoolArn,
187 clientIds: news.cognito.clientIds,
188 groupConfiguration:
189 news.cognito.groupEntityType === undefined
190 ? undefined
191 : { groupEntityType: news.cognito.groupEntityType },
192 },
193 }
194 : {
195 openIdConnectConfiguration: {
196 issuer: news.openIdConnect.issuer,
197 entityIdPrefix: news.openIdConnect.entityIdPrefix,
198 groupConfiguration: news.openIdConnect.groupConfiguration,
199 tokenSelection: news.openIdConnect.tokenSelection,
200 },
201 };
202
203/**
204 * Observed `ConfigurationDetail` → the plain props shape, for diffing the
205 * desired configuration against what is actually deployed.
206 */
207const toObservedConfiguration = (
208 detail: avp.ConfigurationDetail | undefined,
209): IdentitySourceConfiguration | undefined => {
210 if (detail === undefined) return undefined;
211 if (detail.cognitoUserPoolConfiguration !== undefined) {
212 const cognito = detail.cognitoUserPoolConfiguration;
213 return {
214 cognito: {
215 userPoolArn: cognito.userPoolArn,
216 clientIds: unwrapAll(cognito.clientIds),
217 groupEntityType: unwrap(cognito.groupConfiguration?.groupEntityType),
218 },
219 };
220 }
221 const oidc = detail.openIdConnectConfiguration;
222 return {
223 openIdConnect: {
224 issuer: oidc.issuer,
225 entityIdPrefix: unwrap(oidc.entityIdPrefix),
226 groupConfiguration:
227 oidc.groupConfiguration === undefined
228 ? undefined
229 : {
230 groupClaim: unwrap(oidc.groupConfiguration.groupClaim)!,
231 groupEntityType: unwrap(oidc.groupConfiguration.groupEntityType)!,
232 },
233 tokenSelection:
234 oidc.tokenSelection.accessTokenOnly !== undefined
235 ? {
236 accessTokenOnly: {
237 principalIdClaim: unwrap(
238 oidc.tokenSelection.accessTokenOnly.principalIdClaim,
239 ),
240 audiences: oidc.tokenSelection.accessTokenOnly.audiences
241 ? [...oidc.tokenSelection.accessTokenOnly.audiences]
242 : undefined,
243 },
244 }
245 : {
246 identityTokenOnly: {
247 principalIdClaim: unwrap(
248 oidc.tokenSelection.identityTokenOnly.principalIdClaim,
249 ),
250 clientIds: unwrapAll(
251 oidc.tokenSelection.identityTokenOnly.clientIds,
252 ),
253 },
254 },
255 },
256 };
257};
258
259/**
260 * Canonicalize + serialize a configuration for drift comparison: arrays are
261 * sorted (empty ≡ absent) and AWS-filled defaults (`principalIdClaim: "sub"`)
262 * are applied so an untouched deploy is a no-op.
263 */
264const normalize = (config: IdentitySourceConfiguration): string => {
265 const canonical =
266 config.cognito !== undefined
267 ? {
268 cognito: {
269 userPoolArn: config.cognito.userPoolArn,
270 clientIds: sortedOrUndefined(config.cognito.clientIds),
271 groupEntityType: config.cognito.groupEntityType,
272 },
273 }
274 : {
275 openIdConnect: {
276 issuer: config.openIdConnect.issuer,
277 entityIdPrefix: config.openIdConnect.entityIdPrefix,
278 groupConfiguration: config.openIdConnect.groupConfiguration,
279 tokenSelection:
280 config.openIdConnect.tokenSelection.accessTokenOnly !== undefined
281 ? {
282 accessTokenOnly: {
283 principalIdClaim:
284 config.openIdConnect.tokenSelection.accessTokenOnly
285 .principalIdClaim ?? "sub",
286 audiences: sortedOrUndefined(
287 config.openIdConnect.tokenSelection.accessTokenOnly
288 .audiences,
289 ),
290 },
291 }
292 : {
293 identityTokenOnly: {
294 principalIdClaim:
295 config.openIdConnect.tokenSelection.identityTokenOnly
296 .principalIdClaim ?? "sub",
297 clientIds: sortedOrUndefined(
298 config.openIdConnect.tokenSelection.identityTokenOnly
299 .clientIds,
300 ),
301 },
302 },
303 },
304 };
305 return JSON.stringify(canonical);
306};
307
308/** Desired props → the wire `UpdateConfiguration` union (same field shapes). */
309const toUpdateConfiguration = (
310 news: IdentitySourceProps,
311): avp.UpdateConfiguration => toConfiguration(news) as avp.UpdateConfiguration;
312
313export const IdentitySourceProvider = () =>
314 Provider.effect(
315 IdentitySource,
316 Effect.gen(function* () {
317 const observe = Effect.fn(function* (
318 policyStoreId: string,
319 identitySourceId: string,
320 ) {
321 return yield* avp
322 .getIdentitySource({ policyStoreId, identitySourceId })
323 .pipe(
324 Effect.catchTag("ResourceNotFoundException", () =>
325 Effect.succeed(undefined),
326 ),
327 );
328 });
329
330 return IdentitySource.Provider.of({
331 stables: ["policyStoreId", "identitySourceId"],
332
333 // child of a policy store — not enumerable account-wide
334 list: () => Effect.succeed([]),
335
336 read: Effect.fn(function* ({ olds, output }) {
337 const policyStoreId = output?.policyStoreId ?? olds?.policyStoreId;
338 const identitySourceId = output?.identitySourceId;
339 if (policyStoreId === undefined || identitySourceId === undefined) {
340 return undefined;
341 }
342 const source = yield* observe(policyStoreId, identitySourceId);
343 if (source === undefined) return undefined;
344 return { policyStoreId, identitySourceId: source.identitySourceId };
345 }),
346
347 diff: Effect.fn(function* ({ news, olds }) {
348 if (!isResolved(news)) return undefined;
349 if (olds.policyStoreId !== news.policyStoreId) {
350 return { action: "replace" } as const;
351 }
352 // configuration + principalEntityType are mutable via
353 // updateIdentitySource → default update path
354 }),
355
356 reconcile: Effect.fn(function* ({ news, output, session }) {
357 // 1. OBSERVE — cloud state is authoritative
358 const existing =
359 output?.identitySourceId !== undefined
360 ? yield* observe(news.policyStoreId, output.identitySourceId)
361 : undefined;
362
363 // 2. ENSURE — a freshly created policy store is eventually
364 // consistent; createIdentitySource can briefly 404 on it
365 if (existing === undefined) {
366 const created = yield* avp
367 .createIdentitySource({
368 policyStoreId: news.policyStoreId,
369 configuration: toConfiguration(news),
370 principalEntityType: news.principalEntityType,
371 })
372 .pipe(
373 Effect.retry({
374 while: (e): boolean => e._tag === "ResourceNotFoundException",
375 schedule: Schedule.max([
376 Schedule.exponential("1 second"),
377 Schedule.recurs(5),
378 ]),
379 }),
380 );
381 yield* session.note(created.identitySourceId);
382 return {
383 policyStoreId: news.policyStoreId,
384 identitySourceId: created.identitySourceId,
385 };
386 }
387
388 // 3. SYNC — diff observed configuration/principal type vs desired
389 const desired = normalize(
390 news.cognito !== undefined
391 ? { cognito: news.cognito }
392 : { openIdConnect: news.openIdConnect },
393 );
394 // If the response carries no configuration detail, we can't prove
395 // convergence — treat it as drift and (re)apply the desired config.
396 const observedConfiguration = toObservedConfiguration(
397 existing.configuration,
398 );
399 const observed =
400 observedConfiguration === undefined
401 ? undefined
402 : normalize(observedConfiguration);
403 const observedPrincipalType = unwrap(existing.principalEntityType);
404 const principalDrift =
405 news.principalEntityType !== undefined &&
406 observedPrincipalType !== news.principalEntityType;
407 if (desired !== observed || principalDrift) {
408 yield* avp.updateIdentitySource({
409 policyStoreId: news.policyStoreId,
410 identitySourceId: existing.identitySourceId,
411 updateConfiguration: toUpdateConfiguration(news),
412 principalEntityType: news.principalEntityType,
413 });
414 }
415
416 yield* session.note(existing.identitySourceId);
417 return {
418 policyStoreId: news.policyStoreId,
419 identitySourceId: existing.identitySourceId,
420 };
421 }),
422
423 delete: Effect.fn(function* ({ output }) {
424 yield* avp
425 .deleteIdentitySource({
426 policyStoreId: output.policyStoreId,
427 identitySourceId: output.identitySourceId,
428 })
429 .pipe(
430 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
431 );
432 }),
433 });
434 }),
435 );