1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import type * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14import { listAllZones } from "../Zone/lookup.ts";
15import {
16 findByName,
17 findByType,
18 getIdp,
19 isSingletonType,
20 type ObservedIdp,
21 toAttributes,
22} from "./IdentityProviderLookup.ts";
23
24const TypeId = "Cloudflare.Access.IdentityProvider" as const;
25type TypeId = typeof TypeId;
26
27/**
28 * The type of identity provider. Determines which `config` fields are
29 * meaningful — see Cloudflare's IdP integration docs for the per-type
30 * shapes. Immutable — changing the type triggers a replacement.
31 */
32export type IdentityProviderType =
33 | "onetimepin"
34 | "azureAD"
35 | "saml"
36 | "centrify"
37 | "facebook"
38 | "github"
39 | "google-apps"
40 | "google"
41 | "linkedin"
42 | "oidc"
43 | "okta"
44 | "onelogin"
45 | "pingone"
46 | "yandex"
47 | "cloudflare"
48 | (string & {});
49
50/**
51 * The full, untyped per-type configuration surface of an identity
52 * provider. Re-exports distilled's request shape so the complete
53 * Cloudflare config surface (OAuth client credentials, OIDC endpoints,
54 * SAML certs and SSO targets, SCIM claims, …) is available without
55 * re-declaring the structure. Prefer the per-type configs
56 * ({@link OidcConfig}, {@link SamlConfig}, {@link AzureADConfig}, …) —
57 * they are enforced by the discriminated {@link IdentityProviderProps}.
58 */
59export type IdentityProviderConfig =
60 zeroTrust.CreateIdentityProviderForAccountRequest["config"];
61
62/**
63 * OAuth client credentials shared by every OAuth/OIDC-based IdP type.
64 */
65export interface OAuthClientConfig {
66 /**
67 * OAuth client ID issued by the identity provider.
68 */
69 clientId: string;
70 /**
71 * OAuth client secret. Cloudflare masks this field on read, so it
72 * diffs against the previously declared props rather than observed
73 * cloud state.
74 */
75 clientSecret: string;
76}
77
78/**
79 * Configuration for the built-in one-time PIN login method. Takes no
80 * parameters — users receive a PIN at their email address.
81 */
82export interface OneTimePinConfig {}
83
84/**
85 * Configuration for the simple social OAuth providers: `github`,
86 * `facebook`, `linkedin`, and `yandex`.
87 */
88export interface SocialOAuthConfig extends OAuthClientConfig {}
89
90/**
91 * Configuration for the `google` identity provider.
92 */
93export interface GoogleConfig extends OAuthClientConfig {
94 /**
95 * Custom OIDC claims to capture from the token and make available in
96 * Access policies.
97 */
98 claims?: string[];
99 /**
100 * The claim to use as the user's email address.
101 */
102 emailClaimName?: string;
103 /**
104 * Enable Proof Key for Code Exchange (PKCE) on the OAuth flow.
105 */
106 pkceEnabled?: boolean;
107}
108
109/**
110 * Configuration for the `google-apps` (Google Workspace) identity
111 * provider.
112 */
113export interface GoogleAppsConfig extends GoogleConfig {
114 /**
115 * The Google Workspace domain users authenticate from,
116 * e.g. `mycompany.com`.
117 */
118 appsDomain: string;
119}
120
121/**
122 * Configuration for the `azureAD` (Microsoft Entra ID) identity
123 * provider.
124 */
125export interface AzureADConfig extends OAuthClientConfig {
126 /**
127 * The Entra ID directory (tenant) ID.
128 */
129 directoryId: string;
130 /**
131 * Import Entra ID group membership into Access policies.
132 */
133 supportGroups?: boolean;
134 /**
135 * Send the Cloudflare-managed Conditional Access policy context to
136 * Entra ID.
137 */
138 conditionalAccessEnabled?: boolean;
139 /**
140 * OIDC prompt behavior on the Microsoft login page.
141 */
142 prompt?: "login" | "select_account" | "none";
143 /**
144 * Custom OIDC claims to capture from the token and make available in
145 * Access policies.
146 */
147 claims?: string[];
148 /**
149 * The claim to use as the user's email address.
150 */
151 emailClaimName?: string;
152 /**
153 * Enable Proof Key for Code Exchange (PKCE) on the OAuth flow.
154 */
155 pkceEnabled?: boolean;
156}
157
158/**
159 * Configuration for the `centrify` identity provider.
160 */
161export interface CentrifyConfig extends OAuthClientConfig {
162 /**
163 * The Centrify account URL, e.g. `https://abc123.my.centrify.com`.
164 */
165 centrifyAccount: string;
166 /**
167 * The Centrify application ID, e.g. `exampleapp`.
168 */
169 centrifyAppId: string;
170 /**
171 * Custom OIDC claims to capture from the token and make available in
172 * Access policies.
173 */
174 claims?: string[];
175 /**
176 * The claim to use as the user's email address.
177 */
178 emailClaimName?: string;
179}
180
181/**
182 * Configuration for the `okta` identity provider.
183 */
184export interface OktaConfig extends OAuthClientConfig {
185 /**
186 * The Okta account URL, e.g. `https://dev-abc123.okta.com`.
187 */
188 oktaAccount: string;
189 /**
190 * The Okta authorization server ID to use, when not the default.
191 */
192 authorizationServerId?: string;
193 /**
194 * Custom OIDC claims to capture from the token and make available in
195 * Access policies.
196 */
197 claims?: string[];
198 /**
199 * The claim to use as the user's email address.
200 */
201 emailClaimName?: string;
202 /**
203 * Enable Proof Key for Code Exchange (PKCE) on the OAuth flow.
204 */
205 pkceEnabled?: boolean;
206}
207
208/**
209 * Configuration for the `onelogin` identity provider.
210 */
211export interface OneloginConfig extends OAuthClientConfig {
212 /**
213 * The OneLogin account URL, e.g. `https://mycompany.onelogin.com`.
214 */
215 oneloginAccount: string;
216 /**
217 * Custom OIDC claims to capture from the token and make available in
218 * Access policies.
219 */
220 claims?: string[];
221 /**
222 * The claim to use as the user's email address.
223 */
224 emailClaimName?: string;
225 /**
226 * Enable Proof Key for Code Exchange (PKCE) on the OAuth flow.
227 */
228 pkceEnabled?: boolean;
229}
230
231/**
232 * Configuration for the `pingone` (PingOne) identity provider.
233 */
234export interface PingoneConfig extends OAuthClientConfig {
235 /**
236 * The PingOne environment identifier.
237 */
238 pingEnvId: string;
239 /**
240 * Custom OIDC claims to capture from the token and make available in
241 * Access policies.
242 */
243 claims?: string[];
244 /**
245 * The claim to use as the user's email address.
246 */
247 emailClaimName?: string;
248 /**
249 * Enable Proof Key for Code Exchange (PKCE) on the OAuth flow.
250 */
251 pkceEnabled?: boolean;
252}
253
254/**
255 * Configuration for a generic `oidc` identity provider.
256 */
257export interface OidcConfig extends OAuthClientConfig {
258 /**
259 * The authorization endpoint of the IdP,
260 * e.g. `https://idp.example.com/authorize`.
261 */
262 authUrl: string;
263 /**
264 * The token endpoint of the IdP, e.g. `https://idp.example.com/token`.
265 */
266 tokenUrl: string;
267 /**
268 * The JWKS endpoint of the IdP, e.g. `https://idp.example.com/keys`.
269 */
270 certsUrl: string;
271 /**
272 * OAuth scopes to request, e.g. `["openid", "email", "profile"]`.
273 */
274 scopes?: string[];
275 /**
276 * Custom OIDC claims to capture from the token and make available in
277 * Access policies.
278 */
279 claims?: string[];
280 /**
281 * The claim to use as the user's email address.
282 */
283 emailClaimName?: string;
284 /**
285 * Enable Proof Key for Code Exchange (PKCE) on the OAuth flow.
286 */
287 pkceEnabled?: boolean;
288}
289
290/**
291 * A SAML attribute translated into a request header on the origin.
292 */
293export interface SamlHeaderAttribute {
294 /**
295 * The SAML attribute to read.
296 */
297 attributeName?: string;
298 /**
299 * The request header to write the attribute value into.
300 */
301 headerName?: string;
302}
303
304/**
305 * Configuration for a generic `saml` identity provider.
306 */
307export interface SamlConfig {
308 /**
309 * The IdP entity ID / issuer URL.
310 */
311 issuerUrl: string;
312 /**
313 * The IdP's SSO target URL Access redirects sign-ins to.
314 */
315 ssoTargetUrl: string;
316 /**
317 * The IdP's public signing certificate(s), PEM bodies without the
318 * BEGIN/END markers.
319 */
320 idpPublicCerts: string[];
321 /**
322 * The SAML attribute carrying the user's email address.
323 */
324 emailAttributeName?: string;
325 /**
326 * SAML attributes to capture and make available in Access policies.
327 */
328 attributes?: string[];
329 /**
330 * SAML attributes to translate into request headers on the origin.
331 */
332 headerAttributes?: SamlHeaderAttribute[];
333 /**
334 * Sign the SAML authentication request.
335 */
336 signRequest?: boolean;
337}
338
339/**
340 * SCIM provisioning configuration for an identity provider.
341 */
342export interface IdentityProviderScimConfig {
343 /**
344 * Enable SCIM provisioning from the IdP into Cloudflare Access.
345 * @default false
346 */
347 enabled?: boolean;
348 /**
349 * How user identity updates from SCIM affect existing Access sessions.
350 */
351 identityUpdateBehavior?: "automatic" | "reauth" | "no_action";
352 /**
353 * Deprovision a user's seat when SCIM deprovisions the user.
354 * @default false
355 */
356 seatDeprovision?: boolean;
357 /**
358 * Revoke a user's session when SCIM deprovisions the user.
359 * @default false
360 */
361 userDeprovision?: boolean;
362}
363
364/**
365 * Props shared by every identity provider type.
366 */
367export interface IdentityProviderBaseProps {
368 /**
369 * Display name shown to users on the Access login page. Used as a
370 * stable identifier so the provider can locate the IdP by name during
371 * adoption / state recovery. If omitted, a unique name is generated
372 * from the app, stage, and logical ID.
373 *
374 * @default ${app}-${stage}-${id}
375 */
376 name?: string;
377 /**
378 * Zone to scope the identity provider to (legacy zone-level Access).
379 * When omitted, the IdP is created at the account level — the modern
380 * Zero Trust organization scope.
381 *
382 * Stable — moving an IdP between scopes triggers a replacement.
383 */
384 zoneId?: string;
385 /**
386 * SCIM provisioning configuration. Mutable.
387 */
388 scimConfig?: IdentityProviderScimConfig;
389}
390
391/**
392 * Input properties of an identity provider — a discriminated union on
393 * `type`, so each provider type only accepts (and requires) its own
394 * config fields. Secret fields (e.g. `clientSecret`) are masked by
395 * Cloudflare on read, so they diff against the previously declared props
396 * rather than observed cloud state.
397 */
398export type IdentityProviderProps = IdentityProviderBaseProps &
399 (
400 | {
401 /** The identity provider type. Immutable — changing it triggers a replacement. */
402 type: "onetimepin";
403 /** One-time PIN takes no configuration. */
404 config?: OneTimePinConfig;
405 }
406 | {
407 /** The identity provider type. Immutable — changing it triggers a replacement. */
408 type: "github" | "facebook" | "linkedin" | "yandex";
409 /** OAuth client credentials for the social provider. */
410 config: SocialOAuthConfig;
411 }
412 | {
413 /** The identity provider type. Immutable — changing it triggers a replacement. */
414 type: "google";
415 /** Google OAuth configuration. */
416 config: GoogleConfig;
417 }
418 | {
419 /** The identity provider type. Immutable — changing it triggers a replacement. */
420 type: "google-apps";
421 /** Google Workspace configuration. */
422 config: GoogleAppsConfig;
423 }
424 | {
425 /** The identity provider type. Immutable — changing it triggers a replacement. */
426 type: "azureAD";
427 /** Microsoft Entra ID configuration. */
428 config: AzureADConfig;
429 }
430 | {
431 /** The identity provider type. Immutable — changing it triggers a replacement. */
432 type: "centrify";
433 /** Centrify configuration. */
434 config: CentrifyConfig;
435 }
436 | {
437 /** The identity provider type. Immutable — changing it triggers a replacement. */
438 type: "okta";
439 /** Okta configuration. */
440 config: OktaConfig;
441 }
442 | {
443 /** The identity provider type. Immutable — changing it triggers a replacement. */
444 type: "onelogin";
445 /** OneLogin configuration. */
446 config: OneloginConfig;
447 }
448 | {
449 /** The identity provider type. Immutable — changing it triggers a replacement. */
450 type: "pingone";
451 /** PingOne configuration. */
452 config: PingoneConfig;
453 }
454 | {
455 /** The identity provider type. Immutable — changing it triggers a replacement. */
456 type: "oidc";
457 /** Generic OIDC configuration. */
458 config: OidcConfig;
459 }
460 | {
461 /** The identity provider type. Immutable — changing it triggers a replacement. */
462 type: "saml";
463 /** Generic SAML configuration. */
464 config: SamlConfig;
465 /**
466 * The UID of the SAML encryption certificate set assigned to
467 * this identity provider. Only meaningful for SAML IdPs with
468 * response encryption configured.
469 */
470 samlCertificateSetId?: string;
471 }
472 | {
473 /** The identity provider type. Immutable — changing it triggers a replacement. */
474 type: "cloudflare";
475 /** The Cloudflare-managed (WARP) IdP takes no user configuration. */
476 config?: IdentityProviderConfig;
477 }
478 );
479
480export interface IdentityProviderAttributes {
481 /** UUID of the identity provider, assigned by Cloudflare. */
482 identityProviderId: string;
483 /** Cloudflare account that owns the identity provider. */
484 accountId: string;
485 /** Zone the IdP is scoped to, or `undefined` for account-scoped IdPs. */
486 zoneId: string | undefined;
487 /** Display name of the identity provider. */
488 name: string;
489 /** The identity provider type. */
490 type: IdentityProviderType;
491 /** Server-generated SCIM base URL (when SCIM is enabled). */
492 scimBaseUrl: string | undefined;
493 /**
494 * SCIM provisioning secret. Returned once when SCIM is first enabled
495 * and carried forward in state afterwards (the API masks it on read).
496 */
497 scimSecret: Redacted.Redacted<string> | undefined;
498 /** Whether SCIM provisioning is enabled. */
499 scimEnabled: boolean;
500}
501
502export type IdentityProvider = Resource<
503 TypeId,
504 IdentityProviderProps,
505 IdentityProviderAttributes,
506 never,
507 Providers
508>;
509
510/**
511 * A Cloudflare Zero Trust Access identity provider — the login method
512 * (one-time PIN, generic OIDC/SAML, or a named provider like GitHub,
513 * Google, Okta, or Azure AD) users authenticate with before Access
514 * policies evaluate.
515 *
516 * Props are a discriminated union on `type`: each provider type only
517 * accepts (and requires) its own config fields, so a missing
518 * `directoryId` on an `azureAD` IdP or a GitHub config on an `oidc` IdP
519 * is a compile-time error. The `type` is immutable (config shapes are
520 * disjoint per type — changing it replaces the IdP); name, config, and
521 * SCIM settings converge in place. Cloudflare masks secret config fields
522 * (`clientSecret`, API tokens) on read, so those fields diff against
523 * your previously declared props instead of observed cloud state.
524 *
525 * By default the IdP is created at the account level (the modern Zero
526 * Trust organization scope); pass `zoneId` to scope it to a single zone
527 * (legacy zone-level Access). Moving between scopes replaces the IdP.
528 * ### Creating an Identity Provider
529 * **Example:** One-time PIN (no external dependencies)
530 * ```typescript
531 * const otp = yield* Cloudflare.Access.IdentityProvider("Pin", {
532 * type: "onetimepin",
533 * });
534 * ```
535 *
536 * **Example:** Generic OIDC provider
537 * ```typescript
538 * const oidc = yield* Cloudflare.Access.IdentityProvider("Sso", {
539 * type: "oidc",
540 * config: {
541 * clientId: "my-client-id",
542 * clientSecret: "my-client-secret",
543 * authUrl: "https://idp.example.com/authorize",
544 * tokenUrl: "https://idp.example.com/token",
545 * certsUrl: "https://idp.example.com/keys",
546 * scopes: ["openid", "email", "profile"],
547 * },
548 * });
549 * ```
550 *
551 * **Example:** Microsoft Entra ID (Azure AD)
552 * ```typescript
553 * const entra = yield* Cloudflare.Access.IdentityProvider("Entra", {
554 * type: "azureAD",
555 * config: {
556 * clientId: "my-client-id",
557 * clientSecret: "my-client-secret",
558 * directoryId: "my-tenant-id",
559 * supportGroups: true,
560 * },
561 * });
562 * ```
563 *
564 * **Example:** Zone-scoped IdP (legacy zone-level Access)
565 * ```typescript
566 * const zoneIdp = yield* Cloudflare.Access.IdentityProvider("ZoneSso", {
567 * zoneId: zone.zoneId,
568 * type: "github",
569 * config: {
570 * clientId: "my-client-id",
571 * clientSecret: "my-client-secret",
572 * },
573 * });
574 * ```
575 *
576 * ### Restricting an Application to an IdP
577 * **Example:** Allow only this IdP on an Access application
578 * ```typescript
579 * yield* Cloudflare.Access.Application("Admin", {
580 * domain: "admin.example.com",
581 * allowedIdps: [oidc.identityProviderId],
582 * });
583 * ```
584 *
585 * @see https://developers.cloudflare.com/cloudflare-one/identity/idp-integration/
586 *
587 * @resource
588 * @product Access
589 * @category Cloudflare One (Zero Trust)
590 */
591export const IdentityProvider = Resource<IdentityProvider>(TypeId);
592
593/**
594 * Returns true if the given value is an IdentityProvider resource.
595 */
596export const isIdentityProvider = (value: unknown): value is IdentityProvider =>
597 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
598
599export const IdentityProviderProvider = () =>
600 Provider.succeed(IdentityProvider, {
601 stables: ["identityProviderId", "accountId", "zoneId", "type"],
602
603 diff: Effect.fn(function* ({ olds, news, output }) {
604 const { accountId } = yield* yield* CloudflareEnvironment;
605 if (!isResolved(news)) return undefined;
606 if ((output?.accountId ?? accountId) !== accountId) {
607 return { action: "replace" } as const;
608 }
609 // Config shapes are disjoint per type — model a type change as a
610 // replacement even though the API technically allows the PUT.
611 const oldType = output?.type ?? olds?.type;
612 if (oldType !== undefined && oldType !== news.type) {
613 return { action: "replace" } as const;
614 }
615 // Scope change (zone <-> account, or a different zone) replaces.
616 if (output !== undefined || olds !== undefined) {
617 const oldZone = output?.zoneId ?? olds?.zoneId;
618 if ((oldZone === undefined) !== (news.zoneId === undefined)) {
619 return { action: "replace" } as const;
620 }
621 // zoneId is Input<string>; compare only once both are concrete.
622 if (
623 typeof oldZone === "string" &&
624 typeof news.zoneId === "string" &&
625 oldZone !== news.zoneId
626 ) {
627 return { action: "replace" } as const;
628 }
629 }
630 return undefined;
631 }),
632
633 read: Effect.fn(function* ({ id, output, olds }) {
634 const { accountId } = yield* yield* CloudflareEnvironment;
635 const acct = output?.accountId ?? accountId;
636 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
637
638 // Owned path — refresh by the cached id. Carry the SCIM secret
639 // forward from prior output; the API never returns it on GET.
640 if (output?.identityProviderId) {
641 const observed = yield* getIdp(zoneId, acct, output.identityProviderId);
642 if (observed) {
643 return toAttributes(observed, zoneId, acct, output.scimSecret);
644 }
645 }
646
647 // Cold read — locate by deterministic name. Access IdPs carry no
648 // ownership markers, so report the match as Unowned to gate adoption.
649 const name = yield* resolveName(id, olds?.name ?? output?.name);
650 let match = yield* findByName(zoneId, acct, name);
651 // The singleton types (`cloudflare`, `onetimepin`) exist at most once
652 // per scope and their display name is user-irrelevant (the managed
653 // WARP IdP's is often "") — locate them by type when the name scan
654 // misses so adoption works regardless of the declared name.
655 const type = olds?.type ?? output?.type;
656 if (!match && isSingletonType(type)) {
657 match = yield* findByType(zoneId, acct, type);
658 }
659 if (match) {
660 return Unowned(toAttributes(match, zoneId, acct, output?.scimSecret));
661 }
662 return undefined;
663 }),
664
665 list: Effect.fn(function* () {
666 const { accountId } = yield* yield* CloudflareEnvironment;
667
668 // Account-scoped collection — exhaustively paginate the account's
669 // identity providers and hydrate each into the `read` Attributes
670 // shape. The SCIM secret is masked by the API on list, so it is
671 // carried as undefined (there is no prior state to thread through).
672 const accountIdps = yield* zeroTrust.listIdentityProvidersForAccount
673 .pages({ accountId })
674 .pipe(
675 Stream.runCollect,
676 Effect.map((chunk) =>
677 Array.from(chunk).flatMap((page) =>
678 (page.result ?? []).map((idp) =>
679 toAttributes(
680 idp as unknown as ObservedIdp,
681 undefined,
682 accountId,
683 undefined,
684 ),
685 ),
686 ),
687 ),
688 );
689
690 // Zone-scoped IdPs (legacy zone-level Access) — fan out across the
691 // account's zones. Zones without zone-level Access reject the
692 // route; skip them.
693 const zones = yield* listAllZones(accountId);
694 const zoneGroups = yield* Effect.forEach(
695 zones,
696 (zone) =>
697 zeroTrust.listIdentityProvidersForZone
698 .pages({ zoneId: zone.id })
699 .pipe(
700 Stream.runCollect,
701 Effect.map((chunk) =>
702 Array.from(chunk).flatMap((page) =>
703 (page.result ?? []).map((idp) =>
704 toAttributes(
705 idp as unknown as ObservedIdp,
706 zone.id,
707 accountId,
708 undefined,
709 ),
710 ),
711 ),
712 ),
713 Effect.catchTag("Forbidden", () => Effect.succeed([])),
714 ),
715 { concurrency: 10 },
716 );
717
718 // The zone route can echo account-level IdPs back — keep each IdP
719 // once, under the scope that owns it.
720 const seen = new Set(accountIdps.map((idp) => idp.identityProviderId));
721 return [
722 ...accountIdps,
723 ...zoneGroups.flat().filter((idp) => !seen.has(idp.identityProviderId)),
724 ];
725 }),
726
727 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
728 const { accountId } = yield* yield* CloudflareEnvironment;
729 // A scope change replaces (see diff), so news' scope is the scope.
730 const zoneId = news.zoneId as string | undefined;
731 const generatedName = yield* createPhysicalName({ id });
732
733 // 1. Observe — the cached id is a hint; fall back to a name scan
734 // (and, for the singleton types, a type scan) so out-of-band
735 // deletes / lost state converge.
736 let observed = output?.identityProviderId
737 ? yield* getIdp(zoneId, accountId, output.identityProviderId)
738 : undefined;
739 if (!observed) {
740 observed = yield* findByName(
741 zoneId,
742 accountId,
743 news.name ?? generatedName,
744 );
745 }
746 // Singleton types (`cloudflare`, `onetimepin`) exist at most once per
747 // scope — creating a second one is rejected by the API, so an
748 // existing one found by type is ours to converge (ownership is gated
749 // upstream: `read` reports it as Unowned and the engine only lets
750 // reconcile run once adoption is approved).
751 if (!observed && isSingletonType(news.type)) {
752 observed = yield* findByType(zoneId, accountId, news.type);
753 }
754
755 // Singletons keep their observed display name when none is declared —
756 // renaming the managed WARP IdP to a generated physical name on
757 // adoption would be surprising. Everything else defaults to the
758 // deterministic physical name (the resource's cold-read identity).
759 const name =
760 news.name ??
761 (isSingletonType(news.type) && observed
762 ? observed.name
763 : generatedName);
764
765 // 2. Ensure — create with the full desired body when missing. Names
766 // are not unique on Cloudflare's side, so there is no
767 // AlreadyExists race to tolerate for the non-singleton types (a
768 // singleton conflict is caught by the type scan above).
769 if (!observed) {
770 const created = yield* createIdp(zoneId, accountId, {
771 name,
772 type: news.type,
773 config: news.config ?? {},
774 scimConfig: news.scimConfig,
775 samlCertificateSetId: samlCertificateSetIdOf(news),
776 });
777 return toAttributes(
778 created as ObservedIdp,
779 zoneId,
780 accountId,
781 output?.scimSecret,
782 );
783 }
784
785 // 3. Sync — the update API is a PUT of the full desired state.
786 // Non-secret aspects (name, SCIM enablement) diff against
787 // observed cloud state; the config diffs against the previously
788 // declared props because Cloudflare masks secrets on read.
789 const dirty =
790 observed.name !== name ||
791 JSON.stringify(news.config ?? {}) !==
792 JSON.stringify(olds?.config ?? null) ||
793 samlCertificateSetIdOf(news) !== samlCertificateSetIdOf(olds) ||
794 (news.scimConfig !== undefined &&
795 !sameScim(observed.scimConfig, news.scimConfig));
796 if (dirty) {
797 const updated = yield* updateIdp(zoneId, accountId, observed.id ?? "", {
798 name,
799 type: news.type,
800 config: news.config ?? {},
801 scimConfig: news.scimConfig,
802 samlCertificateSetId: samlCertificateSetIdOf(news),
803 });
804 return toAttributes(
805 updated as ObservedIdp,
806 zoneId,
807 accountId,
808 output?.scimSecret,
809 );
810 }
811
812 return toAttributes(observed, zoneId, accountId, output?.scimSecret);
813 }),
814
815 delete: Effect.fn(function* ({ output }) {
816 // An IdP referenced by an application's `allowedIdps` can fail
817 // deletion — applications referencing the id as an Input get correct
818 // destroy ordering. A missing IdP (AccessIdentityProviderNotFound,
819 // Cloudflare code 12135) means we're done.
820 yield* deleteIdp(
821 output.zoneId,
822 output.accountId,
823 output.identityProviderId,
824 ).pipe(
825 Effect.catchTag("AccessIdentityProviderNotFound", () => Effect.void),
826 );
827 }),
828 });
829
830/**
831 * The request body shared by the scoped create/update helpers.
832 */
833interface IdpBody {
834 readonly name: string;
835 readonly type: IdentityProviderType;
836 readonly config: IdentityProviderConfig;
837 readonly scimConfig: IdentityProviderScimConfig | undefined;
838 readonly samlCertificateSetId: string | undefined;
839}
840
841/**
842 * The SAML encryption certificate set, when the props are the `saml`
843 * variant. Narrowing helper — `samlCertificateSetId` only exists on the
844 * SAML member of the props union.
845 */
846const samlCertificateSetIdOf = (
847 props: IdentityProviderProps | undefined,
848): string | undefined =>
849 props?.type === "saml" ? props.samlCertificateSetId : undefined;
850
851const createIdp = (
852 zoneId: string | undefined,
853 accountId: string,
854 body: IdpBody,
855) =>
856 zoneId !== undefined
857 ? zeroTrust.createIdentityProviderForZone({ zoneId, ...body })
858 : zeroTrust.createIdentityProviderForAccount({ accountId, ...body });
859
860const updateIdp = (
861 zoneId: string | undefined,
862 accountId: string,
863 identityProviderId: string,
864 body: IdpBody,
865) =>
866 zoneId !== undefined
867 ? zeroTrust.updateIdentityProviderForZone({
868 zoneId,
869 identityProviderId,
870 ...body,
871 })
872 : zeroTrust.updateIdentityProviderForAccount({
873 accountId,
874 identityProviderId,
875 ...body,
876 });
877
878const deleteIdp = (
879 zoneId: string | undefined,
880 accountId: string,
881 identityProviderId: string,
882) =>
883 zoneId !== undefined
884 ? zeroTrust.deleteIdentityProviderForZone({ zoneId, identityProviderId })
885 : zeroTrust.deleteIdentityProviderForAccount({
886 accountId,
887 identityProviderId,
888 });
889
890const resolveName = (id: string, name: string | undefined) =>
891 Effect.gen(function* () {
892 // `""` is a legitimate declared name (the managed `cloudflare` IdP's
893 // display name is empty) — only generate when no name was declared.
894 if (name !== undefined) return name;
895 return yield* createPhysicalName({ id });
896 });
897
898const sameScim = (
899 observed: ObservedIdp["scimConfig"],
900 desired: IdentityProviderScimConfig,
901): boolean => {
902 const o = observed ?? {};
903 const sameBool = (a: boolean | null | undefined, b: boolean | undefined) =>
904 b === undefined || (a ?? false) === b;
905 return (
906 sameBool(o.enabled, desired.enabled) &&
907 sameBool(o.seatDeprovision, desired.seatDeprovision) &&
908 sameBool(o.userDeprovision, desired.userDeprovision) &&
909 (desired.identityUpdateBehavior === undefined ||
910 (o.identityUpdateBehavior ?? "no_action") ===
911 desired.identityUpdateBehavior)
912 );
913};