@open-everywhere/adapters 0.6.0 → 0.8.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.
- package/agent/aws/sso.ts +0 -2
- package/agent/directory/okta.ts +41 -2
- package/agent/instrumentation.ts +67 -4
- package/agent/model/approval-judge.ts +3 -3
- package/agent/model/dream-judge.ts +8 -6
- package/agent/model/gateway-probe.ts +97 -11
- package/agent/model/gateway-vision.ts +10 -6
- package/agent/model/gateway.ts +448 -34
- package/agent/model/meeting-gateway.ts +37 -7
- package/agent/model/model-config-check.ts +243 -0
- package/agent/model/target-url.ts +38 -0
- package/agent/model/transcription-routes.ts +18 -4
- package/agent/oe-oauth/config.ts +71 -3
- package/agent/oe-oauth/errors.ts +18 -1
- package/agent/oe-oauth/oauth-client.ts +118 -2
- package/agent/oe-oauth/token-vault.ts +446 -25
- package/agent/oe-oauth/user-auth-flow.ts +4 -3
- package/agent/operator-holders.ts +47 -0
- package/agent/postgres/neon.ts +1 -1
- package/dist/agent/aws/sso.d.ts +1 -2
- package/dist/agent/aws/sso.d.ts.map +1 -1
- package/dist/agent/aws/sso.js +0 -2
- package/dist/agent/aws/sso.js.map +1 -1
- package/dist/agent/directory/okta.d.ts +10 -0
- package/dist/agent/directory/okta.d.ts.map +1 -1
- package/dist/agent/directory/okta.js +34 -2
- package/dist/agent/directory/okta.js.map +1 -1
- package/dist/agent/instrumentation.d.ts +23 -1
- package/dist/agent/instrumentation.d.ts.map +1 -1
- package/dist/agent/instrumentation.js +59 -3
- package/dist/agent/instrumentation.js.map +1 -1
- package/dist/agent/model/approval-judge.d.ts.map +1 -1
- package/dist/agent/model/approval-judge.js +3 -3
- package/dist/agent/model/approval-judge.js.map +1 -1
- package/dist/agent/model/dream-judge.d.ts +2 -0
- package/dist/agent/model/dream-judge.d.ts.map +1 -1
- package/dist/agent/model/dream-judge.js +6 -4
- package/dist/agent/model/dream-judge.js.map +1 -1
- package/dist/agent/model/gateway-probe.d.ts +22 -4
- package/dist/agent/model/gateway-probe.d.ts.map +1 -1
- package/dist/agent/model/gateway-probe.js +78 -11
- package/dist/agent/model/gateway-probe.js.map +1 -1
- package/dist/agent/model/gateway-vision.d.ts +4 -2
- package/dist/agent/model/gateway-vision.d.ts.map +1 -1
- package/dist/agent/model/gateway-vision.js +6 -4
- package/dist/agent/model/gateway-vision.js.map +1 -1
- package/dist/agent/model/gateway.d.ts +85 -12
- package/dist/agent/model/gateway.d.ts.map +1 -1
- package/dist/agent/model/gateway.js +418 -30
- package/dist/agent/model/gateway.js.map +1 -1
- package/dist/agent/model/meeting-gateway.d.ts +8 -2
- package/dist/agent/model/meeting-gateway.d.ts.map +1 -1
- package/dist/agent/model/meeting-gateway.js +29 -7
- package/dist/agent/model/meeting-gateway.js.map +1 -1
- package/dist/agent/model/model-config-check.d.ts +57 -0
- package/dist/agent/model/model-config-check.d.ts.map +1 -0
- package/dist/agent/model/model-config-check.js +207 -0
- package/dist/agent/model/model-config-check.js.map +1 -0
- package/dist/agent/model/target-url.d.ts +10 -0
- package/dist/agent/model/target-url.d.ts.map +1 -0
- package/dist/agent/model/target-url.js +32 -0
- package/dist/agent/model/target-url.js.map +1 -0
- package/dist/agent/model/transcription-routes.d.ts +3 -0
- package/dist/agent/model/transcription-routes.d.ts.map +1 -1
- package/dist/agent/model/transcription-routes.js +16 -4
- package/dist/agent/model/transcription-routes.js.map +1 -1
- package/dist/agent/oe-oauth/config.d.ts +28 -2
- package/dist/agent/oe-oauth/config.d.ts.map +1 -1
- package/dist/agent/oe-oauth/config.js +40 -1
- package/dist/agent/oe-oauth/config.js.map +1 -1
- package/dist/agent/oe-oauth/errors.d.ts +14 -7
- package/dist/agent/oe-oauth/errors.d.ts.map +1 -1
- package/dist/agent/oe-oauth/errors.js +16 -1
- package/dist/agent/oe-oauth/errors.js.map +1 -1
- package/dist/agent/oe-oauth/oauth-client.d.ts +46 -0
- package/dist/agent/oe-oauth/oauth-client.d.ts.map +1 -1
- package/dist/agent/oe-oauth/oauth-client.js +82 -1
- package/dist/agent/oe-oauth/oauth-client.js.map +1 -1
- package/dist/agent/oe-oauth/token-vault.d.ts +19 -3
- package/dist/agent/oe-oauth/token-vault.d.ts.map +1 -1
- package/dist/agent/oe-oauth/token-vault.js +396 -22
- package/dist/agent/oe-oauth/token-vault.js.map +1 -1
- package/dist/agent/oe-oauth/user-auth-flow.d.ts.map +1 -1
- package/dist/agent/oe-oauth/user-auth-flow.js +3 -2
- package/dist/agent/oe-oauth/user-auth-flow.js.map +1 -1
- package/dist/agent/operator-holders.d.ts +13 -0
- package/dist/agent/operator-holders.d.ts.map +1 -0
- package/dist/agent/operator-holders.js +40 -0
- package/dist/agent/operator-holders.js.map +1 -0
- package/dist/agent/postgres/neon.d.ts +0 -15
- package/dist/agent/postgres/neon.d.ts.map +1 -1
- package/dist/agent/postgres/neon.js +1 -1
- package/dist/agent/postgres/neon.js.map +1 -1
- package/package.json +20 -2
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
// The own-rolled OAuth token vault — DCR client, pending-authorization and token records over core's
|
|
2
2
|
// encrypted KV. Refresh is single-flight in-process AND cross-process, because the provider ROTATES tokens.
|
|
3
|
-
import { agentDisplayName, encryptedKvVault, getSecretVia, OE_OAUTH_CALLBACK_PATH } from "@open-everywhere/core/agent/adapter-api.js";
|
|
4
|
-
import { isStaticConfidential, OE_OAUTH_MODE_STATIC } from "./config.js";
|
|
5
|
-
import { discoverEndpoints, registerClient, buildAuthorizationUrl, buildStaticAuthorizationUrl, exchangeCode, exchangeCodeConfidential, refreshToken, refreshTokenConfidential, revokeToken, revokeTokenConfidential, } from "./oauth-client.js";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { agentDisplayName, checkXaaIdentityBinding, encryptedKvVault, recordXaaRefusal, getSecretVia, OE_OAUTH_CALLBACK_PATH, verifyOidcIdToken, } from "@open-everywhere/core/agent/adapter-api.js";
|
|
4
|
+
import { isStaticConfidential, isXaa, OE_OAUTH_MODE_STATIC, OE_OAUTH_MODE_XAA, } from "./config.js";
|
|
5
|
+
import { discoverEndpoints, registerClient, buildAuthorizationUrl, buildStaticAuthorizationUrl, exchangeCode, exchangeCodeConfidential, refreshToken, refreshTokenConfidential, revokeToken, revokeTokenConfidential, discoverOidc, fetchJwks, requestIdJag, jwtBearerGrant, } from "./oauth-client.js";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { makePkce, makeState } from "./pkce.js";
|
|
8
|
-
import { OeOauthNeedsConsentError, OeOauthNeedsReconsentError, OeOauthRefreshContendedError, isInvalidGrant } from "./errors.js";
|
|
8
|
+
import { OeOauthAccessRemovedError, OeOauthNeedsConsentError, OeOauthNeedsReconsentError, OeOauthRefreshContendedError, isInvalidGrant } from "./errors.js";
|
|
9
9
|
function isStaticClient(rec) {
|
|
10
10
|
return rec.mode === OE_OAUTH_MODE_STATIC;
|
|
11
11
|
}
|
|
12
|
+
/** The IdP scopes the Cross App Access sign-in asks for: an id token (`openid`) and a refresh token to exchange
|
|
13
|
+
* (`offline_access`). Fixed — the app's own scopes ride leg 1 and leg 2, never this sign-in. */
|
|
14
|
+
const XAA_IDP_SCOPES = ["openid", "offline_access"];
|
|
15
|
+
/** RFC 6749 / 8693 error codes that mean the IdP REFUSED this person's ID-JAG on policy (the admin removed the
|
|
16
|
+
* pairing or the assignment). `invalid_grant` is NOT here: it means the IdP sign-in itself lapsed, and a new
|
|
17
|
+
* sign-in fixes that. */
|
|
18
|
+
const XAA_IDP_REFUSALS = new Set(["access_denied", "unauthorized_client", "invalid_target"]);
|
|
19
|
+
/** Error codes that mean the APP refused the ID-JAG for this person. No new sign-in fixes these. */
|
|
20
|
+
const XAA_APP_REFUSALS = new Set(["invalid_grant", "access_denied", "unauthorized_client"]);
|
|
21
|
+
/** How long a discovered OIDC document is reused before it is fetched again. */
|
|
22
|
+
const OIDC_DISCOVERY_TTL_MS = 60 * 60 * 1000;
|
|
12
23
|
const DEFAULT_PENDING_TTL_MS = 15 * 60 * 1000; // 15 min consent window
|
|
13
24
|
const DEFAULT_EXPIRY_SKEW_MS = 60 * 1000; // refresh when within 60s of expiry
|
|
14
25
|
// How long an expired pending row is KEPT before the sweep destroys it. Not a security window — completion
|
|
@@ -51,9 +62,17 @@ export function makeOeOauthTokenVault(source, deps = {}) {
|
|
|
51
62
|
const newClaimId = deps.newClaimId ?? (() => randomUUID());
|
|
52
63
|
const sleep = deps.sleep ?? realSleep;
|
|
53
64
|
const resolveSecret = deps.resolveSecret ?? ((broker, ref) => getSecretVia(broker, ref));
|
|
65
|
+
const newNonce = deps.newNonce ?? makeState;
|
|
66
|
+
const verifyIdToken = deps.verifyIdToken ?? verifyOidcIdToken;
|
|
67
|
+
const bindIdentity = deps.bindIdentity ?? checkXaaIdentityBinding;
|
|
68
|
+
const recordRefusal = deps.recordRefusal ?? recordXaaRefusal;
|
|
54
69
|
const dcrRef = (providerRef) => `oe-oauth:dcr:${providerRef}`;
|
|
55
70
|
const pendRef = (state) => `oe-oauth:pend:${state}`;
|
|
56
71
|
const tokRef = (providerRef, subject) => `oe-oauth:tok:${providerRef}:${subjectKey(subject)}`;
|
|
72
|
+
// The IdP grant is keyed by the IdP CLIENT (issuer + client id), not by the connection: every `xaa` connection
|
|
73
|
+
// that names the same IdP client shares one person's sign-in. Hashed so the ref carries no URL punctuation.
|
|
74
|
+
const idpKey = (cfg) => createHash("sha256").update(`${cfg.idpIssuer}\n${cfg.idpClientId}`).digest("hex").slice(0, 32);
|
|
75
|
+
const idpRef = (cfg, subject) => `oe-oauth:xaa-idp:${idpKey(cfg)}:${subjectKey(subject)}`;
|
|
57
76
|
function providerConfig(providerRef) {
|
|
58
77
|
const cfg = providersNow().get(providerRef);
|
|
59
78
|
if (!cfg) {
|
|
@@ -91,6 +110,8 @@ export function makeOeOauthTokenVault(source, deps = {}) {
|
|
|
91
110
|
// directly gets an error instead of a discovery request built from an absent `resource`.
|
|
92
111
|
if (isStaticConfidential(cfg))
|
|
93
112
|
throw new Error(`connection "${providerRef}": declares the "${OE_OAUTH_MODE_STATIC}" mode, which registers no dynamic client.`);
|
|
113
|
+
if (isXaa(cfg))
|
|
114
|
+
throw new Error(`connection "${providerRef}": declares the "${OE_OAUTH_MODE_XAA}" mode, which registers no dynamic client.`);
|
|
94
115
|
return vault.withLock(dcrRef(providerRef), async () => {
|
|
95
116
|
const again = await readJson(dcrRef(providerRef));
|
|
96
117
|
if (again && !dcrRecordNeedsBackfill(again))
|
|
@@ -175,6 +196,263 @@ export function makeOeOauthTokenVault(source, deps = {}) {
|
|
|
175
196
|
...(cfg.tokenHeaders ? { headers: cfg.tokenHeaders } : {}),
|
|
176
197
|
};
|
|
177
198
|
}
|
|
199
|
+
/** A confidential client for one `xaa` leg, secret read through its broker PER CALL. The error names the ref. */
|
|
200
|
+
async function xaaClient(cfg, leg) {
|
|
201
|
+
const [clientId, secretRef, broker] = leg === "idp" ? [cfg.idpClientId, cfg.idpClientSecretRef, cfg.idpClientSecretBroker] : [cfg.resourceClientId, cfg.resourceClientSecretRef, cfg.resourceClientSecretBroker];
|
|
202
|
+
const secret = await resolveSecret(broker, secretRef);
|
|
203
|
+
if (typeof secret !== "string" || secret === "") {
|
|
204
|
+
throw new Error(`connection "${cfg.ref}": the ${leg === "idp" ? "identity-provider" : "app"} client secret at ref "${secretRef}" (broker "${broker}") has no stored value — ` +
|
|
205
|
+
`store it with \`oe secret set ${secretRef}\` before this connection can mint a token.`);
|
|
206
|
+
}
|
|
207
|
+
return { clientId, clientSecret: secret, bodyEncoding: "form" };
|
|
208
|
+
}
|
|
209
|
+
// The IdP's discovered endpoints, per issuer, reused for an hour: every mint's leg 1 needs the token endpoint,
|
|
210
|
+
// and a discovery hop per mint would double the IdP traffic for nothing.
|
|
211
|
+
const oidcCache = new Map();
|
|
212
|
+
async function oidcFor(cfg) {
|
|
213
|
+
const hit = oidcCache.get(cfg.idpIssuer);
|
|
214
|
+
if (hit && now() - hit.at < OIDC_DISCOVERY_TTL_MS)
|
|
215
|
+
return hit.endpoints;
|
|
216
|
+
const endpoints = await discoverOidc(cfg.idpIssuer, doFetch);
|
|
217
|
+
oidcCache.set(cfg.idpIssuer, { at: now(), endpoints });
|
|
218
|
+
return endpoints;
|
|
219
|
+
}
|
|
220
|
+
/** An `xaa` connection acts only AS A PERSON: the ID-JAG names a human. There is no service account to mint. */
|
|
221
|
+
function requireXaaPerson(providerRef, subject) {
|
|
222
|
+
if (subject.type !== "user") {
|
|
223
|
+
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.`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Start the person's IdP sign-in for an `xaa` connection: OIDC auth code with PKCE and a nonce, `openid` +
|
|
228
|
+
* `offline_access`. The pending row is the same shape as the other dialects', stamped `mode: "xaa"`.
|
|
229
|
+
*/
|
|
230
|
+
async function xaaStartAuthorization(cfg, subject, options) {
|
|
231
|
+
requireXaaPerson(cfg.ref, subject);
|
|
232
|
+
const oidc = await oidcFor(cfg);
|
|
233
|
+
const redirectUri = redirectUriOf();
|
|
234
|
+
if (!redirectUri)
|
|
235
|
+
throw new Error(`connection "${cfg.ref}": OE_PUBLIC_BASE_URL is unset — cannot build a redirect_uri.`);
|
|
236
|
+
const state = newState();
|
|
237
|
+
const pkce = newPkce();
|
|
238
|
+
const nonce = newNonce();
|
|
239
|
+
const pending = {
|
|
240
|
+
providerRef: cfg.ref,
|
|
241
|
+
subject,
|
|
242
|
+
pkceVerifier: pkce.verifier,
|
|
243
|
+
tokenEndpoint: oidc.tokenEndpoint,
|
|
244
|
+
clientId: cfg.idpClientId,
|
|
245
|
+
mode: OE_OAUTH_MODE_XAA,
|
|
246
|
+
nonce,
|
|
247
|
+
redirectUri,
|
|
248
|
+
scopes: XAA_IDP_SCOPES,
|
|
249
|
+
createdAt: now(),
|
|
250
|
+
expiresAt: now() + (options.expiresInMs ?? pendingTtl),
|
|
251
|
+
...(options.consent ? { consent: options.consent } : {}),
|
|
252
|
+
};
|
|
253
|
+
await vault.write(pendRef(state), JSON.stringify(pending), { connector: cfg.ref });
|
|
254
|
+
// The PKCE pair and the nonce ride `extraParams`: this function's own parameters (client_id, redirect_uri,
|
|
255
|
+
// state, scope) are applied after them and win, so nothing here can be overridden.
|
|
256
|
+
const url = buildStaticAuthorizationUrl({
|
|
257
|
+
authorizationEndpoint: oidc.authorizationEndpoint,
|
|
258
|
+
clientId: cfg.idpClientId,
|
|
259
|
+
redirectUri,
|
|
260
|
+
state,
|
|
261
|
+
scopes: XAA_IDP_SCOPES,
|
|
262
|
+
extraParams: { nonce, code_challenge: pkce.challenge, code_challenge_method: "S256" },
|
|
263
|
+
});
|
|
264
|
+
return { url };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Complete an `xaa` sign-in: exchange the code at the IdP, VERIFY the id token (signature, iss, aud, nonce), and
|
|
268
|
+
* admit the sign-in only when the identity-link plane confirms the IdP account is the requester's own. Then store
|
|
269
|
+
* the person's IdP refresh token as their IdP grant. A refusal SPENDS the link (the pending row is consumed).
|
|
270
|
+
*/
|
|
271
|
+
async function xaaCompleteAuthorization(state, pending, code) {
|
|
272
|
+
const cfg = providersNow().get(pending.providerRef);
|
|
273
|
+
if (cfg === undefined || !isXaa(cfg) || cfg.idpClientId !== pending.clientId) {
|
|
274
|
+
throw new Error(`connection "${pending.providerRef}": this sign-in started under an "${OE_OAUTH_MODE_XAA}" declaration that no longer stands — start again.`);
|
|
275
|
+
}
|
|
276
|
+
const spend = () => vault.write(pendRef(state), JSON.stringify({ ...pending, consumed: true, pkceVerifier: "", nonce: "", consent: undefined }), { connector: pending.providerRef });
|
|
277
|
+
const tok = await exchangeCodeConfidential(pending.tokenEndpoint, { code, redirectUri: pending.redirectUri, codeVerifier: pending.pkceVerifier }, await xaaClient(cfg, "idp"), doFetch);
|
|
278
|
+
let signer;
|
|
279
|
+
try {
|
|
280
|
+
if (!tok.id_token)
|
|
281
|
+
throw new Error("the IdP returned no id token");
|
|
282
|
+
const oidc = await oidcFor(cfg);
|
|
283
|
+
signer = (await verifyIdToken(tok.id_token, { jwks: (await fetchJwks(oidc.jwksUri, doFetch)), issuer: cfg.idpIssuer, audience: cfg.idpClientId, nonce: pending.nonce ?? "" })).sub;
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
await spend();
|
|
287
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
288
|
+
// Every refused attempt leaves one audit row — a forged or replayed id token most of all.
|
|
289
|
+
if (pending.subject.type === "user")
|
|
290
|
+
await recordRefusal({ principalSubject: pending.subject.id, connection: pending.providerRef, reason });
|
|
291
|
+
throw new Error(`connection "${pending.providerRef}": the identity-provider sign-in was refused — ${reason}.`);
|
|
292
|
+
}
|
|
293
|
+
if (pending.subject.type !== "user") {
|
|
294
|
+
await spend();
|
|
295
|
+
throw new Error(`connection "${pending.providerRef}": the "${OE_OAUTH_MODE_XAA}" sign-in acts only as a person.`);
|
|
296
|
+
}
|
|
297
|
+
// 🔴 THE BINDING. Whoever opened the link signed in; the grant is stored for the requester only when that IdP
|
|
298
|
+
// account is a CONFIRMED account of theirs. Otherwise one person could mint another person's app tokens.
|
|
299
|
+
const verdict = await bindIdentity({ principalSubject: pending.subject.id, signerSubject: signer, connection: pending.providerRef });
|
|
300
|
+
if (!verdict.ok) {
|
|
301
|
+
await spend();
|
|
302
|
+
throw new Error(`connection "${pending.providerRef}": the identity-provider sign-in was refused — ${verdict.reason}.`);
|
|
303
|
+
}
|
|
304
|
+
if (!tok.refresh_token) {
|
|
305
|
+
await spend();
|
|
306
|
+
await recordRefusal({ principalSubject: pending.subject.id, connection: pending.providerRef, reason: "the IdP returned no refresh token", signerSubject: verdict.idpNativeId });
|
|
307
|
+
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.`);
|
|
308
|
+
}
|
|
309
|
+
const gRef = idpRef(cfg, pending.subject);
|
|
310
|
+
const rebound = (await readJson(gRef)) !== null;
|
|
311
|
+
const grant = { refreshToken: tok.refresh_token, idpSubject: verdict.idpNativeId, obtainedAt: now() };
|
|
312
|
+
await vault.write(gRef, JSON.stringify(grant), { connector: pending.providerRef });
|
|
313
|
+
await spend();
|
|
314
|
+
return { rebound };
|
|
315
|
+
}
|
|
316
|
+
/** The RFC 6749 `error` code an upstream failure carried, if any. */
|
|
317
|
+
function oauthErrorOf(e) {
|
|
318
|
+
const code = e?.oauthError;
|
|
319
|
+
return typeof code === "string" ? code.toLowerCase() : undefined;
|
|
320
|
+
}
|
|
321
|
+
/** 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
|
|
322
|
+
* lifetime. Never a refresh token from the app — a re-mint is the only refresh. */
|
|
323
|
+
async function xaaMint(cfg, providerRef, subject, grant) {
|
|
324
|
+
const oidc = await oidcFor(cfg);
|
|
325
|
+
const legParams = { ...(cfg.resource ? { resource: cfg.resource } : {}), ...(cfg.scopes ? { scopes: cfg.scopes } : {}) };
|
|
326
|
+
let idJag;
|
|
327
|
+
try {
|
|
328
|
+
idJag = await requestIdJag(oidc.tokenEndpoint, { subjectToken: grant.refreshToken, audience: cfg.audience, ...legParams }, await xaaClient(cfg, "idp"), doFetch);
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
const code = oauthErrorOf(e);
|
|
332
|
+
if (code === "invalid_grant")
|
|
333
|
+
throw new OeOauthNeedsReconsentError(providerRef, subject, "the identity-provider sign-in is no longer valid (invalid_grant)");
|
|
334
|
+
if (code !== undefined && XAA_IDP_REFUSALS.has(code))
|
|
335
|
+
throw new OeOauthAccessRemovedError(providerRef, subject, "idp", code);
|
|
336
|
+
throw e;
|
|
337
|
+
}
|
|
338
|
+
let tok;
|
|
339
|
+
try {
|
|
340
|
+
tok = await jwtBearerGrant(cfg.resourceTokenEndpoint, { assertion: idJag, ...legParams }, await xaaClient(cfg, "app"), doFetch);
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
const code = oauthErrorOf(e);
|
|
344
|
+
if (code !== undefined && XAA_APP_REFUSALS.has(code))
|
|
345
|
+
throw new OeOauthAccessRemovedError(providerRef, subject, "app", code);
|
|
346
|
+
throw e;
|
|
347
|
+
}
|
|
348
|
+
const { refresh_token: _ignored, ...withoutRefresh } = tok;
|
|
349
|
+
return (await storeToken(providerRef, subject, withoutRefresh)).accessToken;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Read-or-mint an `xaa` connection's app token. A cached token serves until its expiry (less the skew), so a
|
|
353
|
+
* person costs ONE mint per token lifetime — the IdP caps ID-JAGs per person per app per month. The CROSS-PROCESS
|
|
354
|
+
* claim sits on the person's IdP GRANT row (it always exists here; the app-token row may not yet), and a loser
|
|
355
|
+
* polls for the winner's token or for the claim to come free, bounded like the refresh path.
|
|
356
|
+
*/
|
|
357
|
+
async function xaaAccessTokenFor(cfg, providerRef, subject) {
|
|
358
|
+
requireXaaPerson(providerRef, subject);
|
|
359
|
+
const ref = tokRef(providerRef, subject);
|
|
360
|
+
const fast = await readJson(ref);
|
|
361
|
+
if (fast && !isExpired(fast))
|
|
362
|
+
return fast.accessToken;
|
|
363
|
+
const gRef = idpRef(cfg, subject);
|
|
364
|
+
return vault.withLock(ref, async () => {
|
|
365
|
+
const deadline = now() + LOSER_WAIT_BUDGET_MS;
|
|
366
|
+
for (;;) {
|
|
367
|
+
const rec = await readJson(ref);
|
|
368
|
+
if (rec && !isExpired(rec))
|
|
369
|
+
return rec.accessToken;
|
|
370
|
+
const grant = await readJson(gRef);
|
|
371
|
+
if (!grant)
|
|
372
|
+
throw new OeOauthNeedsConsentError(providerRef, subject);
|
|
373
|
+
const claimId = newClaimId();
|
|
374
|
+
if (await vault.claim(gRef, claimId, now(), CLAIM_LEASE_TTL_MS)) {
|
|
375
|
+
try {
|
|
376
|
+
// Another process may have minted and released between our read and our claim: adopt its token.
|
|
377
|
+
const won = await readJson(ref);
|
|
378
|
+
if (won && !isExpired(won))
|
|
379
|
+
return won.accessToken;
|
|
380
|
+
// 🔴 RE-CHECK THE BINDING AT EACH MINT, not only at sign-in. An operator who revokes the `okta` link or
|
|
381
|
+
// suspends the set must stop the minting at once; the stored grant alone would keep minting until the
|
|
382
|
+
// IdP refresh token dies. The check runs per MINT (once per token lifetime), never per cached read.
|
|
383
|
+
const still = await bindIdentity({ principalSubject: subject.id, signerSubject: grant.idpSubject, connection: providerRef, stage: "mint" });
|
|
384
|
+
// An UNREADABLE link plane says nothing about the person: refuse this mint, keep the grant, and let a
|
|
385
|
+
// retry succeed. Only a definite refusal (the link is gone) deletes the grant.
|
|
386
|
+
if (!still.ok && still.unreadable) {
|
|
387
|
+
throw new Error(`connection "${providerRef}": the identity-link plane could not be read, so the Okta grant could not be re-checked — retry.`);
|
|
388
|
+
}
|
|
389
|
+
if (!still.ok) {
|
|
390
|
+
await vault.delete(gRef);
|
|
391
|
+
throw new OeOauthNeedsConsentError(providerRef, subject);
|
|
392
|
+
}
|
|
393
|
+
return await xaaMint(cfg, providerRef, subject, grant);
|
|
394
|
+
}
|
|
395
|
+
finally {
|
|
396
|
+
await vault.releaseClaim(gRef, claimId);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (now() >= deadline)
|
|
400
|
+
throw new OeOauthRefreshContendedError(providerRef, subject);
|
|
401
|
+
await sleep(LOSER_POLL_MS);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/** How a failed IdP revocation is reported (OPE-2565): `idp:` and the issuer's host, the name an operator knows it by. */
|
|
406
|
+
function idpReportRef(cfg) {
|
|
407
|
+
try {
|
|
408
|
+
return `idp:${new URL(cfg.idpIssuer).host}`;
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
return "idp:";
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Run one revocation call, and retry it ONCE on a failure (OPE-2565). A second failure is warned and handed to
|
|
416
|
+
* `onUnrevoked`, so the purge's caller records it: the row is deleted anyway, and the token stays valid at the
|
|
417
|
+
* provider until it expires. Never throws — a vendor failure never blocks the local delete.
|
|
418
|
+
*/
|
|
419
|
+
async function revokeWithRetry(providerRef, call, onUnrevoked, what) {
|
|
420
|
+
for (let attempt = 1;; attempt++) {
|
|
421
|
+
try {
|
|
422
|
+
await call();
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
catch (e) {
|
|
426
|
+
if (attempt < 2)
|
|
427
|
+
continue;
|
|
428
|
+
// Message only: the client's errors are built never to carry token material.
|
|
429
|
+
console.warn(`[oe-oauth] ${what} failed twice for "${providerRef}" (purging locally anyway; the token stays valid there until it expires): ${e instanceof Error ? e.message : String(e)}`);
|
|
430
|
+
onUnrevoked?.(providerRef);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Purge a person's IdP grant: revoke the IdP refresh token at the IdP (one retry), then delete the row. A failure
|
|
437
|
+
* is reported as `idp:<issuer host>`, never under the connection's ref: the token that stays valid is the IdP
|
|
438
|
+
* grant, which every `xaa` connection on that IdP shares, so naming the connection would send the operator to
|
|
439
|
+
* the wrong system.
|
|
440
|
+
*/
|
|
441
|
+
async function forgetIdpGrant(cfg, subject, onUnrevoked) {
|
|
442
|
+
const gRef = idpRef(cfg, subject);
|
|
443
|
+
return vault.withLock(gRef, async () => {
|
|
444
|
+
const grant = await readJson(gRef);
|
|
445
|
+
if (grant) {
|
|
446
|
+
await revokeWithRetry(idpReportRef(cfg), async () => {
|
|
447
|
+
const oidc = await oidcFor(cfg);
|
|
448
|
+
if (oidc.revocationEndpoint) {
|
|
449
|
+
await revokeTokenConfidential(oidc.revocationEndpoint, { token: grant.refreshToken, tokenTypeHint: "refresh_token" }, await xaaClient(cfg, "idp"), doFetch);
|
|
450
|
+
}
|
|
451
|
+
}, onUnrevoked, "IdP revocation");
|
|
452
|
+
}
|
|
453
|
+
return vault.delete(gRef);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
178
456
|
/**
|
|
179
457
|
* Exchange the callback's `code` against ONE pending row, in the dialect that row was minted in.
|
|
180
458
|
* 🔴 The row's stamp and the live config must AGREE: a disagreement is REFUSED rather than resolved.
|
|
@@ -184,6 +462,9 @@ export function makeOeOauthTokenVault(source, deps = {}) {
|
|
|
184
462
|
// mode existed, and it must not start failing on a config-absent ref now. The drift check below
|
|
185
463
|
// therefore runs only when there IS a config to compare against.
|
|
186
464
|
const cfg = providersNow().get(pending.providerRef);
|
|
465
|
+
if (cfg !== undefined && isXaa(cfg)) {
|
|
466
|
+
throw new Error(`connection "${pending.providerRef}": this authorization started under another dialect, but the provider config now declares "${OE_OAUTH_MODE_XAA}" — start again.`);
|
|
467
|
+
}
|
|
187
468
|
const startedStatic = pending.mode === OE_OAUTH_MODE_STATIC;
|
|
188
469
|
if (cfg !== undefined && isStaticConfidential(cfg) !== startedStatic) {
|
|
189
470
|
throw new Error(`connection "${pending.providerRef}": this authorization started as ${startedStatic ? "a static confidential client" : "a public PKCE client"}, ` +
|
|
@@ -217,8 +498,9 @@ export function makeOeOauthTokenVault(source, deps = {}) {
|
|
|
217
498
|
/**
|
|
218
499
|
* PURGE one (provider, subject) record: vendor revocation FIRST, then the at-rest row. The ORDER makes a
|
|
219
500
|
* racing cross-process refresh benign; where a vendor cannot cascade, the access token lives out its TTL.
|
|
501
|
+
* A revocation call that fails is retried once; a second failure goes to `onUnrevoked` (OPE-2565).
|
|
220
502
|
*/
|
|
221
|
-
async function forget(providerRef, subject) {
|
|
503
|
+
async function forget(providerRef, subject, onUnrevoked) {
|
|
222
504
|
const ref = tokRef(providerRef, subject);
|
|
223
505
|
return vault.withLock(ref, async () => {
|
|
224
506
|
const rec = await readJson(ref);
|
|
@@ -233,21 +515,19 @@ export function makeOeOauthTokenVault(source, deps = {}) {
|
|
|
233
515
|
// whereas revoking an access token kills only that one.
|
|
234
516
|
const token = rec.refreshToken ?? rec.accessToken;
|
|
235
517
|
if (client?.revocationEndpoint && token) {
|
|
236
|
-
|
|
237
|
-
|
|
518
|
+
const endpoint = client.revocationEndpoint;
|
|
519
|
+
const hint = rec.refreshToken ? "refresh_token" : "access_token";
|
|
520
|
+
// A vendor failure NEVER blocks the local delete — leaving a secret at rest with us because someone
|
|
521
|
+
// else's server is down is the worse of the two outcomes. It is no longer only a warning (OPE-2565):
|
|
522
|
+
// after one retry it is reported, and the purge's caller records it as a failure.
|
|
523
|
+
await revokeWithRetry(providerRef, async () => {
|
|
238
524
|
if (cfg && isStaticConfidential(cfg)) {
|
|
239
|
-
await revokeTokenConfidential(
|
|
525
|
+
await revokeTokenConfidential(endpoint, { token, tokenTypeHint: hint }, await confidentialClientFor(cfg), doFetch);
|
|
240
526
|
}
|
|
241
527
|
else {
|
|
242
|
-
await revokeToken(
|
|
528
|
+
await revokeToken(endpoint, { token, clientId: client.clientId, tokenTypeHint: hint }, doFetch);
|
|
243
529
|
}
|
|
244
|
-
}
|
|
245
|
-
catch (e) {
|
|
246
|
-
// A vendor failure NEVER blocks the local delete — leaving a secret at rest with us
|
|
247
|
-
// because someone else's server is down is the worse of the two outcomes. Message only:
|
|
248
|
-
// the client's errors are built never to carry token material.
|
|
249
|
-
console.warn(`[oe-oauth] revocation at the provider failed for "${providerRef}" (purging locally anyway): ${e instanceof Error ? e.message : String(e)}`);
|
|
250
|
-
}
|
|
530
|
+
}, onUnrevoked, "revocation at the provider");
|
|
251
531
|
}
|
|
252
532
|
}
|
|
253
533
|
// Runs even when there was no record to read (a corrupt/undecryptable envelope reads as null —
|
|
@@ -299,17 +579,66 @@ export function makeOeOauthTokenVault(source, deps = {}) {
|
|
|
299
579
|
/** {@link sweepExpiredPending} — destroy the expired pending-authorization rows. */
|
|
300
580
|
sweepExpiredPending,
|
|
301
581
|
/**
|
|
302
|
-
* Purge EVERY
|
|
582
|
+
* Purge EVERY provider's token record for `subject` — the declared ones, and any undeclared one the vault
|
|
583
|
+
* still holds a row for (OPE-2560) — only the vault knows the full set. A
|
|
303
584
|
* provider whose purge THROWS does not stop the others, and it rethrows only if every one failed.
|
|
304
585
|
*/
|
|
305
|
-
async forgetAll(subject) {
|
|
586
|
+
async forgetAll(subject, onUnrevoked) {
|
|
306
587
|
let purged = 0;
|
|
307
588
|
let failures = 0;
|
|
308
589
|
let lastError;
|
|
309
590
|
const all = providersNow();
|
|
310
591
|
for (const providerRef of all.keys()) {
|
|
311
592
|
try {
|
|
312
|
-
if (await forget(providerRef, subject))
|
|
593
|
+
if (await forget(providerRef, subject, onUnrevoked))
|
|
594
|
+
purged++;
|
|
595
|
+
}
|
|
596
|
+
catch (e) {
|
|
597
|
+
failures++;
|
|
598
|
+
lastError = e;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// (OPE-2560) A token row for a connection that is NOT declared here is still a live grant at rest: a
|
|
602
|
+
// declaration an operator removed, or a process that declares none at all (the `oe erase` operator CLI
|
|
603
|
+
// loads no connection module). Its ref names the provider, so it goes through the same `forget`: revoked at
|
|
604
|
+
// the provider when the stored DCR client names a revocation endpoint, then deleted. A static provider's
|
|
605
|
+
// client lives only in its declaration, so with none its token is deleted locally and cannot be revoked.
|
|
606
|
+
// The ref is `oe-oauth:tok:<providerRef>:<subject key>`; a provider ref holds no ':', so the split is exact.
|
|
607
|
+
const tokPrefix = "oe-oauth:tok:";
|
|
608
|
+
const tokSuffix = `:${subjectKey(subject)}`;
|
|
609
|
+
let undeclaredAttempts = 0;
|
|
610
|
+
try {
|
|
611
|
+
for (const ref of await vault.listRefs(tokPrefix)) {
|
|
612
|
+
if (!ref.endsWith(tokSuffix))
|
|
613
|
+
continue;
|
|
614
|
+
const providerRef = ref.slice(tokPrefix.length, ref.length - tokSuffix.length);
|
|
615
|
+
if (providerRef.length === 0 || providerRef.includes(":") || all.has(providerRef))
|
|
616
|
+
continue;
|
|
617
|
+
undeclaredAttempts++;
|
|
618
|
+
try {
|
|
619
|
+
if (await forget(providerRef, subject, onUnrevoked))
|
|
620
|
+
purged++;
|
|
621
|
+
}
|
|
622
|
+
catch (e) {
|
|
623
|
+
failures++;
|
|
624
|
+
lastError = e;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch (e) {
|
|
629
|
+
failures++;
|
|
630
|
+
lastError = e;
|
|
631
|
+
undeclaredAttempts++;
|
|
632
|
+
}
|
|
633
|
+
// (OPE-2453) A person's IdP grant is shared by every `xaa` connection naming one IdP client, so it is purged
|
|
634
|
+
// once per IdP client, after the per-connection rows. It is the credential that mints all of them.
|
|
635
|
+
const seenIdp = new Set();
|
|
636
|
+
for (const cfg of all.values()) {
|
|
637
|
+
if (!isXaa(cfg) || seenIdp.has(idpKey(cfg)))
|
|
638
|
+
continue;
|
|
639
|
+
seenIdp.add(idpKey(cfg));
|
|
640
|
+
try {
|
|
641
|
+
if (await forgetIdpGrant(cfg, subject, onUnrevoked))
|
|
313
642
|
purged++;
|
|
314
643
|
}
|
|
315
644
|
catch (e) {
|
|
@@ -317,7 +646,29 @@ export function makeOeOauthTokenVault(source, deps = {}) {
|
|
|
317
646
|
lastError = e;
|
|
318
647
|
}
|
|
319
648
|
}
|
|
320
|
-
|
|
649
|
+
// An IdP grant whose declaration is GONE is still a live IdP refresh token at rest. With no config there is no
|
|
650
|
+
// client to revoke it with, so it is deleted locally and the gap is said out loud.
|
|
651
|
+
// The ref is `oe-oauth:xaa-idp:<32 hex>:<subject key>`; the subject key is matched EXACTLY, never by suffix.
|
|
652
|
+
const covered = new Set([...all.values()].filter(isXaa).map((cfg) => idpRef(cfg, subject)));
|
|
653
|
+
const orphanRef = /^oe-oauth:xaa-idp:[0-9a-f]{32}:(.*)$/u;
|
|
654
|
+
let orphanFailed = false;
|
|
655
|
+
try {
|
|
656
|
+
for (const ref of await vault.listRefs("oe-oauth:xaa-idp:")) {
|
|
657
|
+
if (orphanRef.exec(ref)?.[1] !== subjectKey(subject) || covered.has(ref))
|
|
658
|
+
continue;
|
|
659
|
+
console.warn(`[oe-oauth] an IdP grant with no declared xaa connection was deleted locally — it could not be revoked at the IdP.`);
|
|
660
|
+
if (await vault.delete(ref))
|
|
661
|
+
purged++;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
catch (e) {
|
|
665
|
+
failures++;
|
|
666
|
+
lastError = e;
|
|
667
|
+
orphanFailed = true;
|
|
668
|
+
}
|
|
669
|
+
// "Every purge attempt failed" counts only the attempts that ran: the orphan sweep is one attempt only when
|
|
670
|
+
// it failed. Counting it always would let a purge where every provider failed return 0 with no error.
|
|
671
|
+
if (failures > 0 && purged === 0 && failures === all.size + undeclaredAttempts + seenIdp.size + (orphanFailed ? 1 : 0))
|
|
321
672
|
throw lastError;
|
|
322
673
|
return purged;
|
|
323
674
|
},
|
|
@@ -328,13 +679,29 @@ export function makeOeOauthTokenVault(source, deps = {}) {
|
|
|
328
679
|
* counts as absent (mirrors the mint's readJson semantics — fail toward re-consent, never crash).
|
|
329
680
|
*/
|
|
330
681
|
async hasToken(providerRef, subject) {
|
|
331
|
-
|
|
682
|
+
if ((await readJson(tokRef(providerRef, subject))) !== null)
|
|
683
|
+
return true;
|
|
684
|
+
// (OPE-2453) For an `xaa` connection the person's GRANT is their IdP sign-in: with it, the next mint
|
|
685
|
+
// succeeds with no consent screen, even for an app this person has never used.
|
|
686
|
+
const cfg = providersNow().get(providerRef);
|
|
687
|
+
return cfg !== undefined && isXaa(cfg) && (await readJson(idpRef(cfg, subject))) !== null;
|
|
332
688
|
},
|
|
333
689
|
/**
|
|
334
690
|
* Start an interactive authorization: ensure the client, mint PKCE and an opaque state, persist the pending
|
|
335
691
|
* row, and return the URL. It ALSO sweeps the expired pending rows, LAST and best-effort.
|
|
336
692
|
*/
|
|
337
693
|
async startAuthorization(providerRef, subject, options) {
|
|
694
|
+
const xaaCfg = providersNow().get(providerRef);
|
|
695
|
+
if (xaaCfg !== undefined && isXaa(xaaCfg)) {
|
|
696
|
+
const out = await xaaStartAuthorization(xaaCfg, subject, options);
|
|
697
|
+
try {
|
|
698
|
+
await sweepExpiredPending();
|
|
699
|
+
}
|
|
700
|
+
catch (e) {
|
|
701
|
+
console.warn(`[oe-oauth] the expired-pending sweep failed (the authorization URL is unaffected): ${e instanceof Error ? e.message : String(e)}`);
|
|
702
|
+
}
|
|
703
|
+
return out;
|
|
704
|
+
}
|
|
338
705
|
const client = await ensureClient(providerRef);
|
|
339
706
|
const redirectUri = redirectUriOf();
|
|
340
707
|
if (!redirectUri)
|
|
@@ -399,6 +766,10 @@ export function makeOeOauthTokenVault(source, deps = {}) {
|
|
|
399
766
|
throw new Error("authorization state already used");
|
|
400
767
|
if (now() > pending.expiresAt)
|
|
401
768
|
throw new Error("authorization state expired");
|
|
769
|
+
if (pending.mode === OE_OAUTH_MODE_XAA) {
|
|
770
|
+
const { rebound } = await xaaCompleteAuthorization(state, pending, code);
|
|
771
|
+
return { providerRef: pending.providerRef, subject: pending.subject, rebound, ...(pending.consent ? { consent: pending.consent } : {}) };
|
|
772
|
+
}
|
|
402
773
|
const tok = await exchangeForPending(pending, code);
|
|
403
774
|
// 🔴 READ BEFORE THE WRITE. `storeToken` writes the row unconditionally (last-write-wins), so the
|
|
404
775
|
// only moment the REBIND is observable is here, before it. A second admin replacing the app row
|
|
@@ -428,6 +799,9 @@ export function makeOeOauthTokenVault(source, deps = {}) {
|
|
|
428
799
|
* single-flight runs — an in-process mutex, then a cross-process row CLAIM that elects ONE refresher.
|
|
429
800
|
*/
|
|
430
801
|
async accessTokenFor(providerRef, subject) {
|
|
802
|
+
const xaaCfg = providersNow().get(providerRef);
|
|
803
|
+
if (xaaCfg !== undefined && isXaa(xaaCfg))
|
|
804
|
+
return xaaAccessTokenFor(xaaCfg, providerRef, subject);
|
|
431
805
|
const ref = tokRef(providerRef, subject);
|
|
432
806
|
const fast = await readJson(ref);
|
|
433
807
|
if (!fast)
|