1import * as Config from "effect/Config";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";
6import * as Stream from "effect/Stream";
7import * as ChildProcess from "effect/process/ChildProcess";
8import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
9import { cachedFunction } from "../Util/cached-function.ts";
10
11export class AccessError extends Schema.TaggedError<AccessError>()(
12 "AccessError",
13 {
14 message: Schema.String,
15 cause: Schema.optional(Schema.Defect()),
16 },
17) {}
18
19export class Access extends Context.Service<
20 Access,
21 {
22 readonly getAccessHeaders: (
23 domain: string,
24 ) => Effect.Effect<Record<string, string>, AccessError>;
25 }
26>()("alchemy/Cloudflare/Access") {}
27
28export const AccessLive = Layer.effect(
29 Access,
30 Effect.gen(function* () {
31 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
32 const domainUsesAccess = yield* cachedFunction((domain: string) =>
33 Effect.promise((signal) =>
34 fetch(`https://${domain}`, { redirect: "manual", signal }),
35 ).pipe(
36 Effect.map(
37 (response) =>
38 response.status === 302 &&
39 (response.headers
40 .get("location")
41 ?.includes("cloudflareaccess.com") ??
42 false),
43 ),
44 Effect.timeout(1000),
45 Effect.catch(() => Effect.succeed(false)),
46 ),
47 );
48 const login = (domain: string) =>
49 ChildProcess.make("cloudflared", ["access", "login", domain]).pipe(
50 spawner.spawn,
51 Effect.flatMap((process) => Stream.runCollect(process.stdout)),
52 Effect.mapError(
53 (error) =>
54 new AccessError({
55 message:
56 `The domain "${domain}" uses Cloudflare Access, but \`cloudflared\` is not installed. ` +
57 `Please install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation.`,
58 cause: error,
59 }),
60 ),
61 Effect.flatMap((stdout) => {
62 const matches = stdout
63 .toString()
64 .match(/fetched your token:\n\n(.*)/m);
65 return matches && matches.length >= 2
66 ? Effect.succeed({ Cookie: `CF_Authorization=${matches[1]}` })
67 : Effect.fail(
68 new AccessError({
69 message: "Failed to authenticate with Cloudflare Access",
70 }),
71 );
72 }),
73 Effect.scoped,
74 );
75
76 const getEnv = (name: string) =>
77 Config.String(name)
78
79 .pipe(Effect.catchTag("ConfigError", () => Effect.succeed(undefined)));
80
81 return Access.of({
82 getAccessHeaders: Effect.fn(function* (domain) {
83 if (!(yield* domainUsesAccess(domain))) {
84 return {};
85 }
86 const clientId = yield* getEnv("CLOUDFLARE_ACCESS_CLIENT_ID");
87 const clientSecret = yield* getEnv("CLOUDFLARE_ACCESS_CLIENT_SECRET");
88 if (clientId && clientSecret) {
89 return {
90 "CF-Access-Client-Id": clientId,
91 "CF-Access-Client-Secret": clientSecret,
92 } as Record<string, string>;
93 }
94
95 if (clientId !== undefined || clientSecret !== undefined) {
96 yield* Effect.logWarning(
97 "Both CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET must be set to use Access Service Token authentication. " +
98 `Only ${
99 clientId !== undefined
100 ? "CLOUDFLARE_ACCESS_CLIENT_ID"
101 : "CLOUDFLARE_ACCESS_CLIENT_SECRET"
102 } was found.`,
103 );
104 }
105
106 return yield* login(domain);
107 }),
108 });
109 }),
110);