1import { adopt, OwnedBySomeoneElse } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import { findZoneByName } from "@/Cloudflare/Zone/lookup";
5import * as Provider from "@/Provider";
6import * as Test from "@/Test/Alchemy";
7import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
8import { expect } from "alchemy-test";
9import * as Cause from "effect/Cause";
10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";
12import * as Option from "effect/Option";
13import { MinimumLogLevel } from "effect/References";
14import * as Schedule from "effect/Schedule";
15import * as Stream from "effect/Stream";
16import * as HttpClient from "effect/http/HttpClient";
17import IdpLookupWorker from "./fixtures/idp-lookup-worker.ts";
18
19const { test } = Test.make({ providers: Cloudflare.providers() });
20
21const logLevel = Effect.provideService(
22 MinimumLogLevel,
23 process.env.DEBUG ? "Debug" : "Info",
24);
25
26const zoneName =
27 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
28
29const resolveZoneId = Effect.gen(function* () {
30 const { accountId } = yield* yield* CloudflareEnvironment;
31 const zone = yield* findZoneByName({ accountId, name: zoneName });
32 if (!zone) {
33 return yield* Effect.die(
34 new Error(`zone "${zoneName}" not found in account`),
35 );
36 }
37 return zone.id;
38});
39
40// Ride out 403 blips (`Forbidden`) while the harness-minted token
41// propagates across Cloudflare's edge. Zone-level when `zoneId` is set,
42// account-level otherwise — mirroring the provider's own scoping.
43const getIdp = (
44 zoneId: string | undefined,
45 accountId: string,
46 identityProviderId: string,
47) =>
48 (zoneId !== undefined
49 ? zeroTrust.getIdentityProviderForZone({ zoneId, identityProviderId })
50 : zeroTrust.getIdentityProviderForAccount({ accountId, identityProviderId })
51 ).pipe(
52 Effect.retry({
53 while: (e) => e._tag === "Forbidden",
54 schedule: Schedule.exponential("500 millis"),
55 times: 8,
56 }),
57 );
58
59// A deleted IdP surfaces as `AccessIdentityProviderNotFound` (Cloudflare
60// code 12135, `access.api.error.not_found`).
61const expectGone = (
62 zoneId: string | undefined,
63 accountId: string,
64 identityProviderId: string,
65) =>
66 getIdp(zoneId, accountId, identityProviderId).pipe(
67 Effect.flatMap(() => Effect.fail({ _tag: "IdpNotDeleted" } as const)),
68 Effect.catchTag("AccessIdentityProviderNotFound", () => Effect.void),
69 Effect.retry({
70 while: (e) => e._tag === "IdpNotDeleted",
71 schedule: Schedule.max([
72 Schedule.exponential("500 millis"),
73 Schedule.recurs(10),
74 ]),
75 }),
76 );
77
78// Generic OIDC config with documentation-only placeholder endpoints —
79// Cloudflare validates the shape, not the reachability.
80const oidcConfig = {
81 clientId: "alchemy-test-client",
82 clientSecret: "alchemy-test-secret",
83 authUrl: "https://idp.alchemy-test.example/authorize",
84 tokenUrl: "https://idp.alchemy-test.example/token",
85 certsUrl: "https://idp.alchemy-test.example/keys",
86 scopes: ["openid", "email", "profile"],
87};
88
89test.provider(
90 "create, verify, and destroy an OIDC IdP",
91 (stack) =>
92 Effect.gen(function* () {
93 const { accountId } = yield* yield* CloudflareEnvironment;
94
95 yield* stack.destroy();
96
97 const idp = yield* stack.deploy(
98 Cloudflare.Access.IdentityProvider("BasicOidc", {
99 name: "alchemy-zt-idp-basic",
100 type: "oidc",
101 config: oidcConfig,
102 }),
103 );
104
105 expect(idp.identityProviderId).toBeTruthy();
106 expect(idp.accountId).toEqual(accountId);
107 expect(idp.name).toEqual("alchemy-zt-idp-basic");
108 expect(idp.type).toEqual("oidc");
109
110 const live = yield* getIdp(undefined, accountId, idp.identityProviderId);
111 expect(live.name).toEqual("alchemy-zt-idp-basic");
112 expect(live.type).toEqual("oidc");
113 // Cloudflare masks the client secret on read.
114 expect(
115 (live.config as { clientSecret?: string | null }).clientSecret ?? null,
116 ).toBeNull();
117
118 yield* stack.destroy();
119 yield* expectGone(undefined, accountId, idp.identityProviderId);
120 }).pipe(logLevel),
121 { tags: ["provider:cloudflare", "provider:cloudflare:access", "live"] },
122);
123
124test.provider(
125 "update name and config in place (same id)",
126 (stack) =>
127 Effect.gen(function* () {
128 const { accountId } = yield* yield* CloudflareEnvironment;
129
130 yield* stack.destroy();
131
132 const initial = yield* stack.deploy(
133 Cloudflare.Access.IdentityProvider("UpdateOidc", {
134 name: "alchemy-zt-idp-update",
135 type: "oidc",
136 config: oidcConfig,
137 }),
138 );
139
140 // Note: assert the config change through `claims` — distilled decodes
141 // the GET response through a discriminated union whose matched variant
142 // does not carry the oidc-only fields (authUrl/tokenUrl/…), so those
143 // are stripped from the decoded value even though Cloudflare returns
144 // them on the wire.
145 const updated = yield* stack.deploy(
146 Cloudflare.Access.IdentityProvider("UpdateOidc", {
147 name: "alchemy-zt-idp-update-v2",
148 type: "oidc",
149 config: {
150 ...oidcConfig,
151 claims: ["email", "groups"],
152 },
153 }),
154 );
155
156 // Same IdP mutated in place — not a replacement.
157 expect(updated.identityProviderId).toEqual(initial.identityProviderId);
158 expect(updated.name).toEqual("alchemy-zt-idp-update-v2");
159
160 const live = yield* getIdp(
161 undefined,
162 accountId,
163 updated.identityProviderId,
164 );
165 expect(live.name).toEqual("alchemy-zt-idp-update-v2");
166 expect(
167 [
168 ...((live.config as { claims?: string[] | null }).claims ?? []),
169 ].sort(),
170 ).toEqual(["email", "groups"]);
171
172 // Redeploying identical props is a no-op (still the same IdP).
173 const noop = yield* stack.deploy(
174 Cloudflare.Access.IdentityProvider("UpdateOidc", {
175 name: "alchemy-zt-idp-update-v2",
176 type: "oidc",
177 config: {
178 ...oidcConfig,
179 claims: ["email", "groups"],
180 },
181 }),
182 );
183 expect(noop.identityProviderId).toEqual(initial.identityProviderId);
184
185 yield* stack.destroy();
186 yield* expectGone(undefined, accountId, initial.identityProviderId);
187 }).pipe(logLevel),
188 { tags: ["provider:cloudflare", "provider:cloudflare:access", "live"] },
189);
190
191test.provider(
192 "list enumerates the deployed IdP",
193 (stack) =>
194 Effect.gen(function* () {
195 yield* stack.destroy();
196
197 const deployed = yield* stack.deploy(
198 Cloudflare.Access.IdentityProvider("ListOidc", {
199 name: "alchemy-zt-idp-list",
200 type: "oidc",
201 config: oidcConfig,
202 }),
203 );
204
205 const provider = yield* Provider.findProvider(
206 Cloudflare.Access.IdentityProvider,
207 );
208 const all = yield* provider.list();
209
210 expect(
211 all.some((x) => x.identityProviderId === deployed.identityProviderId),
212 ).toBe(true);
213
214 yield* stack.destroy();
215 yield* expectGone(
216 undefined,
217 deployed.accountId,
218 deployed.identityProviderId,
219 );
220 }).pipe(logLevel),
221 { tags: ["provider:cloudflare", "provider:cloudflare:access", "live"] },
222);
223
224test.provider(
225 "changing the type replaces the IdP",
226 (stack) =>
227 Effect.gen(function* () {
228 const { accountId } = yield* yield* CloudflareEnvironment;
229
230 yield* stack.destroy();
231
232 const oidc = yield* stack.deploy(
233 Cloudflare.Access.IdentityProvider("ReplaceIdp", {
234 name: "alchemy-zt-idp-replace",
235 type: "oidc",
236 config: oidcConfig,
237 }),
238 );
239
240 // The name is the resource's cold-read identity, so a replacement
241 // (type change) pairs with a rename — keeping the old name would make
242 // the engine find the doomed sibling and refuse to adopt it.
243 const github = yield* stack.deploy(
244 Cloudflare.Access.IdentityProvider("ReplaceIdp", {
245 name: "alchemy-zt-idp-replace-github",
246 type: "github",
247 config: {
248 clientId: "alchemy-test-client",
249 clientSecret: "alchemy-test-secret",
250 },
251 }),
252 );
253
254 // Type is immutable in our model — the engine must have replaced it.
255 expect(github.identityProviderId).not.toEqual(oidc.identityProviderId);
256 expect(github.type).toEqual("github");
257
258 const live = yield* getIdp(
259 undefined,
260 accountId,
261 github.identityProviderId,
262 );
263 expect(live.type).toEqual("github");
264 // The old IdP was deleted by the replacement.
265 yield* expectGone(undefined, accountId, oidc.identityProviderId);
266
267 yield* stack.destroy();
268 yield* expectGone(undefined, accountId, github.identityProviderId);
269 }).pipe(logLevel),
270 { tags: ["provider:cloudflare", "provider:cloudflare:access", "live"] },
271);
272
273test.provider(
274 "zone-scoped IdP lifecycle (create, rename, destroy)",
275 (stack) =>
276 Effect.gen(function* () {
277 const { accountId } = yield* yield* CloudflareEnvironment;
278 const zoneId = yield* resolveZoneId;
279
280 yield* stack.destroy();
281
282 const idp = yield* stack.deploy(
283 Cloudflare.Access.IdentityProvider("ZoneOidc", {
284 zoneId,
285 name: "alchemy-zt-idp-zone",
286 type: "oidc",
287 config: oidcConfig,
288 }),
289 );
290
291 expect(idp.identityProviderId).toBeTruthy();
292 expect(idp.zoneId).toEqual(zoneId);
293
294 // Out-of-band via the zone-scoped route.
295 const live = yield* getIdp(zoneId, accountId, idp.identityProviderId);
296 expect(live.name).toEqual("alchemy-zt-idp-zone");
297 expect(live.type).toEqual("oidc");
298
299 // Rename converges in place — same IdP, same scope.
300 const renamed = yield* stack.deploy(
301 Cloudflare.Access.IdentityProvider("ZoneOidc", {
302 zoneId,
303 name: "alchemy-zt-idp-zone-v2",
304 type: "oidc",
305 config: oidcConfig,
306 }),
307 );
308 expect(renamed.identityProviderId).toEqual(idp.identityProviderId);
309 expect(renamed.name).toEqual("alchemy-zt-idp-zone-v2");
310
311 yield* stack.destroy();
312 yield* expectGone(zoneId, accountId, idp.identityProviderId);
313 }).pipe(logLevel),
314 {
315 tags: [
316 "provider:cloudflare",
317 "provider:cloudflare:access",
318 "provider:cloudflare:zone",
319 "live",
320 ],
321 },
322);
323
324test.provider(
325 "moving an IdP between scopes replaces it",
326 (stack) =>
327 Effect.gen(function* () {
328 const { accountId } = yield* yield* CloudflareEnvironment;
329 const zoneId = yield* resolveZoneId;
330
331 yield* stack.destroy();
332
333 const accountScoped = yield* stack.deploy(
334 Cloudflare.Access.IdentityProvider("ScopeMove", {
335 name: "alchemy-zt-idp-scope-move",
336 type: "oidc",
337 config: oidcConfig,
338 }),
339 );
340 expect(accountScoped.zoneId).toBeUndefined();
341
342 // Adding zoneId is a scope change — a replacement, paired with a
343 // rename so the doomed sibling isn't found by the cold-read scan.
344 const zoneScoped = yield* stack.deploy(
345 Cloudflare.Access.IdentityProvider("ScopeMove", {
346 zoneId,
347 name: "alchemy-zt-idp-scope-move-zone",
348 type: "oidc",
349 config: oidcConfig,
350 }),
351 );
352 expect(zoneScoped.identityProviderId).not.toEqual(
353 accountScoped.identityProviderId,
354 );
355 expect(zoneScoped.zoneId).toEqual(zoneId);
356
357 // The old account-scoped IdP was deleted by the replacement.
358 yield* expectGone(undefined, accountId, accountScoped.identityProviderId);
359
360 yield* stack.destroy();
361 yield* expectGone(zoneId, accountId, zoneScoped.identityProviderId);
362 }).pipe(logLevel),
363 {
364 tags: [
365 "provider:cloudflare",
366 "provider:cloudflare:access",
367 "provider:cloudflare:zone",
368 "live",
369 ],
370 },
371);
372
373// Adoption + data-source coverage below. Live IdPs carry no ownership
374// markers, so the provider locates them by name (or by type for the
375// singleton `cloudflare` / `onetimepin` types) and the engine gates
376// takeover behind `adopt(true)`.
377
378const forbiddenRetryPolicy = {
379 schedule: Schedule.exponential("500 millis"),
380 times: 8,
381} as const;
382
383const retryForbidden = <A, E extends { _tag: string }, R>(
384 effect: Effect.Effect<A, E, R>,
385) =>
386 effect.pipe(
387 Effect.retry({
388 while: (e): boolean => e._tag === "Forbidden",
389 ...forbiddenRetryPolicy,
390 }),
391 );
392
393interface LiveIdp {
394 readonly id?: string | null;
395 readonly name?: string | null;
396 readonly type?: string | null;
397}
398
399const findLiveIdp = (
400 accountId: string,
401 predicate: (idp: { name: string; type: string }) => boolean,
402) =>
403 retryForbidden(
404 zeroTrust.listIdentityProvidersForAccount.items({ accountId }).pipe(
405 Stream.filter(predicate),
406 Stream.runHead,
407 Effect.map(Option.getOrUndefined),
408 Effect.map((idp): LiveIdp | undefined => idp as LiveIdp | undefined),
409 ),
410 );
411
412/**
413 * Pull the {@link OwnedBySomeoneElse} value out of a Cause regardless of
414 * whether the engine raised it as a typed failure or a defect.
415 */
416const findOwnedError = (
417 cause: Cause.Cause<unknown>,
418): OwnedBySomeoneElse | undefined =>
419 cause.reasons
420 .map((reason) =>
421 Cause.isFailReason(reason)
422 ? reason.error
423 : Cause.isDieReason(reason)
424 ? reason.defect
425 : undefined,
426 )
427 .find(
428 (value): value is OwnedBySomeoneElse =>
429 value instanceof OwnedBySomeoneElse,
430 );
431
432test.provider(
433 "adoption — existing IdP errors without adopt, takes over with adopt(true)",
434 (stack) =>
435 Effect.gen(function* () {
436 const { accountId } = yield* yield* CloudflareEnvironment;
437
438 yield* stack.destroy();
439
440 // Create the IdP out-of-band so the stack has no state of its own for
441 // it — exactly the "already configured in the dashboard" scenario. A
442 // leftover from an interrupted run is fine: it is the same fixture.
443 const NAME = "alchemy-zt-idp-adopt";
444 const pre =
445 (yield* findLiveIdp(accountId, (idp) => idp.name === NAME)) ??
446 (yield* retryForbidden(
447 zeroTrust.createIdentityProviderForAccount({
448 accountId,
449 name: NAME,
450 type: "oidc",
451 config: oidcConfig,
452 }),
453 ).pipe(Effect.map((created): LiveIdp => created as LiveIdp)));
454 expect(pre.id).toBeTruthy();
455
456 // Without `adopt`: Access IdPs carry no ownership markers, so the
457 // engine cannot prove we created it and refuses to take it over.
458 const error = yield* stack
459 .deploy(
460 Cloudflare.Access.IdentityProvider("AdoptOidc", {
461 name: NAME,
462 type: "oidc",
463 config: oidcConfig,
464 }),
465 )
466 .pipe(
467 Effect.as(undefined),
468 Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))),
469 );
470 expect(error).toBeInstanceOf(OwnedBySomeoneElse);
471
472 // With `adopt(true)`: the engine takes over the pre-existing IdP
473 // (same physical id) — no duplicate create.
474 const adopted = yield* stack.deploy(
475 Cloudflare.Access.IdentityProvider("AdoptOidc", {
476 name: NAME,
477 type: "oidc",
478 config: oidcConfig,
479 }).pipe(adopt(true)),
480 );
481 expect(adopted.identityProviderId).toEqual(pre.id);
482
483 // Adopted means owned — destroy deletes the live IdP.
484 yield* stack.destroy();
485 yield* expectGone(undefined, accountId, adopted.identityProviderId);
486 }).pipe(logLevel),
487 { tags: ["provider:cloudflare", "provider:cloudflare:access", "live"] },
488);
489
490// The `cloudflare` (WARP) IdP is an account singleton whose display name is
491// `""` when provisioned from the Zero Trust dashboard. Both rounds run in
492// ONE sequential case because the singleton is account-global and
493// `test.provider` cases within a file run concurrently.
494test.provider(
495 "adoption — cloudflare-type IdP by empty name, then by type alone",
496 (stack) =>
497 Effect.gen(function* () {
498 const { accountId } = yield* yield* CloudflareEnvironment;
499
500 yield* stack.destroy();
501
502 const findWarp = findLiveIdp(
503 accountId,
504 (idp) => idp.type === "cloudflare",
505 );
506
507 // Round 1 — the user-reported regression: an explicit `name: ""` must
508 // match the live IdP (a truthiness check used to swallow `""` and
509 // probe for a generated physical name instead, so the provider tried
510 // to create a second singleton and failed).
511 const pre1 =
512 (yield* findWarp) ??
513 (yield* retryForbidden(
514 zeroTrust.createIdentityProviderForAccount({
515 accountId,
516 name: "",
517 type: "cloudflare",
518 config: { restrictToAccountMembers: true },
519 }),
520 ).pipe(Effect.map((created): LiveIdp => created as LiveIdp)));
521 expect(pre1.id).toBeTruthy();
522
523 const adopted1 = yield* stack.deploy(
524 Cloudflare.Access.IdentityProvider("CloudflareIDP", {
525 name: "",
526 type: "cloudflare",
527 config: { restrictToAccountMembers: true },
528 }).pipe(adopt(true)),
529 );
530 expect(adopted1.identityProviderId).toEqual(pre1.id);
531 expect(adopted1.name).toEqual("");
532
533 yield* stack.destroy();
534 yield* expectGone(undefined, accountId, adopted1.identityProviderId);
535
536 // Round 2 — omitted name: singletons are located by type and keep
537 // their observed display name (no rename to a generated physical
538 // name).
539 const pre2 = yield* retryForbidden(
540 zeroTrust.createIdentityProviderForAccount({
541 accountId,
542 name: "",
543 type: "cloudflare",
544 config: { restrictToAccountMembers: true },
545 }),
546 ).pipe(Effect.map((created): LiveIdp => created as LiveIdp));
547 expect(pre2.id).toBeTruthy();
548
549 const adopted2 = yield* stack.deploy(
550 Cloudflare.Access.IdentityProvider("CloudflareIDP", {
551 type: "cloudflare",
552 config: { restrictToAccountMembers: true },
553 }).pipe(adopt(true)),
554 );
555 expect(adopted2.identityProviderId).toEqual(pre2.id);
556 expect(adopted2.name).toEqual("");
557
558 yield* stack.destroy();
559 yield* expectGone(undefined, accountId, adopted2.identityProviderId);
560 }).pipe(logLevel),
561 { tags: ["provider:cloudflare", "provider:cloudflare:access", "live"] },
562);
563
564test.provider(
565 "getIdentityProvider data source feeds another resource's props",
566 (stack) =>
567 Effect.gen(function* () {
568 const { accountId } = yield* yield* CloudflareEnvironment;
569
570 yield* stack.destroy();
571
572 const NAME = "alchemy-zt-idp-lookup";
573
574 // First deploy creates the IdP alone so the live IdP exists before
575 // the next plan's data-source Output resolves.
576 const idp = yield* stack.deploy(
577 Cloudflare.Access.IdentityProvider("LookupOidc", {
578 name: NAME,
579 type: "oidc",
580 config: oidcConfig,
581 }),
582 );
583
584 // Second deploy consumes the lookup Output IN PLACE OF a resource
585 // reference: `allowedIdps` receives the looked-up id — the "gate an
586 // application on an IdP managed outside the stack" use case.
587 const result = yield* stack.deploy(
588 Effect.gen(function* () {
589 const same = yield* Cloudflare.Access.IdentityProvider("LookupOidc", {
590 name: NAME,
591 type: "oidc",
592 config: oidcConfig,
593 });
594 yield* Cloudflare.Zone.Zone("TestZone", {
595 name: zoneName,
596 }).pipe(adopt(true));
597 const app = yield* Cloudflare.Access.Application("LookupGatedApp", {
598 type: "self_hosted",
599 domain: `alchemy-test-idp-lookup.${zoneName}`,
600 sessionDuration: "24h",
601 allowedIdps: [
602 Cloudflare.Access.getIdentityProvider({
603 name: NAME,
604 }).identityProviderId.as<string>(),
605 ],
606 });
607 return {
608 deployedId: same.identityProviderId,
609 app,
610 missing: Cloudflare.Access.getIdentityProvider({
611 name: "alchemy-zt-idp-lookup-nonexistent",
612 }),
613 };
614 }),
615 );
616
617 expect(result.deployedId).toEqual(idp.identityProviderId);
618 expect(result.missing).toBeUndefined();
619 expect(result.app.applicationId).toBeTruthy();
620
621 // The application's allowed-IdP list must carry the looked-up id —
622 // verified out-of-band against the live application.
623 const liveApp = yield* zeroTrust
624 .getAccessApplicationForAccount({
625 accountId,
626 appId: result.app.applicationId,
627 })
628 .pipe(
629 Effect.retry({
630 while: (e): boolean => e._tag === "Forbidden",
631 ...forbiddenRetryPolicy,
632 }),
633 );
634 const allowed =
635 (liveApp as { allowedIdps?: ReadonlyArray<string | null> | null })
636 .allowedIdps ?? [];
637 expect(allowed).toContain(idp.identityProviderId);
638
639 yield* stack.destroy();
640 yield* expectGone(undefined, idp.accountId, idp.identityProviderId);
641 }).pipe(logLevel),
642 {
643 tags: [
644 "provider:cloudflare",
645 "provider:cloudflare:access",
646 "provider:cloudflare:zone",
647 "live",
648 ],
649 },
650);
651
652class LookupNotServing extends Data.TaggedError("LookupNotServing")<{
653 message: string;
654}> {}
655
656test.provider(
657 "GetIdentityProvider binding resolves the IdP at runtime inside a Worker",
658 (stack) =>
659 Effect.gen(function* () {
660 yield* stack.destroy();
661
662 const deployed = yield* stack.deploy(
663 Effect.gen(function* () {
664 const idp = yield* Cloudflare.Access.IdentityProvider(
665 "WorkerLookupOidc",
666 {
667 name: "alchemy-zt-idp-worker-lookup",
668 type: "oidc",
669 config: oidcConfig,
670 },
671 );
672 const worker = yield* IdpLookupWorker;
673 return { idp, worker };
674 }),
675 );
676
677 // Anchor on the expected marker, never a bare 200 — fresh workers.dev
678 // hostnames serve a placeholder with 200 while propagating, and the
679 // freshly-minted scoped token can 403 (→ worker 500) for ~30s.
680 const client = yield* HttpClient.HttpClient;
681 const body = yield* client.get(`${deployed.worker.url}/`).pipe(
682 Effect.flatMap((res) =>
683 Effect.gen(function* () {
684 const text = yield* res.text;
685 if (res.status !== 200 || !text.includes("identityProviderId")) {
686 return yield* new LookupNotServing({
687 message: `status=${res.status} body=${text.slice(0, 300)}`,
688 });
689 }
690 return JSON.parse(text) as {
691 identityProviderId: string | null;
692 type: string | null;
693 };
694 }),
695 ),
696 Effect.retry({
697 while: (e): boolean => e._tag === "LookupNotServing",
698 schedule: Schedule.max([
699 Schedule.min([
700 Schedule.exponential("1 second"),
701 Schedule.spaced("5 seconds"),
702 ]),
703 Schedule.recurs(15),
704 ]),
705 }),
706 );
707
708 expect(body.identityProviderId).toEqual(deployed.idp.identityProviderId);
709 expect(body.type).toEqual("oidc");
710
711 yield* stack.destroy();
712 yield* expectGone(
713 undefined,
714 deployed.idp.accountId,
715 deployed.idp.identityProviderId,
716 );
717 }).pipe(logLevel),
718 {
719 tags: [
720 "provider:cloudflare",
721 "provider:cloudflare:access",
722 "provider:cloudflare:worker",
723 "live",
724 ],
725 timeout: 180_000,
726 },
727);
728
729// Compile-time contract of the per-type configs — never executed. The
730// discriminated Props union must reject a config from the wrong provider
731// type and enforce each type's required fields. Type-level assertions
732// (not @ts-expect-error) so the checks don't depend on where tsc anchors
733// a multi-line object-literal diagnostic.
734type IdpProps = Cloudflare.Access.IdentityProviderProps;
735type Extends<A, B> = [A] extends [B] ? true : false;
736type Not<T extends boolean> = T extends true ? false : true;
737type Assert<T extends true> = T;
738
739// Valid shapes are accepted.
740type _OkOidc = Assert<
741 Extends<{ type: "oidc"; config: typeof oidcConfig }, IdpProps>
742>;
743type _OkPin = Assert<Extends<{ type: "onetimepin" }, IdpProps>>;
744type _OkSaml = Assert<
745 Extends<
746 {
747 type: "saml";
748 config: {
749 issuerUrl: string;
750 ssoTargetUrl: string;
751 idpPublicCerts: string[];
752 };
753 samlCertificateSetId: string;
754 },
755 IdpProps
756 >
757>;
758// An OAuth-only config is rejected on an oidc IdP (missing endpoints).
759type _BadOidc = Assert<
760 Not<
761 Extends<
762 { type: "oidc"; config: { clientId: string; clientSecret: string } },
763 IdpProps
764 >
765 >
766>;
767// azureAD requires directoryId.
768type _BadAzure = Assert<
769 Not<
770 Extends<
771 { type: "azureAD"; config: { clientId: string; clientSecret: string } },
772 IdpProps
773 >
774 >
775>;
776// saml requires issuerUrl/ssoTargetUrl/idpPublicCerts.
777type _BadSaml = Assert<
778 Not<Extends<{ type: "saml"; config: { issuerUrl: string } }, IdpProps>>
779>;