@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
package/src/http/mcp-bridge.ts
CHANGED
|
@@ -11,8 +11,11 @@ let _bridgeServer: McpServer | null = null;
|
|
|
11
11
|
function getBridgeServer(): McpServer {
|
|
12
12
|
if (!_bridgeServer) {
|
|
13
13
|
// Always full tool surface: the bridge is how scripts reach every SDK
|
|
14
|
-
// tool, including when SCRIPTS_ONLY_MCP trims the external MCP server
|
|
15
|
-
|
|
14
|
+
// tool, including when SCRIPTS_ONLY_MCP trims the external MCP server or
|
|
15
|
+
// CAPABILITIES trims the externally exposed tool groups. Capability flags
|
|
16
|
+
// shape the agents' MCP tool list, not what scripts can do — the scripts
|
|
17
|
+
// surface is governed by SDK_ALLOWLIST instead.
|
|
18
|
+
_bridgeServer = createServer({ scriptsOnly: false, fullSurface: true });
|
|
16
19
|
}
|
|
17
20
|
return _bridgeServer;
|
|
18
21
|
}
|
package/src/http/mcp-oauth.ts
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
applyMcpOAuthRefresh,
|
|
7
7
|
consumeMcpOAuthPending,
|
|
8
8
|
deleteMcpOAuthToken,
|
|
9
|
-
gcMcpOAuthPending,
|
|
10
9
|
getMcpOAuthToken,
|
|
11
10
|
insertMcpOAuthPending,
|
|
12
11
|
setMcpServerAuthMethod,
|
|
@@ -407,6 +406,103 @@ async function prepareAuthorizeFlow(
|
|
|
407
406
|
return built.url;
|
|
408
407
|
}
|
|
409
408
|
|
|
409
|
+
// ─── Callback completion (shared with the static /api/oauth/callback route) ──
|
|
410
|
+
|
|
411
|
+
interface OAuthCallbackParams {
|
|
412
|
+
code?: string;
|
|
413
|
+
state?: string;
|
|
414
|
+
error?: string;
|
|
415
|
+
error_description?: string;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Complete an MCP-flow OAuth callback: consume the `mcp` pending row, exchange
|
|
420
|
+
* the code, upsert the token, and flip `authMethod=oauth`. Shared by the legacy
|
|
421
|
+
* `/api/mcp-oauth/callback` route and the unified `/api/oauth/callback` route.
|
|
422
|
+
* Returns false (without writing a response) when no `mcp` pending row matches
|
|
423
|
+
* `state`, so the unified handler can report a single invalid-state error.
|
|
424
|
+
*/
|
|
425
|
+
export async function completeMcpOAuthCallback(
|
|
426
|
+
res: ServerResponse,
|
|
427
|
+
query: OAuthCallbackParams,
|
|
428
|
+
): Promise<boolean> {
|
|
429
|
+
const state = query.state;
|
|
430
|
+
if (!state) return false;
|
|
431
|
+
const pending = consumeMcpOAuthPending(state);
|
|
432
|
+
if (!pending) return false;
|
|
433
|
+
|
|
434
|
+
const dashboardBaseUrl = pending.finalRedirect ?? defaultFinalRedirect(pending.mcpServerId);
|
|
435
|
+
|
|
436
|
+
if (query.error) {
|
|
437
|
+
const target = new URL(dashboardBaseUrl);
|
|
438
|
+
target.searchParams.set("oauth", "error");
|
|
439
|
+
target.searchParams.set("error", query.error);
|
|
440
|
+
if (query.error_description) {
|
|
441
|
+
target.searchParams.set("error_description", query.error_description);
|
|
442
|
+
}
|
|
443
|
+
res.writeHead(302, { Location: target.toString() });
|
|
444
|
+
res.end();
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!query.code) {
|
|
449
|
+
jsonError(res, "Missing authorization code", 400);
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
const tokens = await exchangeCodeForTokens({
|
|
455
|
+
tokenUrl: pending.tokenUrl,
|
|
456
|
+
clientId: pending.dcrClientId ?? "",
|
|
457
|
+
clientSecret: pending.dcrClientSecret ?? undefined,
|
|
458
|
+
redirectUri: pending.redirectUri,
|
|
459
|
+
code: query.code,
|
|
460
|
+
codeVerifier: pending.codeVerifier,
|
|
461
|
+
resource: pending.resourceUrl,
|
|
462
|
+
});
|
|
463
|
+
const existing = getMcpOAuthToken(pending.mcpServerId, pending.userId);
|
|
464
|
+
const clientSource =
|
|
465
|
+
existing?.clientSource ??
|
|
466
|
+
(pending.dcrClientId ? ("dcr" as const) : ("preregistered" as const));
|
|
467
|
+
|
|
468
|
+
upsertMcpOAuthToken({
|
|
469
|
+
mcpServerId: pending.mcpServerId,
|
|
470
|
+
userId: pending.userId,
|
|
471
|
+
accessToken: tokens.access_token,
|
|
472
|
+
...(tokens.refresh_token != null ? { refreshToken: tokens.refresh_token } : {}),
|
|
473
|
+
tokenType: tokens.token_type ?? "Bearer",
|
|
474
|
+
expiresAt: computeExpiresAt(tokens.expires_in),
|
|
475
|
+
scope: tokens.scope ?? pending.scopes ?? null,
|
|
476
|
+
resourceUrl: pending.resourceUrl,
|
|
477
|
+
authorizationServerIssuer: pending.authorizationServerIssuer,
|
|
478
|
+
authorizeUrl: pending.authorizeUrl,
|
|
479
|
+
tokenUrl: pending.tokenUrl,
|
|
480
|
+
revocationUrl: pending.revocationUrl,
|
|
481
|
+
dcrClientId: pending.dcrClientId,
|
|
482
|
+
dcrClientSecret: pending.dcrClientSecret,
|
|
483
|
+
clientSource,
|
|
484
|
+
lastRefreshedAt: new Date().toISOString(),
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// Flip authMethod=oauth so resolveSecrets picks this up.
|
|
488
|
+
setMcpServerAuthMethod(pending.mcpServerId, "oauth");
|
|
489
|
+
|
|
490
|
+
const target = new URL(dashboardBaseUrl);
|
|
491
|
+
target.searchParams.set("oauth", "success");
|
|
492
|
+
res.writeHead(302, { Location: target.toString() });
|
|
493
|
+
res.end();
|
|
494
|
+
} catch (err) {
|
|
495
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
496
|
+
console.error("[mcp-oauth] callback exchange failed:", message);
|
|
497
|
+
const target = new URL(dashboardBaseUrl);
|
|
498
|
+
target.searchParams.set("oauth", "error");
|
|
499
|
+
target.searchParams.set("error_description", message);
|
|
500
|
+
res.writeHead(302, { Location: target.toString() });
|
|
501
|
+
res.end();
|
|
502
|
+
}
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
|
|
410
506
|
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
411
507
|
|
|
412
508
|
export async function handleMcpOAuth(
|
|
@@ -420,85 +516,13 @@ export async function handleMcpOAuth(
|
|
|
420
516
|
const parsed = await callbackRoute.parse(req, res, pathSegments, queryParams);
|
|
421
517
|
if (!parsed) return true;
|
|
422
518
|
|
|
423
|
-
|
|
424
|
-
if (!state) {
|
|
519
|
+
if (!parsed.query.state) {
|
|
425
520
|
jsonError(res, "Missing state parameter", 400);
|
|
426
521
|
return true;
|
|
427
522
|
}
|
|
428
|
-
const
|
|
429
|
-
if (!
|
|
523
|
+
const handled = await completeMcpOAuthCallback(res, parsed.query);
|
|
524
|
+
if (!handled) {
|
|
430
525
|
jsonError(res, "Invalid or expired OAuth state", 400);
|
|
431
|
-
return true;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
const dashboardBaseUrl = pending.finalRedirect ?? defaultFinalRedirect(pending.mcpServerId);
|
|
435
|
-
|
|
436
|
-
if (parsed.query.error) {
|
|
437
|
-
const target = new URL(dashboardBaseUrl);
|
|
438
|
-
target.searchParams.set("oauth", "error");
|
|
439
|
-
target.searchParams.set("error", parsed.query.error);
|
|
440
|
-
if (parsed.query.error_description) {
|
|
441
|
-
target.searchParams.set("error_description", parsed.query.error_description);
|
|
442
|
-
}
|
|
443
|
-
res.writeHead(302, { Location: target.toString() });
|
|
444
|
-
res.end();
|
|
445
|
-
return true;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
if (!parsed.query.code) {
|
|
449
|
-
jsonError(res, "Missing authorization code", 400);
|
|
450
|
-
return true;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
try {
|
|
454
|
-
const tokens = await exchangeCodeForTokens({
|
|
455
|
-
tokenUrl: pending.tokenUrl,
|
|
456
|
-
clientId: pending.dcrClientId ?? "",
|
|
457
|
-
clientSecret: pending.dcrClientSecret ?? undefined,
|
|
458
|
-
redirectUri: pending.redirectUri,
|
|
459
|
-
code: parsed.query.code,
|
|
460
|
-
codeVerifier: pending.codeVerifier,
|
|
461
|
-
resource: pending.resourceUrl,
|
|
462
|
-
});
|
|
463
|
-
const existing = getMcpOAuthToken(pending.mcpServerId, pending.userId);
|
|
464
|
-
const clientSource =
|
|
465
|
-
existing?.clientSource ??
|
|
466
|
-
(pending.dcrClientId ? ("dcr" as const) : ("preregistered" as const));
|
|
467
|
-
|
|
468
|
-
upsertMcpOAuthToken({
|
|
469
|
-
mcpServerId: pending.mcpServerId,
|
|
470
|
-
userId: pending.userId,
|
|
471
|
-
accessToken: tokens.access_token,
|
|
472
|
-
refreshToken: tokens.refresh_token ?? null,
|
|
473
|
-
tokenType: tokens.token_type ?? "Bearer",
|
|
474
|
-
expiresAt: computeExpiresAt(tokens.expires_in),
|
|
475
|
-
scope: tokens.scope ?? pending.scopes ?? null,
|
|
476
|
-
resourceUrl: pending.resourceUrl,
|
|
477
|
-
authorizationServerIssuer: pending.authorizationServerIssuer,
|
|
478
|
-
authorizeUrl: pending.authorizeUrl,
|
|
479
|
-
tokenUrl: pending.tokenUrl,
|
|
480
|
-
revocationUrl: pending.revocationUrl,
|
|
481
|
-
dcrClientId: pending.dcrClientId,
|
|
482
|
-
dcrClientSecret: pending.dcrClientSecret,
|
|
483
|
-
clientSource,
|
|
484
|
-
lastRefreshedAt: new Date().toISOString(),
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
// Flip authMethod=oauth so resolveSecrets picks this up.
|
|
488
|
-
setMcpServerAuthMethod(pending.mcpServerId, "oauth");
|
|
489
|
-
|
|
490
|
-
const target = new URL(dashboardBaseUrl);
|
|
491
|
-
target.searchParams.set("oauth", "success");
|
|
492
|
-
res.writeHead(302, { Location: target.toString() });
|
|
493
|
-
res.end();
|
|
494
|
-
} catch (err) {
|
|
495
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
496
|
-
console.error("[mcp-oauth] callback exchange failed:", message);
|
|
497
|
-
const target = new URL(dashboardBaseUrl);
|
|
498
|
-
target.searchParams.set("oauth", "error");
|
|
499
|
-
target.searchParams.set("error_description", message);
|
|
500
|
-
res.writeHead(302, { Location: target.toString() });
|
|
501
|
-
res.end();
|
|
502
526
|
}
|
|
503
527
|
return true;
|
|
504
528
|
}
|
|
@@ -639,6 +663,7 @@ export async function handleMcpOAuth(
|
|
|
639
663
|
refreshToken: refreshed.refresh_token ?? undefined,
|
|
640
664
|
expiresAt: computeExpiresAt(refreshed.expires_in),
|
|
641
665
|
scope: refreshed.scope ?? null,
|
|
666
|
+
expectedTokenVersion: existing.tokenVersion,
|
|
642
667
|
});
|
|
643
668
|
json(res, {
|
|
644
669
|
ok: true,
|
|
@@ -764,31 +789,8 @@ export async function handleMcpOAuth(
|
|
|
764
789
|
return false;
|
|
765
790
|
}
|
|
766
791
|
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
let gcTimer: ReturnType<typeof setInterval> | null = null;
|
|
770
|
-
|
|
771
|
-
export function startMcpOAuthPendingGc(intervalMs = 5 * 60 * 1000): void {
|
|
772
|
-
if (gcTimer) return;
|
|
773
|
-
gcTimer = setInterval(() => {
|
|
774
|
-
try {
|
|
775
|
-
const removed = gcMcpOAuthPending();
|
|
776
|
-
if (removed > 0) {
|
|
777
|
-
console.debug(`[mcp-oauth] GC removed ${removed} expired pending session(s)`);
|
|
778
|
-
}
|
|
779
|
-
} catch (err) {
|
|
780
|
-
console.error("[mcp-oauth] GC failed:", err);
|
|
781
|
-
}
|
|
782
|
-
}, intervalMs);
|
|
783
|
-
if (typeof gcTimer?.unref === "function") gcTimer.unref();
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
export function stopMcpOAuthPendingGc(): void {
|
|
787
|
-
if (gcTimer) {
|
|
788
|
-
clearInterval(gcTimer);
|
|
789
|
-
gcTimer = null;
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
+
// Pending garbage collection now runs through the unified GC in
|
|
793
|
+
// `oauth-callback.ts` (`startOAuthPendingGc`), which sweeps all flows.
|
|
792
794
|
|
|
793
795
|
// Expose internal helpers for the resolveSecrets extension in mcp-servers.ts.
|
|
794
796
|
export { ensureMcpToken };
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { gcMcpOAuthPending } from "@/be/db-queries/mcp-oauth";
|
|
4
|
+
import {
|
|
5
|
+
consumeOAuthPending,
|
|
6
|
+
gcOAuthPending,
|
|
7
|
+
getOAuthAppById,
|
|
8
|
+
updateAuthorizationIdentity,
|
|
9
|
+
upsertAuthorization,
|
|
10
|
+
} from "@/be/db-queries/oauth";
|
|
11
|
+
import { resolveAndStoreJiraCloudId } from "@/jira/oauth";
|
|
12
|
+
import { captureLinearAppUserId } from "@/linear/oauth";
|
|
13
|
+
import { oauthAppRowToProviderConfig } from "@/oauth/ensure-token";
|
|
14
|
+
import { captureIdentity } from "@/oauth/identity-capture";
|
|
15
|
+
import { exchangeAuthorizationCode } from "@/oauth/wrapper";
|
|
16
|
+
import { getPublicMcpBaseUrl } from "@/utils/constants";
|
|
17
|
+
import { registerVolatileSecret, scrubSecrets } from "@/utils/secret-scrubber";
|
|
18
|
+
import { completeMcpOAuthCallback } from "./mcp-oauth";
|
|
19
|
+
import { route } from "./route-def";
|
|
20
|
+
import { json, jsonError } from "./utils";
|
|
21
|
+
|
|
22
|
+
// ─── The single static callback + redirect-uri display ───────────────────────
|
|
23
|
+
|
|
24
|
+
/** The constant, state-keyed OAuth redirect target for all flows. */
|
|
25
|
+
export function staticOAuthCallbackUri(): string {
|
|
26
|
+
return `${getPublicMcpBaseUrl()}/api/oauth/callback`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const callbackRoute = route({
|
|
30
|
+
method: "get",
|
|
31
|
+
path: "/api/oauth/callback",
|
|
32
|
+
pattern: ["api", "oauth", "callback"],
|
|
33
|
+
operationId: "oauth_static_callback",
|
|
34
|
+
summary: "Single static OAuth redirect target (state-keyed, all flows)",
|
|
35
|
+
tags: ["OAuth"],
|
|
36
|
+
auth: { apiKey: false },
|
|
37
|
+
query: z.object({
|
|
38
|
+
code: z.string().optional(),
|
|
39
|
+
state: z.string().optional(),
|
|
40
|
+
error: z.string().optional(),
|
|
41
|
+
error_description: z.string().optional(),
|
|
42
|
+
}),
|
|
43
|
+
responses: {
|
|
44
|
+
200: { description: "OAuth authorization completed" },
|
|
45
|
+
302: { description: "Redirect back to the final destination" },
|
|
46
|
+
400: { description: "Missing or invalid OAuth callback parameters" },
|
|
47
|
+
404: { description: "OAuth app not configured" },
|
|
48
|
+
502: { description: "Token exchange failed" },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const redirectUriRoute = route({
|
|
53
|
+
method: "get",
|
|
54
|
+
path: "/api/oauth/redirect-uri",
|
|
55
|
+
pattern: ["api", "oauth", "redirect-uri"],
|
|
56
|
+
operationId: "oauth_redirect_uri",
|
|
57
|
+
summary: "The static OAuth callback URL to register with providers (pre-creation display)",
|
|
58
|
+
tags: ["OAuth"],
|
|
59
|
+
responses: {
|
|
60
|
+
200: { description: "{ redirectUri: string }" },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
interface OAuthCallbackParams {
|
|
65
|
+
code?: string;
|
|
66
|
+
state?: string;
|
|
67
|
+
error?: string;
|
|
68
|
+
error_description?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Escape a value for safe interpolation into the success HTML page. */
|
|
72
|
+
function escapeHtml(value: string): string {
|
|
73
|
+
return value
|
|
74
|
+
.replace(/&/g, "&")
|
|
75
|
+
.replace(/</g, "<")
|
|
76
|
+
.replace(/>/g, ">")
|
|
77
|
+
.replace(/"/g, """)
|
|
78
|
+
.replace(/'/g, "'");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sendAuthorizedHtml(res: ServerResponse, provider: string, label: string): void {
|
|
82
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
83
|
+
res.end(`<!DOCTYPE html>
|
|
84
|
+
<html>
|
|
85
|
+
<head><title>OAuth Authorized</title></head>
|
|
86
|
+
<body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
|
|
87
|
+
<main style="text-align: center;">
|
|
88
|
+
<h1>${escapeHtml(provider)} authorized</h1>
|
|
89
|
+
<p>Connected the "${escapeHtml(label)}" authorization. You can close this tab.</p>
|
|
90
|
+
</main>
|
|
91
|
+
</body>
|
|
92
|
+
</html>`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 302 to a caller-supplied `finalRedirect`. Rejects non-http(s) schemes
|
|
97
|
+
* (javascript:/data:) so the redirect target can never carry script; an origin
|
|
98
|
+
* allowlist is out of scope for step-4 (noted for a follow-up). Falls back to a
|
|
99
|
+
* plain error page when the target is unsafe.
|
|
100
|
+
*/
|
|
101
|
+
function redirectWith(res: ServerResponse, base: string, params: Record<string, string>): void {
|
|
102
|
+
let target: URL;
|
|
103
|
+
try {
|
|
104
|
+
target = new URL(base);
|
|
105
|
+
} catch {
|
|
106
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
107
|
+
res.end("Invalid redirect target.");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
111
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
112
|
+
res.end("Unsupported redirect scheme.");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const [key, value] of Object.entries(params)) target.searchParams.set(key, value);
|
|
116
|
+
res.writeHead(302, { Location: target.toString() });
|
|
117
|
+
res.end();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Provider-specific post-processing for a completed `flow='tracker'`
|
|
122
|
+
* authorization, folded onto the unified callback from the retired dedicated
|
|
123
|
+
* tracker callbacks:
|
|
124
|
+
*
|
|
125
|
+
* - **jira**: resolve the workspace `cloudId`/`siteUrl` into `oauth_apps`
|
|
126
|
+
* metadata. Required for the Jira REST API — failures propagate to the caller.
|
|
127
|
+
* - **linear**: capture the bot `appUserId` (best-effort — swallowed here so it
|
|
128
|
+
* never fails the OAuth callback).
|
|
129
|
+
*/
|
|
130
|
+
async function runTrackerCallbackPostProcess(provider: string, accessToken: string): Promise<void> {
|
|
131
|
+
if (provider === "jira") {
|
|
132
|
+
await resolveAndStoreJiraCloudId(accessToken);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (provider === "linear") {
|
|
136
|
+
try {
|
|
137
|
+
await captureLinearAppUserId(accessToken);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.warn(
|
|
140
|
+
scrubSecrets(
|
|
141
|
+
`[Linear] Failed to capture appUserId during OAuth completion (non-fatal): ${
|
|
142
|
+
err instanceof Error ? err.message : String(err)
|
|
143
|
+
}`,
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Complete a generic/tracker OAuth callback: consume the pending row, exchange
|
|
152
|
+
* the code against the app's token endpoint, upsert the `(appId, label)`
|
|
153
|
+
* authorization, and capture account identity (best-effort). Returns
|
|
154
|
+
* `{ handled: false }` (without writing a response) when no generic/tracker
|
|
155
|
+
* pending row matches `state`, so callers can fall through to the MCP flow.
|
|
156
|
+
*/
|
|
157
|
+
export async function completeGenericOAuthCallback(
|
|
158
|
+
res: ServerResponse,
|
|
159
|
+
query: OAuthCallbackParams,
|
|
160
|
+
): Promise<{ handled: boolean }> {
|
|
161
|
+
const state = query.state;
|
|
162
|
+
if (!state) return { handled: false };
|
|
163
|
+
|
|
164
|
+
const pending = consumeOAuthPending(state);
|
|
165
|
+
if (!pending) return { handled: false };
|
|
166
|
+
|
|
167
|
+
const app = getOAuthAppById(pending.appId);
|
|
168
|
+
if (!app) {
|
|
169
|
+
if (pending.finalRedirect) {
|
|
170
|
+
redirectWith(res, pending.finalRedirect, { oauth: "error", error: "app_not_found" });
|
|
171
|
+
} else {
|
|
172
|
+
jsonError(res, "OAuth app is no longer configured", 404);
|
|
173
|
+
}
|
|
174
|
+
return { handled: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (query.error) {
|
|
178
|
+
const description = query.error_description ?? query.error;
|
|
179
|
+
if (pending.finalRedirect) {
|
|
180
|
+
redirectWith(res, pending.finalRedirect, {
|
|
181
|
+
oauth: "error",
|
|
182
|
+
error: query.error,
|
|
183
|
+
error_description: description,
|
|
184
|
+
});
|
|
185
|
+
} else {
|
|
186
|
+
jsonError(res, description, 400);
|
|
187
|
+
}
|
|
188
|
+
return { handled: true };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!query.code) {
|
|
192
|
+
if (pending.finalRedirect) {
|
|
193
|
+
redirectWith(res, pending.finalRedirect, { oauth: "error", error: "missing_code" });
|
|
194
|
+
} else {
|
|
195
|
+
jsonError(res, "Missing authorization code", 400);
|
|
196
|
+
}
|
|
197
|
+
return { handled: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const config = oauthAppRowToProviderConfig(app);
|
|
202
|
+
const tokens = await exchangeAuthorizationCode(config, {
|
|
203
|
+
code: query.code,
|
|
204
|
+
codeVerifier: pending.codeVerifier,
|
|
205
|
+
redirectUri: pending.redirectUri,
|
|
206
|
+
});
|
|
207
|
+
// No `expires_in` means the provider issued a non-expiring token (e.g. the
|
|
208
|
+
// GitHub OAuth preset, which returns a long-lived token and no refresh
|
|
209
|
+
// token). Store NULL — a fabricated expiry would later drive the sweep /
|
|
210
|
+
// resolveOAuthBindingToken to mark the authorization refresh-failed (no
|
|
211
|
+
// refresh token to rotate) while the token is still perfectly valid. NULL
|
|
212
|
+
// means "does not expire / never proactively refresh" throughout.
|
|
213
|
+
const expiresAt = tokens.expiresIn
|
|
214
|
+
? new Date(Date.now() + tokens.expiresIn * 1000).toISOString()
|
|
215
|
+
: null;
|
|
216
|
+
|
|
217
|
+
const authorization = upsertAuthorization({
|
|
218
|
+
appId: pending.appId,
|
|
219
|
+
label: pending.label,
|
|
220
|
+
accessToken: tokens.accessToken,
|
|
221
|
+
...(tokens.refreshToken != null ? { refreshToken: tokens.refreshToken } : {}),
|
|
222
|
+
...(tokens.tokenType ? { tokenType: tokens.tokenType } : {}),
|
|
223
|
+
expiresAt,
|
|
224
|
+
...(tokens.scope != null ? { scope: tokens.scope } : {}),
|
|
225
|
+
...(pending.userId ? { userId: pending.userId, connectedByUserId: pending.userId } : {}),
|
|
226
|
+
status: "active",
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const identity = await captureIdentity({
|
|
230
|
+
userinfoUrl: app.userinfoUrl,
|
|
231
|
+
accessToken: tokens.accessToken,
|
|
232
|
+
idToken: tokens.idToken,
|
|
233
|
+
});
|
|
234
|
+
if (identity) {
|
|
235
|
+
updateAuthorizationIdentity(authorization.id, {
|
|
236
|
+
accountEmail: identity.accountEmail,
|
|
237
|
+
identityJson: identity.identityJson,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Tracker (Linear/Jira) flows fold onto this unified handler; run the
|
|
242
|
+
// provider-specific post-exchange step that used to live in the dedicated
|
|
243
|
+
// tracker callbacks. Jira cloudId resolution is REQUIRED (jiraFetch depends
|
|
244
|
+
// on it) so a failure surfaces via the outer catch; Linear appUserId
|
|
245
|
+
// capture is best-effort and swallowed inside the helper.
|
|
246
|
+
if (pending.flow === "tracker") {
|
|
247
|
+
await runTrackerCallbackPostProcess(app.provider, tokens.accessToken);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (pending.finalRedirect) {
|
|
251
|
+
redirectWith(res, pending.finalRedirect, { oauth: "success" });
|
|
252
|
+
} else {
|
|
253
|
+
sendAuthorizedHtml(res, app.provider, pending.label);
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const rawMessage = err instanceof Error ? err.message : String(err);
|
|
257
|
+
// The provider token-endpoint body can echo back secrets — scrub before it
|
|
258
|
+
// reaches the log AND the browser/finalRedirect. The posted client_secret
|
|
259
|
+
// and PKCE code verifier are DB-sourced (not in scrubSecrets' env/shape
|
|
260
|
+
// cache), so register them as volatile first, like ensure-token.ts does.
|
|
261
|
+
if (app.clientSecret) registerVolatileSecret(app.clientSecret, "oauth-client-secret");
|
|
262
|
+
if (pending.codeVerifier) registerVolatileSecret(pending.codeVerifier, "oauth-code-verifier");
|
|
263
|
+
const message = scrubSecrets(rawMessage);
|
|
264
|
+
console.warn(`[oauth] callback exchange failed for ${app.provider}: ${message}`);
|
|
265
|
+
if (pending.finalRedirect) {
|
|
266
|
+
redirectWith(res, pending.finalRedirect, { oauth: "error", error_description: message });
|
|
267
|
+
} else {
|
|
268
|
+
jsonError(res, `Token exchange failed: ${message}`, 502);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { handled: true };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
export async function handleOAuthCallback(
|
|
277
|
+
req: IncomingMessage,
|
|
278
|
+
res: ServerResponse,
|
|
279
|
+
pathSegments: string[],
|
|
280
|
+
queryParams: URLSearchParams,
|
|
281
|
+
): Promise<boolean> {
|
|
282
|
+
if (redirectUriRoute.match(req.method, pathSegments)) {
|
|
283
|
+
json(res, { redirectUri: staticOAuthCallbackUri() });
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!callbackRoute.match(req.method, pathSegments)) return false;
|
|
288
|
+
|
|
289
|
+
const parsed = await callbackRoute.parse(req, res, pathSegments, queryParams);
|
|
290
|
+
if (!parsed) return true;
|
|
291
|
+
|
|
292
|
+
if (!parsed.query.state) {
|
|
293
|
+
jsonError(res, "Missing state parameter", 400);
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const generic = await completeGenericOAuthCallback(res, parsed.query);
|
|
298
|
+
if (generic.handled) return true;
|
|
299
|
+
|
|
300
|
+
// Not a generic/tracker pending — try the MCP flow (same static callback).
|
|
301
|
+
const mcpHandled = await completeMcpOAuthCallback(res, parsed.query);
|
|
302
|
+
if (!mcpHandled) {
|
|
303
|
+
jsonError(res, "Invalid or expired OAuth state", 400);
|
|
304
|
+
}
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─── Unified pending garbage collector (all flows) ───────────────────────────
|
|
309
|
+
|
|
310
|
+
let gcTimer: ReturnType<typeof setInterval> | null = null;
|
|
311
|
+
|
|
312
|
+
export function startOAuthPendingGc(intervalMs = 5 * 60 * 1000): void {
|
|
313
|
+
if (gcTimer) return;
|
|
314
|
+
gcTimer = setInterval(() => {
|
|
315
|
+
try {
|
|
316
|
+
const removed = gcOAuthPending() + gcMcpOAuthPending();
|
|
317
|
+
if (removed > 0) {
|
|
318
|
+
console.debug(`[oauth] GC removed ${removed} expired pending session(s)`);
|
|
319
|
+
}
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.error("[oauth] pending GC failed:", err);
|
|
322
|
+
}
|
|
323
|
+
}, intervalMs);
|
|
324
|
+
if (typeof gcTimer?.unref === "function") gcTimer.unref();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function stopOAuthPendingGc(): void {
|
|
328
|
+
if (gcTimer) {
|
|
329
|
+
clearInterval(gcTimer);
|
|
330
|
+
gcTimer = null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
4
|
-
import { exchangeCode } from "@/oauth/wrapper";
|
|
3
|
+
import { completeGenericOAuthCallback } from "./oauth-callback";
|
|
5
4
|
import { route } from "./route-def";
|
|
6
5
|
import { jsonError } from "./utils";
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Legacy per-provider OAuth redirect target. Retained as a thin compatibility
|
|
9
|
+
* wrapper over the unified, state-keyed callback: everything routes through
|
|
10
|
+
* {@link completeGenericOAuthCallback} by `state`, so the `{provider}` path
|
|
11
|
+
* param is now purely cosmetic. New apps use the single static
|
|
12
|
+
* `/api/oauth/callback` instead. The old linear/jira 409 asymmetry is gone —
|
|
13
|
+
* tracker flows resolve through the same handler.
|
|
14
|
+
*/
|
|
10
15
|
const genericOAuthCallbackRoute = route({
|
|
11
16
|
method: "get",
|
|
12
17
|
path: "/api/oauth/{provider}/callback",
|
|
13
18
|
pattern: ["api", "oauth", null, "callback"],
|
|
14
19
|
operationId: "oauth_generic_callback",
|
|
15
|
-
summary: "
|
|
20
|
+
summary: "Legacy per-provider OAuth redirect target (delegates to the static callback)",
|
|
16
21
|
tags: ["OAuth"],
|
|
17
22
|
auth: { apiKey: false },
|
|
18
23
|
params: z.object({
|
|
@@ -32,25 +37,10 @@ const genericOAuthCallbackRoute = route({
|
|
|
32
37
|
200: { description: "OAuth authorization completed" },
|
|
33
38
|
400: { description: "Missing or invalid OAuth callback parameters" },
|
|
34
39
|
404: { description: "OAuth app not configured" },
|
|
35
|
-
409: { description: "Provider has a dedicated OAuth callback" },
|
|
36
40
|
502: { description: "Token exchange failed" },
|
|
37
41
|
},
|
|
38
42
|
});
|
|
39
43
|
|
|
40
|
-
function sendAuthorizedHtml(res: ServerResponse, provider: string): void {
|
|
41
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
42
|
-
res.end(`<!DOCTYPE html>
|
|
43
|
-
<html>
|
|
44
|
-
<head><title>OAuth Authorized</title></head>
|
|
45
|
-
<body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
|
|
46
|
-
<main style="text-align: center;">
|
|
47
|
-
<h1>${provider} authorized</h1>
|
|
48
|
-
<p>You can close this tab.</p>
|
|
49
|
-
</main>
|
|
50
|
-
</body>
|
|
51
|
-
</html>`);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
44
|
export async function handleGenericOAuth(
|
|
55
45
|
req: IncomingMessage,
|
|
56
46
|
res: ServerResponse,
|
|
@@ -62,44 +52,14 @@ export async function handleGenericOAuth(
|
|
|
62
52
|
const parsed = await genericOAuthCallbackRoute.parse(req, res, pathSegments, queryParams);
|
|
63
53
|
if (!parsed) return true;
|
|
64
54
|
|
|
65
|
-
|
|
66
|
-
if (DEDICATED_CALLBACK_PROVIDERS.has(provider)) {
|
|
67
|
-
jsonError(
|
|
68
|
-
res,
|
|
69
|
-
"Provider linear uses the dedicated OAuth flow at /api/trackers/linear/authorize and /api/trackers/linear/callback.",
|
|
70
|
-
409,
|
|
71
|
-
);
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (parsed.query.error) {
|
|
76
|
-
jsonError(res, parsed.query.error_description ?? parsed.query.error, 400);
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (!parsed.query.code || !parsed.query.state) {
|
|
55
|
+
if (!parsed.query.state) {
|
|
81
56
|
jsonError(res, "Missing code or state parameter", 400);
|
|
82
57
|
return true;
|
|
83
58
|
}
|
|
84
59
|
|
|
85
|
-
const
|
|
86
|
-
if (!
|
|
87
|
-
jsonError(res,
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
await exchangeCode(config, parsed.query.code, parsed.query.state);
|
|
93
|
-
sendAuthorizedHtml(res, provider);
|
|
94
|
-
} catch (err) {
|
|
95
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
96
|
-
const status = message.includes("Invalid or expired OAuth state") ? 400 : 502;
|
|
97
|
-
jsonError(
|
|
98
|
-
res,
|
|
99
|
-
status === 400 ? "Invalid or expired OAuth state" : `Token exchange failed: ${message}`,
|
|
100
|
-
status,
|
|
101
|
-
);
|
|
60
|
+
const { handled } = await completeGenericOAuthCallback(res, parsed.query);
|
|
61
|
+
if (!handled) {
|
|
62
|
+
jsonError(res, "Invalid or expired OAuth state", 400);
|
|
102
63
|
}
|
|
103
|
-
|
|
104
64
|
return true;
|
|
105
65
|
}
|