@open-everywhere/adapters 0.5.0 → 0.7.0

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 (75) hide show
  1. package/agent/directory/okta.ts +41 -2
  2. package/agent/meetings/native-http.ts +72 -0
  3. package/agent/meetings/native-meet.ts +173 -0
  4. package/agent/meetings/native-sources.ts +14 -0
  5. package/agent/meetings/native-teams.ts +119 -0
  6. package/agent/meetings/native-zoom.ts +98 -0
  7. package/agent/model/gateway.ts +44 -4
  8. package/agent/model/meeting-gateway.ts +88 -0
  9. package/agent/model/transcription-routes.ts +347 -0
  10. package/agent/oe-oauth/config.ts +71 -3
  11. package/agent/oe-oauth/errors.ts +17 -0
  12. package/agent/oe-oauth/oauth-client.ts +118 -2
  13. package/agent/oe-oauth/token-vault.ts +359 -8
  14. package/agent/register-defaults.ts +11 -0
  15. package/agent/sandbox/long-lived-box.ts +464 -0
  16. package/dist/agent/directory/okta.d.ts +10 -0
  17. package/dist/agent/directory/okta.d.ts.map +1 -1
  18. package/dist/agent/directory/okta.js +34 -2
  19. package/dist/agent/directory/okta.js.map +1 -1
  20. package/dist/agent/meetings/native-http.d.ts +22 -0
  21. package/dist/agent/meetings/native-http.d.ts.map +1 -0
  22. package/dist/agent/meetings/native-http.js +61 -0
  23. package/dist/agent/meetings/native-http.js.map +1 -0
  24. package/dist/agent/meetings/native-meet.d.ts +6 -0
  25. package/dist/agent/meetings/native-meet.d.ts.map +1 -0
  26. package/dist/agent/meetings/native-meet.js +176 -0
  27. package/dist/agent/meetings/native-meet.js.map +1 -0
  28. package/dist/agent/meetings/native-sources.d.ts +2 -0
  29. package/dist/agent/meetings/native-sources.d.ts.map +1 -0
  30. package/dist/agent/meetings/native-sources.js +13 -0
  31. package/dist/agent/meetings/native-sources.js.map +1 -0
  32. package/dist/agent/meetings/native-teams.d.ts +6 -0
  33. package/dist/agent/meetings/native-teams.d.ts.map +1 -0
  34. package/dist/agent/meetings/native-teams.js +118 -0
  35. package/dist/agent/meetings/native-teams.js.map +1 -0
  36. package/dist/agent/meetings/native-zoom.d.ts +7 -0
  37. package/dist/agent/meetings/native-zoom.d.ts.map +1 -0
  38. package/dist/agent/meetings/native-zoom.js +98 -0
  39. package/dist/agent/meetings/native-zoom.js.map +1 -0
  40. package/dist/agent/model/gateway.d.ts +10 -0
  41. package/dist/agent/model/gateway.d.ts.map +1 -1
  42. package/dist/agent/model/gateway.js +43 -5
  43. package/dist/agent/model/gateway.js.map +1 -1
  44. package/dist/agent/model/meeting-gateway.d.ts +24 -0
  45. package/dist/agent/model/meeting-gateway.d.ts.map +1 -0
  46. package/dist/agent/model/meeting-gateway.js +75 -0
  47. package/dist/agent/model/meeting-gateway.js.map +1 -0
  48. package/dist/agent/model/transcription-routes.d.ts +89 -0
  49. package/dist/agent/model/transcription-routes.d.ts.map +1 -0
  50. package/dist/agent/model/transcription-routes.js +279 -0
  51. package/dist/agent/model/transcription-routes.js.map +1 -0
  52. package/dist/agent/oe-oauth/config.d.ts +28 -2
  53. package/dist/agent/oe-oauth/config.d.ts.map +1 -1
  54. package/dist/agent/oe-oauth/config.js +40 -1
  55. package/dist/agent/oe-oauth/config.js.map +1 -1
  56. package/dist/agent/oe-oauth/errors.d.ts +14 -0
  57. package/dist/agent/oe-oauth/errors.d.ts.map +1 -1
  58. package/dist/agent/oe-oauth/errors.js +15 -0
  59. package/dist/agent/oe-oauth/errors.js.map +1 -1
  60. package/dist/agent/oe-oauth/oauth-client.d.ts +46 -0
  61. package/dist/agent/oe-oauth/oauth-client.d.ts.map +1 -1
  62. package/dist/agent/oe-oauth/oauth-client.js +82 -1
  63. package/dist/agent/oe-oauth/oauth-client.js.map +1 -1
  64. package/dist/agent/oe-oauth/token-vault.d.ts +15 -0
  65. package/dist/agent/oe-oauth/token-vault.d.ts.map +1 -1
  66. package/dist/agent/oe-oauth/token-vault.js +317 -7
  67. package/dist/agent/oe-oauth/token-vault.js.map +1 -1
  68. package/dist/agent/register-defaults.d.ts.map +1 -1
  69. package/dist/agent/register-defaults.js +9 -0
  70. package/dist/agent/register-defaults.js.map +1 -1
  71. package/dist/agent/sandbox/long-lived-box.d.ts +70 -0
  72. package/dist/agent/sandbox/long-lived-box.d.ts.map +1 -0
  73. package/dist/agent/sandbox/long-lived-box.js +414 -0
  74. package/dist/agent/sandbox/long-lived-box.js.map +1 -0
  75. package/package.json +39 -2
