1import * as tokenValidation from "@distilled.cloud/cloudflare/token-validation";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13import { listAllZones } from "../Zone/lookup.ts";
14
15const TypeId = "Cloudflare.TokenValidation.Configuration" as const;
16type 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 */
22export 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
64export 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
100export 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
121export 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 */
193export const TokenConfiguration = Resource<TokenConfiguration>(TypeId);
194
195/**
196 * Returns true if the given value is a TokenConfiguration resource.
197 */
198export const isTokenConfiguration = (
199 value: unknown,
200): value is TokenConfiguration =>
201 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
202
203export 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
340type ObservedConfiguration = tokenValidation.GetConfigurationResponse;
341
342/**
343 * Read a configuration by UUID, mapping "gone"
344 * (`TokenConfigurationNotFound`, HTTP 404) to `undefined`.
345 */
346const 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 */
358const 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
369const createConfigurationTitle = (id: string, title: string | undefined) =>
370 Effect.gen(function* () {
371 return title ?? (yield* createPhysicalName({ id, lowercase: true }));
372 });
373
374const sameStrings = (observed: readonly string[], desired: readonly string[]) =>
375 observed.length === desired.length &&
376 [...observed].sort().join("") === [...desired].sort().join("");
377
378const 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
383const 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
395const 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});