| 1 | import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import type * as Redacted from "effect/Redacted"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | |
| 7 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 8 | import { isResolved } from "../../Diff.ts"; |
| 9 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 10 | import * as Provider from "../../Provider.ts"; |
| 11 | import { Resource } from "../../Resource.ts"; |
| 12 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 13 | import type { Providers } from "../Providers.ts"; |
| 14 | import { listAllZones } from "../Zone/lookup.ts"; |
| 15 | import { |
| 16 | findByName, |
| 17 | findByType, |
| 18 | getIdp, |
| 19 | isSingletonType, |
| 20 | type ObservedIdp, |
| 21 | toAttributes, |
| 22 | } from "./IdentityProviderLookup.ts"; |
| 23 | |
| 24 | const TypeId = "Cloudflare.Access.IdentityProvider" as const; |
| 25 | type 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 | */ |
| 32 | export 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 | */ |
| 59 | export type IdentityProviderConfig = |
| 60 | zeroTrust.CreateIdentityProviderForAccountRequest["config"]; |
| 61 | |
| 62 | /** |
| 63 | * OAuth client credentials shared by every OAuth/OIDC-based IdP type. |
| 64 | */ |
| 65 | export 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 | */ |
| 82 | export interface OneTimePinConfig {} |
| 83 | |
| 84 | /** |
| 85 | * Configuration for the simple social OAuth providers: `github`, |
| 86 | * `facebook`, `linkedin`, and `yandex`. |
| 87 | */ |
| 88 | export interface SocialOAuthConfig extends OAuthClientConfig {} |
| 89 | |
| 90 | /** |
| 91 | * Configuration for the `google` identity provider. |
| 92 | */ |
| 93 | export 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 | */ |
| 113 | export 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 | */ |
| 125 | export 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 | */ |
| 161 | export 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 | */ |
| 184 | export 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 | */ |
| 211 | export 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 | */ |
| 234 | export 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 | */ |
| 257 | export 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 | */ |
| 293 | export 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 | */ |
| 307 | export 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 | */ |
| 342 | export 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 | */ |
| 367 | export 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 | */ |
| 398 | export 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 | |
| 480 | export 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 | |
| 502 | export 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 | */ |
| 591 | export const IdentityProvider = Resource<IdentityProvider>(TypeId); |
| 592 | |
| 593 | /** |
| 594 | * Returns true if the given value is an IdentityProvider resource. |
| 595 | */ |
| 596 | export const isIdentityProvider = (value: unknown): value is IdentityProvider => |
| 597 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 598 | |
| 599 | export 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 | */ |
| 833 | interface 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 | */ |
| 846 | const samlCertificateSetIdOf = ( |
| 847 | props: IdentityProviderProps | undefined, |
| 848 | ): string | undefined => |
| 849 | props?.type === "saml" ? props.samlCertificateSetId : undefined; |
| 850 | |
| 851 | const 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 | |
| 860 | const 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 | |
| 878 | const 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 | |
| 890 | const 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 | |
| 898 | const 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 | }; |