1import * as Cache from "effect/Cache";
2import * as Config from "effect/Config";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Stream from "effect/Stream";
7import * as ChildProcess from "effect/process/ChildProcess";
8import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
9import { ConfigError, SystemError } from "../RuntimeError.shared.ts";
10
11export class Access extends Context.Service<
12 Access,
13 {
14 readonly getAccessHeaders: (
15 domain: string,
16 ) => Effect.Effect<Record<string, string>, ConfigError | SystemError>;
17 }
18>()("cloudflare-runtime/remote-bindings/Access") {}
19
20export const layer = Layer.effect(
21 Access,
22 Effect.gen(function* () {
23 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
24 const usesAccessCache = yield* Cache.make({
25 // Intentional: probe failures (timeout, DNS, network blip) are coerced
26 // to "domain does not use Access" so we don't pop a login prompt for
27 // every transient hiccup. This mirrors workers-sdk's behavior.
28 lookup: (domain: string) =>
29 Effect.promise((signal) =>
30 fetch(`https://${domain}`, { redirect: "manual", signal }),
31 ).pipe(
32 Effect.map(
33 (response) =>
34 response.status === 302 &&
35 (response.headers
36 .get("location")
37 ?.includes("cloudflareaccess.com") ??
38 false),
39 ),
40 Effect.timeout(1000),
41 Effect.orElseSucceed(() => false),
42 ),
43 capacity: Infinity,
44 });
45 const login = (domain: string) =>
46 ChildProcess.make("cloudflared", ["access", "login", domain]).pipe(
47 spawner.spawn,
48 Effect.flatMap((process) => Stream.runCollect(process.stdout)),
49 Effect.mapError(
50 (error) =>
51 new SystemError({
52 subtag: "CloudflaredMissing",
53 message: `The domain "${domain}" uses Cloudflare Access but the \`cloudflared\` CLI could not be invoked.`,
54 hint: "Install cloudflared from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation, or set CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET.",
55 cause: error,
56 }),
57 ),
58 Effect.flatMap((stdout) => {
59 const matches = stdout
60 .toString()
61 .match(/fetched your token:\n\n(.*)/m);
62 return matches && matches.length >= 2
63 ? Effect.succeed({ Cookie: `CF_Authorization=${matches[1]}` })
64 : Effect.fail(
65 new SystemError({
66 subtag: "CloudflaredAuth",
67 message:
68 "Failed to extract a token from `cloudflared access login`.",
69 hint: "Try running `cloudflared access login <domain>` manually to debug.",
70 detail: { stdout: stdout.toString() },
71 }),
72 );
73 }),
74 Effect.scoped,
75 );
76
77 const getEnv = (name: string) =>
78 Config.String(name).pipe(
79 Effect.catchTag("ConfigError", () => Effect.succeed(undefined)),
80 );
81
82 return Access.of({
83 getAccessHeaders: Effect.fn(function* (domain) {
84 const domainUsesAccess = yield* Cache.get(usesAccessCache, domain);
85 if (!domainUsesAccess) {
86 return {};
87 }
88 const clientId = yield* getEnv("CLOUDFLARE_ACCESS_CLIENT_ID");
89 const clientSecret = yield* getEnv("CLOUDFLARE_ACCESS_CLIENT_SECRET");
90 if (clientId && clientSecret) {
91 return {
92 "CF-Access-Client-Id": clientId,
93 "CF-Access-Client-Secret": clientSecret,
94 } as Record<string, string>;
95 }
96
97 if (clientId !== undefined || clientSecret !== undefined) {
98 return yield* Effect.fail(
99 new ConfigError({
100 subtag: "AccessTokenIncomplete",
101 message:
102 "Both CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET must be set to use Access service-token authentication.",
103 hint: `Only ${
104 clientId !== undefined
105 ? "CLOUDFLARE_ACCESS_CLIENT_ID"
106 : "CLOUDFLARE_ACCESS_CLIENT_SECRET"
107 } was found. Set the missing variable, unset both to fall back to interactive login, or remove the value to disable service-token auth.`,
108 }),
109 );
110 }
111
112 return yield* login(domain);
113 }),
114 });
115 }),
116);