@open-everywhere/adapters 0.1.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/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +43 -0
- package/agent/aws/cli.ts +121 -0
- package/agent/aws/config.ts +71 -0
- package/agent/aws/register.ts +65 -0
- package/agent/aws/sso.ts +567 -0
- package/agent/aws/sts.ts +191 -0
- package/agent/blob/hosting.ts +24 -0
- package/agent/browser/config.ts +62 -0
- package/agent/browser/register.ts +66 -0
- package/agent/browser/sandbox.ts +39 -0
- package/agent/contributions.ts +12 -0
- package/agent/directory/config.ts +138 -0
- package/agent/directory/okta.ts +226 -0
- package/agent/directory/register.ts +16 -0
- package/agent/instrumentation.ts +42 -0
- package/agent/model/approval-judge.ts +57 -0
- package/agent/model/dream-judge.ts +105 -0
- package/agent/model/gateway-vision.ts +162 -0
- package/agent/model/gateway.ts +85 -0
- package/agent/oe-oauth/config.ts +246 -0
- package/agent/oe-oauth/errors.ts +77 -0
- package/agent/oe-oauth/oauth-client.ts +376 -0
- package/agent/oe-oauth/pkce.ts +21 -0
- package/agent/oe-oauth/register.ts +32 -0
- package/agent/oe-oauth/token-vault.ts +652 -0
- package/agent/oe-oauth/user-auth-flow.ts +76 -0
- package/agent/postgres/neon.ts +108 -0
- package/agent/sandbox/backend-stop.ts +259 -0
- package/agent/sandbox/created-sandbox-registry.ts +27 -0
- package/agent/sandbox/extend-workspace-time.ts +77 -0
- package/agent/sandbox/vercel-lifecycle.ts +252 -0
- package/dist/agent/aws/cli.d.ts +24 -0
- package/dist/agent/aws/cli.d.ts.map +1 -0
- package/dist/agent/aws/cli.js +89 -0
- package/dist/agent/aws/cli.js.map +1 -0
- package/dist/agent/aws/config.d.ts +27 -0
- package/dist/agent/aws/config.d.ts.map +1 -0
- package/dist/agent/aws/config.js +46 -0
- package/dist/agent/aws/config.js.map +1 -0
- package/dist/agent/aws/register.d.ts +23 -0
- package/dist/agent/aws/register.d.ts.map +1 -0
- package/dist/agent/aws/register.js +39 -0
- package/dist/agent/aws/register.js.map +1 -0
- package/dist/agent/aws/sso.d.ts +109 -0
- package/dist/agent/aws/sso.d.ts.map +1 -0
- package/dist/agent/aws/sso.js +421 -0
- package/dist/agent/aws/sso.js.map +1 -0
- package/dist/agent/aws/sts.d.ts +27 -0
- package/dist/agent/aws/sts.d.ts.map +1 -0
- package/dist/agent/aws/sts.js +135 -0
- package/dist/agent/aws/sts.js.map +1 -0
- package/dist/agent/blob/hosting.d.ts +4 -0
- package/dist/agent/blob/hosting.d.ts.map +1 -0
- package/dist/agent/blob/hosting.js +20 -0
- package/dist/agent/blob/hosting.js.map +1 -0
- package/dist/agent/browser/config.d.ts +24 -0
- package/dist/agent/browser/config.d.ts.map +1 -0
- package/dist/agent/browser/config.js +50 -0
- package/dist/agent/browser/config.js.map +1 -0
- package/dist/agent/browser/register.d.ts +7 -0
- package/dist/agent/browser/register.d.ts.map +1 -0
- package/dist/agent/browser/register.js +47 -0
- package/dist/agent/browser/register.js.map +1 -0
- package/dist/agent/browser/sandbox.d.ts +23 -0
- package/dist/agent/browser/sandbox.d.ts.map +1 -0
- package/dist/agent/browser/sandbox.js +27 -0
- package/dist/agent/browser/sandbox.js.map +1 -0
- package/dist/agent/contributions.d.ts +16 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +11 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/directory/config.d.ts +39 -0
- package/dist/agent/directory/config.d.ts.map +1 -0
- package/dist/agent/directory/config.js +101 -0
- package/dist/agent/directory/config.js.map +1 -0
- package/dist/agent/directory/okta.d.ts +19 -0
- package/dist/agent/directory/okta.d.ts.map +1 -0
- package/dist/agent/directory/okta.js +178 -0
- package/dist/agent/directory/okta.js.map +1 -0
- package/dist/agent/directory/register.d.ts +6 -0
- package/dist/agent/directory/register.d.ts.map +1 -0
- package/dist/agent/directory/register.js +16 -0
- package/dist/agent/directory/register.js.map +1 -0
- package/dist/agent/instrumentation.d.ts +5 -0
- package/dist/agent/instrumentation.d.ts.map +1 -0
- package/dist/agent/instrumentation.js +39 -0
- package/dist/agent/instrumentation.js.map +1 -0
- package/dist/agent/model/approval-judge.d.ts +15 -0
- package/dist/agent/model/approval-judge.d.ts.map +1 -0
- package/dist/agent/model/approval-judge.js +46 -0
- package/dist/agent/model/approval-judge.js.map +1 -0
- package/dist/agent/model/dream-judge.d.ts +40 -0
- package/dist/agent/model/dream-judge.d.ts.map +1 -0
- package/dist/agent/model/dream-judge.js +80 -0
- package/dist/agent/model/dream-judge.js.map +1 -0
- package/dist/agent/model/gateway-vision.d.ts +53 -0
- package/dist/agent/model/gateway-vision.d.ts.map +1 -0
- package/dist/agent/model/gateway-vision.js +129 -0
- package/dist/agent/model/gateway-vision.js.map +1 -0
- package/dist/agent/model/gateway.d.ts +30 -0
- package/dist/agent/model/gateway.d.ts.map +1 -0
- package/dist/agent/model/gateway.js +55 -0
- package/dist/agent/model/gateway.js.map +1 -0
- package/dist/agent/oe-oauth/config.d.ts +69 -0
- package/dist/agent/oe-oauth/config.d.ts.map +1 -0
- package/dist/agent/oe-oauth/config.js +182 -0
- package/dist/agent/oe-oauth/config.js.map +1 -0
- package/dist/agent/oe-oauth/errors.d.ts +63 -0
- package/dist/agent/oe-oauth/errors.d.ts.map +1 -0
- package/dist/agent/oe-oauth/errors.js +73 -0
- package/dist/agent/oe-oauth/errors.js.map +1 -0
- package/dist/agent/oe-oauth/oauth-client.d.ts +124 -0
- package/dist/agent/oe-oauth/oauth-client.d.ts.map +1 -0
- package/dist/agent/oe-oauth/oauth-client.js +262 -0
- package/dist/agent/oe-oauth/oauth-client.js.map +1 -0
- package/dist/agent/oe-oauth/pkce.d.ts +8 -0
- package/dist/agent/oe-oauth/pkce.d.ts.map +1 -0
- package/dist/agent/oe-oauth/pkce.js +18 -0
- package/dist/agent/oe-oauth/pkce.js.map +1 -0
- package/dist/agent/oe-oauth/register.d.ts +17 -0
- package/dist/agent/oe-oauth/register.d.ts.map +1 -0
- package/dist/agent/oe-oauth/register.js +24 -0
- package/dist/agent/oe-oauth/register.js.map +1 -0
- package/dist/agent/oe-oauth/token-vault.d.ts +107 -0
- package/dist/agent/oe-oauth/token-vault.d.ts.map +1 -0
- package/dist/agent/oe-oauth/token-vault.js +498 -0
- package/dist/agent/oe-oauth/token-vault.js.map +1 -0
- package/dist/agent/oe-oauth/user-auth-flow.d.ts +10 -0
- package/dist/agent/oe-oauth/user-auth-flow.d.ts.map +1 -0
- package/dist/agent/oe-oauth/user-auth-flow.js +66 -0
- package/dist/agent/oe-oauth/user-auth-flow.js.map +1 -0
- package/dist/agent/postgres/neon.d.ts +29 -0
- package/dist/agent/postgres/neon.d.ts.map +1 -0
- package/dist/agent/postgres/neon.js +76 -0
- package/dist/agent/postgres/neon.js.map +1 -0
- package/dist/agent/sandbox/backend-stop.d.ts +98 -0
- package/dist/agent/sandbox/backend-stop.d.ts.map +1 -0
- package/dist/agent/sandbox/backend-stop.js +188 -0
- package/dist/agent/sandbox/backend-stop.js.map +1 -0
- package/dist/agent/sandbox/created-sandbox-registry.d.ts +15 -0
- package/dist/agent/sandbox/created-sandbox-registry.d.ts.map +1 -0
- package/dist/agent/sandbox/created-sandbox-registry.js +26 -0
- package/dist/agent/sandbox/created-sandbox-registry.js.map +1 -0
- package/dist/agent/sandbox/extend-workspace-time.d.ts +31 -0
- package/dist/agent/sandbox/extend-workspace-time.d.ts.map +1 -0
- package/dist/agent/sandbox/extend-workspace-time.js +63 -0
- package/dist/agent/sandbox/extend-workspace-time.js.map +1 -0
- package/dist/agent/sandbox/vercel-lifecycle.d.ts +38 -0
- package/dist/agent/sandbox/vercel-lifecycle.d.ts.map +1 -0
- package/dist/agent/sandbox/vercel-lifecycle.js +244 -0
- package/dist/agent/sandbox/vercel-lifecycle.js.map +1 -0
- package/package.json +204 -0
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { EncryptedKvVault } from "@open-everywhere/core/agent/adapter-api.js";
|
|
2
|
+
import { type OeOauthProviderConfig } from "./config.js";
|
|
3
|
+
import { type OAuthFetch } from "./oauth-client.js";
|
|
4
|
+
/** A subject a token is minted for — the service identity, or a specific user principal. */
|
|
5
|
+
export type OeOauthSubject = {
|
|
6
|
+
readonly type: "user";
|
|
7
|
+
readonly id: string;
|
|
8
|
+
} | {
|
|
9
|
+
readonly type: "app";
|
|
10
|
+
};
|
|
11
|
+
/** What one {@link OeOauthTokenVault.sweepExpiredPending} run looked at. The four counts
|
|
12
|
+
* RECONCILE: `scanned` === `deleted` + `live` + `unreadable` + (rows another caller removed
|
|
13
|
+
* between the listing and the delete). */
|
|
14
|
+
interface PendingSweepReport {
|
|
15
|
+
/** Pending rows the enumeration returned. */
|
|
16
|
+
readonly scanned: number;
|
|
17
|
+
/** Rows destroyed — past `expiresAt` + the retention window. */
|
|
18
|
+
readonly deleted: number;
|
|
19
|
+
/** Rows KEPT because they may still complete (inside the window, or inside the grace). */
|
|
20
|
+
readonly live: number;
|
|
21
|
+
/** Rows KEPT because their record did not read, so their expiry could not be established. */
|
|
22
|
+
readonly unreadable: number;
|
|
23
|
+
}
|
|
24
|
+
export declare const CLAIM_LEASE_TTL_MS = 30000;
|
|
25
|
+
/** Injectable seams — every live default is used byte-for-byte in production. */
|
|
26
|
+
export interface OeOauthVaultDeps {
|
|
27
|
+
readonly vault?: EncryptedKvVault;
|
|
28
|
+
readonly fetch?: OAuthFetch;
|
|
29
|
+
readonly now?: () => number;
|
|
30
|
+
readonly newState?: () => string;
|
|
31
|
+
readonly newPkce?: () => {
|
|
32
|
+
readonly verifier: string;
|
|
33
|
+
readonly challenge: string;
|
|
34
|
+
};
|
|
35
|
+
/** The OE-owned callback redirect URI. Default: OE_PUBLIC_BASE_URL + the callback route path. */
|
|
36
|
+
readonly redirectUri?: () => string | undefined;
|
|
37
|
+
readonly pendingTtlMs?: number;
|
|
38
|
+
/** How long past `expiresAt` an unusable pending row is kept. Default {@link DEFAULT_PENDING_RETENTION_MS}. */
|
|
39
|
+
readonly pendingRetentionMs?: number;
|
|
40
|
+
readonly expirySkewMs?: number;
|
|
41
|
+
/** Mints the random, non-secret cross-process claim id. Default: `randomUUID`. */
|
|
42
|
+
readonly newClaimId?: () => string;
|
|
43
|
+
/** Backoff wait between a loser's poll iterations. Default: real `setTimeout`. Tests inject a
|
|
44
|
+
* clock-advancing (timeout scenario) or microtask-yielding (concurrency scenario) stub. */
|
|
45
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Read a `static-confidential` provider's CLIENT SECRET by ref through its broker — the one credential path
|
|
48
|
+
* every other stored secret takes. Read PER TOKEN CALL, so a rotated secret takes effect at once.
|
|
49
|
+
*/
|
|
50
|
+
readonly resolveSecret?: (broker: string | undefined, ref: string) => Promise<string>;
|
|
51
|
+
}
|
|
52
|
+
declare function subjectKey(subject: OeOauthSubject): string;
|
|
53
|
+
/**
|
|
54
|
+
* Construct a token vault over the configured providers. One instance is built at registration and
|
|
55
|
+
* shared, so the in-process single-flight lock (core withLock) coordinates all callers in the process.
|
|
56
|
+
*/
|
|
57
|
+
export declare function makeOeOauthTokenVault(providers: ReadonlyMap<string, OeOauthProviderConfig>, deps?: OeOauthVaultDeps): {
|
|
58
|
+
subjectKey: typeof subjectKey;
|
|
59
|
+
/** {@link forget} — purge ONE provider's token record for a subject. */
|
|
60
|
+
forget: (providerRef: string, subject: OeOauthSubject) => Promise<boolean>;
|
|
61
|
+
/** {@link sweepExpiredPending} — destroy the expired pending-authorization rows. */
|
|
62
|
+
sweepExpiredPending: () => Promise<PendingSweepReport>;
|
|
63
|
+
/**
|
|
64
|
+
* Purge EVERY configured provider's token record for `subject` — only the vault knows the full set. A
|
|
65
|
+
* provider whose purge THROWS does not stop the others, and it rethrows only if every one failed.
|
|
66
|
+
*/
|
|
67
|
+
forgetAll(subject: OeOauthSubject): Promise<number>;
|
|
68
|
+
/**
|
|
69
|
+
* (OPE-93) Cheap EXISTENCE probe: is there a stored token record for (provider, subject) RIGHT NOW?
|
|
70
|
+
* A local vault read only — never a mint, never a refresh. Existence is the right signal even if the
|
|
71
|
+
* access token is expired: the real mint path ({@link accessTokenFor}) refreshes it. A corrupt record
|
|
72
|
+
* counts as absent (mirrors the mint's readJson semantics — fail toward re-consent, never crash).
|
|
73
|
+
*/
|
|
74
|
+
hasToken(providerRef: string, subject: OeOauthSubject): Promise<boolean>;
|
|
75
|
+
/**
|
|
76
|
+
* Start an interactive authorization: ensure the client, mint PKCE and an opaque state, persist the pending
|
|
77
|
+
* row, and return the URL. It ALSO sweeps the expired pending rows, LAST and best-effort.
|
|
78
|
+
*/
|
|
79
|
+
startAuthorization(providerRef: string, subject: OeOauthSubject, options: {
|
|
80
|
+
readonly expiresInMs?: number;
|
|
81
|
+
readonly consent?: unknown;
|
|
82
|
+
}): Promise<{
|
|
83
|
+
url: string;
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* Complete the callback: look up the pending record by `state`, exchange the `code`, store the
|
|
87
|
+
* tokens, and consume the pending row (single-use, verifier scrubbed). Returns the provider ref,
|
|
88
|
+
* the SUBJECT the row was bound for and whether that bind REPLACED an existing row — the three
|
|
89
|
+
* facts the callback route's `connector.credential_bound` audit row carries (OPE-1937). NEVER a
|
|
90
|
+
* token: the caller writes an audit row from this, and no token material may reach it.
|
|
91
|
+
* Throws on unknown/expired/consumed state or an exchange failure — the callback route renders it.
|
|
92
|
+
*/
|
|
93
|
+
completeAuthorization(state: string, code: string): Promise<{
|
|
94
|
+
providerRef: string;
|
|
95
|
+
subject: OeOauthSubject;
|
|
96
|
+
rebound: boolean;
|
|
97
|
+
consent?: unknown;
|
|
98
|
+
}>;
|
|
99
|
+
/**
|
|
100
|
+
* Read-or-refresh the access token. The fast path returns a valid token with no lock; otherwise the two-layer
|
|
101
|
+
* single-flight runs — an in-process mutex, then a cross-process row CLAIM that elects ONE refresher.
|
|
102
|
+
*/
|
|
103
|
+
accessTokenFor(providerRef: string, subject: OeOauthSubject): Promise<string>;
|
|
104
|
+
};
|
|
105
|
+
export type OeOauthTokenVault = ReturnType<typeof makeOeOauthTokenVault>;
|
|
106
|
+
export {};
|
|
107
|
+
//# sourceMappingURL=token-vault.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-vault.d.ts","sourceRoot":"","sources":["../../../agent/oe-oauth/token-vault.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// The own-rolled OAuth token vault — DCR client, pending-authorization and token records over core's
|
|
2
|
+
// encrypted KV. Refresh is single-flight in-process AND cross-process, because the provider ROTATES tokens.
|
|
3
|
+
import { 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";
|
|
7
|
+
import { makePkce, makeState } from "./pkce.js";
|
|
8
|
+
import { OeOauthNeedsConsentError, OeOauthNeedsReconsentError, OeOauthRefreshContendedError, isInvalidGrant } from "./errors.js";
|
|
9
|
+
function isStaticClient(rec) {
|
|
10
|
+
return rec.mode === OE_OAUTH_MODE_STATIC;
|
|
11
|
+
}
|
|
12
|
+
const DEFAULT_PENDING_TTL_MS = 15 * 60 * 1000; // 15 min consent window
|
|
13
|
+
const DEFAULT_EXPIRY_SKEW_MS = 60 * 1000; // refresh when within 60s of expiry
|
|
14
|
+
// How long an expired pending row is KEPT before the sweep destroys it. Not a security window — completion
|
|
15
|
+
// already refuses a row past `expiresAt` — it buys the true error, "authorization state expired".
|
|
16
|
+
const DEFAULT_PENDING_RETENTION_MS = 60 * 60 * 1000;
|
|
17
|
+
// A claim is a LEASE: the winner stamps it on the row with an atomic CAS and releases it. A claim older than
|
|
18
|
+
// the lease TTL counts as abandoned and may be stolen, so a crashed winner never wedges the row.
|
|
19
|
+
export const CLAIM_LEASE_TTL_MS = 30_000;
|
|
20
|
+
// A LOSER never calls the token endpoint — it waits for the winner's rotated token and adopts it, then fails
|
|
21
|
+
// closed with a retryable error. DELIBERATELY shorter than the lease, so it never steals a valid claim.
|
|
22
|
+
const LOSER_WAIT_BUDGET_MS = 15_000;
|
|
23
|
+
// Poll cadence while a loser waits for the winner's rotated token to land.
|
|
24
|
+
const LOSER_POLL_MS = 250;
|
|
25
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
26
|
+
function subjectKey(subject) {
|
|
27
|
+
return subject.type === "app" ? "app" : `user:${subject.id}`;
|
|
28
|
+
}
|
|
29
|
+
function defaultRedirectUri() {
|
|
30
|
+
const base = process.env.OE_PUBLIC_BASE_URL?.trim().replace(/\/+$/, "");
|
|
31
|
+
if (!base)
|
|
32
|
+
return undefined;
|
|
33
|
+
const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`;
|
|
34
|
+
return `${withScheme}${OE_OAUTH_CALLBACK_PATH}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Construct a token vault over the configured providers. One instance is built at registration and
|
|
38
|
+
* shared, so the in-process single-flight lock (core withLock) coordinates all callers in the process.
|
|
39
|
+
*/
|
|
40
|
+
export function makeOeOauthTokenVault(providers, deps = {}) {
|
|
41
|
+
const vault = deps.vault ?? encryptedKvVault();
|
|
42
|
+
const doFetch = deps.fetch ?? ((input, init) => fetch(input, init));
|
|
43
|
+
const now = deps.now ?? Date.now;
|
|
44
|
+
const newState = deps.newState ?? makeState;
|
|
45
|
+
const newPkce = deps.newPkce ?? makePkce;
|
|
46
|
+
const redirectUriOf = deps.redirectUri ?? defaultRedirectUri;
|
|
47
|
+
const pendingTtl = deps.pendingTtlMs ?? DEFAULT_PENDING_TTL_MS;
|
|
48
|
+
const pendingRetention = deps.pendingRetentionMs ?? DEFAULT_PENDING_RETENTION_MS;
|
|
49
|
+
const skew = deps.expirySkewMs ?? DEFAULT_EXPIRY_SKEW_MS;
|
|
50
|
+
const newClaimId = deps.newClaimId ?? (() => randomUUID());
|
|
51
|
+
const sleep = deps.sleep ?? realSleep;
|
|
52
|
+
const resolveSecret = deps.resolveSecret ?? ((broker, ref) => getSecretVia(broker, ref));
|
|
53
|
+
const dcrRef = (providerRef) => `oe-oauth:dcr:${providerRef}`;
|
|
54
|
+
const pendRef = (state) => `oe-oauth:pend:${state}`;
|
|
55
|
+
const tokRef = (providerRef, subject) => `oe-oauth:tok:${providerRef}:${subjectKey(subject)}`;
|
|
56
|
+
function providerConfig(providerRef) {
|
|
57
|
+
const cfg = providers.get(providerRef);
|
|
58
|
+
if (!cfg)
|
|
59
|
+
throw new Error(`connection "${providerRef}": no oe-oauth provider config (set OE_OAUTH_CONFIG / OE_OAUTH_NOTION_RESOURCE).`);
|
|
60
|
+
return cfg;
|
|
61
|
+
}
|
|
62
|
+
async function readJson(ref) {
|
|
63
|
+
const raw = await vault.read(ref);
|
|
64
|
+
if (raw === null)
|
|
65
|
+
return null;
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(raw);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null; // a corrupt record is treated as absent — fail closed to re-consent, never crash a mint
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** True when {@link ensureDcrClient} should attempt a BACKFILL (OPE-1037) — re-run discovery and
|
|
74
|
+
* merge what it learns onto an EXISTING record, never re-register. Only a field discovery alone
|
|
75
|
+
* can supply belongs here; `clientId`/`registrationEndpoint` never change by this path. */
|
|
76
|
+
function dcrRecordNeedsBackfill(rec) {
|
|
77
|
+
return rec.revocationEndpoint === undefined;
|
|
78
|
+
}
|
|
79
|
+
/** Read-or-register the provider's DCR client, under the DCR-ref lock (no double registration).
|
|
80
|
+
* Also BACKFILLS (OPE-1037): a record missing a field the adapter now understands is re-checked
|
|
81
|
+
* against live discovery and merged, never re-registered — see the module header. */
|
|
82
|
+
async function ensureDcrClient(providerRef) {
|
|
83
|
+
const existing = await readJson(dcrRef(providerRef));
|
|
84
|
+
if (existing && !dcrRecordNeedsBackfill(existing))
|
|
85
|
+
return existing;
|
|
86
|
+
const cfg = providerConfig(providerRef);
|
|
87
|
+
// Unreachable through {@link ensureClient}, which routes a static provider away from this
|
|
88
|
+
// function entirely. Stated as a refusal rather than a cast so a future caller that reaches it
|
|
89
|
+
// directly gets an error instead of a discovery request built from an absent `resource`.
|
|
90
|
+
if (isStaticConfidential(cfg))
|
|
91
|
+
throw new Error(`connection "${providerRef}": declares the "${OE_OAUTH_MODE_STATIC}" mode, which registers no dynamic client.`);
|
|
92
|
+
return vault.withLock(dcrRef(providerRef), async () => {
|
|
93
|
+
const again = await readJson(dcrRef(providerRef));
|
|
94
|
+
if (again && !dcrRecordNeedsBackfill(again))
|
|
95
|
+
return again; // another caller already completed it
|
|
96
|
+
if (again) {
|
|
97
|
+
// BACKFILL, not registration: re-discover and merge the missing field(s) onto the record
|
|
98
|
+
// that is already there. Best-effort — a discovery failure or a server that genuinely
|
|
99
|
+
// publishes none must never block the mint the caller actually asked for.
|
|
100
|
+
try {
|
|
101
|
+
const endpoints = await discoverEndpoints(cfg.resource, doFetch);
|
|
102
|
+
if (endpoints.revocationEndpoint === undefined)
|
|
103
|
+
return again; // this provider cannot be told
|
|
104
|
+
const backfilled = { ...again, revocationEndpoint: endpoints.revocationEndpoint };
|
|
105
|
+
await vault.write(dcrRef(providerRef), JSON.stringify(backfilled), { connector: providerRef });
|
|
106
|
+
return backfilled;
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
console.warn(`[oe-oauth] DCR backfill discovery failed for "${providerRef}" (using the frozen record): ${e instanceof Error ? e.message : String(e)}`);
|
|
110
|
+
return again;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const redirectUri = redirectUriOf();
|
|
114
|
+
if (!redirectUri) {
|
|
115
|
+
throw new Error(`connection "${providerRef}": OE_PUBLIC_BASE_URL is unset — cannot register a DCR client without a redirect_uri.`);
|
|
116
|
+
}
|
|
117
|
+
const endpoints = await discoverEndpoints(cfg.resource, doFetch);
|
|
118
|
+
const { clientId } = await registerClient(endpoints.registrationEndpoint, { clientName: cfg.clientName ?? "Open Everywhere", redirectUri, ...(cfg.scopes ? { scopes: cfg.scopes } : {}) }, doFetch);
|
|
119
|
+
const record = {
|
|
120
|
+
clientId,
|
|
121
|
+
authorizationEndpoint: endpoints.authorizationEndpoint,
|
|
122
|
+
tokenEndpoint: endpoints.tokenEndpoint,
|
|
123
|
+
registrationEndpoint: endpoints.registrationEndpoint,
|
|
124
|
+
...(endpoints.revocationEndpoint ? { revocationEndpoint: endpoints.revocationEndpoint } : {}),
|
|
125
|
+
resource: cfg.resource,
|
|
126
|
+
...(cfg.scopes ? { scopes: cfg.scopes } : {}),
|
|
127
|
+
registeredAt: now(),
|
|
128
|
+
};
|
|
129
|
+
await vault.write(dcrRef(providerRef), JSON.stringify(record), { connector: providerRef });
|
|
130
|
+
return record;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/** The client of a `static-confidential` provider, straight from config. NO network call, NO vault
|
|
134
|
+
* read, NO vault write, NO lock — see the module header for why there is nothing to persist. */
|
|
135
|
+
function staticClient(cfg) {
|
|
136
|
+
return {
|
|
137
|
+
mode: OE_OAUTH_MODE_STATIC,
|
|
138
|
+
clientId: cfg.clientId,
|
|
139
|
+
authorizationEndpoint: cfg.authorizationEndpoint,
|
|
140
|
+
tokenEndpoint: cfg.tokenEndpoint,
|
|
141
|
+
...(cfg.revocationEndpoint ? { revocationEndpoint: cfg.revocationEndpoint } : {}),
|
|
142
|
+
...(cfg.scopes ? { scopes: cfg.scopes } : {}),
|
|
143
|
+
...(cfg.authorizeParams ? { authorizeParams: cfg.authorizeParams } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The provider's client, in whichever dialect its config DECLARES. A static provider never reaches
|
|
148
|
+
* `ensureDcrClient`, so it never discovers, registers or writes a DCR row — the two share no network path.
|
|
149
|
+
*/
|
|
150
|
+
async function ensureClient(providerRef) {
|
|
151
|
+
const cfg = providers.get(providerRef);
|
|
152
|
+
return cfg !== undefined && isStaticConfidential(cfg) ? staticClient(cfg) : ensureDcrClient(providerRef);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Resolve a static provider's confidential client, client secret included, for ONE token call.
|
|
156
|
+
*
|
|
157
|
+
* 🔴 THE ERROR NAMES THE REF, NEVER THE VALUE. A missing secret is the ordinary un-provisioned
|
|
158
|
+
* state (the operator has not run `oe secret set` yet), and it has to say which ref to fill —
|
|
159
|
+
* which is a config string, not secret material.
|
|
160
|
+
*/
|
|
161
|
+
async function confidentialClientFor(cfg) {
|
|
162
|
+
const secret = await resolveSecret(cfg.clientSecretBroker, cfg.clientSecretRef);
|
|
163
|
+
if (typeof secret !== "string" || secret === "") {
|
|
164
|
+
throw new Error(`connection "${cfg.ref}": the OAuth client secret at ref "${cfg.clientSecretRef}" (broker "${cfg.clientSecretBroker}") has no stored value — ` +
|
|
165
|
+
`store it with \`oe secret set ${cfg.clientSecretRef}\` before this connection can mint a token.`);
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
clientId: cfg.clientId,
|
|
169
|
+
clientSecret: secret,
|
|
170
|
+
bodyEncoding: cfg.bodyEncoding,
|
|
171
|
+
...(cfg.tokenHeaders ? { headers: cfg.tokenHeaders } : {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Exchange the callback's `code` against ONE pending row, in the dialect that row was minted in.
|
|
176
|
+
* 🔴 The row's stamp and the live config must AGREE: a disagreement is REFUSED rather than resolved.
|
|
177
|
+
*/
|
|
178
|
+
async function exchangeForPending(pending, code) {
|
|
179
|
+
// `providers.get`, not providerConfig: the discovery path never read the config here before this
|
|
180
|
+
// mode existed, and it must not start failing on a config-absent ref now. The drift check below
|
|
181
|
+
// therefore runs only when there IS a config to compare against.
|
|
182
|
+
const cfg = providers.get(pending.providerRef);
|
|
183
|
+
const startedStatic = pending.mode === OE_OAUTH_MODE_STATIC;
|
|
184
|
+
if (cfg !== undefined && isStaticConfidential(cfg) !== startedStatic) {
|
|
185
|
+
throw new Error(`connection "${pending.providerRef}": this authorization started as ${startedStatic ? "a static confidential client" : "a public PKCE client"}, ` +
|
|
186
|
+
`but the provider config no longer declares that mode — start again.`);
|
|
187
|
+
}
|
|
188
|
+
if (startedStatic) {
|
|
189
|
+
if (cfg === undefined || !isStaticConfidential(cfg)) {
|
|
190
|
+
throw new Error(`connection "${pending.providerRef}": no static confidential provider config, so the client that started this authorization cannot be resolved — start again.`);
|
|
191
|
+
}
|
|
192
|
+
return exchangeCodeConfidential(pending.tokenEndpoint, { code, redirectUri: pending.redirectUri }, await confidentialClientFor(cfg), doFetch);
|
|
193
|
+
}
|
|
194
|
+
if (!pending.resource)
|
|
195
|
+
throw new Error(`connection "${pending.providerRef}": this authorization record carries no resource indicator — start again.`);
|
|
196
|
+
return exchangeCode(pending.tokenEndpoint, { code, verifier: pending.pkceVerifier, clientId: pending.clientId, redirectUri: pending.redirectUri, resource: pending.resource }, doFetch);
|
|
197
|
+
}
|
|
198
|
+
function isExpired(rec) {
|
|
199
|
+
return rec.expiresAt !== undefined && now() >= rec.expiresAt - skew;
|
|
200
|
+
}
|
|
201
|
+
async function storeToken(providerRef, subject, tok, priorRefresh) {
|
|
202
|
+
const refresh = tok.refresh_token ?? priorRefresh; // keep the prior refresh if the server didn't rotate
|
|
203
|
+
const record = {
|
|
204
|
+
accessToken: tok.access_token,
|
|
205
|
+
...(refresh ? { refreshToken: refresh } : {}),
|
|
206
|
+
...(typeof tok.expires_in === "number" ? { expiresAt: now() + tok.expires_in * 1000 } : {}),
|
|
207
|
+
...(tok.scope ? { scopes: tok.scope.split(/\s+/).filter(Boolean) } : {}),
|
|
208
|
+
obtainedAt: now(),
|
|
209
|
+
};
|
|
210
|
+
await vault.write(tokRef(providerRef, subject), JSON.stringify(record), { connector: providerRef });
|
|
211
|
+
return record;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* PURGE one (provider, subject) record: vendor revocation FIRST, then the at-rest row. The ORDER makes a
|
|
215
|
+
* racing cross-process refresh benign; where a vendor cannot cascade, the access token lives out its TTL.
|
|
216
|
+
*/
|
|
217
|
+
async function forget(providerRef, subject) {
|
|
218
|
+
const ref = tokRef(providerRef, subject);
|
|
219
|
+
return vault.withLock(ref, async () => {
|
|
220
|
+
const rec = await readJson(ref);
|
|
221
|
+
if (rec) {
|
|
222
|
+
// The client, in whichever dialect this provider declares. A STATIC provider's client comes
|
|
223
|
+
// from config — which is a read, not a registration, so the "never registers" rule above
|
|
224
|
+
// holds on both paths. `providers.get` rather than `providerConfig`: a purge must still
|
|
225
|
+
// destroy the row for a provider whose config an operator has already removed.
|
|
226
|
+
const cfg = providers.get(providerRef);
|
|
227
|
+
const client = cfg && isStaticConfidential(cfg) ? staticClient(cfg) : await readJson(dcrRef(providerRef));
|
|
228
|
+
// Prefer the REFRESH token: revoking it kills the whole grant family (RFC 7009 §2.1),
|
|
229
|
+
// whereas revoking an access token kills only that one.
|
|
230
|
+
const token = rec.refreshToken ?? rec.accessToken;
|
|
231
|
+
if (client?.revocationEndpoint && token) {
|
|
232
|
+
try {
|
|
233
|
+
const hint = rec.refreshToken ? "refresh_token" : "access_token";
|
|
234
|
+
if (cfg && isStaticConfidential(cfg)) {
|
|
235
|
+
await revokeTokenConfidential(client.revocationEndpoint, { token, tokenTypeHint: hint }, await confidentialClientFor(cfg), doFetch);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
await revokeToken(client.revocationEndpoint, { token, clientId: client.clientId, tokenTypeHint: hint }, doFetch);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
// A vendor failure NEVER blocks the local delete — leaving a secret at rest with us
|
|
243
|
+
// because someone else's server is down is the worse of the two outcomes. Message only:
|
|
244
|
+
// the client's errors are built never to carry token material.
|
|
245
|
+
console.warn(`[oe-oauth] revocation at the provider failed for "${providerRef}" (purging locally anyway): ${e instanceof Error ? e.message : String(e)}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Runs even when there was no record to read (a corrupt/undecryptable envelope reads as null —
|
|
250
|
+
// readJson's fail-toward-reconsent semantics — and that row must still be destroyed).
|
|
251
|
+
return vault.delete(ref);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* DESTROY the pending rows past `expiresAt` plus the retention grace: a row keyed by the opaque `state` can
|
|
256
|
+
* never be named again, yet an unconsumed one still holds its PKCE verifier. A row that will not READ is KEPT.
|
|
257
|
+
*/
|
|
258
|
+
async function sweepExpiredPending() {
|
|
259
|
+
const refs = await vault.listRefs("oe-oauth:pend:");
|
|
260
|
+
let deleted = 0;
|
|
261
|
+
let live = 0;
|
|
262
|
+
let unreadable = 0;
|
|
263
|
+
for (const ref of refs) {
|
|
264
|
+
// The read is guarded PER ROW. `readJson` swallows a JSON parse failure but NOT a decrypt
|
|
265
|
+
// failure: `vault.read` THROWS on an undecryptable envelope (a tampered blob, or an AES key
|
|
266
|
+
// rotated out from under the row). Unguarded, ONE such row would abort the whole sweep — and
|
|
267
|
+
// because the mint calls this best-effort, the abort would be swallowed and the sweep would
|
|
268
|
+
// silently do nothing from then on. A row we cannot read is the case this loop must survive,
|
|
269
|
+
// not the case that stops it.
|
|
270
|
+
let rec;
|
|
271
|
+
try {
|
|
272
|
+
rec = await readJson(ref);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
unreadable++;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (!rec || typeof rec.expiresAt !== "number" || !Number.isFinite(rec.expiresAt)) {
|
|
279
|
+
unreadable++;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (now() <= rec.expiresAt + pendingRetention) {
|
|
283
|
+
live++;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (await vault.delete(ref))
|
|
287
|
+
deleted++;
|
|
288
|
+
}
|
|
289
|
+
return { scanned: refs.length, deleted, live, unreadable };
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
subjectKey,
|
|
293
|
+
/** {@link forget} — purge ONE provider's token record for a subject. */
|
|
294
|
+
forget,
|
|
295
|
+
/** {@link sweepExpiredPending} — destroy the expired pending-authorization rows. */
|
|
296
|
+
sweepExpiredPending,
|
|
297
|
+
/**
|
|
298
|
+
* Purge EVERY configured provider's token record for `subject` — only the vault knows the full set. A
|
|
299
|
+
* provider whose purge THROWS does not stop the others, and it rethrows only if every one failed.
|
|
300
|
+
*/
|
|
301
|
+
async forgetAll(subject) {
|
|
302
|
+
let purged = 0;
|
|
303
|
+
let failures = 0;
|
|
304
|
+
let lastError;
|
|
305
|
+
for (const providerRef of providers.keys()) {
|
|
306
|
+
try {
|
|
307
|
+
if (await forget(providerRef, subject))
|
|
308
|
+
purged++;
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
failures++;
|
|
312
|
+
lastError = e;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (failures > 0 && purged === 0 && failures === providers.size)
|
|
316
|
+
throw lastError;
|
|
317
|
+
return purged;
|
|
318
|
+
},
|
|
319
|
+
/**
|
|
320
|
+
* (OPE-93) Cheap EXISTENCE probe: is there a stored token record for (provider, subject) RIGHT NOW?
|
|
321
|
+
* A local vault read only — never a mint, never a refresh. Existence is the right signal even if the
|
|
322
|
+
* access token is expired: the real mint path ({@link accessTokenFor}) refreshes it. A corrupt record
|
|
323
|
+
* counts as absent (mirrors the mint's readJson semantics — fail toward re-consent, never crash).
|
|
324
|
+
*/
|
|
325
|
+
async hasToken(providerRef, subject) {
|
|
326
|
+
return (await readJson(tokRef(providerRef, subject))) !== null;
|
|
327
|
+
},
|
|
328
|
+
/**
|
|
329
|
+
* Start an interactive authorization: ensure the client, mint PKCE and an opaque state, persist the pending
|
|
330
|
+
* row, and return the URL. It ALSO sweeps the expired pending rows, LAST and best-effort.
|
|
331
|
+
*/
|
|
332
|
+
async startAuthorization(providerRef, subject, options) {
|
|
333
|
+
const client = await ensureClient(providerRef);
|
|
334
|
+
const redirectUri = redirectUriOf();
|
|
335
|
+
if (!redirectUri)
|
|
336
|
+
throw new Error(`connection "${providerRef}": OE_PUBLIC_BASE_URL is unset — cannot build a redirect_uri.`);
|
|
337
|
+
const state = newState();
|
|
338
|
+
// A static confidential provider does NOT mint a PKCE pair. The mint is skipped rather than
|
|
339
|
+
// minted-and-discarded, so nothing writes a verifier this flow will never present.
|
|
340
|
+
const pkce = isStaticClient(client) ? { verifier: "", challenge: "" } : newPkce();
|
|
341
|
+
const pending = {
|
|
342
|
+
providerRef,
|
|
343
|
+
subject,
|
|
344
|
+
pkceVerifier: pkce.verifier,
|
|
345
|
+
tokenEndpoint: client.tokenEndpoint,
|
|
346
|
+
clientId: client.clientId,
|
|
347
|
+
...(isStaticClient(client) ? { mode: OE_OAUTH_MODE_STATIC } : { resource: client.resource }),
|
|
348
|
+
redirectUri,
|
|
349
|
+
...(client.scopes ? { scopes: client.scopes } : {}),
|
|
350
|
+
createdAt: now(),
|
|
351
|
+
expiresAt: now() + (options.expiresInMs ?? pendingTtl),
|
|
352
|
+
...(options.consent ? { consent: options.consent } : {}),
|
|
353
|
+
};
|
|
354
|
+
await vault.write(pendRef(state), JSON.stringify(pending), { connector: providerRef });
|
|
355
|
+
const url = isStaticClient(client)
|
|
356
|
+
? buildStaticAuthorizationUrl({
|
|
357
|
+
authorizationEndpoint: client.authorizationEndpoint,
|
|
358
|
+
clientId: client.clientId,
|
|
359
|
+
redirectUri,
|
|
360
|
+
state,
|
|
361
|
+
...(client.scopes ? { scopes: client.scopes } : {}),
|
|
362
|
+
...(client.authorizeParams ? { extraParams: client.authorizeParams } : {}),
|
|
363
|
+
})
|
|
364
|
+
: buildAuthorizationUrl({
|
|
365
|
+
authorizationEndpoint: client.authorizationEndpoint,
|
|
366
|
+
clientId: client.clientId,
|
|
367
|
+
redirectUri,
|
|
368
|
+
state,
|
|
369
|
+
codeChallenge: pkce.challenge,
|
|
370
|
+
resource: client.resource,
|
|
371
|
+
...(client.scopes ? { scopes: client.scopes } : {}),
|
|
372
|
+
});
|
|
373
|
+
try {
|
|
374
|
+
await sweepExpiredPending();
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
console.warn(`[oe-oauth] the expired-pending sweep failed (the authorization URL is unaffected): ${e instanceof Error ? e.message : String(e)}`);
|
|
378
|
+
}
|
|
379
|
+
return { url };
|
|
380
|
+
},
|
|
381
|
+
/**
|
|
382
|
+
* Complete the callback: look up the pending record by `state`, exchange the `code`, store the
|
|
383
|
+
* tokens, and consume the pending row (single-use, verifier scrubbed). Returns the provider ref,
|
|
384
|
+
* the SUBJECT the row was bound for and whether that bind REPLACED an existing row — the three
|
|
385
|
+
* facts the callback route's `connector.credential_bound` audit row carries (OPE-1937). NEVER a
|
|
386
|
+
* token: the caller writes an audit row from this, and no token material may reach it.
|
|
387
|
+
* Throws on unknown/expired/consumed state or an exchange failure — the callback route renders it.
|
|
388
|
+
*/
|
|
389
|
+
async completeAuthorization(state, code) {
|
|
390
|
+
const pending = await readJson(pendRef(state));
|
|
391
|
+
if (!pending)
|
|
392
|
+
throw new Error("unknown or expired authorization state");
|
|
393
|
+
if (pending.consumed)
|
|
394
|
+
throw new Error("authorization state already used");
|
|
395
|
+
if (now() > pending.expiresAt)
|
|
396
|
+
throw new Error("authorization state expired");
|
|
397
|
+
const tok = await exchangeForPending(pending, code);
|
|
398
|
+
// 🔴 READ BEFORE THE WRITE. `storeToken` writes the row unconditionally (last-write-wins), so the
|
|
399
|
+
// only moment the REBIND is observable is here, before it. A second admin replacing the app row
|
|
400
|
+
// every routine spends is the case this flag exists to make visible.
|
|
401
|
+
// 🔴 AND IT IS A READ-BEFORE-WRITE WITH NO CROSS-PROCESS LOCK, so say what it proves. This method
|
|
402
|
+
// takes no lock at all, and `withLock` is a per-key IN-PROCESS mutex — two callbacks landing on
|
|
403
|
+
// separate instances for one subject can both read `null` and both report `false`, though one of
|
|
404
|
+
// them did replace the other. A SERIAL second consent, which is the case the row exists for,
|
|
405
|
+
// always reports `true`. Closing the window needs a compare-and-set write in the store, not here.
|
|
406
|
+
const rebound = (await readJson(tokRef(pending.providerRef, pending.subject))) !== null;
|
|
407
|
+
await storeToken(pending.providerRef, pending.subject, tok);
|
|
408
|
+
// Consume single-use + scrub the verifier (defeats replay; the verifier is spent).
|
|
409
|
+
// (OPE-1619) SCRUB THE CARRIAGE HERE TOO, for the same reason and at the same moment. It was
|
|
410
|
+
// spent the instant it was handed back below, and it holds a room id, a thread id, a
|
|
411
|
+
// principal id and a paused-session id — data with no remaining purpose. Leaving it until
|
|
412
|
+
// `sweepExpiredPending` collects the row would keep one person's conversation addresses at
|
|
413
|
+
// rest for the whole retention window past their last use.
|
|
414
|
+
await vault.write(pendRef(state), JSON.stringify({ ...pending, consumed: true, pkceVerifier: "", consent: undefined }), { connector: pending.providerRef });
|
|
415
|
+
// (OPE-1619) Hand the carriage back verbatim. The three refusals above run FIRST, so a consent
|
|
416
|
+
// that arrives on an unknown, spent or EXPIRED state returns no carriage at all — there is
|
|
417
|
+
// nothing for the return hop to resume from, which is where "past the TTL, a fresh ask" is
|
|
418
|
+
// enforced at its root rather than by a second check downstream.
|
|
419
|
+
return { providerRef: pending.providerRef, subject: pending.subject, rebound, ...(pending.consent ? { consent: pending.consent } : {}) };
|
|
420
|
+
},
|
|
421
|
+
/**
|
|
422
|
+
* Read-or-refresh the access token. The fast path returns a valid token with no lock; otherwise the two-layer
|
|
423
|
+
* single-flight runs — an in-process mutex, then a cross-process row CLAIM that elects ONE refresher.
|
|
424
|
+
*/
|
|
425
|
+
async accessTokenFor(providerRef, subject) {
|
|
426
|
+
const ref = tokRef(providerRef, subject);
|
|
427
|
+
const fast = await readJson(ref);
|
|
428
|
+
if (!fast)
|
|
429
|
+
throw new OeOauthNeedsConsentError(providerRef, subject);
|
|
430
|
+
if (!isExpired(fast))
|
|
431
|
+
return fast.accessToken;
|
|
432
|
+
return vault.withLock(ref, async () => {
|
|
433
|
+
const rec = await readJson(ref);
|
|
434
|
+
if (!rec)
|
|
435
|
+
throw new OeOauthNeedsConsentError(providerRef, subject);
|
|
436
|
+
if (!isExpired(rec))
|
|
437
|
+
return rec.accessToken; // an in-process winner already refreshed
|
|
438
|
+
if (!rec.refreshToken)
|
|
439
|
+
throw new OeOauthNeedsReconsentError(providerRef, subject, "no refresh token stored");
|
|
440
|
+
const presentedRefresh = rec.refreshToken;
|
|
441
|
+
// Layer 2 — CROSS-PROCESS single-flight. The in-process mutex above serialises THIS process;
|
|
442
|
+
// the atomic row-claim serialises across processes/instances (advisory locks are unusable over
|
|
443
|
+
// the pooled, session-less PgClient — see the kv-vault module doc). Exactly one claimant refreshes.
|
|
444
|
+
const claimId = newClaimId();
|
|
445
|
+
const won = await vault.claim(ref, claimId, now(), CLAIM_LEASE_TTL_MS);
|
|
446
|
+
if (won) {
|
|
447
|
+
try {
|
|
448
|
+
// `providers.get`, not providerConfig — see {@link ensureClient}: a discovery refresh
|
|
449
|
+
// must still resolve from a complete DCR record when the config entry is absent.
|
|
450
|
+
const cfg = providers.get(providerRef);
|
|
451
|
+
let tok;
|
|
452
|
+
try {
|
|
453
|
+
// The dialect decides the refresh call and nothing else about this block. A static
|
|
454
|
+
// provider takes no discovery hop and no DCR read on its way here.
|
|
455
|
+
if (cfg !== undefined && isStaticConfidential(cfg)) {
|
|
456
|
+
tok = await refreshTokenConfidential(cfg.tokenEndpoint, { refreshToken: presentedRefresh, ...(cfg.scopes ? { scopes: cfg.scopes } : {}) }, await confidentialClientFor(cfg), doFetch);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
const client = await ensureDcrClient(providerRef);
|
|
460
|
+
tok = await refreshToken(client.tokenEndpoint, { refreshToken: presentedRefresh, clientId: client.clientId, resource: client.resource, ...(client.scopes ? { scopes: client.scopes } : {}) }, doFetch);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
catch (e) {
|
|
464
|
+
if (isInvalidGrant(e)) {
|
|
465
|
+
// Last-resort net: another process rotated in the sliver before we claimed (or a stale
|
|
466
|
+
// lease let two refresh). If the stored refresh token advanced and the access token is
|
|
467
|
+
// fresh, adopt it instead of bricking the grant.
|
|
468
|
+
const rec2 = await readJson(ref);
|
|
469
|
+
if (rec2 && !isExpired(rec2) && rec2.refreshToken !== rec.refreshToken)
|
|
470
|
+
return rec2.accessToken;
|
|
471
|
+
throw new OeOauthNeedsReconsentError(providerRef, subject, "refresh token rejected (invalid_grant)");
|
|
472
|
+
}
|
|
473
|
+
throw e;
|
|
474
|
+
}
|
|
475
|
+
const stored = await storeToken(providerRef, subject, tok, rec.refreshToken);
|
|
476
|
+
return stored.accessToken;
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
await vault.releaseClaim(ref, claimId);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// Lost the claim — a live winner (another instance) is refreshing. Do NOT touch the token
|
|
483
|
+
// endpoint (presenting the consumed refresh token would trip reuse detection). Wait for the
|
|
484
|
+
// winner's rotated token to land and adopt it; bound the wait and fail closed on timeout.
|
|
485
|
+
const deadline = now() + LOSER_WAIT_BUDGET_MS;
|
|
486
|
+
for (;;) {
|
|
487
|
+
const rec2 = await readJson(ref);
|
|
488
|
+
if (rec2 && !isExpired(rec2))
|
|
489
|
+
return rec2.accessToken; // adopted the winner's fresh token
|
|
490
|
+
if (now() >= deadline)
|
|
491
|
+
throw new OeOauthRefreshContendedError(providerRef, subject);
|
|
492
|
+
await sleep(LOSER_POLL_MS);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
//# sourceMappingURL=token-vault.js.map
|