@sanoma-ai/workflows 0.10.2 → 0.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CLOUD-IDENTITY.md +128 -17
  2. package/README.md +56 -13
  3. package/dist/apply.d.ts +11 -4
  4. package/dist/apply.js +127 -36
  5. package/dist/apply.js.map +1 -1
  6. package/dist/call.d.ts +5 -2
  7. package/dist/call.js +65 -20
  8. package/dist/call.js.map +1 -1
  9. package/dist/change.d.ts +5 -6
  10. package/dist/change.js +6 -9
  11. package/dist/change.js.map +1 -1
  12. package/dist/client.d.ts +18 -0
  13. package/dist/client.js +35 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/cloud-identity.d.ts +5 -1
  16. package/dist/cloud-identity.js +13 -77
  17. package/dist/cloud-identity.js.map +1 -1
  18. package/dist/config.d.ts +45 -4
  19. package/dist/config.js +73 -11
  20. package/dist/config.js.map +1 -1
  21. package/dist/db.d.ts +2 -0
  22. package/dist/db.js +7 -0
  23. package/dist/db.js.map +1 -1
  24. package/dist/deadlines.d.ts +26 -13
  25. package/dist/deadlines.js +50 -11
  26. package/dist/deadlines.js.map +1 -1
  27. package/dist/define.d.ts +29 -0
  28. package/dist/define.js +3 -0
  29. package/dist/define.js.map +1 -1
  30. package/dist/deployment-key.d.ts +20 -1
  31. package/dist/deployment-key.js +28 -3
  32. package/dist/deployment-key.js.map +1 -1
  33. package/dist/describe.js +28 -1
  34. package/dist/describe.js.map +1 -1
  35. package/dist/description.d.ts +3 -0
  36. package/dist/description.js +2 -0
  37. package/dist/description.js.map +1 -1
  38. package/dist/dns.d.ts +20 -0
  39. package/dist/dns.js +26 -0
  40. package/dist/dns.js.map +1 -0
  41. package/dist/drift.d.ts +4 -2
  42. package/dist/drift.js +13 -4
  43. package/dist/drift.js.map +1 -1
  44. package/dist/host.d.ts +13 -4
  45. package/dist/host.js +14 -5
  46. package/dist/host.js.map +1 -1
  47. package/dist/identity-broker.d.ts +14 -0
  48. package/dist/identity-broker.js +84 -0
  49. package/dist/identity-broker.js.map +1 -0
  50. package/dist/identity-provisioning.d.ts +29 -0
  51. package/dist/identity-provisioning.js +178 -0
  52. package/dist/identity-provisioning.js.map +1 -0
  53. package/dist/identity-service.d.ts +25 -0
  54. package/dist/identity-service.js +148 -0
  55. package/dist/identity-service.js.map +1 -0
  56. package/dist/index.d.ts +11 -6
  57. package/dist/index.js +9 -5
  58. package/dist/index.js.map +1 -1
  59. package/dist/ledger.d.ts +30 -1
  60. package/dist/ledger.js +31 -0
  61. package/dist/ledger.js.map +1 -1
  62. package/dist/login-code.d.ts +17 -0
  63. package/dist/login-code.js +32 -0
  64. package/dist/login-code.js.map +1 -0
  65. package/dist/op.d.ts +85 -10
  66. package/dist/op.js +20 -0
  67. package/dist/op.js.map +1 -1
  68. package/dist/outline.js +3 -3
  69. package/dist/outline.js.map +1 -1
  70. package/dist/provisioning-identity.d.ts +27 -0
  71. package/dist/provisioning-identity.js +81 -0
  72. package/dist/provisioning-identity.js.map +1 -0
  73. package/dist/resource.d.ts +73 -10
  74. package/dist/resource.js +103 -14
  75. package/dist/resource.js.map +1 -1
  76. package/dist/resources.d.ts +4 -2
  77. package/dist/resources.js +16 -6
  78. package/dist/resources.js.map +1 -1
  79. package/dist/run.d.ts +3 -0
  80. package/dist/run.js.map +1 -1
  81. package/dist/runtime-ops.d.ts +182 -0
  82. package/dist/runtime-ops.js +294 -0
  83. package/dist/runtime-ops.js.map +1 -0
  84. package/dist/scenario.d.ts +6 -3
  85. package/dist/scenario.js +13 -1
  86. package/dist/scenario.js.map +1 -1
  87. package/dist/seal.d.ts +27 -0
  88. package/dist/seal.js +76 -0
  89. package/dist/seal.js.map +1 -0
  90. package/dist/secret-store.d.ts +6 -4
  91. package/dist/secret-store.js +11 -6
  92. package/dist/secret-store.js.map +1 -1
  93. package/dist/secret.d.ts +7 -0
  94. package/dist/secret.js +13 -1
  95. package/dist/secret.js.map +1 -1
  96. package/dist/shared.d.ts +15 -4
  97. package/dist/shared.js +27 -7
  98. package/dist/shared.js.map +1 -1
  99. package/dist/ssh-key.d.ts +11 -0
  100. package/dist/ssh-key.js +44 -0
  101. package/dist/ssh-key.js.map +1 -0
  102. package/dist/tokens.d.ts +35 -0
  103. package/dist/tokens.js +71 -0
  104. package/dist/tokens.js.map +1 -0
  105. package/dist/wakeup-service.js +2 -7
  106. package/dist/wakeup-service.js.map +1 -1
  107. package/dist/wakeup.d.ts +18 -0
  108. package/dist/wakeup.js +15 -0
  109. package/dist/wakeup.js.map +1 -1
  110. package/dist/worker.d.ts +2 -2
  111. package/dist/worker.js +17 -9
  112. package/dist/worker.js.map +1 -1
  113. package/oxlint.json +2 -1
  114. package/package.json +5 -1
  115. package/src/apply.ts +166 -48
  116. package/src/call.ts +88 -18
  117. package/src/change.ts +9 -20
  118. package/src/client.ts +48 -7
  119. package/src/cloud-identity.ts +16 -76
  120. package/src/config.ts +116 -18
  121. package/src/db.ts +8 -1
  122. package/src/deadlines.ts +66 -20
  123. package/src/define.ts +33 -0
  124. package/src/deployment-key.ts +34 -8
  125. package/src/describe.ts +39 -2
  126. package/src/description.ts +2 -0
  127. package/src/dns.ts +29 -0
  128. package/src/drift.ts +13 -4
  129. package/src/host.ts +22 -6
  130. package/src/identity-broker.ts +94 -0
  131. package/src/identity-provisioning.ts +208 -0
  132. package/src/identity-service.ts +179 -0
  133. package/src/index.ts +20 -1
  134. package/src/ledger.ts +52 -1
  135. package/src/login-code.ts +52 -0
  136. package/src/op.ts +94 -11
  137. package/src/outline.ts +3 -3
  138. package/src/provisioning-identity.ts +106 -0
  139. package/src/resource.ts +155 -20
  140. package/src/resources.ts +14 -4
  141. package/src/run.ts +3 -0
  142. package/src/runtime-ops.ts +313 -0
  143. package/src/scenario.ts +24 -6
  144. package/src/seal.ts +95 -0
  145. package/src/secret-store.ts +12 -9
  146. package/src/secret.ts +16 -1
  147. package/src/shared.ts +31 -7
  148. package/src/ssh-key.ts +48 -0
  149. package/src/tokens.ts +100 -0
  150. package/src/wakeup-service.ts +2 -8
  151. package/src/wakeup.ts +21 -0
  152. package/src/worker.ts +29 -11