@@ -21,6 +21,8 @@ export interface DiscoveredEndpoints {
21
21
  /** A token endpoint response (authorization-code exchange or refresh). Only the fields we persist. */
22
22
  export interface OAuthTokenResponse {
23
23
  readonly access_token: string;
24
+ /** OIDC: present when the request asked for `openid` (the Cross App Access sign-in, OPE-2453). */
25
+ readonly id_token?: string;
24
26
  readonly refresh_token?: string;
25
27
  readonly expires_in?: number;
26
28
  readonly scope?: string;
@@ -301,11 +303,16 @@ async function postTokenConfidential(
301
303
  */
302
304
  export async function exchangeCodeConfidential(
303
305
  tokenEndpoint: string,
304
- input: { readonly code: string; readonly redirectUri: string },
306
+ input: { readonly code: string; readonly redirectUri: string; readonly codeVerifier?: string },
305
307
  client: ConfidentialClient,
306
308
  fetchImpl: OAuthFetch,
307
309
  ): Promise<OAuthTokenResponse> {
308
- const res = await postTokenConfidential(tokenEndpoint, client, { grant_type: "authorization_code", code: input.code, redirect_uri: input.redirectUri }, fetchImpl);
310
+ const res = await postTokenConfidential(
311
+ tokenEndpoint,
312
+ client,
313
+ { grant_type: "authorization_code", code: input.code, redirect_uri: input.redirectUri, ...(input.codeVerifier ? { code_verifier: input.codeVerifier } : {}) },
314
+ fetchImpl,
315
+ );
309
316
  return readTokenResponse(tokenEndpoint, res);
310
317
  }
311
318
 
@@ -374,3 +381,112 @@ export function refreshToken(
374
381
  fetchImpl,
375
382
  );
376
383
  }
384
+
385
+ // ── Cross App Access (OPE-2453): OIDC sign-in at the IdP, then the two ID-JAG legs ─────────────────────────────
386
+
387
+ /** RFC 8693 / ID-JAG wire constants. The draft is `draft-ietf-oauth-identity-assertion-authz-grant-04`, so these
388
+ * names can still move before an RFC; each lives here once. */
389
+ export const GRANT_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange";
390
+ export const GRANT_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer";
391
+ export const TOKEN_TYPE_ID_JAG = "urn:ietf:params:oauth:token-type:id-jag";
392
+ export const TOKEN_TYPE_REFRESH_TOKEN = "urn:ietf:params:oauth:token-type:refresh_token";
393
+
394
+ /** The IdP endpoints an OIDC discovery document names. */
395
+ export interface OidcEndpoints {
396
+ readonly authorizationEndpoint: string;
397
+ readonly tokenEndpoint: string;
398
+ readonly jwksUri: string;
399
+ readonly revocationEndpoint?: string;
400
+ }
401
+
402
+ /**
403
+ * OIDC discovery at `issuer` (OpenID Connect Discovery §4). The document's `issuer` MUST equal the configured one
404
+ * (§4.3): a mismatch means the document is not this IdP's, so it is refused. Every endpoint must be https.
405
+ */
406
+ export async function discoverOidc(issuer: string, fetchImpl: OAuthFetch): Promise<OidcEndpoints> {
407
+ const url = `${trimSlash(issuer)}/.well-known/openid-configuration`;
408
+ const res = await fetchImpl(url, { headers: { accept: "application/json" }, redirect: "error" });
409
+ if (!res.ok) throw new OeOauthError(`OIDC discovery failed for ${issuer}`, { httpStatus: res.status, ...(await readError(res)) });
410
+ const doc = (await res.json().catch(() => ({}))) as Record<string, unknown>;
411
+ if (doc["issuer"] !== issuer) throw new OeOauthError(`OIDC discovery for ${issuer} names a different issuer — refused`);
412
+ const https = (k: string): string | undefined => {
413
+ const v = doc[k];
414
+ if (typeof v !== "string" || v === "") return undefined;
415
+ if (!v.startsWith("https://")) throw new OeOauthError(`OIDC discovery for ${issuer}: ${k} is not https — refused`);
416
+ return v;
417
+ };
418
+ const authorizationEndpoint = https("authorization_endpoint");
419
+ const tokenEndpoint = https("token_endpoint");
420
+ const jwksUri = https("jwks_uri");
421
+ const revocationEndpoint = https("revocation_endpoint");
422
+ if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) {
423
+ throw new OeOauthError(`OIDC discovery for ${issuer} is missing authorization_endpoint, token_endpoint or jwks_uri`);
424
+ }
425
+ return { authorizationEndpoint, tokenEndpoint, jwksUri, ...(revocationEndpoint ? { revocationEndpoint } : {}) };
426
+ }
427
+
428
+ /** Fetch the IdP's JWKS document — the keys an id token must verify against. */
429
+ export async function fetchJwks(jwksUri: string, fetchImpl: OAuthFetch): Promise<{ keys: Record<string, unknown>[] }> {
430
+ const res = await fetchImpl(jwksUri, { headers: { accept: "application/json" }, redirect: "error" });
431
+ if (!res.ok) throw new OeOauthError(`JWKS fetch failed at ${jwksUri}`, { httpStatus: res.status });
432
+ const doc = (await res.json().catch(() => ({}))) as { keys?: unknown };
433
+ if (!Array.isArray(doc.keys)) throw new OeOauthError(`JWKS at ${jwksUri} carries no keys array`);
434
+ return { keys: doc.keys as Record<string, unknown>[] };
435
+ }
436
+
437
+ /**
438
+ * LEG 1 — RFC 8693 token exchange at the IdP: the person's IdP refresh token for an ID-JAG naming `audience`. OE
439
+ * authenticates as its IdP client (HTTP Basic). The ID-JAG comes back in `access_token`, and the response must
440
+ * say `issued_token_type` is an ID-JAG: anything else is not the grant leg 2 expects.
441
+ */
442
+ export async function requestIdJag(
443
+ tokenEndpoint: string,
444
+ input: { readonly subjectToken: string; readonly audience: string; readonly resource?: string; readonly scopes?: readonly string[] },
445
+ client: ConfidentialClient,
446
+ fetchImpl: OAuthFetch,
447
+ ): Promise<string> {
448
+ const res = await postTokenConfidential(
449
+ tokenEndpoint,
450
+ client,
451
+ {
452
+ grant_type: GRANT_TOKEN_EXCHANGE,
453
+ requested_token_type: TOKEN_TYPE_ID_JAG,
454
+ subject_token: input.subjectToken,
455
+ subject_token_type: TOKEN_TYPE_REFRESH_TOKEN,
456
+ audience: input.audience,
457
+ ...(input.resource ? { resource: input.resource } : {}),
458
+ ...(input.scopes && input.scopes.length ? { scope: input.scopes.join(" ") } : {}),
459
+ },
460
+ fetchImpl,
461
+ );
462
+ const body = (await readTokenResponse(tokenEndpoint, res)) as OAuthTokenResponse & { issued_token_type?: unknown };
463
+ if (body.issued_token_type !== TOKEN_TYPE_ID_JAG) {
464
+ throw new OeOauthError(`token endpoint ${tokenEndpoint} answered the ID-JAG exchange with another token type — refused`);
465
+ }
466
+ return body.access_token;
467
+ }
468
+
469
+ /**
470
+ * LEG 2 — RFC 7523 JWT bearer grant at the APP's authorization server: the ID-JAG for the app's access token. OE
471
+ * authenticates with the client the APP issued it (HTTP Basic); the app checks the ID-JAG's `client_id` against
472
+ * it. The draft says the app SHOULD NOT return a refresh token, so the caller re-mints at expiry.
473
+ */
474
+ export async function jwtBearerGrant(
475
+ tokenEndpoint: string,
476
+ input: { readonly assertion: string; readonly resource?: string; readonly scopes?: readonly string[] },
477
+ client: ConfidentialClient,
478
+ fetchImpl: OAuthFetch,
479
+ ): Promise<OAuthTokenResponse> {
480
+ const res = await postTokenConfidential(
481
+ tokenEndpoint,
482
+ client,
483
+ {
484
+ grant_type: GRANT_JWT_BEARER,
485
+ assertion: input.assertion,
486
+ ...(input.resource ? { resource: input.resource } : {}),
487
+ ...(input.scopes && input.scopes.length ? { scope: input.scopes.join(" ") } : {}),
488
+ },
489
+ fetchImpl,
490
+ );
491
+ return readTokenResponse(tokenEndpoint, res);
492
+ }
@@ -2,8 +2,25 @@
2
2
  // encrypted KV. Refresh is single-flight in-process AND cross-process, because the provider ROTATES tokens.
3
3
 
4
4
  import type { EncryptedKvVault } from "@open-everywhere/core/agent/adapter-api.js";
5
- import { agentDisplayName, encryptedKvVault, getSecretVia, OE_OAUTH_CALLBACK_PATH } from "@open-everywhere/core/agent/adapter-api.js";
6
- import { isStaticConfidential, OE_OAUTH_MODE_STATIC, type OeOauthProviderConfig, type OeOauthStaticProviderConfig } from "./config.js";
5
+ import {
6
+ agentDisplayName,
7
+ checkXaaIdentityBinding,
8
+ encryptedKvVault,
9
+ recordXaaRefusal,
10
+ getSecretVia,
11
+ OE_OAUTH_CALLBACK_PATH,
12
+ verifyOidcIdToken,
13
+ type XaaBindingVerdict,
14
+ } from "@open-everywhere/core/agent/adapter-api.js";
15
+ import {
16
+ isStaticConfidential,
17
+ isXaa,
18
+ OE_OAUTH_MODE_STATIC,
19
+ OE_OAUTH_MODE_XAA,
20
+ type OeOauthProviderConfig,
21
+ type OeOauthStaticProviderConfig,
22
+ type OeOauthXaaProviderConfig,
23
+ } from "./config.js";
7
24
  import {
8
25
  discoverEndpoints,
9
26
  registerClient,
@@ -15,13 +32,18 @@ import {
15
32
  refreshTokenConfidential,
16
33
  revokeToken,
17
34
  revokeTokenConfidential,
35
+ discoverOidc,
36
+ fetchJwks,
37
+ requestIdJag,
38
+ jwtBearerGrant,
18
39
  type ConfidentialClient,
40
+ type OidcEndpoints,
19
41
  type OAuthFetch,
20
42
  type OAuthTokenResponse,
21
43
  } from "./oauth-client.js";
22
- import { randomUUID } from "node:crypto";
44
+ import { createHash, randomUUID } from "node:crypto";
23
45
  import { makePkce, makeState } from "./pkce.js";
24
- import { OeOauthNeedsConsentError, OeOauthNeedsReconsentError, OeOauthRefreshContendedError, isInvalidGrant } from "./errors.js";
46
+ import { OeOauthAccessRemovedError, OeOauthNeedsConsentError, OeOauthNeedsReconsentError, OeOauthRefreshContendedError, isInvalidGrant } from "./errors.js";
25
47
 
26
48
  /** A subject a token is minted for — the service identity, or a specific user principal. */
27
49
  export type OeOauthSubject = { readonly type: "user"; readonly id: string } | { readonly type: "app" };
@@ -77,9 +99,11 @@ interface PendingRecord {
77
99
  /** The RFC 8707 resource indicator. ABSENT on a `static-confidential` row — that dialect sends
78
100
  * none — and present on every discovery row, including every row written before OPE-1086. */
79
101
  readonly resource?: string;
80
- /** Stamped ONLY on a `static-confidential` row, from the config's declared mode at mint time. Its
102
+ /** Stamped ONLY on a `static-confidential` or an `xaa` row, from the config's declared mode at mint time. Its
81
103
  * absence is the discovery dialect, which is what every pre-OPE-1086 row means. */
82
- readonly mode?: typeof OE_OAUTH_MODE_STATIC;
104
+ readonly mode?: typeof OE_OAUTH_MODE_STATIC | typeof OE_OAUTH_MODE_XAA;
105
+ /** The OIDC `nonce` of an `xaa` sign-in (OPE-2453): the id token must carry it back. Absent on other rows. */
106
+ readonly nonce?: string;
83
107
  readonly redirectUri: string;
84
108
  readonly scopes?: readonly string[];
85
109
  readonly createdAt: number;
@@ -106,6 +130,31 @@ interface PendingSweepReport {
106
130
  readonly unreadable: number;
107
131
  }
108
132
 
133
+ /**
134
+ * A person's IdP grant for Cross App Access (OPE-2453): the IdP refresh token their one sign-in yielded, and the
135
+ * IdP account (`sub`) the identity-link plane confirmed as theirs. ONE row per (IdP client, person), shared by every
136
+ * `xaa` connection that names the same IdP client — so one sign-in serves every paired app.
137
+ */
138
+ interface IdpGrantRecord {
139
+ readonly refreshToken: string;
140
+ readonly idpSubject: string;
141
+ readonly obtainedAt: number;
142
+ }
143
+
144
+ /** The IdP scopes the Cross App Access sign-in asks for: an id token (`openid`) and a refresh token to exchange
145
+ * (`offline_access`). Fixed — the app's own scopes ride leg 1 and leg 2, never this sign-in. */
146
+ const XAA_IDP_SCOPES = ["openid", "offline_access"] as const;
147
+
148
+ /** RFC 6749 / 8693 error codes that mean the IdP REFUSED this person's ID-JAG on policy (the admin removed the
149
+ * pairing or the assignment). `invalid_grant` is NOT here: it means the IdP sign-in itself lapsed, and a new
150
+ * sign-in fixes that. */
151
+ const XAA_IDP_REFUSALS: ReadonlySet<string> = new Set(["access_denied", "unauthorized_client", "invalid_target"]);
152
+ /** Error codes that mean the APP refused the ID-JAG for this person. No new sign-in fixes these. */
153
+ const XAA_APP_REFUSALS: ReadonlySet<string> = new Set(["invalid_grant", "access_denied", "unauthorized_client"]);
154
+
155
+ /** How long a discovered OIDC document is reused before it is fetched again. */
156
+ const OIDC_DISCOVERY_TTL_MS = 60 * 60 * 1000;
157
+
109
158
  /** A persisted token record. `expiresAt` absent ⇒ no known expiry (never proactively refreshed). */
110
159
  interface TokenRecord {
111
160
  readonly accessToken: string;
@@ -156,6 +205,20 @@ export interface OeOauthVaultDeps {
156
205
  * every other stored secret takes. Read PER TOKEN CALL, so a rotated secret takes effect at once.
157
206
  */
158
207
  readonly resolveSecret?: (broker: string | undefined, ref: string) => Promise<string>;
208
+ /** Mints the OIDC nonce of an `xaa` sign-in. Default: the same generator as the opaque state. */
209
+ readonly newNonce?: () => string;
210
+ /** Verify an `xaa` sign-in's id token. Default: core's `verifyOidcIdToken` (jose, full signature check). */
211
+ readonly verifyIdToken?: typeof verifyOidcIdToken;
212
+ /** Decide whether the IdP account that signed in may complete this requester's `xaa` link. Default: core's
213
+ * `checkXaaIdentityBinding` — a confirmed `okta` account on the requester's link-set, refusal by default. */
214
+ readonly bindIdentity?: (input: {
215
+ readonly principalSubject: string;
216
+ readonly signerSubject: string | undefined;
217
+ readonly connection: string;
218
+ readonly stage?: "sign-in" | "mint";
219
+ }) => Promise<XaaBindingVerdict>;
220
+ /** Audit an `xaa` sign-in refused before the binding ran. Default: core's `recordXaaRefusal`. */
221
+ readonly recordRefusal?: typeof recordXaaRefusal;
159
222
  }
160
223
 
161
224
  function subjectKey(subject: OeOauthSubject): string {
@@ -191,10 +254,18 @@ export function makeOeOauthTokenVault(source: OeOauthProviderSource, deps: OeOau
191
254
  const newClaimId = deps.newClaimId ?? (() => randomUUID());
192
255
  const sleep = deps.sleep ?? realSleep;
193
256
  const resolveSecret = deps.resolveSecret ?? ((broker: string | undefined, ref: string) => getSecretVia(broker, ref));
257
+ const newNonce = deps.newNonce ?? makeState;
258
+ const verifyIdToken = deps.verifyIdToken ?? verifyOidcIdToken;
259
+ const bindIdentity = deps.bindIdentity ?? checkXaaIdentityBinding;
260
+ const recordRefusal = deps.recordRefusal ?? recordXaaRefusal;
194
261
 
195
262
  const dcrRef = (providerRef: string) => `oe-oauth:dcr:${providerRef}`;
196
263
  const pendRef = (state: string) => `oe-oauth:pend:${state}`;
197
264
  const tokRef = (providerRef: string, subject: OeOauthSubject) => `oe-oauth:tok:${providerRef}:${subjectKey(subject)}`;
265
+ // The IdP grant is keyed by the IdP CLIENT (issuer + client id), not by the connection: every `xaa` connection
266
+ // that names the same IdP client shares one person's sign-in. Hashed so the ref carries no URL punctuation.
267
+ const idpKey = (cfg: OeOauthXaaProviderConfig) => createHash("sha256").update(`${cfg.idpIssuer}\n${cfg.idpClientId}`).digest("hex").slice(0, 32);
268
+ const idpRef = (cfg: OeOauthXaaProviderConfig, subject: OeOauthSubject) => `oe-oauth:xaa-idp:${idpKey(cfg)}:${subjectKey(subject)}`;
198
269
 
199
270
  function providerConfig(providerRef: string): OeOauthProviderConfig {
200
271
  const cfg = providersNow().get(providerRef);
@@ -234,6 +305,7 @@ export function makeOeOauthTokenVault(source: OeOauthProviderSource, deps: OeOau
234
305
  // function entirely. Stated as a refusal rather than a cast so a future caller that reaches it
235
306
  // directly gets an error instead of a discovery request built from an absent `resource`.
236
307
  if (isStaticConfidential(cfg)) throw new Error(`connection "${providerRef}": declares the "${OE_OAUTH_MODE_STATIC}" mode, which registers no dynamic client.`);
308
+ if (isXaa(cfg)) throw new Error(`connection "${providerRef}": declares the "${OE_OAUTH_MODE_XAA}" mode, which registers no dynamic client.`);
237
309
  return vault.withLock(dcrRef(providerRef), async () => {
238
310
  const again = await readJson<DcrClientRecord>(dcrRef(providerRef));
239
311
  if (again && !dcrRecordNeedsBackfill(again)) return again; // another caller already completed it
@@ -324,6 +396,230 @@ export function makeOeOauthTokenVault(source: OeOauthProviderSource, deps: OeOau
324
396
  };
325
397
  }
326
398
 
399
+ /** A confidential client for one `xaa` leg, secret read through its broker PER CALL. The error names the ref. */
400
+ async function xaaClient(cfg: OeOauthXaaProviderConfig, leg: "idp" | "app"): Promise<ConfidentialClient> {
401
+ const [clientId, secretRef, broker] =
402
+ leg === "idp" ? [cfg.idpClientId, cfg.idpClientSecretRef, cfg.idpClientSecretBroker] : [cfg.resourceClientId, cfg.resourceClientSecretRef, cfg.resourceClientSecretBroker];
403
+ const secret = await resolveSecret(broker, secretRef);
404
+ if (typeof secret !== "string" || secret === "") {
405
+ throw new Error(
406
+ `connection "${cfg.ref}": the ${leg === "idp" ? "identity-provider" : "app"} client secret at ref "${secretRef}" (broker "${broker}") has no stored value — ` +
407
+ `store it with \`oe secret set ${secretRef}\` before this connection can mint a token.`,
408
+ );
409
+ }
410
+ return { clientId, clientSecret: secret, bodyEncoding: "form" };
411
+ }
412
+
413
+ // The IdP's discovered endpoints, per issuer, reused for an hour: every mint's leg 1 needs the token endpoint,
414
+ // and a discovery hop per mint would double the IdP traffic for nothing.
415
+ const oidcCache = new Map<string, { readonly at: number; readonly endpoints: OidcEndpoints }>();
416
+ async function oidcFor(cfg: OeOauthXaaProviderConfig): Promise<OidcEndpoints> {
417
+ const hit = oidcCache.get(cfg.idpIssuer);
418
+ if (hit && now() - hit.at < OIDC_DISCOVERY_TTL_MS) return hit.endpoints;
419
+ const endpoints = await discoverOidc(cfg.idpIssuer, doFetch);
420
+ oidcCache.set(cfg.idpIssuer, { at: now(), endpoints });
421
+ return endpoints;
422
+ }
423
+
424
+ /** An `xaa` connection acts only AS A PERSON: the ID-JAG names a human. There is no service account to mint. */
425
+ function requireXaaPerson(providerRef: string, subject: OeOauthSubject): asserts subject is { readonly type: "user"; readonly id: string } {
426
+ if (subject.type !== "user") {
427
+ throw new Error(`connection "${providerRef}": the "${OE_OAUTH_MODE_XAA}" sign-in acts only as a person — it has no service account to mint for.`);
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Start the person's IdP sign-in for an `xaa` connection: OIDC auth code with PKCE and a nonce, `openid` +
433
+ * `offline_access`. The pending row is the same shape as the other dialects', stamped `mode: "xaa"`.
434
+ */
435
+ async function xaaStartAuthorization(
436
+ cfg: OeOauthXaaProviderConfig,
437
+ subject: OeOauthSubject,
438
+ options: { readonly expiresInMs?: number; readonly consent?: unknown },
439
+ ): Promise<{ url: string }> {
440
+ requireXaaPerson(cfg.ref, subject);
441
+ const oidc = await oidcFor(cfg);
442
+ const redirectUri = redirectUriOf();
443
+ if (!redirectUri) throw new Error(`connection "${cfg.ref}": OE_PUBLIC_BASE_URL is unset — cannot build a redirect_uri.`);
444
+ const state = newState();
445
+ const pkce = newPkce();
446
+ const nonce = newNonce();
447
+ const pending: PendingRecord = {
448
+ providerRef: cfg.ref,
449
+ subject,
450
+ pkceVerifier: pkce.verifier,
451
+ tokenEndpoint: oidc.tokenEndpoint,
452
+ clientId: cfg.idpClientId,
453
+ mode: OE_OAUTH_MODE_XAA,
454
+ nonce,
455
+ redirectUri,
456
+ scopes: XAA_IDP_SCOPES,
457
+ createdAt: now(),
458
+ expiresAt: now() + (options.expiresInMs ?? pendingTtl),
459
+ ...(options.consent ? { consent: options.consent } : {}),
460
+ };
461
+ await vault.write(pendRef(state), JSON.stringify(pending), { connector: cfg.ref });
462
+ // The PKCE pair and the nonce ride `extraParams`: this function's own parameters (client_id, redirect_uri,
463
+ // state, scope) are applied after them and win, so nothing here can be overridden.
464
+ const url = buildStaticAuthorizationUrl({
465
+ authorizationEndpoint: oidc.authorizationEndpoint,
466
+ clientId: cfg.idpClientId,
467
+ redirectUri,
468
+ state,
469
+ scopes: XAA_IDP_SCOPES,
470
+ extraParams: { nonce, code_challenge: pkce.challenge, code_challenge_method: "S256" },
471
+ });
472
+ return { url };
473
+ }
474
+
475
+ /**
476
+ * Complete an `xaa` sign-in: exchange the code at the IdP, VERIFY the id token (signature, iss, aud, nonce), and
477
+ * admit the sign-in only when the identity-link plane confirms the IdP account is the requester's own. Then store
478
+ * the person's IdP refresh token as their IdP grant. A refusal SPENDS the link (the pending row is consumed).
479
+ */
480
+ async function xaaCompleteAuthorization(state: string, pending: PendingRecord, code: string): Promise<{ rebound: boolean }> {
481
+ const cfg = providersNow().get(pending.providerRef);
482
+ if (cfg === undefined || !isXaa(cfg) || cfg.idpClientId !== pending.clientId) {
483
+ throw new Error(`connection "${pending.providerRef}": this sign-in started under an "${OE_OAUTH_MODE_XAA}" declaration that no longer stands — start again.`);
484
+ }
485
+ const spend = () => vault.write(pendRef(state), JSON.stringify({ ...pending, consumed: true, pkceVerifier: "", nonce: "", consent: undefined }), { connector: pending.providerRef });
486
+ const tok = await exchangeCodeConfidential(pending.tokenEndpoint, { code, redirectUri: pending.redirectUri, codeVerifier: pending.pkceVerifier }, await xaaClient(cfg, "idp"), doFetch);
487
+ let signer: string | undefined;
488
+ try {
489
+ if (!tok.id_token) throw new Error("the IdP returned no id token");
490
+ const oidc = await oidcFor(cfg);
491
+ signer = (await verifyIdToken(tok.id_token, { jwks: (await fetchJwks(oidc.jwksUri, doFetch)) as Parameters<typeof verifyOidcIdToken>[1]["jwks"], issuer: cfg.idpIssuer, audience: cfg.idpClientId, nonce: pending.nonce ?? "" })).sub;
492
+ } catch (e) {
493
+ await spend();
494
+ const reason = e instanceof Error ? e.message : String(e);
495
+ // Every refused attempt leaves one audit row — a forged or replayed id token most of all.
496
+ if (pending.subject.type === "user") await recordRefusal({ principalSubject: pending.subject.id, connection: pending.providerRef, reason });
497
+ throw new Error(`connection "${pending.providerRef}": the identity-provider sign-in was refused — ${reason}.`);
498
+ }
499
+ if (pending.subject.type !== "user") {
500
+ await spend();
501
+ throw new Error(`connection "${pending.providerRef}": the "${OE_OAUTH_MODE_XAA}" sign-in acts only as a person.`);
502
+ }
503
+ // 🔴 THE BINDING. Whoever opened the link signed in; the grant is stored for the requester only when that IdP
504
+ // account is a CONFIRMED account of theirs. Otherwise one person could mint another person's app tokens.
505
+ const verdict = await bindIdentity({ principalSubject: pending.subject.id, signerSubject: signer, connection: pending.providerRef });
506
+ if (!verdict.ok) {
507
+ await spend();
508
+ throw new Error(`connection "${pending.providerRef}": the identity-provider sign-in was refused — ${verdict.reason}.`);
509
+ }
510
+ if (!tok.refresh_token) {
511
+ await spend();
512
+ await recordRefusal({ principalSubject: pending.subject.id, connection: pending.providerRef, reason: "the IdP returned no refresh token", signerSubject: verdict.idpNativeId });
513
+ throw new Error(`connection "${pending.providerRef}": the IdP returned no refresh token — the OE app at the IdP must allow the refresh-token grant and the offline_access scope.`);
514
+ }
515
+ const gRef = idpRef(cfg, pending.subject);
516
+ const rebound = (await readJson<IdpGrantRecord>(gRef)) !== null;
517
+ const grant: IdpGrantRecord = { refreshToken: tok.refresh_token, idpSubject: verdict.idpNativeId, obtainedAt: now() };
518
+ await vault.write(gRef, JSON.stringify(grant), { connector: pending.providerRef });
519
+ await spend();
520
+ return { rebound };
521
+ }
522
+
523
+ /** The RFC 6749 `error` code an upstream failure carried, if any. */
524
+ function oauthErrorOf(e: unknown): string | undefined {
525
+ const code = (e as { oauthError?: unknown } | null)?.oauthError;
526
+ return typeof code === "string" ? code.toLowerCase() : undefined;
527
+ }
528
+
529
+ /** ONE `xaa` mint, run by the claim winner: leg 1 at the IdP, leg 2 at the app, then cache the app token for its
530
+ * lifetime. Never a refresh token from the app — a re-mint is the only refresh. */
531
+ async function xaaMint(cfg: OeOauthXaaProviderConfig, providerRef: string, subject: OeOauthSubject, grant: IdpGrantRecord): Promise<string> {
532
+ const oidc = await oidcFor(cfg);
533
+ const legParams = { ...(cfg.resource ? { resource: cfg.resource } : {}), ...(cfg.scopes ? { scopes: cfg.scopes } : {}) };
534
+ let idJag: string;
535
+ try {
536
+ idJag = await requestIdJag(oidc.tokenEndpoint, { subjectToken: grant.refreshToken, audience: cfg.audience, ...legParams }, await xaaClient(cfg, "idp"), doFetch);
537
+ } catch (e) {
538
+ const code = oauthErrorOf(e);
539
+ if (code === "invalid_grant") throw new OeOauthNeedsReconsentError(providerRef, subject, "the identity-provider sign-in is no longer valid (invalid_grant)");
540
+ if (code !== undefined && XAA_IDP_REFUSALS.has(code)) throw new OeOauthAccessRemovedError(providerRef, subject, "idp", code);
541
+ throw e;
542
+ }
543
+ let tok: OAuthTokenResponse;
544
+ try {
545
+ tok = await jwtBearerGrant(cfg.resourceTokenEndpoint, { assertion: idJag, ...legParams }, await xaaClient(cfg, "app"), doFetch);
546
+ } catch (e) {
547
+ const code = oauthErrorOf(e);
548
+ if (code !== undefined && XAA_APP_REFUSALS.has(code)) throw new OeOauthAccessRemovedError(providerRef, subject, "app", code);
549
+ throw e;
550
+ }
551
+ const { refresh_token: _ignored, ...withoutRefresh } = tok;
552
+ return (await storeToken(providerRef, subject, withoutRefresh)).accessToken;
553
+ }
554
+
555
+ /**
556
+ * Read-or-mint an `xaa` connection's app token. A cached token serves until its expiry (less the skew), so a
557
+ * person costs ONE mint per token lifetime — the IdP caps ID-JAGs per person per app per month. The CROSS-PROCESS
558
+ * claim sits on the person's IdP GRANT row (it always exists here; the app-token row may not yet), and a loser
559
+ * polls for the winner's token or for the claim to come free, bounded like the refresh path.
560
+ */
561
+ async function xaaAccessTokenFor(cfg: OeOauthXaaProviderConfig, providerRef: string, subject: OeOauthSubject): Promise<string> {
562
+ requireXaaPerson(providerRef, subject);
563
+ const ref = tokRef(providerRef, subject);
564
+ const fast = await readJson<TokenRecord>(ref);
565
+ if (fast && !isExpired(fast)) return fast.accessToken;
566
+ const gRef = idpRef(cfg, subject);
567
+ return vault.withLock(ref, async () => {
568
+ const deadline = now() + LOSER_WAIT_BUDGET_MS;
569
+ for (;;) {
570
+ const rec = await readJson<TokenRecord>(ref);
571
+ if (rec && !isExpired(rec)) return rec.accessToken;
572
+ const grant = await readJson<IdpGrantRecord>(gRef);
573
+ if (!grant) throw new OeOauthNeedsConsentError(providerRef, subject);
574
+ const claimId = newClaimId();
575
+ if (await vault.claim(gRef, claimId, now(), CLAIM_LEASE_TTL_MS)) {
576
+ try {
577
+ // Another process may have minted and released between our read and our claim: adopt its token.
578
+ const won = await readJson<TokenRecord>(ref);
579
+ if (won && !isExpired(won)) return won.accessToken;
580
+ // 🔴 RE-CHECK THE BINDING AT EACH MINT, not only at sign-in. An operator who revokes the `okta` link or
581
+ // suspends the set must stop the minting at once; the stored grant alone would keep minting until the
582
+ // IdP refresh token dies. The check runs per MINT (once per token lifetime), never per cached read.
583
+ const still = await bindIdentity({ principalSubject: subject.id, signerSubject: grant.idpSubject, connection: providerRef, stage: "mint" });
584
+ // An UNREADABLE link plane says nothing about the person: refuse this mint, keep the grant, and let a
585
+ // retry succeed. Only a definite refusal (the link is gone) deletes the grant.
586
+ if (!still.ok && still.unreadable) {
587
+ throw new Error(`connection "${providerRef}": the identity-link plane could not be read, so the Okta grant could not be re-checked — retry.`);
588
+ }
589
+ if (!still.ok) {
590
+ await vault.delete(gRef);
591
+ throw new OeOauthNeedsConsentError(providerRef, subject);
592
+ }
593
+ return await xaaMint(cfg, providerRef, subject, grant);
594
+ } finally {
595
+ await vault.releaseClaim(gRef, claimId);
596
+ }
597
+ }
598
+ if (now() >= deadline) throw new OeOauthRefreshContendedError(providerRef, subject);
599
+ await sleep(LOSER_POLL_MS);
600
+ }
601
+ });
602
+ }
603
+
604
+ /** Purge a person's IdP grant: revoke the IdP refresh token at the IdP (best-effort), then delete the row. */
605
+ async function forgetIdpGrant(cfg: OeOauthXaaProviderConfig, subject: OeOauthSubject): Promise<boolean> {
606
+ const gRef = idpRef(cfg, subject);
607
+ return vault.withLock(gRef, async () => {
608
+ const grant = await readJson<IdpGrantRecord>(gRef);
609
+ if (grant) {
610
+ try {
611
+ const oidc = await oidcFor(cfg);
612
+ if (oidc.revocationEndpoint) {
613
+ await revokeTokenConfidential(oidc.revocationEndpoint, { token: grant.refreshToken, tokenTypeHint: "refresh_token" }, await xaaClient(cfg, "idp"), doFetch);
614
+ }
615
+ } catch (e) {
616
+ console.warn(`[oe-oauth] IdP revocation failed for "${cfg.ref}" (purging locally anyway): ${e instanceof Error ? e.message : String(e)}`);
617
+ }
618
+ }
619
+ return vault.delete(gRef);
620
+ });
621
+ }
622
+
327
623
  /**
328
624
  * Exchange the callback's `code` against ONE pending row, in the dialect that row was minted in.
329
625
  * 🔴 The row's stamp and the live config must AGREE: a disagreement is REFUSED rather than resolved.
@@ -333,6 +629,9 @@ export function makeOeOauthTokenVault(source: OeOauthProviderSource, deps: OeOau
333
629
  // mode existed, and it must not start failing on a config-absent ref now. The drift check below
334
630
  // therefore runs only when there IS a config to compare against.
335
631
  const cfg = providersNow().get(pending.providerRef);
632
+ if (cfg !== undefined && isXaa(cfg)) {
633
+ throw new Error(`connection "${pending.providerRef}": this authorization started under another dialect, but the provider config now declares "${OE_OAUTH_MODE_XAA}" — start again.`);
634
+ }
336
635
  const startedStatic = pending.mode === OE_OAUTH_MODE_STATIC;
337
636
  if (cfg !== undefined && isStaticConfidential(cfg) !== startedStatic) {
338
637
  throw new Error(
@@ -469,7 +768,39 @@ export function makeOeOauthTokenVault(source: OeOauthProviderSource, deps: OeOau
469
768
  lastError = e;
470
769
  }
471
770
  }
472
- if (failures > 0 && purged === 0 && failures === all.size) throw lastError;
771
+ // (OPE-2453) A person's IdP grant is shared by every `xaa` connection naming one IdP client, so it is purged
772
+ // once per IdP client, after the per-connection rows. It is the credential that mints all of them.
773
+ const seenIdp = new Set<string>();
774
+ for (const cfg of all.values()) {
775
+ if (!isXaa(cfg) || seenIdp.has(idpKey(cfg))) continue;
776
+ seenIdp.add(idpKey(cfg));
777
+ try {
778
+ if (await forgetIdpGrant(cfg, subject)) purged++;
779
+ } catch (e) {
780
+ failures++;
781
+ lastError = e;
782
+ }
783
+ }
784
+ // An IdP grant whose declaration is GONE is still a live IdP refresh token at rest. With no config there is no
785
+ // client to revoke it with, so it is deleted locally and the gap is said out loud.
786
+ // The ref is `oe-oauth:xaa-idp:<32 hex>:<subject key>`; the subject key is matched EXACTLY, never by suffix.
787
+ const covered = new Set([...all.values()].filter(isXaa).map((cfg) => idpRef(cfg, subject)));
788
+ const orphanRef = /^oe-oauth:xaa-idp:[0-9a-f]{32}:(.*)$/u;
789
+ let orphanFailed = false;
790
+ try {
791
+ for (const ref of await vault.listRefs("oe-oauth:xaa-idp:")) {
792
+ if (orphanRef.exec(ref)?.[1] !== subjectKey(subject) || covered.has(ref)) continue;
793
+ console.warn(`[oe-oauth] an IdP grant with no declared xaa connection was deleted locally — it could not be revoked at the IdP.`);
794
+ if (await vault.delete(ref)) purged++;
795
+ }
796
+ } catch (e) {
797
+ failures++;
798
+ lastError = e;
799
+ orphanFailed = true;
800
+ }
801
+ // "Every purge attempt failed" counts only the attempts that ran: the orphan sweep is one attempt only when
802
+ // it failed. Counting it always would let a purge where every provider failed return 0 with no error.
803
+ if (failures > 0 && purged === 0 && failures === all.size + seenIdp.size + (orphanFailed ? 1 : 0)) throw lastError;
473
804
  return purged;
474
805
  },
475
806
 
@@ -480,7 +811,11 @@ export function makeOeOauthTokenVault(source: OeOauthProviderSource, deps: OeOau
480
811
  * counts as absent (mirrors the mint's readJson semantics — fail toward re-consent, never crash).
481
812
  */
482
813
  async hasToken(providerRef: string, subject: OeOauthSubject): Promise<boolean> {
483
- return (await readJson<TokenRecord>(tokRef(providerRef, subject))) !== null;
814
+ if ((await readJson<TokenRecord>(tokRef(providerRef, subject))) !== null) return true;
815
+ // (OPE-2453) For an `xaa` connection the person's GRANT is their IdP sign-in: with it, the next mint
816
+ // succeeds with no consent screen, even for an app this person has never used.
817
+ const cfg = providersNow().get(providerRef);
818
+ return cfg !== undefined && isXaa(cfg) && (await readJson<IdpGrantRecord>(idpRef(cfg, subject))) !== null;
484
819
  },
485
820
 
486
821
  /**
@@ -492,6 +827,16 @@ export function makeOeOauthTokenVault(source: OeOauthProviderSource, deps: OeOau
492
827
  subject: OeOauthSubject,
493
828
  options: { readonly expiresInMs?: number; readonly consent?: unknown },
494
829
  ): Promise<{ url: string }> {
830
+ const xaaCfg = providersNow().get(providerRef);
831
+ if (xaaCfg !== undefined && isXaa(xaaCfg)) {
832
+ const out = await xaaStartAuthorization(xaaCfg, subject, options);
833
+ try {
834
+ await sweepExpiredPending();
835
+ } catch (e) {
836
+ console.warn(`[oe-oauth] the expired-pending sweep failed (the authorization URL is unaffected): ${e instanceof Error ? e.message : String(e)}`);
837
+ }
838
+ return out;
839
+ }
495
840
  const client = await ensureClient(providerRef);
496
841
  const redirectUri = redirectUriOf();
497
842
  if (!redirectUri) throw new Error(`connection "${providerRef}": OE_PUBLIC_BASE_URL is unset — cannot build a redirect_uri.`);
@@ -552,6 +897,10 @@ export function makeOeOauthTokenVault(source: OeOauthProviderSource, deps: OeOau
552
897
  if (!pending) throw new Error("unknown or expired authorization state");
553
898
  if (pending.consumed) throw new Error("authorization state already used");
554
899
  if (now() > pending.expiresAt) throw new Error("authorization state expired");
900
+ if (pending.mode === OE_OAUTH_MODE_XAA) {
901
+ const { rebound } = await xaaCompleteAuthorization(state, pending, code);
902
+ return { providerRef: pending.providerRef, subject: pending.subject, rebound, ...(pending.consent ? { consent: pending.consent } : {}) };
903
+ }
555
904
  const tok = await exchangeForPending(pending, code);
556
905
  // 🔴 READ BEFORE THE WRITE. `storeToken` writes the row unconditionally (last-write-wins), so the
557
906
  // only moment the REBIND is observable is here, before it. A second admin replacing the app row
@@ -586,6 +935,8 @@ export function makeOeOauthTokenVault(source: OeOauthProviderSource, deps: OeOau
586
935
  * single-flight runs — an in-process mutex, then a cross-process row CLAIM that elects ONE refresher.
587
936
  */
588
937
  async accessTokenFor(providerRef: string, subject: OeOauthSubject): Promise<string> {
938
+ const xaaCfg = providersNow().get(providerRef);
939
+ if (xaaCfg !== undefined && isXaa(xaaCfg)) return xaaAccessTokenFor(xaaCfg, providerRef, subject);
589
940
  const ref = tokRef(providerRef, subject);
590
941
  const fast = await readJson<TokenRecord>(ref);
591
942
  if (!fast) throw new OeOauthNeedsConsentError(providerRef, subject);
@@ -26,6 +26,8 @@ import { registerAwsAccess } from "./aws/register.js";
26
26
  import { awsAccessEnabled } from "./aws/config.js";
27
27
  import { registerDirectoryAdapter } from "./directory/register.js";
28
28
  import { registerGatewayRouteProbe } from "./model/gateway-probe.js";
29
+ import { registerLongLivedBoxes } from "./sandbox/long-lived-box.js";
30
+ import { registerNativeTranscriptSources } from "./meetings/native-sources.js";
29
31
  import { registerNeon } from "./postgres/neon.js";
30
32
  import {
31
33
  registerDbCredentialBroker,
@@ -197,6 +199,15 @@ async function wireDefaultAdapters(options: DefaultAdapterOptions): Promise<Defa
197
199
  provider.register();
198
200
  }
199
201
 
202
+ // The long-lived box (OPE-2459): one box backend per sandbox backend, picked by the same rule as the
203
+ // turn-end stopper. A box is what a meeting bot (and later the person's browser) runs in. just-bash
204
+ // registers a backend that refuses with a reason, so a feature never runs a box somewhere unreviewed.
205
+ registerLongLivedBoxes();
206
+
207
+ // The native meeting-transcript readers (OPE-2471): Teams, Meet and Zoom. Each reads only behind the meeting
208
+ // policy's `native` block and a member's `meeting.recap` card, so registering all three is harmless.
209
+ registerNativeTranscriptSources();
210
+
200
211
  // The deployment-status surface's model probe (OPE-2230). It registers a function, and nothing
201
212
  // calls it until somebody reads `GET /eve/v1/oe/status`.
202
213
  registerGatewayRouteProbe();