1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Predicate from "effect/Predicate";
5import * as Redacted from "effect/Redacted";
6import * as Stream from "effect/Stream";
7
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14
15export type ServiceTokenProps = {
16 /**
17 * Display name for the service token. Used as a stable identifier so the
18 * provider can locate the token by name during adoption / state recovery.
19 * If omitted, a unique name is generated from the stack/stage/logical id.
20 *
21 * @default ${app}-${stage}-${id}
22 */
23 name?: string;
24 /**
25 * How long the service token is valid. Must be in the format `300ms` or
26 * `2h45m`. Valid time units are: ns, us (or µs), ms, s, m, h.
27 *
28 * @default "8760h" (1 year)
29 */
30 duration?: string;
31 /**
32 * A version number identifying the current client secret. Incrementing it
33 * triggers a rotation — a new client secret is generated and the previous
34 * secret remains valid until `previousClientSecretExpiresAt`.
35 *
36 * @default 1
37 */
38 clientSecretVersion?: number;
39 /**
40 * The expiration of the previous client secret (RFC3339 timestamp). Only
41 * meaningful after a rotation; may be extended to give consumers more
42 * time to roll over to the new secret.
43 */
44 previousClientSecretExpiresAt?: string;
45};
46
47export type ServiceToken = Resource<
48 "Cloudflare.Access.ServiceToken",
49 ServiceTokenProps,
50 {
51 /** UUID of the service token assigned by Cloudflare. */
52 serviceTokenId: string;
53 /** Cloudflare account that owns the service token. */
54 accountId: string;
55 /** Client ID sent in the `CF-Access-Client-ID` request header. */
56 clientId: string;
57 /**
58 * Client secret sent in the `CF-Access-Client-Secret` request header.
59 * Cloudflare only returns it on create and rotate, so the provider
60 * persists it (redacted) and carries it forward on read.
61 */
62 clientSecret: Redacted.Redacted<string> | undefined;
63 /** Display name reported by Cloudflare. */
64 name: string;
65 /** Validity duration reported by Cloudflare, e.g. `8760h`. */
66 duration: string | undefined;
67 /** Expiration timestamp of the current client secret. */
68 expiresAt: string | undefined;
69 /** The client secret version this token was last reconciled to. */
70 clientSecretVersion: number;
71 },
72 never,
73 Providers
74>;
75
76/**
77 * A Cloudflare Zero Trust Access service token. Service tokens let
78 * machine-to-machine clients authenticate to Access-protected applications
79 * by sending the `CF-Access-Client-ID` / `CF-Access-Client-Secret` headers.
80 *
81 * The client secret is only revealed by Cloudflare on create and rotate; the
82 * provider stores it redacted in state and carries it forward across reads.
83 * ### Creating a Service Token
84 * **Example:** Basic token with a generated name
85 * ```typescript
86 * const token = yield* Cloudflare.Access.ServiceToken("Ci", {});
87 * // token.clientId / token.clientSecret authenticate requests
88 * ```
89 *
90 * **Example:** Token with an explicit name and validity
91 * ```typescript
92 * const token = yield* Cloudflare.Access.ServiceToken("Deploys", {
93 * name: "deploy-bot",
94 * duration: "17520h", // 2 years
95 * });
96 * ```
97 *
98 * ### Rotating the Secret
99 * **Example:** Increment clientSecretVersion to rotate
100 * ```typescript
101 * const token = yield* Cloudflare.Access.ServiceToken("Ci", {
102 * clientSecretVersion: 2, // was 1 — bumping rotates the secret
103 * });
104 * ```
105 *
106 * ### Authorizing a Token
107 * **Example:** Reference from an Access policy
108 * ```typescript
109 * const token = yield* Cloudflare.Access.ServiceToken("Ci", {});
110 *
111 * const policy = yield* Cloudflare.Access.Policy("AllowCi", {
112 * decision: "non_identity",
113 * include: [{ serviceToken: { tokenId: token.serviceTokenId } }],
114 * });
115 * ```
116 *
117 * @resource
118 * @product Access
119 * @category Cloudflare One (Zero Trust)
120 */
121export const ServiceToken = Resource<ServiceToken>(
122 "Cloudflare.Access.ServiceToken",
123);
124
125export const isServiceToken = (value: unknown): value is ServiceToken =>
126 Predicate.hasProperty(value, "Type") &&
127 value.Type === "Cloudflare.Access.ServiceToken";
128
129export const ServiceTokenProvider = () =>
130 Provider.succeed(ServiceToken, {
131 stables: ["serviceTokenId", "accountId", "clientId"],
132 list: Effect.fn(function* () {
133 const { accountId } = yield* yield* CloudflareEnvironment;
134 return yield* zeroTrust.listAccessServiceTokensForAccount
135 .pages({ accountId })
136 .pipe(
137 Stream.runCollect,
138 Effect.map((chunk) =>
139 Array.from(chunk).flatMap((page) =>
140 (page.result ?? [])
141 .filter(
142 (
143 t,
144 ): t is (typeof page.result)[number] & {
145 id: string;
146 clientId: string;
147 } => t.id != null && t.clientId != null,
148 )
149 .map((t) => ({
150 serviceTokenId: t.id,
151 accountId,
152 clientId: t.clientId,
153 // The secret is only revealed on create/rotate, never on
154 // enumeration — match read and leave it undefined.
155 clientSecret: undefined,
156 name: t.name ?? "",
157 duration: t.duration ?? undefined,
158 expiresAt: t.expiresAt ?? undefined,
159 clientSecretVersion: 1,
160 })),
161 ),
162 ),
163 );
164 }),
165 diff: Effect.fn(function* ({ news, output }) {
166 const { accountId } = yield* yield* CloudflareEnvironment;
167 if (!isResolved(news)) return undefined;
168 if ((output?.accountId ?? accountId) !== accountId) {
169 return { action: "replace" } as const;
170 }
171 // name/duration converge via PUT; clientSecretVersion bumps rotate.
172 }),
173 read: Effect.fn(function* ({ id, output, olds }) {
174 const { accountId } = yield* yield* CloudflareEnvironment;
175 const acct = output?.accountId ?? accountId;
176 if (output?.serviceTokenId) {
177 const direct = yield* zeroTrust
178 .getAccessServiceTokenForAccount({
179 accountId: acct,
180 serviceTokenId: output.serviceTokenId,
181 })
182 .pipe(
183 Effect.catchTag("AccessServiceTokenNotFound", () =>
184 Effect.succeed(undefined),
185 ),
186 );
187 if (direct && direct.id) {
188 return {
189 serviceTokenId: direct.id,
190 accountId: acct,
191 clientId: direct.clientId ?? output.clientId,
192 // The API never returns the secret on GET — carry it forward.
193 clientSecret: output.clientSecret,
194 name: direct.name ?? output.name,
195 duration: direct.duration ?? output.duration,
196 expiresAt: direct.expiresAt ?? output.expiresAt,
197 clientSecretVersion: output.clientSecretVersion,
198 };
199 }
200 }
201 const name = yield* createTokenName(id, olds?.name ?? output?.name);
202 const existing = yield* findTokenByName(acct, name);
203 if (!existing || !existing.id || !existing.clientId) return undefined;
204 return {
205 serviceTokenId: existing.id,
206 accountId: acct,
207 clientId: existing.clientId,
208 clientSecret: output?.clientSecret,
209 name: existing.name ?? name,
210 duration: existing.duration ?? undefined,
211 expiresAt: existing.expiresAt ?? undefined,
212 clientSecretVersion: output?.clientSecretVersion ?? 1,
213 };
214 }),
215 reconcile: Effect.fn(function* ({
216 id,
217 news = {} as ServiceTokenProps,
218 olds,
219 output,
220 }) {
221 const { accountId } = yield* yield* CloudflareEnvironment;
222 const name = yield* createTokenName(id, news.name);
223 const acct = output?.accountId ?? accountId;
224 const desiredVersion = news.clientSecretVersion ?? 1;
225
226 // Observe — prefer the cached id, fall back to a name lookup so we
227 // recover from out-of-band deletes and partial state-persistence
228 // failures.
229 let observed: ObservedToken | undefined;
230 if (output?.serviceTokenId) {
231 observed = yield* zeroTrust
232 .getAccessServiceTokenForAccount({
233 accountId: acct,
234 serviceTokenId: output.serviceTokenId,
235 })
236 .pipe(
237 Effect.catchTag("AccessServiceTokenNotFound", () =>
238 Effect.succeed(undefined),
239 ),
240 );
241 }
242 if (!observed || !observed.id) {
243 observed = yield* findTokenByName(acct, name);
244 }
245
246 // Ensure — create the token when missing, capturing the one-time
247 // client secret. Tolerate a same-named create race by re-observing.
248 if (!observed || !observed.id) {
249 const created = yield* zeroTrust
250 .createAccessServiceTokenForAccount({
251 accountId: acct,
252 name,
253 duration: news.duration,
254 clientSecretVersion: news.clientSecretVersion,
255 previousClientSecretExpiresAt: news.previousClientSecretExpiresAt,
256 })
257 .pipe(
258 Effect.catch((err) =>
259 Effect.gen(function* () {
260 const existing = yield* findTokenByName(acct, name);
261 if (existing && existing.id) return existing;
262 return yield* Effect.fail(err);
263 }),
264 ),
265 );
266 if (!created.id || !created.clientId) {
267 return yield* Effect.fail(
268 new Error("ServiceToken: created token missing id"),
269 );
270 }
271 const fresh = yield* zeroTrust.getAccessServiceTokenForAccount({
272 accountId: acct,
273 serviceTokenId: created.id,
274 });
275 return {
276 serviceTokenId: created.id,
277 accountId: acct,
278 clientId: created.clientId,
279 clientSecret:
280 "clientSecret" in created &&
281 typeof created.clientSecret === "string"
282 ? Redacted.make(created.clientSecret)
283 : output?.clientSecret,
284 name: created.name ?? name,
285 duration: created.duration ?? news.duration,
286 expiresAt: fresh.expiresAt ?? undefined,
287 clientSecretVersion: desiredVersion,
288 };
289 }
290
291 // Sync — converge name/duration via PUT only when the observed state
292 // differs from the desired state. The PUT deliberately omits
293 // clientSecretVersion: rotation must go through the explicit rotate
294 // endpoint, which is the only call that returns the new secret.
295 let synced = observed;
296 if (observed.name !== name || differs(news.duration, observed.duration)) {
297 const updated = yield* zeroTrust.updateAccessServiceTokenForAccount({
298 accountId: acct,
299 serviceTokenId: observed.id,
300 name,
301 duration: news.duration,
302 previousClientSecretExpiresAt: news.previousClientSecretExpiresAt,
303 });
304 synced = {
305 id: updated.id ?? observed.id,
306 clientId: updated.clientId ?? observed.clientId,
307 name: updated.name ?? observed.name,
308 duration: updated.duration ?? observed.duration,
309 expiresAt: updated.expiresAt ?? observed.expiresAt,
310 };
311 }
312
313 // Rotate — when the declared secret version moved past the version we
314 // last reconciled, mint a new secret. The previous secret stays valid
315 // until previousClientSecretExpiresAt.
316 const priorVersion =
317 output?.clientSecretVersion ?? olds?.clientSecretVersion ?? 1;
318 let clientSecret = output?.clientSecret;
319 if (desiredVersion > priorVersion) {
320 const rotated = yield* zeroTrust.rotateAccessServiceToken({
321 accountId: acct,
322 serviceTokenId: synced.id!,
323 previousClientSecretExpiresAt: news.previousClientSecretExpiresAt,
324 });
325 if (typeof rotated.clientSecret === "string") {
326 clientSecret = Redacted.make(rotated.clientSecret);
327 }
328 synced = {
329 ...synced,
330 clientId: rotated.clientId ?? synced.clientId,
331 name: rotated.name ?? synced.name,
332 duration: rotated.duration ?? synced.duration,
333 };
334 }
335
336 if (!synced.id || !synced.clientId) {
337 return yield* Effect.fail(
338 new Error("ServiceToken: ensured token missing id"),
339 );
340 }
341 return {
342 serviceTokenId: synced.id,
343 accountId: acct,
344 clientId: synced.clientId,
345 clientSecret,
346 name: synced.name ?? name,
347 duration: synced.duration ?? news.duration,
348 expiresAt: synced.expiresAt ?? undefined,
349 clientSecretVersion: Math.max(desiredVersion, priorVersion),
350 };
351 }),
352 delete: Effect.fn(function* ({ output }) {
353 yield* zeroTrust
354 .deleteAccessServiceTokenForAccount({
355 accountId: output.accountId,
356 serviceTokenId: output.serviceTokenId,
357 })
358 .pipe(Effect.catchTag("AccessServiceTokenNotFound", () => Effect.void));
359 }),
360 });
361
362const createTokenName = (id: string, name: string | undefined) =>
363 Effect.gen(function* () {
364 if (name) return name;
365 return yield* createPhysicalName({ id });
366 });
367
368const findTokenByName = (acct: string, name: string) =>
369 zeroTrust.listAccessServiceTokensForAccount.items({ accountId: acct }).pipe(
370 Stream.filter((t): t is ObservedToken => t.name === name),
371 Stream.runHead,
372 Effect.map(Option.getOrUndefined),
373 Effect.catch(() => Effect.succeed(undefined)),
374 );
375
376// Skip no-op PUTs while treating an unset prop as "keep whatever Cloudflare
377// has" rather than a difference.
378const differs = (
379 desired: string | undefined,
380 observed: string | null | undefined,
381) => desired !== undefined && desired !== (observed ?? undefined);
382
383type ObservedToken = {
384 id?: string;
385 clientId?: string | null;
386 clientSecret?: string | null;
387 name?: string | null;
388 duration?: string | null;
389 expiresAt?: string | null;
390};