1import type * as cf from "@cloudflare/workers-types";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import type { RuntimeContext } from "../../RuntimeContext.ts";
5import type { Application, ApplicationProps } from "../Access/Application.ts";
6
7/**
8 * Protect this Worker with Cloudflare Access (`access` prop). Two forms:
9 *
10 * - a `Cloudflare.Access.Application` — enroll into a **shared**
11 * application (one policy set across several Workers). Note Access
12 * policies are application-wide: every Worker enrolled in the
13 * application is gated by the same policies.
14 * - `{ policies, ... }` — a **dedicated** application owned by this
15 * Worker, created/updated/deleted with it. Access configuration
16 * (policies, session duration, IdPs) lives on applications, so
17 * per-Worker configuration means a per-Worker application — this form
18 * declares one for you, namespaced under the Worker (`<Worker>/Access`).
19 *
20 * Either way, enrolling pushes this Worker's `worker` destination (and a
21 * `preview_worker` destination unless `previews: false`) onto the
22 * application through its binding contract, so the application deploys
23 * with — and converges on — the destinations of every enrolled Worker.
24 * Removing the prop (or the Worker) un-enrolls it on the application's
25 * next reconcile.
26 */
27export type WorkerAccessConfig = Application | WorkerAccessApplication;
28
29/**
30 * A dedicated Access application owned by this Worker — per-Worker
31 * policies without declaring the application yourself.
32 */
33export interface WorkerAccessApplication {
34 /**
35 * Policies gating access to this Worker — inline bodies, reusable
36 * `Cloudflare.Access.Policy` resources, or policy ids (see
37 * `ApplicationProps["policies"]`).
38 */
39 policies: NonNullable<ApplicationProps["policies"]>;
40 /** Display name for the application. Auto-generated when omitted. */
41 name?: string;
42 /** Session lifetime, e.g. `"24h"`. */
43 sessionDuration?: string;
44 /** Allowed identity-provider UUIDs. */
45 allowedIdps?: string[];
46 /** Skip the IdP picker when exactly one IdP is allowed. */
47 autoRedirectToIdentity?: boolean;
48 /** Show the application in the App Launcher dashboard. */
49 appLauncherVisible?: boolean;
50 /**
51 * Also protect the Worker's version preview URLs.
52 * @default true
53 */
54 previews?: boolean;
55}
56
57export class WorkerAccessIdentityError extends Data.TaggedError(
58 "WorkerAccessIdentityError",
59)<{
60 message: string;
61 cause?: unknown;
62}> {}
63
64/**
65 * The identity Cloudflare Access authenticated for the current request,
66 * as resolved by `ctx.access.getIdentity()`. Field names follow the wire
67 * format of Access's identity payload (snake_case); identity providers can
68 * attach additional fields, captured by the index signature.
69 */
70export interface WorkerAccessIdentity {
71 /** The user's email address, if the identity provider supplies one. */
72 readonly email?: string;
73 /** The user's display name. */
74 readonly name?: string;
75 /** The user's unique identifier. */
76 readonly user_uuid?: string;
77 /** The Cloudflare account id the Access organization belongs to. */
78 readonly account_id?: string;
79 /** Login timestamp (Unix epoch seconds). */
80 readonly iat?: number;
81 /** The user's IP address at authentication time. */
82 readonly ip?: string;
83 /** Authentication methods used (e.g. `"pwd"`). */
84 readonly amr?: string[];
85 /** Identity-provider information. */
86 readonly idp?: { id: string; type: string };
87 /** Where the user authenticated from. */
88 readonly geo?: { country: string };
89 /** Group memberships from the identity provider. */
90 readonly groups?: Array<{ id: string; name: string; email?: string }>;
91 /** Device posture check results, keyed by check id. */
92 readonly devicePosture?: Record<string, unknown>;
93 /** True when the user connected via Cloudflare WARP. */
94 readonly is_warp?: boolean;
95 /** True when the user is authenticated via Cloudflare Gateway. */
96 readonly is_gateway?: boolean;
97 /** Service-token client id, when authenticated via a service token. */
98 readonly service_token_id?: string;
99 /** True when the request authenticated with a service token. */
100 readonly service_token_status?: boolean;
101 readonly [key: string]: unknown;
102}
103
104/**
105 * Effect-native view of the Cloudflare Access runtime API on the execution
106 * context (`ctx.access`). Present only when the request was admitted by a
107 * Cloudflare Access policy (see the `access` prop on `Cloudflare.Worker`),
108 * or when simulated locally via the Worker's `dev.access` config.
109 */
110export interface WorkerExecutionContextAccess {
111 /** The Access application audience (AUD) tag that admitted this request. */
112 readonly aud: string;
113 /**
114 * Resolve the authenticated identity — email, name, groups, device
115 * posture, etc. Resolves `undefined` when Access has no identity for the
116 * request (e.g. some service-token flows).
117 */
118 getIdentity(): Effect.Effect<
119 WorkerAccessIdentity | undefined,
120 WorkerAccessIdentityError,
121 RuntimeContext
122 >;
123}
124
125/**
126 * The shape workerd exposes on `ctx.access` (typed locally — this package's
127 * workers-types predates it) and that the local dev simulation mirrors.
128 */
129interface RawAccessContext {
130 readonly aud: string;
131 getIdentity(): Promise<WorkerAccessIdentity | undefined>;
132}
133
134const makeAccessContext = (
135 raw: RawAccessContext,
136): WorkerExecutionContextAccess => ({
137 aud: raw.aud,
138 getIdentity: () =>
139 Effect.tryPromise({
140 try: () => raw.getIdentity(),
141 catch: (cause) =>
142 new WorkerAccessIdentityError({
143 message:
144 cause instanceof Error
145 ? cause.message
146 : "Unknown Access identity resolution error",
147 cause,
148 }),
149 }),
150});
151
152/** The env key `dev.access` is lowered into by the local worker provider. */
153export const DEV_ACCESS_ENV_KEY = "ALCHEMY_DEV_ACCESS";
154
155/**
156 * Resolve the current request's Access context: workerd's native
157 * `ctx.access` when the request came through Cloudflare Access, the
158 * `dev.access` simulation under `alchemy dev`, and `undefined` (an
159 * unauthenticated request) otherwise. Backs the `access` member of the
160 * per-event `WorkerExecutionContext`.
161 */
162export const resolveAccessContext = (
163 ctx: cf.ExecutionContext,
164 env: Record<string, unknown> | undefined,
165): WorkerExecutionContextAccess | undefined => {
166 // Deployed behind Access: workerd populates ctx.access natively.
167 const native = (ctx as cf.ExecutionContext & { access?: RawAccessContext })
168 .access;
169 if (native !== undefined) {
170 return makeAccessContext(native);
171 }
172 // Local dev: the Worker's `dev.access` config is lowered into an env
173 // binding; absent config simulates an unauthenticated request.
174 const dev = env?.[DEV_ACCESS_ENV_KEY] as
175 | { aud?: string; identity?: WorkerAccessIdentity }
176 | undefined;
177 if (dev !== undefined) {
178 return {
179 aud: dev.aud ?? "dev",
180 getIdentity: () => Effect.succeed(dev.identity),
181 };
182 }
183 return undefined;
184};