package/src/ledger.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ConnectionSnapshot } from "./connection.ts";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { appendFile, mkdir, readFile, truncate } from "node:fs/promises";
4
5
  import { join, resolve } from "node:path";
5
6
  import { setTimeout as delay } from "node:timers/promises";
@@ -8,7 +9,7 @@ import { type Db, LEDGER_TABLE as TABLE, lazyPool, pgCode } from "./db.ts";
8
9
  import type { Approver, Principal } from "./define.ts";
9
10
  import { type ErrorInfo, isFinal, keepCode } from "./errors.ts";
10
11
  import { shown, warn } from "./log.ts";
11
- import type { Effect } from "./op.ts";
12
+ import type { Effect, SignInMethod } from "./op.ts";
12
13
  import type { RecordedDecision } from "./policy.ts";
13
14
  import type { Run } from "./run.ts";
14
15
  import { errorMessage } from "./shared.ts";
@@ -137,8 +138,58 @@ export type LedgerRecord = {
137
138
  | { type: "run.finished"; output: unknown }
138
139
  /** `error` as for `op.called`. */
139
140
  | { type: "run.failed"; error: ErrorInfo }
141
+ /**
142
+ * A declared person signed in to the staff login, by `method`; written outside any run, in the
143
+ * person's own `signin:<person>` (`signInRecord`), its `at` when.
144
+ */
145
+ | { type: "signin.recorded"; person: string; method: SignInMethod }
146
+ /** `by` minted an invite for the declared `person`, good until `expiresAt` (ISO 8601); in `invite:<person>`, outside any run. */
147
+ | { type: "invite.minted"; person: string; by: string; expiresAt: string }
140
148
  );
