@desplega.ai/agent-swarm 1.119.6 → 1.121.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/README.md +2 -2
- package/dist/{actions-qe5zjcbr.js → actions-mwshtkf7.js} +6 -6
- package/dist/{anthropic-messages-qjxzsabf.js → anthropic-messages-4gn944cn.js} +29 -13
- package/dist/{app-31x7k3vj.js → app-skbty91x.js} +4 -4
- package/dist/{assistant-4crt9dve.js → assistant-tg7dhfj8.js} +13 -13
- package/dist/{azure-openai-responses-ntfd05zd.js → azure-openai-responses-79zc0wyy.js} +28 -13
- package/dist/{boot-reembed-8g69ttz4.js → boot-reembed-ntjmeyh2.js} +4 -4
- package/dist/{boot-reembed-q648j733.js → boot-reembed-q5x0xpcp.js} +5 -5
- package/dist/{boot-scrub-logs-sfmzd2hj.js → boot-scrub-logs-5gznsfbe.js} +4 -4
- package/dist/{claude-adapter-9n3nzt65.js → claude-adapter-vxvcym3r.js} +3 -3
- package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-g807894s.js} +2 -2
- package/dist/{cli-bpvgs2y6.js → cli-072t49hf.js} +2123 -1533
- package/dist/{cli-g6qz2kts.js → cli-0df2g86x.js} +336 -18
- package/dist/cli-1728qh70.js +791 -0
- package/dist/{cli-vg1jxfqz.js → cli-3b1ayz83.js} +5 -5
- package/dist/{cli-r93nz80j.js → cli-3fxqwfh3.js} +1 -1
- package/dist/{cli-w6rhc30s.js → cli-3jrdwnn9.js} +20 -5
- package/dist/{cli-pasyk3vc.js → cli-46etyprn.js} +10 -5
- package/dist/{cli-433c0f4b.js → cli-4pfweq4x.js} +8 -8
- package/dist/{cli-2r7s8jhp.js → cli-5c4327dx.js} +7 -1
- package/dist/{cli-4s84mw72.js → cli-5hpvd15x.js} +2 -2
- package/dist/{cli-branks1w.js → cli-6s3ytc5x.js} +1 -1
- package/dist/{cli-da3x3btq.js → cli-7pt9sc83.js} +2 -2
- package/dist/{cli-2gv8ygwr.js → cli-7pzae66m.js} +2 -2
- package/dist/{cli-qs7nb3zn.js → cli-8wrympd7.js} +27 -1
- package/dist/{cli-qw1ccvxm.js → cli-95rbe11h.js} +70 -51
- package/dist/{cli-6rvm3jkk.js → cli-agdrxyex.js} +1 -1
- package/dist/{cli-wr706gez.js → cli-av2s7my4.js} +167 -66
- package/dist/{cli-59jy217p.js → cli-avw9ev3x.js} +55 -21
- package/dist/{cli-fkxxh5d4.js → cli-b2mqkg1h.js} +2 -2
- package/dist/{cli-1arw00dp.js → cli-ccjwgarc.js} +12 -3
- package/dist/{cli-pscrc6tw.js → cli-ch7t5v7n.js} +5 -0
- package/dist/{cli-tghre7g6.js → cli-cw70hymf.js} +1 -0
- package/dist/{cli-n61x2aph.js → cli-dmzv0986.js} +816 -1335
- package/dist/{cli-d1reabed.js → cli-g9vva2qh.js} +2 -2
- package/dist/{cli-ner6af7m.js → cli-gdpx70nc.js} +24048 -23715
- package/dist/{cli-96feg24n.js → cli-ghq3v4c9.js} +2 -2
- package/dist/{cli-wxbnjgce.js → cli-ky8esb8t.js} +3 -3
- package/dist/{cli-gdqdkr3j.js → cli-nf7sadev.js} +2 -2
- package/dist/{cli-h6cf69x5.js → cli-p9w7kb3h.js} +1 -1
- package/dist/{cli-xeatwfbe.js → cli-ry656xem.js} +24 -17
- package/dist/{cli-q8dway4w.js → cli-s8bdcke2.js} +1 -1
- package/dist/cli-s8s499xy.js +81 -0
- package/dist/{cli-sbkwvn5e.js → cli-srjqn7fd.js} +1 -1
- package/dist/cli-tv7vekye.js +367 -0
- package/dist/{cli-gg5bd32n.js → cli-vg3zrw6h.js} +4 -4
- package/dist/{cli-vx0y68jv.js → cli-vp9r4ap4.js} +5 -4
- package/dist/{cli-mg280wsv.js → cli-we156bmz.js} +1 -1
- package/dist/{cli-86fdnerz.js → cli-wwsaj97q.js} +9 -4
- package/dist/{cli-e0y603nn.js → cli-x0e0yvnq.js} +101 -1
- package/dist/{cli-mw3xpgwb.js → cli-x4apc9t3.js} +4 -4
- package/dist/cli.js +26 -21
- package/dist/{codex-adapter-6j2b38tg.js → codex-adapter-mmy741zd.js} +8 -9
- package/dist/{codex-session-runner-vz215wgw.js → codex-session-runner-sazmx9zt.js} +8 -9
- package/dist/{commands-6xr20jbe.js → commands-cxxye5e4.js} +3 -3
- package/dist/{db-1mmvk1z4.js → db-ab1954b7.js} +5 -3
- package/dist/{google-generative-ai-n1d3xt4n.js → google-generative-ai-wwa2ked5.js} +14 -18
- package/dist/{google-vertex-mxst23gy.js → google-vertex-71xmdd7n.js} +14 -18
- package/dist/{handlers-xvrknk8w.js → handlers-fmfe2343.js} +13 -13
- package/dist/{hook-qm7he8wn.js → hook-ygnw3cvt.js} +7 -8
- package/dist/{http-hj08zq67.js → http-kactcc0z.js} +1128 -536
- package/dist/{index-688jph6a.js → index-6erq0wvk.js} +11 -11
- package/dist/{index-gze4fg2w.js → index-g86j2yt5.js} +9 -9
- package/dist/{index-etz7d8md.js → index-gmzs6a2q.js} +10 -10
- package/dist/{index-wj8dfpwh.js → index-hmp82wn0.js} +12 -12
- package/dist/{keepalive-94wn9yxw.js → keepalive-3cvt26dw.js} +18 -14
- package/dist/{lead-rvcqkp11.js → lead-ysf26cjr.js} +31 -32
- package/dist/{maintenance-qhfc4dnp.js → maintenance-9npwptbb.js} +5 -5
- package/dist/{mistral-conversations-ykcg66za.js → mistral-conversations-26ya4yah.js} +17 -13
- package/dist/{oauth-refresh-sweep-rj8wgvsw.js → oauth-refresh-sweep-0r2v76vw.js} +23 -17
- package/dist/{onboard-8bv8q1wc.js → onboard-ddb1674a.js} +2 -2
- package/dist/{openai-codex-responses-zy0907j9.js → openai-codex-responses-6rr2ye0s.js} +78 -38
- package/dist/{openai-completions-xk4p56n9.js → openai-completions-zfa9nkvt.js} +130 -33
- package/dist/{openai-responses-4gv3ca5d.js → openai-responses-9wmdks0g.js} +38 -14
- package/dist/{opencode-adapter-5bwzvbwj.js → opencode-adapter-h6rkxwrk.js} +2 -2
- package/dist/{openrouter-images-4fsrrzes.js → openrouter-images-dgq98729.js} +10 -3
- package/dist/{otel-impl-1kf6fr2m.js → otel-impl-b43bkq8e.js} +4 -4
- package/dist/{pi-messages-zb8v76wx.js → pi-messages-nzjbbz47.js} +4 -6
- package/dist/{pi-mono-adapter-9sg69kg3.js → pi-mono-adapter-ke789738.js} +2328 -1281
- package/dist/{pricing-refresh-e84tar2s.js → pricing-refresh-2rqf114z.js} +5 -5
- package/dist/{rbac-roles-atytq5ch.js → rbac-roles-5wkhc8xv.js} +5 -5
- package/dist/{rbac-roles-pg0zvnwn.js → rbac-roles-6vtafvzb.js} +4 -4
- package/dist/{seed-pricing-xd6nxq7k.js → seed-pricing-23wrb873.js} +4 -4
- package/dist/{setup-4z389yk0.js → setup-6rny4fk6.js} +2 -2
- package/dist/{worker-m61r0t7m.js → worker-yapewe14.js} +31 -32
- package/openapi.json +2671 -1811
- package/package.json +10 -5
- package/plugin/commands/swarm-chat.md +2 -0
- package/plugin/pi-skills/swarm-chat/SKILL.md +2 -0
- package/src/be/connection-bindings-blob-migration.ts +123 -0
- package/src/be/db-queries/mcp-oauth.ts +409 -247
- package/src/be/db-queries/oauth.ts +813 -131
- package/src/be/db.ts +97 -6
- package/src/be/migrations/117_unified_oauth.sql +624 -0
- package/src/be/migrations/119_agent_avatar.sql +7 -0
- package/src/be/migrations/120_task_title.sql +3 -0
- package/src/be/oauth-credential-bindings.ts +48 -14
- package/src/be/oauth-encryption-backfill.ts +84 -0
- package/src/be/oauth-refresh-sweep.ts +38 -23
- package/src/be/script-connections.ts +1130 -163
- package/src/be/script-credential-broker.ts +37 -27
- package/src/be/scripts/typecheck.ts +3 -2
- package/src/be/seed-capabilities.ts +62 -0
- package/src/be/seed-pricing.ts +1 -1
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
- package/src/be/vendored-openapi.ts +98 -0
- package/src/commands/runner.ts +69 -6
- package/src/http/agents.ts +23 -5
- package/src/http/all-routes.ts +1 -0
- package/src/http/core.ts +8 -5
- package/src/http/index.ts +18 -5
- package/src/http/mcp-bridge.ts +5 -2
- package/src/http/mcp-oauth.ts +103 -101
- package/src/http/oauth-callback.ts +332 -0
- package/src/http/oauth-generic.ts +14 -54
- package/src/http/poll.ts +5 -1
- package/src/http/script-connections.ts +651 -170
- package/src/http/scripts.ts +4 -2
- package/src/http/sessions.ts +1 -1
- package/src/http/tasks.ts +35 -0
- package/src/http/trackers/jira.ts +22 -34
- package/src/http/trackers/linear.ts +21 -36
- package/src/http/x.ts +6 -2
- package/src/jira/app.ts +12 -6
- package/src/jira/oauth.ts +48 -48
- package/src/linear/app.ts +44 -2
- package/src/linear/oauth.ts +12 -37
- package/src/oauth/app-validation.ts +27 -9
- package/src/oauth/ensure-mcp-token.ts +1 -0
- package/src/oauth/ensure-token.ts +331 -108
- package/src/oauth/identity-capture.ts +103 -0
- package/src/oauth/keepalive.ts +14 -9
- package/src/oauth/presets.ts +242 -0
- package/src/oauth/wrapper.ts +197 -76
- package/src/prompts/base-prompt.ts +48 -4
- package/src/prompts/session-templates.ts +13 -1
- package/src/providers/claude-managed-models.ts +8 -0
- package/src/rbac/legacy-policy.ts +2 -1
- package/src/rbac/permissions.ts +7 -3
- package/src/scripts-runtime/credential-broker/broker.ts +51 -8
- package/src/scripts-runtime/credential-broker/fetch-patch.ts +30 -3
- package/src/scripts-runtime/credential-broker/index.ts +1 -1
- package/src/scripts-runtime/credential-broker/types.ts +55 -8
- package/src/scripts-runtime/egress-secrets.ts +6 -2
- package/src/scripts-runtime/eval-harness.ts +2 -2
- package/src/scripts-runtime/executors/types.ts +2 -1
- package/src/scripts-runtime/loader.ts +3 -0
- package/src/scripts-runtime/types/stdlib.d.ts +11 -0
- package/src/server.ts +230 -126
- package/src/tests/agent-avatar-resolution.test.ts +123 -0
- package/src/tests/agents-list-model-display.test.ts +10 -0
- package/src/tests/base-prompt.test.ts +72 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/connection-embedded-auth.test.ts +709 -0
- package/src/tests/context-window.test.ts +1 -0
- package/src/tests/credential-broker.test.ts +123 -125
- package/src/tests/db-queries-oauth.test.ts +126 -1
- package/src/tests/ensure-token.test.ts +44 -2
- package/src/tests/list-endpoint-slimming.test.ts +45 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +20 -1
- package/src/tests/mcp-oauth-queries.test.ts +154 -8
- package/src/tests/oauth-access-token-tool.test.ts +37 -1
- package/src/tests/oauth-callback-flow.test.ts +534 -0
- package/src/tests/oauth-credential-bindings.test.ts +50 -22
- package/src/tests/oauth-presets.test.ts +275 -0
- package/src/tests/oauth-refresh-failure.test.ts +263 -0
- package/src/tests/oauth-refresh-sweep.test.ts +83 -3
- package/src/tests/oauth-wrapper.test.ts +77 -55
- package/src/tests/prompt-template-session.test.ts +6 -5
- package/src/tests/rbac-charact-misc-tools.test.ts +60 -0
- package/src/tests/rbac-engine.test.ts +2 -1
- package/src/tests/script-connections-http.test.ts +356 -11
- package/src/tests/script-connections.test.ts +102 -0
- package/src/tests/sdk-allowlist.test.ts +4 -1
- package/src/tests/seed-capabilities.test.ts +92 -0
- package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
- package/src/tests/sessions.test.ts +46 -0
- package/src/tests/spec-base-url.test.ts +266 -0
- package/src/tests/status.test.ts +3 -4
- package/src/tests/swarm-config-reserved-keys.test.ts +4 -2
- package/src/tests/task-title-route.test.ts +106 -0
- package/src/tests/tool-annotations.test.ts +6 -3
- package/src/tests/tracker-fold.test.ts +412 -0
- package/src/tests/unified-oauth-migration.test.ts +455 -0
- package/src/tests/vendored-openapi.test.ts +263 -0
- package/src/tools/credential-bindings/tool.ts +232 -77
- package/src/tools/oauth-access-token.ts +55 -6
- package/src/tools/script-connections/tool.ts +73 -53
- package/src/tools/swarm-config/set-config.ts +24 -50
- package/src/tools/update-profile.ts +15 -5
- package/src/tracker/types.ts +12 -0
- package/src/types.ts +29 -0
- package/src/utils/context-window.ts +1 -0
- package/src/workflows/executors/swarm-script.ts +6 -2
- package/vendored-openapi/README.md +22 -0
- package/vendored-openapi/github.json +7759 -0
- package/vendored-openapi/gmail.json +393 -0
- package/vendored-openapi/jira.json +3396 -0
- package/vendored-openapi/linear.json +252 -0
- package/vendored-openapi/manifest.json +128 -0
- package/vendored-openapi/slack.json +2895 -0
- package/dist/cli-0pygq9r1.js +0 -429
- package/dist/cli-8hhms4hp.js +0 -393
- package/dist/cli-z9gzq07x.js +0 -27
- package/src/scripts-runtime/credential-broker/store.ts +0 -72
- package/dist/{otel-g299k41b.js → otel-63jr5v38.js} +1 -1
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
acquireOAuthRefreshLock,
|
|
3
|
+
getAuthorizationById,
|
|
4
|
+
getDefaultAuthorizationIdForProvider,
|
|
3
5
|
getOAuthApp,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
+
getOAuthAppById,
|
|
7
|
+
markAuthorizationRefreshFailed,
|
|
8
|
+
type OAuthAuthorization,
|
|
6
9
|
releaseOAuthRefreshLock,
|
|
10
|
+
updateAuthorizationTokens,
|
|
7
11
|
} from "../be/db-queries/oauth";
|
|
8
|
-
import type { OAuthApp
|
|
9
|
-
import {
|
|
12
|
+
import type { OAuthApp } from "../tracker/types";
|
|
13
|
+
import { registerVolatileSecret, scrubSecrets } from "../utils/secret-scrubber";
|
|
14
|
+
import { type OAuthProviderConfig, performTokenRefreshRequest } from "./wrapper";
|
|
10
15
|
|
|
11
16
|
function parseMetadata(metadataJson: string | null | undefined): Record<string, unknown> {
|
|
12
17
|
try {
|
|
@@ -31,9 +36,11 @@ function stringRecord(value: unknown): Record<string, string> | undefined {
|
|
|
31
36
|
*/
|
|
32
37
|
export function oauthAppRowToProviderConfig(app: OAuthApp): OAuthProviderConfig {
|
|
33
38
|
const metadata = parseMetadata(app.metadata);
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
(typeof metadata.actor === "string" ? { actor: metadata.actor } :
|
|
39
|
+
const liftedExtraParams = stringRecord(parseMetadata(app.extraParamsJson));
|
|
40
|
+
const extraParams = {
|
|
41
|
+
...(typeof metadata.actor === "string" ? { actor: metadata.actor } : {}),
|
|
42
|
+
...liftedExtraParams,
|
|
43
|
+
};
|
|
37
44
|
|
|
38
45
|
return {
|
|
39
46
|
provider: app.provider,
|
|
@@ -46,37 +53,66 @@ export function oauthAppRowToProviderConfig(app: OAuthApp): OAuthProviderConfig
|
|
|
46
53
|
.split(",")
|
|
47
54
|
.map((scope) => scope.trim())
|
|
48
55
|
.filter(Boolean),
|
|
49
|
-
extraParams,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
...(metadata.tokenBodyFormat === "json" ? { tokenBodyFormat: "json" as const } : {}),
|
|
55
|
-
requiresRefreshTokenRotation: app.provider === "jira",
|
|
56
|
+
extraParams: Object.keys(extraParams).length > 0 ? extraParams : undefined,
|
|
57
|
+
scopeSeparator: app.scopeSeparator,
|
|
58
|
+
tokenAuthStyle: app.tokenAuthStyle,
|
|
59
|
+
tokenBodyFormat: app.tokenBodyFormat,
|
|
60
|
+
requiresRefreshTokenRotation: app.requiresRefreshTokenRotation,
|
|
56
61
|
};
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const REFRESH_LOCK_POLL_MS = 250;
|
|
64
|
+
// ─── Typed refresh error ─────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export type OAuthRefreshFailureReason = "refresh_rejected" | "lock_timeout" | "no_refresh_token";
|
|
63
67
|
|
|
64
68
|
/**
|
|
65
|
-
*
|
|
69
|
+
* Thrown by the authorization-keyed refresh path when an expiring authorization
|
|
70
|
+
* cannot be refreshed. Carries structured context so downstream surfaces (the
|
|
71
|
+
* credential broker → script failedBindings, HTTP endpoints, alerting) can
|
|
72
|
+
* report *which* authorization broke and *why* instead of a silent drop.
|
|
73
|
+
*
|
|
74
|
+
* Shape precedent: {@link CodexOAuthRefreshError} in
|
|
75
|
+
* src/providers/codex-oauth/storage.ts.
|
|
66
76
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
export class OAuthRefreshError extends Error {
|
|
78
|
+
constructor(
|
|
79
|
+
public readonly authorizationId: string,
|
|
80
|
+
public readonly appId: string,
|
|
81
|
+
public readonly reason: OAuthRefreshFailureReason,
|
|
82
|
+
public readonly authorizationLabel: string,
|
|
83
|
+
message?: string,
|
|
84
|
+
) {
|
|
85
|
+
super(message ?? `OAuth authorization '${authorizationLabel}' refresh failed (${reason})`);
|
|
86
|
+
this.name = "OAuthRefreshError";
|
|
87
|
+
}
|
|
70
88
|
}
|
|
71
89
|
|
|
72
|
-
|
|
73
|
-
const
|
|
90
|
+
function authorizationLabelFor(app: OAuthApp, authorization: OAuthAuthorization): string {
|
|
91
|
+
const base = app.displayName?.trim() || app.provider;
|
|
92
|
+
return authorization.label && authorization.label !== "default"
|
|
93
|
+
? `${base} (${authorization.label})`
|
|
94
|
+
: base;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Refresh locks (in-process queue + cross-process DB lock) ─────────────────
|
|
98
|
+
|
|
99
|
+
const refreshLocks = new Map<string, Promise<void>>();
|
|
100
|
+
const REFRESH_LOCK_TTL_MS = 2 * 60 * 1000;
|
|
101
|
+
const REFRESH_LOCK_WAIT_MS = 30 * 1000;
|
|
102
|
+
const REFRESH_LOCK_POLL_MS = 250;
|
|
103
|
+
const DEFAULT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
|
104
|
+
|
|
105
|
+
async function withAuthorizationRefreshLock<T>(
|
|
106
|
+
authorizationId: string,
|
|
107
|
+
fn: () => Promise<T>,
|
|
108
|
+
): Promise<T> {
|
|
109
|
+
const previous = refreshLocks.get(authorizationId) ?? Promise.resolve();
|
|
74
110
|
let release!: () => void;
|
|
75
111
|
const current = new Promise<void>((resolve) => {
|
|
76
112
|
release = resolve;
|
|
77
113
|
});
|
|
78
114
|
const next = previous.catch(() => undefined).then(() => current);
|
|
79
|
-
refreshLocks.set(
|
|
115
|
+
refreshLocks.set(authorizationId, next);
|
|
80
116
|
|
|
81
117
|
await previous.catch(() => undefined);
|
|
82
118
|
|
|
@@ -84,8 +120,8 @@ async function withProviderRefreshLock<T>(provider: string, fn: () => Promise<T>
|
|
|
84
120
|
return await fn();
|
|
85
121
|
} finally {
|
|
86
122
|
release();
|
|
87
|
-
if (refreshLocks.get(
|
|
88
|
-
refreshLocks.delete(
|
|
123
|
+
if (refreshLocks.get(authorizationId) === next) {
|
|
124
|
+
refreshLocks.delete(authorizationId);
|
|
89
125
|
}
|
|
90
126
|
}
|
|
91
127
|
}
|
|
@@ -94,120 +130,307 @@ function sleep(ms: number): Promise<void> {
|
|
|
94
130
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
95
131
|
}
|
|
96
132
|
|
|
97
|
-
function tokenRowChanged(current: OAuthTokens | null, observed: OAuthTokens | null): boolean {
|
|
98
|
-
if (!observed) return current !== null;
|
|
99
|
-
if (!current) return true;
|
|
100
|
-
return (
|
|
101
|
-
current.accessToken !== observed.accessToken ||
|
|
102
|
-
current.refreshToken !== observed.refreshToken ||
|
|
103
|
-
current.expiresAt !== observed.expiresAt
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
133
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
134
|
+
* An authorization needs a refresh when it is expiring within `bufferMs`, is
|
|
135
|
+
* already in a non-terminal broken state (`refresh-failed` / `expired`), or
|
|
136
|
+
* when `force` is set — so the sweep and on-demand resolution retry broken
|
|
137
|
+
* rows each pass and self-heal once the provider recovers.
|
|
111
138
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* refresh token to
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* Keepalive callers should pass a larger value (e.g. 13h) to force
|
|
119
|
-
* a proactive refresh well before the token actually expires.
|
|
139
|
+
* A NULL `expiresAt` means the provider issued a non-expiring token (e.g. the
|
|
140
|
+
* GitHub OAuth preset, which has no refresh token). It is treated as "does not
|
|
141
|
+
* expire / never proactively refresh" — NOT as expired. Refreshing it would
|
|
142
|
+
* mark it `refresh-failed` (no refresh token to rotate) while the token is
|
|
143
|
+
* still valid. Only an explicit `force` (manual refresh) or a broken status
|
|
144
|
+
* refreshes such a row.
|
|
120
145
|
*/
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
146
|
+
function authorizationNeedsRefresh(
|
|
147
|
+
authorization: OAuthAuthorization,
|
|
148
|
+
bufferMs = DEFAULT_REFRESH_BUFFER_MS,
|
|
149
|
+
force = false,
|
|
150
|
+
): boolean {
|
|
151
|
+
if (authorization.status === "refresh-failed" || authorization.status === "expired") return true;
|
|
152
|
+
if (force) return true;
|
|
153
|
+
if (!authorization.expiresAt) return false;
|
|
154
|
+
const expiresAt = new Date(authorization.expiresAt).getTime();
|
|
155
|
+
if (Number.isNaN(expiresAt)) return true;
|
|
156
|
+
return expiresAt - Date.now() < bufferMs;
|
|
130
157
|
}
|
|
131
158
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
159
|
+
// ─── Refresh notification (cached provider-client invalidation) ──────────────
|
|
160
|
+
//
|
|
161
|
+
// A background sweep or another request path can rotate an authorization's
|
|
162
|
+
// access token while a module-level SDK client still holds the stale one (the
|
|
163
|
+
// Linear `LinearClient` cache is the canonical case). Listeners registered here
|
|
164
|
+
// fire after every successful persist so those caches can invalidate — closing
|
|
165
|
+
// the staleness gap that used to require a reactive `resetLinearClient()` call
|
|
166
|
+
// on the same code path as the refresh.
|
|
167
|
+
|
|
168
|
+
export interface AuthorizationRefreshedEvent {
|
|
169
|
+
authorizationId: string;
|
|
170
|
+
appId: string;
|
|
171
|
+
provider: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type AuthorizationRefreshedListener = (event: AuthorizationRefreshedEvent) => void;
|
|
175
|
+
|
|
176
|
+
const authorizationRefreshedListeners = new Set<AuthorizationRefreshedListener>();
|
|
137
177
|
|
|
138
178
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* background refresh sweep's keep-alive path.
|
|
143
|
-
*
|
|
144
|
-
* Like {@link ensureTokenOrThrow}, stays silent (no throw) when the provider
|
|
145
|
-
* has no app config or no stored refresh token — callers that need those to
|
|
146
|
-
* be errors must pre-check.
|
|
179
|
+
* Subscribe to successful authorization token refreshes. Returns an
|
|
180
|
+
* unsubscribe function. Listeners must be cheap + non-throwing (throws are
|
|
181
|
+
* caught and logged so one bad listener can't fail a refresh).
|
|
147
182
|
*/
|
|
148
|
-
export
|
|
149
|
-
|
|
183
|
+
export function onAuthorizationRefreshed(listener: AuthorizationRefreshedListener): () => void {
|
|
184
|
+
authorizationRefreshedListeners.add(listener);
|
|
185
|
+
return () => {
|
|
186
|
+
authorizationRefreshedListeners.delete(listener);
|
|
187
|
+
};
|
|
150
188
|
}
|
|
151
189
|
|
|
190
|
+
function notifyAuthorizationRefreshed(event: AuthorizationRefreshedEvent): void {
|
|
191
|
+
for (const listener of authorizationRefreshedListeners) {
|
|
192
|
+
try {
|
|
193
|
+
listener(event);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
console.error(
|
|
196
|
+
scrubSecrets(
|
|
197
|
+
`[OAuth] authorization-refreshed listener failed for ${event.provider}: ${
|
|
198
|
+
err instanceof Error ? err.message : String(err)
|
|
199
|
+
}`,
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── Authorization-keyed core ────────────────────────────────────────────────
|
|
207
|
+
|
|
152
208
|
/**
|
|
153
|
-
* Strict
|
|
154
|
-
*
|
|
209
|
+
* Strict authorization-keyed refresh. Refreshes the given authorization if it
|
|
210
|
+
* is expiring (or already broken), persisting `status='active'` +
|
|
211
|
+
* `lastRefreshedAt` on success and `status='refresh-failed'` +
|
|
212
|
+
* `lastErrorMessage` (scrubbed) + a thrown {@link OAuthRefreshError} on
|
|
213
|
+
* failure.
|
|
155
214
|
*
|
|
156
|
-
* Stays silent (no throw) when the
|
|
157
|
-
*
|
|
158
|
-
*
|
|
215
|
+
* Stays silent (no throw, no status change) when the authorization is missing,
|
|
216
|
+
* revoked, or has no provider-facing app config — those are "not connected"
|
|
217
|
+
* states, not refresh failures.
|
|
159
218
|
*/
|
|
160
|
-
export async function
|
|
161
|
-
|
|
162
|
-
|
|
219
|
+
export async function ensureAuthorizationTokenOrThrow(
|
|
220
|
+
authorizationId: string,
|
|
221
|
+
bufferMs?: number,
|
|
222
|
+
force = false,
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
const initial = getAuthorizationById(authorizationId);
|
|
225
|
+
if (!initial || initial.status === "revoked") return;
|
|
226
|
+
if (!authorizationNeedsRefresh(initial, bufferMs, force)) return;
|
|
163
227
|
|
|
164
|
-
await
|
|
228
|
+
await withAuthorizationRefreshLock(authorizationId, async () => {
|
|
165
229
|
const waitStartedAt = Date.now();
|
|
166
230
|
|
|
167
|
-
while (
|
|
168
|
-
const
|
|
169
|
-
if (
|
|
231
|
+
while (true) {
|
|
232
|
+
const current = getAuthorizationById(authorizationId);
|
|
233
|
+
if (!current || current.status === "revoked") return;
|
|
234
|
+
// Another caller refreshed (tokenVersion bumped) since this call loaded
|
|
235
|
+
// the row — its work satisfies ours. Without this check a waiter that
|
|
236
|
+
// force-refreshes would rotate again immediately after the winner,
|
|
237
|
+
// burning provider refresh calls.
|
|
238
|
+
if (current.tokenVersion !== initial.tokenVersion) return;
|
|
239
|
+
if (!authorizationNeedsRefresh(current, bufferMs, force)) return;
|
|
240
|
+
|
|
241
|
+
const app = getOAuthAppById(current.appId);
|
|
242
|
+
// No provider-facing app config (missing, or a DCR/MCP app): nothing this
|
|
243
|
+
// path can refresh — leave status untouched.
|
|
244
|
+
if (!app || app.mcpServerId !== null) return;
|
|
170
245
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
);
|
|
176
|
-
return;
|
|
246
|
+
if (!current.refreshToken) {
|
|
247
|
+
const label = authorizationLabelFor(app, current);
|
|
248
|
+
const message = `OAuth authorization '${label}' cannot refresh: no refresh token stored`;
|
|
249
|
+
markAuthorizationRefreshFailed(authorizationId, message);
|
|
250
|
+
throw new OAuthRefreshError(authorizationId, app.id, "no_refresh_token", label, message);
|
|
177
251
|
}
|
|
178
252
|
|
|
179
|
-
const lockOwner = acquireOAuthRefreshLock(
|
|
253
|
+
const lockOwner = acquireOAuthRefreshLock(`authz:${authorizationId}`, REFRESH_LOCK_TTL_MS);
|
|
180
254
|
if (!lockOwner) {
|
|
181
255
|
if (Date.now() - waitStartedAt > REFRESH_LOCK_WAIT_MS) {
|
|
182
|
-
|
|
256
|
+
const label = authorizationLabelFor(app, current);
|
|
257
|
+
const message = `Timed out waiting for OAuth refresh lock for '${label}'`;
|
|
258
|
+
markAuthorizationRefreshFailed(authorizationId, message);
|
|
259
|
+
throw new OAuthRefreshError(authorizationId, app.id, "lock_timeout", label, message);
|
|
183
260
|
}
|
|
184
261
|
await sleep(REFRESH_LOCK_POLL_MS);
|
|
185
262
|
continue;
|
|
186
263
|
}
|
|
187
264
|
|
|
188
265
|
try {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
|
|
266
|
+
// Re-read under the cross-process lock: another node may have refreshed
|
|
267
|
+
// (tokenVersion bumped → no longer expiring) or revoked while we waited.
|
|
268
|
+
const locked = getAuthorizationById(authorizationId);
|
|
269
|
+
if (!locked || locked.status === "revoked") return;
|
|
270
|
+
if (locked.tokenVersion !== initial.tokenVersion) return;
|
|
271
|
+
if (!authorizationNeedsRefresh(locked, bufferMs, force)) return;
|
|
272
|
+
|
|
273
|
+
const lockedApp = getOAuthAppById(locked.appId);
|
|
274
|
+
if (!lockedApp || lockedApp.mcpServerId !== null) return;
|
|
196
275
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
276
|
+
if (!locked.refreshToken) {
|
|
277
|
+
const label = authorizationLabelFor(lockedApp, locked);
|
|
278
|
+
const message = `OAuth authorization '${label}' cannot refresh: no refresh token stored`;
|
|
279
|
+
markAuthorizationRefreshFailed(authorizationId, message);
|
|
280
|
+
throw new OAuthRefreshError(
|
|
281
|
+
authorizationId,
|
|
282
|
+
lockedApp.id,
|
|
283
|
+
"no_refresh_token",
|
|
284
|
+
label,
|
|
285
|
+
message,
|
|
201
286
|
);
|
|
202
|
-
return;
|
|
203
287
|
}
|
|
204
288
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
289
|
+
const config = oauthAppRowToProviderConfig(lockedApp);
|
|
290
|
+
try {
|
|
291
|
+
const refreshed = await performTokenRefreshRequest(config, locked.refreshToken);
|
|
292
|
+
const persisted = updateAuthorizationTokens(authorizationId, {
|
|
293
|
+
accessToken: refreshed.accessToken,
|
|
294
|
+
refreshToken: refreshed.refreshToken ?? locked.refreshToken,
|
|
295
|
+
expiresAt: refreshed.expiresAt,
|
|
296
|
+
scope: refreshed.scope ?? null,
|
|
297
|
+
expectedTokenVersion: locked.tokenVersion,
|
|
298
|
+
});
|
|
299
|
+
if (!persisted) {
|
|
300
|
+
// Optimistic-concurrency loss to a concurrent writer — re-evaluate.
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
notifyAuthorizationRefreshed({
|
|
304
|
+
authorizationId,
|
|
305
|
+
appId: lockedApp.id,
|
|
306
|
+
provider: lockedApp.provider,
|
|
307
|
+
});
|
|
308
|
+
return;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
const label = authorizationLabelFor(lockedApp, locked);
|
|
311
|
+
// The provider token endpoint can echo the submitted refresh_token /
|
|
312
|
+
// client_secret back in an invalid_grant body (which lands in
|
|
313
|
+
// err.message). scrubSecrets only knows env/vendor-fixed shapes, so
|
|
314
|
+
// register this attempt's own secrets as volatile before scrubbing —
|
|
315
|
+
// otherwise they'd be persisted to lastErrorMessage and surfaced in
|
|
316
|
+
// the UI tooltip.
|
|
317
|
+
if (locked.refreshToken)
|
|
318
|
+
registerVolatileSecret(locked.refreshToken, "oauth-refresh-token");
|
|
319
|
+
if (locked.accessToken) registerVolatileSecret(locked.accessToken, "oauth-access-token");
|
|
320
|
+
if (lockedApp.clientSecret)
|
|
321
|
+
registerVolatileSecret(lockedApp.clientSecret, "oauth-client-secret");
|
|
322
|
+
const message = scrubSecrets(err instanceof Error ? err.message : String(err));
|
|
323
|
+
markAuthorizationRefreshFailed(authorizationId, message);
|
|
324
|
+
if (err instanceof OAuthRefreshError) throw err;
|
|
325
|
+
throw new OAuthRefreshError(
|
|
326
|
+
authorizationId,
|
|
327
|
+
lockedApp.id,
|
|
328
|
+
"refresh_rejected",
|
|
329
|
+
label,
|
|
330
|
+
message,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
208
333
|
} finally {
|
|
209
|
-
releaseOAuthRefreshLock(
|
|
334
|
+
releaseOAuthRefreshLock(`authz:${authorizationId}`, lockOwner);
|
|
210
335
|
}
|
|
211
336
|
}
|
|
212
337
|
});
|
|
213
338
|
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Reactive authorization-keyed refresh — never throws. Refresh failures are
|
|
342
|
+
* persisted (`refresh-failed`) by {@link ensureAuthorizationTokenOrThrow} and
|
|
343
|
+
* logged here so a single dead-token incident doesn't tear down an unrelated
|
|
344
|
+
* request path.
|
|
345
|
+
*/
|
|
346
|
+
export async function ensureAuthorizationToken(
|
|
347
|
+
authorizationId: string,
|
|
348
|
+
bufferMs?: number,
|
|
349
|
+
): Promise<void> {
|
|
350
|
+
try {
|
|
351
|
+
await ensureAuthorizationTokenOrThrow(authorizationId, bufferMs);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
console.error(
|
|
354
|
+
scrubSecrets(
|
|
355
|
+
`[OAuth] Failed to refresh authorization ${authorizationId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Force a refresh of a specific authorization regardless of remaining lifetime,
|
|
363
|
+
* through the same lock + persistence path. Used by the manual refresh endpoint
|
|
364
|
+
* and the background sweep.
|
|
365
|
+
*/
|
|
366
|
+
export async function forceRefreshAuthorizationOrThrow(authorizationId: string): Promise<void> {
|
|
367
|
+
// `force` refreshes regardless of remaining lifetime — including non-expiring
|
|
368
|
+
// (NULL expiresAt) tokens, which the buffer path deliberately skips.
|
|
369
|
+
await ensureAuthorizationTokenOrThrow(authorizationId, undefined, true);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── Provider-string compatibility wrappers ──────────────────────────────────
|
|
373
|
+
//
|
|
374
|
+
// Resolve a provider to its migrated `default` authorization and delegate to
|
|
375
|
+
// the authorization-keyed core. Tracker callers migrate off these in step-8.
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Reactive per-provider refresh — never throws. See {@link ensureToken} history:
|
|
379
|
+
* refresh failures are logged, not surfaced.
|
|
380
|
+
*/
|
|
381
|
+
export async function ensureToken(provider: string, bufferMs?: number): Promise<void> {
|
|
382
|
+
try {
|
|
383
|
+
await ensureTokenOrThrow(provider, bufferMs);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
console.error(
|
|
386
|
+
`[OAuth] Failed to refresh ${provider} token:`,
|
|
387
|
+
err instanceof Error ? err.message : err,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Strict per-provider refresh. Delegates to the default authorization.
|
|
394
|
+
*
|
|
395
|
+
* Stays silent (no throw) when the provider isn't connected (no app / no
|
|
396
|
+
* default authorization) or the default authorization has no stored refresh
|
|
397
|
+
* token — those are "not connected" states, preserved for legacy tracker /
|
|
398
|
+
* oauth-access-token callers. A genuine provider rejection still throws.
|
|
399
|
+
*/
|
|
400
|
+
export async function ensureTokenOrThrow(provider: string, bufferMs?: number): Promise<void> {
|
|
401
|
+
const authorizationId = getDefaultAuthorizationIdForProvider(provider);
|
|
402
|
+
if (!authorizationId) return;
|
|
403
|
+
try {
|
|
404
|
+
await ensureAuthorizationTokenOrThrow(authorizationId, bufferMs);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
if (err instanceof OAuthRefreshError && err.reason === "no_refresh_token") {
|
|
407
|
+
// Legacy provider-string semantics: a provider with no refresh token is
|
|
408
|
+
// "not connected", not a refresh failure — stay silent.
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
throw err;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Force-refresh a provider's default authorization. Silent when not connected.
|
|
417
|
+
*/
|
|
418
|
+
export async function forceRefreshTokenOrThrow(provider: string): Promise<void> {
|
|
419
|
+
const authorizationId = getDefaultAuthorizationIdForProvider(provider);
|
|
420
|
+
if (!authorizationId) return;
|
|
421
|
+
try {
|
|
422
|
+
await forceRefreshAuthorizationOrThrow(authorizationId);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
if (err instanceof OAuthRefreshError && err.reason === "no_refresh_token") return;
|
|
425
|
+
throw err;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Build an OAuthProviderConfig from the oauth_apps table for any provider.
|
|
431
|
+
* Retained for callers that only need the provider config (not a refresh).
|
|
432
|
+
*/
|
|
433
|
+
export function getOAuthConfig(provider: string): OAuthProviderConfig | null {
|
|
434
|
+
const app = getOAuthApp(provider);
|
|
435
|
+
return app ? oauthAppRowToProviderConfig(app) : null;
|
|
436
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { scrubSecrets } from "@/utils/secret-scrubber";
|
|
2
|
+
import { assertOAuthEgressUrlSafe } from "./app-validation";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Best-effort account identity captured after a successful token exchange.
|
|
6
|
+
* Display-only — never used for authorization decisions.
|
|
7
|
+
*/
|
|
8
|
+
export interface CapturedIdentity {
|
|
9
|
+
accountEmail: string | null;
|
|
10
|
+
/** JSON string of the raw identity claims we surfaced (email/login/sub). */
|
|
11
|
+
identityJson: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const IDENTITY_FETCH_TIMEOUT_MS = 5_000;
|
|
15
|
+
|
|
16
|
+
function pickString(value: unknown): string | null {
|
|
17
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Extract the display-relevant identity fields from a claims/userinfo object. */
|
|
21
|
+
function extractIdentity(source: Record<string, unknown>): CapturedIdentity | null {
|
|
22
|
+
const email = pickString(source.email);
|
|
23
|
+
const login = pickString(source.login) ?? pickString(source.preferred_username);
|
|
24
|
+
const sub = pickString(source.sub) ?? pickString(source.id);
|
|
25
|
+
if (!email && !login && !sub) return null;
|
|
26
|
+
const claims: Record<string, string> = {};
|
|
27
|
+
if (email) claims.email = email;
|
|
28
|
+
if (login) claims.login = login;
|
|
29
|
+
if (sub) claims.sub = sub;
|
|
30
|
+
return { accountEmail: email, identityJson: JSON.stringify(claims) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Decode a JWT id_token payload without signature verification (display-only). */
|
|
34
|
+
function decodeIdToken(idToken: string): CapturedIdentity | null {
|
|
35
|
+
const parts = idToken.split(".");
|
|
36
|
+
const payloadSegment = parts[1];
|
|
37
|
+
if (!payloadSegment) return null;
|
|
38
|
+
try {
|
|
39
|
+
const payload = Buffer.from(
|
|
40
|
+
payloadSegment.replace(/-/g, "+").replace(/_/g, "/"),
|
|
41
|
+
"base64",
|
|
42
|
+
).toString("utf8");
|
|
43
|
+
const parsed = JSON.parse(payload);
|
|
44
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
45
|
+
return extractIdentity(parsed as Record<string, unknown>);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Capture the connected account's identity after a token exchange. Prefers a
|
|
53
|
+
* `userinfoUrl` probe (Bearer), falling back to decoding an OIDC `id_token`.
|
|
54
|
+
* All failures are swallowed (scrubbed log) — identity capture must NEVER fail
|
|
55
|
+
* the OAuth callback. Returns null when nothing could be captured.
|
|
56
|
+
*/
|
|
57
|
+
export async function captureIdentity(input: {
|
|
58
|
+
userinfoUrl?: string | null;
|
|
59
|
+
accessToken: string;
|
|
60
|
+
idToken?: string | null;
|
|
61
|
+
}): Promise<CapturedIdentity | null> {
|
|
62
|
+
try {
|
|
63
|
+
if (input.userinfoUrl) {
|
|
64
|
+
// Fail-closed host re-check at egress: we are about to send a live bearer.
|
|
65
|
+
assertOAuthEgressUrlSafe(input.userinfoUrl);
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const timer = setTimeout(() => controller.abort(), IDENTITY_FETCH_TIMEOUT_MS);
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(input.userinfoUrl, {
|
|
70
|
+
headers: {
|
|
71
|
+
authorization: `Bearer ${input.accessToken}`,
|
|
72
|
+
accept: "application/json",
|
|
73
|
+
},
|
|
74
|
+
signal: controller.signal,
|
|
75
|
+
// Never follow a redirect: a public userinfoUrl could 302 the bearer
|
|
76
|
+
// toward an internal metadata endpoint. A 3xx is not `.ok`, so it is
|
|
77
|
+
// simply ignored below.
|
|
78
|
+
redirect: "manual",
|
|
79
|
+
});
|
|
80
|
+
if (response.ok) {
|
|
81
|
+
const data = (await response.json()) as unknown;
|
|
82
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
83
|
+
const identity = extractIdentity(data as Record<string, unknown>);
|
|
84
|
+
if (identity) return identity;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (input.idToken) {
|
|
93
|
+
return decodeIdToken(input.idToken);
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.warn(
|
|
97
|
+
scrubSecrets(
|
|
98
|
+
`[oauth] identity capture failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|