| 1 | import type * as cf from "@cloudflare/workers-types"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import type { RuntimeContext } from "../../RuntimeContext.ts"; |
| 5 | import 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 | */ |
| 27 | export 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 | */ |
| 33 | export 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 | |
| 57 | export 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 | */ |
| 70 | export 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 | */ |
| 110 | export 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 | */ |
| 129 | interface RawAccessContext { |
| 130 | readonly aud: string; |
| 131 | getIdentity(): Promise<WorkerAccessIdentity | undefined>; |
| 132 | } |
| 133 | |
| 134 | const 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. */ |
| 153 | export 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 | */ |
| 162 | export 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 | }; |