141
149
 
150
+ /** What a sign-in's or an invite's record names as its workflow: the staff login, which is no run. */
151
+ const LOGIN = "login";
152
+
153
+ /**
154
+ * The record of a sign-in or an invite, written outside any run (`SanomaClient.recordSignIn`,
155
+ * `recordInvite`): in the run `signin:<person>` or `invite:<person>`, each with an id of its own,
156
+ * `seq` 0, so a person's records read in the order they were written.
157
+ */
158
+ export function loginRecord(
159
+ app: string,
160
+ actor: Principal,
161
+ body: Extract<LedgerBody, { type: "signin.recorded" | "invite.minted" }>,
162
+ at = Date.now(),
163
+ ): LedgerRecord {
164
+ const runId = `${body.type === "signin.recorded" ? "signin" : "invite"}:${body.person}`;
165
+ return {
166
+ v: 1,
167
+ app,
168
+ id: `${runId}:${body.type}:${at}:${randomUUID()}`,
169
+ runId,
170
+ seq: 0,
171
+ at,
172
+ actor,
173
+ workflow: LOGIN,
174
+ ...body,
175
+ } as LedgerRecord;
176
+ }
177
+
178
+ /** When each of `people` last signed in, and how, by id: from their `signin:<person>` records; one who never did is left out. */
179
+ export async function lastSignIns(
180
+ store: LedgerStore,
181
+ people: readonly string[],
182
+ ): Promise<Record<string, { at: number; method: SignInMethod }>> {
183
+ const last = await Promise.all(
184
+ people.map(async (person) => {
185
+ const records = await store.read(`signin:${person}`);
186
+ const latest = records.reduce<LedgerRecord | undefined>((a, r) => (a && a.at > r.at ? a : r), undefined);
187
+ return latest?.type === "signin.recorded" ? [[person, { at: latest.at, method: latest.method }] as const] : [];
188
+ }),
189
+ );
190
+ return Object.fromEntries(last.flat());
191
+ }
192
+
142
193
  /**
143
194
  * The `ctx.all` member a record was written in. `id` is `all:<seq>`, the run's next `seq` when
144
195
  * the `ctx.all` began, so a replay names it the same; `index` is the member's position, `size`
@@ -0,0 +1,52 @@
1
+ import { z } from "zod";
2
+ import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
3
+ import type { Op } from "./op.ts";
4
+ import { openLoginCode } from "./seal.ts";
5
+ import type { LOGIN_CODE_WORKFLOW } from "./shared.ts";
6
+
7
+ // The built-in `login-code` workflow: one mail with a sign-in code, through the config's
8
+ // `login.sender`. The app starts it as the runtime's agent `login` (`LOGIN_AGENT`) with the code
9
+ // sealed (`sealLoginCode`), so neither the run's input nor the ledger shows it: the call is
10
+ // recorded with its body as `<text>` (`recorded` in `call.ts`). It is a workflow like any other:
11
+ // the call goes through `ctx`, under the policy, in the ledger.
12
+
13
+ /** An operation on `ctx`. */
14
+ type OpCall = (input: unknown) => Promise<unknown>;
15
+
16
+ /** The workflow's input: to whom, and the code, sealed. */
17
+ const LoginCodeInput = z.object({ to: z.string().min(1), code: z.string().min(1) });
18
+
19
+ /** What a sign-in code is sent as, for checks before any is: a sender must take it, and the policy is asked about it. */
20
+ export const CODE_MAIL = { to: "person@example.com", subject: "Your sign-in code", text: "<text>" };
21
+
22
+ /** The mail's text: the code, what it is for, and how long it lasts; nothing else, and no link. */
23
+ const textOf = (company: string, code: string) =>
24
+ `Your sign-in code for ${company} is ${code}.\n\n` +
25
+ "It works once, for five minutes. If you did not ask for it, ignore this mail.\n";
26
+
27
+ /** The login-code workflow for a config: it sends through `sender`, saying it is `company`'s. */
28
+ export function loginCodeWorkflow(
29
+ sender: Op,
30
+ company: string,
31
+ ): WorkflowDefinition<readonly Op[], typeof LoginCodeInput> {
32
+ return defineWorkflow({
33
+ // The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
34
+ name: "login-code" satisfies typeof LOGIN_CODE_WORKFLOW,
35
+ title: "Send a sign-in code",
36
+ trigger: "manual",
37
+ input: LoginCodeInput,
38
+ uses: [sender],
39
+ run: async (ctx, input) => {
40
+ const { vendor, resource, name } = sender;
41
+ const mail = {
42
+ to: input.to,
43
+ subject: `Your ${company} sign-in code`,
44
+ text: textOf(company, openLoginCode(input.code, input.to)),
45
+ };
46
+ // `ctx` called directly, so the outline places the call.
47
+ return (ctx as unknown as Record<string, Record<string, Record<string, OpCall>>>)[vendor]![resource]![name]!(
48
+ mail,
49
+ );
50
+ },
51
+ });
52
+ }
package/src/op.ts CHANGED
@@ -360,12 +360,36 @@ export interface ConnectorSetup {
360
360
  /** What the app gives a connector's model half (`ConnectorSetup.model`): the secrets, as a login half reads them. */
361
361
  export type ModelContext = Pick<LoginContext, "secrets">;
362
362
 
363
- /** What the app gives a connector's login half (`ConnectorSetup.login`) when it makes it for a public site. */
363
+ /**
364
+ * What a login vouches for about who is asking: a name they gave (`id`, the loopback's), a
365
+ * verified email, a GitHub login. The app admits the declared person they match (`personOf`).
366
+ */
367
+ export interface LoginIdentity {
368
+ id?: string;
369
+ email?: string;
370
+ github?: string;
371
+ }
372
+
373
+ /** How a login half lets a person in: an email code, GitHub, an invite link, an email and a password. */
374
+ export type LoginMethod = "code" | "github" | "invite" | "password";
375
+
376
+ /** How a person signed in to the staff login, which the ledger records (`signin.recorded`): all but a password. */
377
+ export type SignInMethod = Exclude<LoginMethod, "password">;
378
+
379
+ /** The company's own OAuth clients, as a login takes them: GitHub's is its GitHub App's client. */
380
+ export interface SocialClients {
381
+ github?: { clientId: string; clientSecret: string };
382
+ }
383
+
384
+ /**
385
+ * What the app gives a connector's login half (`ConnectorSetup.login`) when it makes it: for a
386
+ * public site, once per site; for the staff login, once, with `staff`.
387
+ */
364
388
  export interface LoginContext {
365
389
  /** The company's database (`resolved.databaseUrl`), where a login like Better Auth keeps its users. */
366
390
  databaseUrl: string;
367
391
  /**
368
- * A 32-byte key derived from the deployment's `SANOMA_SECRETS_KEY` for this login (HKDF-SHA256,
392
+ * A 32-byte key derived from the deployment's `SANOMA_SIGNING_KEY` for this login (HKDF-SHA256,
369
393
  * purpose `login <connector>`), hex; a login signs its cookies with it and never asks a person for a secret.
370
394
  */
371
395
  key: string;
@@ -377,9 +401,29 @@ export interface LoginContext {
377
401
  /**
378
402
  * The origins the site answers on, scheme included and `:*` for any port:
379
403
  * `["http://www.localhost:*", "https://sanoma.example"]`; a login needs the protocol for its
380
- * cookies and allowed hosts.
404
+ * cookies and allowed hosts. Empty for the staff login, which answers on any host the app does.
381
405
  */
382
406
  origins: readonly string[];
407
+ /**
408
+ * Sends a sign-in code to an email, through the config's `login.sender` (a run of the built-in
409
+ * `login-code` workflow, as the runtime's agent `login`); absent when the config names no
410
+ * sender, and then the login offers no code.
411
+ */
412
+ sendCode?(email: string, code: string): Promise<void>;
413
+ /**
414
+ * For a public site's login: the company's own OAuth clients (its GitHub App's), each a method
415
+ * when given, by which a visitor signs in or, signed in, links that account (`LoginIdentity.github`).
416
+ */
417
+ social?: SocialClients;
418
+ /** For the staff login only: who it may let in, the company's social clients, and where it tells of a sign-in. */
419
+ staff?: {
420
+ /** Whether the people files declare the person `identity` names; a login never makes a user for anyone else. */
421
+ admits(identity: LoginIdentity): boolean;
422
+ /** The company's own OAuth clients, each a method when given. */
423
+ social: SocialClients;
424
+ /** Tells the app of a sign-in by `method` of the person `identity` names, which the ledger records. Must not throw. */
425
+ record(method: SignInMethod, identity: LoginIdentity): Promise<void>;
426
+ };
383
427
  }
384
428
 
385
429
  /**
@@ -390,23 +434,62 @@ export interface LoginContext {
390
434
  export const CLIENT_IP_HEADER = "x-sanoma-client-ip";
391
435
 
392
436
  /**
393
- * A connector's login half: how a public site tells who a visitor is. The app makes one with
394
- * `login(context)` once per public site, when it starts; a login that keeps tables in the
395
- * database migrates them itself there, serialized and idempotent. It answers the provider's
396
- * subject only; the app makes the principal from it (`visitor:<connector>:<subject>`, in
397
- * `visitors`), so a connector never sets a visitor's groups. A visitor's address, for rate
398
- * limits, is the `CLIENT_IP_HEADER` header.
437
+ * A request's origin, https when a proxy that ends TLS says so (`x-forwarded-proto: https`), as
438
+ * Start's `getRequestUrl({ xForwardedProto: true })` reads it: the absolute URLs a page or an invite writes, and a login's cookies, take it.
439
+ */
440
+ export function requestOrigin(request: Request): string {
441
+ const url = new URL(request.url);
442
+ if (request.headers.get("x-forwarded-proto") === "https") url.protocol = "https:";
443
+ return url.origin;
444
+ }
445
+
446
+ /** Whether a change comes from the page's own origin (a login's CSRF check): the browser says so, or its `Origin` names the request's host. */
447
+ export function sameOrigin(request: Request): boolean {
448
+ const site = request.headers.get("sec-fetch-site");
449
+ if (site !== null) return site === "same-origin";
450
+ const origin = URL.parse(request.headers.get("origin") ?? "");
451
+ return origin !== null && origin.host === request.headers.get("host");
452
+ }
453
+
454
+ /**
455
+ * A connector's login half: how a public site tells who a visitor is, and, made with
456
+ * `LoginContext.staff`, how the app's staff sign in. The app makes one with `login(context)` once
457
+ * per public site (and once for staff), when it starts; a login that keeps tables in the database
458
+ * migrates them itself there, serialized and idempotent. It answers the provider's subject and
459
+ * what it verified; the app makes the principal from it (`visitor:<connector>:<subject>`, in
460
+ * `visitors`, or the declared person the identity matches), so a connector never sets groups. A
461
+ * visitor's address, for rate limits, is the `CLIENT_IP_HEADER` header.
399
462
  */
400
463
  export interface LoginHalf {
401
- /** Who signed in, as the provider's stable subject, or null; never throws for a missing or bad cookie. */
402
- verify(request: Request): Promise<{ subject: string } | null>;
464
+ /**
465
+ * Who signed in, as the provider's stable subject, with their email when the provider verified
466
+ * it and their GitHub login when they signed in with GitHub or linked it; null for a missing or
467
+ * bad cookie, for which it never throws.
468
+ */
469
+ verify(request: Request): Promise<{ subject: string; email?: string; github?: string } | null>;
403
470
  /** Answers its own routes, `/auth/<vendor>/*`; undefined for any other request. */
404
471
  handle(request: Request): Promise<Response | undefined>;
405
472
  /** The path a page POSTs to sign out, which `handle` serves; the app's own Sign out POSTs it with no body. */
406
473
  signOut?: string;
474
+ /** How a person may sign in now: what the sign-in page offers. */
475
+ methods?: readonly LoginMethod[];
476
+ /**
477
+ * A staff login's invite for `email`, a declared person's: a one-use token, good for
478
+ * `INVITE_SECONDS`, which replaces the last one minted for that email. `request` is the
479
+ * request that asked for it, on the host the invite is for.
480
+ */
481
+ invite?(email: string, request: Request): Promise<string>;
482
+ /**
483
+ * The invite a token is, while it is good: for whom, and the path (on the login's routes) that
484
+ * signs them in with it, using it up.
485
+ */
486
+ invitation?(token: string): Promise<{ email: string; path: string } | undefined>;
407
487
  close?(): Promise<void>;
408
488
  }
409
489
 
490
+ /** How long an invite lasts: 24 hours. */
491
+ export const INVITE_SECONDS = 24 * 60 * 60;
492
+
410
493
  /**
411
494
  * The connector with `setup` laid over what it already carries, as a connector package exports
412
495
  * it: `export const ghost = withSetup(ghostOps, { driver: ghostDriver, fake: fakeGhost, steps: ghostSteps })`. A
package/src/outline.ts CHANGED
@@ -193,7 +193,7 @@ const PARAM =
193
193
  "run must take ctx as its first parameter, by one name: destructured, its calls cannot be read into the graph";
194
194
  const CTX_USE =
195
195
  "ctx is only called, directly (`await ctx.<vendor>.<resource>.<op>(…)`, `ctx.approval(…)`, `ctx.sleep(…)`, " +
196
- "`ctx.all([…])`) or read for ctx.runId: passed on, aliased or destructured, its calls are not in the graph";
196
+ "`ctx.all([…])`) or read for ctx.runId or ctx.startedBy: passed on, aliased or destructured, its calls are not in the graph";
197
197
  const CTX_CALL = (what: string) =>
198
198
  `ctx.${what} is not a call the graph can draw: an operation is ctx.<vendor>.<resource>.<op>(…), ` +
199
199
  "and the built-ins are ctx.approval(…), ctx.sleep(…), ctx.all([…]) and ctx.now()";
@@ -237,7 +237,7 @@ interface Where {
237
237
  }
238
238
 
239
239
  /** The members of `ctx` that are read, not placed: no record, no node. */
240
- const READS = new Set(["now", "runId"]);
240
+ const READS = new Set(["now", "runId", "startedBy"]);
241
241
 
242
242
  /**
243
243
  * The nodes of `fn`'s body (`run`'s, or a callback's the outline reads), each with an empty `path`
@@ -282,7 +282,7 @@ export function outlineBody(
282
282
  return uses;
283
283
  };
284
284
 
285
- /** `ctx.runId`, read. */
285
+ /** `ctx.runId` or `ctx.startedBy`, read. */
286
286
  const isRead = (member: Node) =>
287
287
  !member.computed &&
288
288
  unwrap(member.object)?.type === "Identifier" &&
@@ -0,0 +1,106 @@
1
+ import { Compute } from "google-auth-library";
2
+ import { enrollmentBroker } from "./identity-broker.ts";
3
+ import type { IdentityAudit, IdentityEnrollments } from "./cloud-identity.ts";
4
+
5
+ /** Fixed by the trusted provisioning issuer deployment, never by a request or company config. */
6
+ export interface ProvisioningIdentity {
7
+ serviceAccount: string;
8
+ /** Google's immutable numeric uniqueId for the source service account. */
9
+ subject: string;
10
+ /** Exact WIF provider audience whose trust condition pins this subject. */
11
+ audience: string;
12
+ }
13
+ export interface ProvisioningIdToken {
14
+ idToken: string;
15
+ expiresAt: number;
16
+ }
17
+
18
+ const ACCOUNT = /^[a-z][a-z0-9-]+@[a-z][a-z0-9-]+\.iam\.gserviceaccount\.com$/;
19
+ const AUDIENCE =
20
+ /^\/\/iam\.googleapis\.com\/projects\/[1-9][0-9]+\/locations\/global\/workloadIdentityPools\/[a-z][a-z0-9-]+\/providers\/[a-z][a-z0-9-]+$/;
21
+ const SCOPE = "https://www.googleapis.com/auth/cloud-platform";
22
+ const fail = () => new Error("Provisioning identity unavailable");
23
+
24
+ function identityOf(identity: ProvisioningIdentity): ProvisioningIdentity {
25
+ if (
26
+ !ACCOUNT.test(identity.serviceAccount) ||
27
+ !/^[1-9][0-9]+$/.test(identity.subject) ||
28
+ !AUDIENCE.test(identity.audience)
29
+ )
30
+ throw fail();
31
+ return { serviceAccount: identity.serviceAccount, subject: identity.subject, audience: identity.audience };
32
+ }
33
+
34
+ // The issuer calls Google's authenticated HTTPS endpoint. Check its fixed claims and expiry;
35
+ // signature verification of the returned Google token remains Google's STS responsibility.
36
+ function tokenExpiry(idToken: string, identity: ProvisioningIdentity, now: number): number {
37
+ try {
38
+ if (typeof idToken !== "string" || idToken.length > 8192) throw fail();
39
+ const parts = idToken.split(".");
40
+ if (parts.length !== 3 || !parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part))) throw fail();
41
+ const claims = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"));
42
+ if (
43
+ claims.iss !== "https://accounts.google.com" ||
44
+ claims.sub !== identity.subject ||
45
+ claims.aud !== identity.audience ||
46
+ claims.email !== identity.serviceAccount ||
47
+ claims.email_verified !== true ||
48
+ !Number.isSafeInteger(claims.iat) ||
49
+ !Number.isSafeInteger(claims.exp) ||
50
+ claims.iat * 1000 > now + 30_000 ||
51
+ claims.exp <= claims.iat ||
52
+ claims.exp - claims.iat > 3600 ||
53
+ claims.exp * 1000 <= now + 60_000 ||
54
+ claims.exp * 1000 > now + 3_630_000
55
+ )
56
+ throw fail();
57
+ return claims.exp * 1000;
58
+ } catch {
59
+ throw fail();
60
+ }
61
+ }
62
+
63
+ /** Dedicated Cloud Run attached identity with getOpenIdToken on the fixed source account only. */
64
+ export function attachedGoogleIdTokenIssuer(configuration: ProvisioningIdentity): () => Promise<ProvisioningIdToken> {
65
+ const identity = identityOf(configuration);
66
+ const auth = new Compute({ scopes: [SCOPE] });
67
+ return async () => {
68
+ try {
69
+ const response = await auth.request<{ token: string }>({
70
+ url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${identity.serviceAccount}:generateIdToken`,
71
+ method: "POST",
72
+ data: { audience: identity.audience, includeEmail: true },
73
+ timeout: 10_000,
74
+ });
75
+ const idToken = response.data.token;
76
+ return { idToken, expiresAt: tokenExpiry(idToken, identity, Date.now()) };
77
+ } catch {
78
+ throw fail();
79
+ }
80
+ };
81
+ }
82
+
83
+ /**
84
+ * GET /identity for a separate provisioning credential source. Deploy with its own enrollment
85
+ * reader and attached issuer, outside the runtime broker. OIDC tokens last up to one hour:
86
+ * revocation must remove federation/impersonation trust as well as stopping new issuance.
87
+ */
88
+ export function provisioningIdentityBroker(options: {
89
+ identity: ProvisioningIdentity;
90
+ enrollments: IdentityEnrollments;
91
+ /** Trusted Google issuer. The request cannot select subject, audience or token lifetime. */
92
+ issue(): Promise<ProvisioningIdToken>;
93
+ audit(event: IdentityAudit): Promise<void>;
94
+ }): (request: Request) => Promise<Response> {
95
+ const identity = identityOf(options.identity);
96
+ return enrollmentBroker({
97
+ path: "/identity",
98
+ method: "GET",
99
+ enrollments: options.enrollments,
100
+ accept: (enrollment) => enrollment.serviceAccount === identity.serviceAccount,
101
+ issue: () => options.issue(),
102
+ valid: (token, now) => token.expiresAt === tokenExpiry(token.idToken, identity, now),
103
+ body: (token) => ({ idToken: token.idToken, expiresAt: token.expiresAt }),
104
+ audit: options.audit,
105
+ });
106
+ }