| 1 | import * as tokenValidation from "@distilled.cloud/cloudflare/token-validation"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | import { listAllZones } from "../Zone/lookup.ts"; |
| 14 | |
| 15 | const TypeId = "Cloudflare.TokenValidation.Configuration" as const; |
| 16 | type TypeId = typeof TypeId; |
| 17 | |
| 18 | /** |
| 19 | * A JSON Web Key (JWK) in a token configuration's key set. These are public |
| 20 | * keys (the public half of a JWKS), not secrets. |
| 21 | */ |
| 22 | export type JwkKey = |
| 23 | | { |
| 24 | /** Key type — RSA. */ |
| 25 | kty: "RSA"; |
| 26 | /** RSA signing algorithm the key is used with. */ |
| 27 | alg: "RS256" | "RS384" | "RS512" | "PS256" | "PS384" | "PS512"; |
| 28 | /** Key ID — must match the `kid` header of incoming JWTs. */ |
| 29 | kid: string; |
| 30 | /** RSA modulus (base64url). */ |
| 31 | n: string; |
| 32 | /** RSA public exponent (base64url). */ |
| 33 | e: string; |
| 34 | } |
| 35 | | { |
| 36 | /** Key type — elliptic curve. */ |
| 37 | kty: "EC"; |
| 38 | /** ECDSA P-256 signing algorithm. */ |
| 39 | alg: "ES256"; |
| 40 | /** Curve — P-256. */ |
| 41 | crv: "P-256"; |
| 42 | /** Key ID — must match the `kid` header of incoming JWTs. */ |
| 43 | kid: string; |
| 44 | /** EC x coordinate (base64url). */ |
| 45 | x: string; |
| 46 | /** EC y coordinate (base64url). */ |
| 47 | y: string; |
| 48 | } |
| 49 | | { |
| 50 | /** Key type — elliptic curve. */ |
| 51 | kty: "EC"; |
| 52 | /** ECDSA P-384 signing algorithm. */ |
| 53 | alg: "ES384"; |
| 54 | /** Curve — P-384. */ |
| 55 | crv: "P-384"; |
| 56 | /** Key ID — must match the `kid` header of incoming JWTs. */ |
| 57 | kid: string; |
| 58 | /** EC x coordinate (base64url). */ |
| 59 | x: string; |
| 60 | /** EC y coordinate (base64url). */ |
| 61 | y: string; |
| 62 | }; |
| 63 | |
| 64 | export interface TokenConfigurationProps { |
| 65 | /** |
| 66 | * Zone the token configuration belongs to. |
| 67 | * |
| 68 | * Stable — moving a configuration between zones triggers a replacement. |
| 69 | */ |
| 70 | zoneId: string; |
| 71 | /** |
| 72 | * Human-readable name for the configuration. If omitted, a unique name is |
| 73 | * generated from the app, stage, and logical ID. |
| 74 | * @default ${app}-${stage}-${id} |
| 75 | */ |
| 76 | title?: string; |
| 77 | /** |
| 78 | * A description that gives more details than `title`. |
| 79 | * @default "" |
| 80 | */ |
| 81 | description?: string; |
| 82 | /** |
| 83 | * Where to look for the token on incoming requests, as firewall fields — |
| 84 | * e.g. `http.request.headers["authorization"][0]`. |
| 85 | */ |
| 86 | tokenSources: string[]; |
| 87 | /** |
| 88 | * The JWKS key set used to validate token signatures. Replacing entries |
| 89 | * rotates the key set in place via the credentials endpoint. |
| 90 | */ |
| 91 | keys: JwkKey[]; |
| 92 | /** |
| 93 | * The token format. Only JWT is supported today. Cannot be changed after |
| 94 | * creation — updating this property triggers a replacement. |
| 95 | * @default "JWT" |
| 96 | */ |
| 97 | tokenType?: "JWT"; |
| 98 | } |
| 99 | |
| 100 | export interface TokenConfigurationAttributes { |
| 101 | /** Cloudflare-assigned UUID of the token configuration. */ |
| 102 | configId: string; |
| 103 | /** Zone the configuration belongs to. */ |
| 104 | zoneId: string; |
| 105 | /** Human-readable name of the configuration. */ |
| 106 | title: string; |
| 107 | /** Description of the configuration. */ |
| 108 | description: string; |
| 109 | /** Where the token is looked for on incoming requests. */ |
| 110 | tokenSources: string[]; |
| 111 | /** The token format. */ |
| 112 | tokenType: "JWT" | (string & {}); |
| 113 | /** The JWKS key set currently active on the configuration. */ |
| 114 | keys: JwkKey[]; |
| 115 | /** ISO8601 creation timestamp. */ |
| 116 | createdAt: string; |
| 117 | /** ISO8601 last-modified timestamp. */ |
| 118 | lastUpdated: string; |
| 119 | } |
| 120 | |
| 121 | export type TokenConfiguration = Resource< |
| 122 | TypeId, |
| 123 | TokenConfigurationProps, |
| 124 | TokenConfigurationAttributes, |
| 125 | never, |
| 126 | Providers |
| 127 | >; |
| 128 | |
| 129 | /** |
| 130 | * An API Shield JWT validation token configuration — the JWKS key material |
| 131 | * and token source locations used to validate JSON Web Tokens on a zone. |
| 132 | * |
| 133 | * A configuration holds a set of public JWKs (`keys`) plus the request |
| 134 | * fields where the token is found (`tokenSources`). Rules |
| 135 | * ({@link Rule}) then reference the configuration by UUID in |
| 136 | * their expression (e.g. `is_jwt_valid("<configId>")`) to enforce |
| 137 | * validation on selected hosts/operations. |
| 138 | * |
| 139 | * JWT validation is an API Shield feature (Enterprise add-on) — accounts |
| 140 | * without the entitlement receive the typed `TokenValidationNotEntitled` |
| 141 | * error (Cloudflare code 10403) on every call. |
| 142 | * |
| 143 | * Title, description, and token sources are patched in place; the key set |
| 144 | * is rotated in place via the credentials endpoint. Only `zoneId` and |
| 145 | * `tokenType` force a replacement. |
| 146 | * ### Creating a Configuration |
| 147 | * **Example:** JWT configuration with an RSA key |
| 148 | * ```typescript |
| 149 | * const config = yield* Cloudflare.TokenValidation.TokenConfiguration("ApiJwt", { |
| 150 | * zoneId: zone.zoneId, |
| 151 | * tokenSources: ['http.request.headers["authorization"][0]'], |
| 152 | * keys: [ |
| 153 | * { |
| 154 | * kty: "RSA", |
| 155 | * alg: "RS256", |
| 156 | * kid: "key-2026-01", |
| 157 | * n: "<base64url modulus>", |
| 158 | * e: "AQAB", |
| 159 | * }, |
| 160 | * ], |
| 161 | * }); |
| 162 | * ``` |
| 163 | * |
| 164 | * ### Rotating Keys |
| 165 | * **Example:** Replace the key set in place |
| 166 | * ```typescript |
| 167 | * // Changing `keys` PUTs the full key set to the credentials endpoint — |
| 168 | * // the configuration (and its UUID) stays in place. |
| 169 | * const config = yield* Cloudflare.TokenValidation.TokenConfiguration("ApiJwt", { |
| 170 | * zoneId: zone.zoneId, |
| 171 | * tokenSources: ['http.request.headers["authorization"][0]'], |
| 172 | * keys: [oldKey, newKey], |
| 173 | * }); |
| 174 | * ``` |
| 175 | * |
| 176 | * ### Enforcing Validation |
| 177 | * **Example:** Reference the configuration from a rule |
| 178 | * ```typescript |
| 179 | * yield* Cloudflare.TokenValidation.Rule("RequireJwt", { |
| 180 | * zoneId: zone.zoneId, |
| 181 | * action: "block", |
| 182 | * expression: Output.interpolate`is_jwt_valid("${config.configId}")`, |
| 183 | * selector: { include: [{ host: ["api.example.com"] }] }, |
| 184 | * }); |
| 185 | * ``` |
| 186 | * |
| 187 | * @see https://developers.cloudflare.com/api-shield/security/jwt-validation/ |
| 188 | * |
| 189 | * @resource |
| 190 | * @product Token Validation |
| 191 | * @category Application Security |
| 192 | */ |
| 193 | export const TokenConfiguration = Resource<TokenConfiguration>(TypeId); |
| 194 | |
| 195 | /** |
| 196 | * Returns true if the given value is a TokenConfiguration resource. |
| 197 | */ |
| 198 | export const isTokenConfiguration = ( |
| 199 | value: unknown, |
| 200 | ): value is TokenConfiguration => |
| 201 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 202 | |
| 203 | export const TokenConfigurationProvider = () => |
| 204 | Provider.succeed(TokenConfiguration, { |
| 205 | stables: ["configId", "zoneId", "tokenType", "createdAt"], |
| 206 | |
| 207 | list: Effect.fn(function* () { |
| 208 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 209 | // Configurations are zone-scoped; the list op is keyed per zone. |
| 210 | // Enumerate every zone, fan out the per-zone list, and flatten. |
| 211 | const zones = yield* listAllZones(accountId); |
| 212 | const rows = yield* Effect.forEach( |
| 213 | zones, |
| 214 | (zone) => |
| 215 | tokenValidation.listConfigurations.items({ zoneId: zone.id }).pipe( |
| 216 | Stream.runCollect, |
| 217 | Effect.map((chunk) => |
| 218 | Array.from(chunk).map((c) => toAttributes(c, zone.id)), |
| 219 | ), |
| 220 | // JWT validation is entitlement-gated and freshly minted tokens |
| 221 | // can briefly 403 — skip zones we can't enumerate. |
| 222 | Effect.catchTag(["TokenValidationNotEntitled", "Forbidden"], () => |
| 223 | Effect.succeed([]), |
| 224 | ), |
| 225 | ), |
| 226 | { concurrency: 10 }, |
| 227 | ); |
| 228 | return rows.flat(); |
| 229 | }), |
| 230 | |
| 231 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 232 | if (!isResolved(news)) return undefined; |
| 233 | // Moving between zones replaces the configuration. |
| 234 | const oldZoneId = output?.zoneId ?? olds?.zoneId; |
| 235 | if (typeof oldZoneId === "string" && oldZoneId !== news.zoneId) { |
| 236 | return { action: "replace" } as const; |
| 237 | } |
| 238 | // tokenType is not patchable (and "JWT" is the only value today). |
| 239 | const oldType = output?.tokenType ?? olds?.tokenType ?? "JWT"; |
| 240 | if ((news.tokenType ?? "JWT") !== oldType) { |
| 241 | return { action: "replace" } as const; |
| 242 | } |
| 243 | return undefined; |
| 244 | }), |
| 245 | |
| 246 | read: Effect.fn(function* ({ id, output, olds }) { |
| 247 | const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined); |
| 248 | if (zoneId === undefined) return undefined; |
| 249 | |
| 250 | // Owned path: refresh by our persisted UUID. |
| 251 | if (output?.configId) { |
| 252 | const observed = yield* getConfiguration(zoneId, output.configId); |
| 253 | if (observed) return toAttributes(observed, zoneId); |
| 254 | } |
| 255 | |
| 256 | // Cold read — recover from lost state by matching the deterministic |
| 257 | // physical title. Configurations carry no ownership markers, so an |
| 258 | // existing match is reported as `Unowned` and the engine gates |
| 259 | // takeover behind the adopt policy. |
| 260 | const title = yield* createConfigurationTitle(id, olds?.title); |
| 261 | const match = yield* findByTitle(zoneId, title); |
| 262 | if (match) return Unowned(toAttributes(match, zoneId)); |
| 263 | return undefined; |
| 264 | }), |
| 265 | |
| 266 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 267 | // Inputs have been resolved to concrete values by Plan. |
| 268 | const zoneId = news.zoneId as string; |
| 269 | const title = yield* createConfigurationTitle(id, news.title); |
| 270 | |
| 271 | // 1. Observe — the UUID cached on `output` is a hint, not a |
| 272 | // guarantee: a 404 falls through to "missing" and we recreate. |
| 273 | let observed = output?.configId |
| 274 | ? yield* getConfiguration(zoneId, output.configId) |
| 275 | : undefined; |
| 276 | |
| 277 | // 2. Ensure — greenfield (or out-of-band delete): create with the |
| 278 | // full desired body. Titles are not unique on Cloudflare's side, |
| 279 | // so there is no AlreadyExists race to tolerate. |
| 280 | if (!observed) { |
| 281 | observed = yield* tokenValidation.createConfiguration({ |
| 282 | zoneId, |
| 283 | title, |
| 284 | description: news.description ?? "", |
| 285 | tokenSources: news.tokenSources, |
| 286 | tokenType: news.tokenType ?? "JWT", |
| 287 | credentials: { keys: news.keys }, |
| 288 | }); |
| 289 | return toAttributes(observed, zoneId); |
| 290 | } |
| 291 | |
| 292 | // 3. Sync metadata — diff observed title/description/tokenSources |
| 293 | // against desired; PATCH only the delta, skip on no-op. |
| 294 | const desiredDescription = news.description ?? ""; |
| 295 | const metadataDirty = |
| 296 | observed.title !== title || |
| 297 | observed.description !== desiredDescription || |
| 298 | !sameStrings(observed.tokenSources, news.tokenSources); |
| 299 | if (metadataDirty) { |
| 300 | yield* tokenValidation.patchConfiguration({ |
| 301 | zoneId, |
| 302 | configId: observed.id, |
| 303 | title, |
| 304 | description: desiredDescription, |
| 305 | tokenSources: news.tokenSources, |
| 306 | }); |
| 307 | } |
| 308 | |
| 309 | // 4. Sync key set — compare the observed JWKS against the desired |
| 310 | // one by full key material; PUT the full set on any difference |
| 311 | // (the credentials endpoint replaces the whole key set). |
| 312 | if (!sameKeys(observed.credentials.keys, news.keys)) { |
| 313 | yield* tokenValidation.putConfigurationCredential({ |
| 314 | zoneId, |
| 315 | configId: observed.id, |
| 316 | keys: news.keys, |
| 317 | }); |
| 318 | } |
| 319 | |
| 320 | // 5. Return — re-read so attributes reflect the final cloud state. |
| 321 | const final = yield* getConfiguration(zoneId, observed.id); |
| 322 | return toAttributes(final ?? observed, zoneId); |
| 323 | }), |
| 324 | |
| 325 | delete: Effect.fn(function* ({ output }) { |
| 326 | // Cloudflare may answer DELETE for an already-gone configuration with |
| 327 | // `result: null`, which the typed response decode rejects — observe |
| 328 | // first and treat missing as done. |
| 329 | const observed = yield* getConfiguration(output.zoneId, output.configId); |
| 330 | if (!observed) return; |
| 331 | yield* tokenValidation |
| 332 | .deleteConfiguration({ |
| 333 | zoneId: output.zoneId, |
| 334 | configId: output.configId, |
| 335 | }) |
| 336 | .pipe(Effect.catchTag("TokenConfigurationNotFound", () => Effect.void)); |
| 337 | }), |
| 338 | }); |
| 339 | |
| 340 | type ObservedConfiguration = tokenValidation.GetConfigurationResponse; |
| 341 | |
| 342 | /** |
| 343 | * Read a configuration by UUID, mapping "gone" |
| 344 | * (`TokenConfigurationNotFound`, HTTP 404) to `undefined`. |
| 345 | */ |
| 346 | const getConfiguration = (zoneId: string, configId: string) => |
| 347 | tokenValidation.getConfiguration({ zoneId, configId }).pipe( |
| 348 | Effect.map((c): ObservedConfiguration | undefined => c), |
| 349 | Effect.catchTag("TokenConfigurationNotFound", () => |
| 350 | Effect.succeed(undefined), |
| 351 | ), |
| 352 | ); |
| 353 | |
| 354 | /** |
| 355 | * Find a configuration by exact title. Titles are not unique on |
| 356 | * Cloudflare's side; if several match, pick the oldest for determinism. |
| 357 | */ |
| 358 | const findByTitle = (zoneId: string, title: string) => |
| 359 | tokenValidation.listConfigurations.items({ zoneId }).pipe( |
| 360 | Stream.runCollect, |
| 361 | Effect.map((chunk) => |
| 362 | Array.from(chunk) |
| 363 | .filter((c) => c.title === title) |
| 364 | .sort((a, b) => a.createdAt.localeCompare(b.createdAt)) |
| 365 | .at(0), |
| 366 | ), |
| 367 | ); |
| 368 | |
| 369 | const createConfigurationTitle = (id: string, title: string | undefined) => |
| 370 | Effect.gen(function* () { |
| 371 | return title ?? (yield* createPhysicalName({ id, lowercase: true })); |
| 372 | }); |
| 373 | |
| 374 | const sameStrings = (observed: readonly string[], desired: readonly string[]) => |
| 375 | observed.length === desired.length && |
| 376 | [...observed].sort().join(" ") === [...desired].sort().join(" "); |
| 377 | |
| 378 | const keyIdentity = (key: JwkKey) => |
| 379 | key.kty === "RSA" |
| 380 | ? `RSA:${key.alg}:${key.kid}:${key.n}:${key.e}` |
| 381 | : `EC:${key.alg}:${key.crv}:${key.kid}:${key.x}:${key.y}`; |
| 382 | |
| 383 | const sameKeys = ( |
| 384 | observed: ObservedConfiguration["credentials"]["keys"], |
| 385 | desired: readonly JwkKey[], |
| 386 | ) => { |
| 387 | const observedIds = observed.map((key) => keyIdentity(key as JwkKey)).sort(); |
| 388 | const desiredIds = desired.map(keyIdentity).sort(); |
| 389 | return ( |
| 390 | observedIds.length === desiredIds.length && |
| 391 | observedIds.join(" ") === desiredIds.join(" ") |
| 392 | ); |
| 393 | }; |
| 394 | |
| 395 | const toAttributes = ( |
| 396 | configuration: ObservedConfiguration, |
| 397 | zoneId: string, |
| 398 | ): TokenConfigurationAttributes => ({ |
| 399 | configId: configuration.id, |
| 400 | zoneId, |
| 401 | title: configuration.title, |
| 402 | description: configuration.description, |
| 403 | tokenSources: [...configuration.tokenSources], |
| 404 | tokenType: configuration.tokenType, |
| 405 | // Distilled widens the RSA `alg` enum to an open union (`string & {}`). |
| 406 | keys: configuration.credentials.keys.map((key) => ({ ...key }) as JwkKey), |
| 407 | createdAt: configuration.createdAt, |
| 408 | lastUpdated: configuration.lastUpdated, |
| 409 | }); |