@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/oauth/keepalive.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { listKeepAliveAuthorizations } from "../be/db-queries/oauth";
|
|
2
|
+
import { ensureAuthorizationTokenOrThrow } from "./ensure-token";
|
|
2
3
|
|
|
3
4
|
// Keep refresh tokens warm without constantly rotating strict-rotation
|
|
4
5
|
// providers. Reactive callers still refresh access tokens before API use.
|
|
@@ -6,8 +7,6 @@ const KEEPALIVE_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
|
|
6
7
|
const KEEPALIVE_BUFFER_MS = 10 * 60 * 1000;
|
|
7
8
|
const STARTUP_KEEPALIVE_DELAY_MS = 10_000;
|
|
8
9
|
|
|
9
|
-
const KEEPALIVE_PROVIDERS = ["linear", "jira"] as const;
|
|
10
|
-
|
|
11
10
|
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
12
11
|
let startupKeepaliveTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
13
12
|
let inflightKeepalive: Promise<void> | null = null;
|
|
@@ -41,16 +40,22 @@ function scheduleKeepaliveRun(trigger: "startup" | "interval" | "manual"): Promi
|
|
|
41
40
|
*/
|
|
42
41
|
async function runKeepalive(trigger: "startup" | "interval" | "manual" = "manual"): Promise<void> {
|
|
43
42
|
console.log(`[OAuth Keepalive] Running ${trigger} token refresh check`);
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
const authorizations = listKeepAliveAuthorizations();
|
|
44
|
+
for (const authorization of authorizations) {
|
|
45
|
+
const label = authorization.displayName?.trim() || authorization.provider;
|
|
46
|
+
const named =
|
|
47
|
+
authorization.label && authorization.label !== "default"
|
|
48
|
+
? `${label} (${authorization.label})`
|
|
49
|
+
: label;
|
|
50
|
+
console.log(`[OAuth Keepalive] Running scheduled token refresh for ${named}...`);
|
|
46
51
|
try {
|
|
47
|
-
await
|
|
48
|
-
console.log(`[OAuth Keepalive] ${
|
|
52
|
+
await ensureAuthorizationTokenOrThrow(authorization.authorizationId, KEEPALIVE_BUFFER_MS);
|
|
53
|
+
console.log(`[OAuth Keepalive] ${named} token check completed successfully`);
|
|
49
54
|
} catch (err) {
|
|
50
55
|
const message = err instanceof Error ? err.message : String(err);
|
|
51
|
-
console.error(`[OAuth Keepalive] Failed to refresh ${
|
|
56
|
+
console.error(`[OAuth Keepalive] Failed to refresh ${named} token: ${message}`);
|
|
52
57
|
await notifySlack(
|
|
53
|
-
`⚠️ *OAuth Keepalive Failed*\
|
|
58
|
+
`⚠️ *OAuth Keepalive Failed*\nApp: \`${named}\`\nError: ${message}\n\nManual re-authorization may be required.`,
|
|
54
59
|
);
|
|
55
60
|
}
|
|
56
61
|
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated OAuth presets.
|
|
3
|
+
*
|
|
4
|
+
* Pure, static data — NO database or network access — so this module can be
|
|
5
|
+
* imported from HTTP handlers, MCP tools, and CLI code alike. Each preset
|
|
6
|
+
* generalizes the provider-specific OAuth quirks (endpoints, scope joining,
|
|
7
|
+
* token-endpoint auth style/body format, refresh-token rotation, and any
|
|
8
|
+
* authorization-URL extra params) that used to live in hardcoded per-provider
|
|
9
|
+
* builders. Client credentials are NEVER shipped here: customers always bring
|
|
10
|
+
* their own `clientId`/`clientSecret`.
|
|
11
|
+
*
|
|
12
|
+
* The `setupHints` on each preset are human-readable operator notes surfaced by
|
|
13
|
+
* pickers and by the app-creation response.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface OAuthPreset {
|
|
17
|
+
/** Stable preset identifier referenced by the catalog manifest + pickers. */
|
|
18
|
+
id: string;
|
|
19
|
+
/** Human-friendly provider name. */
|
|
20
|
+
displayName: string;
|
|
21
|
+
/** Default provider slug used when the caller omits one. */
|
|
22
|
+
provider: string;
|
|
23
|
+
authorizeUrl: string;
|
|
24
|
+
tokenUrl: string;
|
|
25
|
+
/** RFC 7009 revocation endpoint, when the provider exposes one. */
|
|
26
|
+
revocationUrl?: string;
|
|
27
|
+
/** Identity-capture hint endpoint (OIDC userinfo or equivalent). */
|
|
28
|
+
userinfoUrl?: string;
|
|
29
|
+
/** Sensible default scopes; customers routinely override/extend these. */
|
|
30
|
+
scopes: string[];
|
|
31
|
+
/**
|
|
32
|
+
* How `scopes` are joined in the authorization URL. RFC 6749 default is a
|
|
33
|
+
* space; some providers (Linear, Slack) require a comma.
|
|
34
|
+
*/
|
|
35
|
+
scopeSeparator?: string;
|
|
36
|
+
/** `"basic"` when the token endpoint needs HTTP Basic client auth. */
|
|
37
|
+
tokenAuthStyle?: "body" | "basic";
|
|
38
|
+
/** `"json"` when the token endpoint needs a JSON (not form) request body. */
|
|
39
|
+
tokenBodyFormat?: "form" | "json";
|
|
40
|
+
/** Provider rotates refresh tokens on every refresh (fail loudly if absent). */
|
|
41
|
+
requiresRefreshTokenRotation?: boolean;
|
|
42
|
+
/** Extra params appended to the authorization URL (e.g. Google offline access). */
|
|
43
|
+
extraParams?: Record<string, string>;
|
|
44
|
+
/** Operator-facing quirk notes surfaced in pickers + the creation response. */
|
|
45
|
+
setupHints: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fields a caller may supply explicitly to override a preset. Every field is
|
|
50
|
+
* optional; anything omitted is filled from the preset.
|
|
51
|
+
*/
|
|
52
|
+
export interface OAuthPresetOverrides {
|
|
53
|
+
provider?: string;
|
|
54
|
+
authorizeUrl?: string;
|
|
55
|
+
tokenUrl?: string;
|
|
56
|
+
revocationUrl?: string | null;
|
|
57
|
+
userinfoUrl?: string | null;
|
|
58
|
+
scopes?: string[];
|
|
59
|
+
scopeSeparator?: string;
|
|
60
|
+
tokenAuthStyle?: "body" | "basic";
|
|
61
|
+
tokenBodyFormat?: "form" | "json";
|
|
62
|
+
requiresRefreshTokenRotation?: boolean;
|
|
63
|
+
extraParams?: Record<string, string>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Result of merging a preset with explicit overrides. */
|
|
67
|
+
export interface HydratedOAuthApp {
|
|
68
|
+
provider: string;
|
|
69
|
+
authorizeUrl: string;
|
|
70
|
+
tokenUrl: string;
|
|
71
|
+
revocationUrl?: string | null;
|
|
72
|
+
userinfoUrl?: string | null;
|
|
73
|
+
scopes: string[];
|
|
74
|
+
scopeSeparator?: string;
|
|
75
|
+
tokenAuthStyle?: "body" | "basic";
|
|
76
|
+
tokenBodyFormat?: "form" | "json";
|
|
77
|
+
requiresRefreshTokenRotation?: boolean;
|
|
78
|
+
extraParams?: Record<string, string>;
|
|
79
|
+
source: "curated-prefill";
|
|
80
|
+
setupHints: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const OAUTH_PRESETS: readonly OAuthPreset[] = [
|
|
84
|
+
{
|
|
85
|
+
id: "google",
|
|
86
|
+
displayName: "Google",
|
|
87
|
+
provider: "google",
|
|
88
|
+
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
89
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
90
|
+
revocationUrl: "https://oauth2.googleapis.com/revoke",
|
|
91
|
+
userinfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
|
|
92
|
+
scopes: ["openid", "email", "profile"],
|
|
93
|
+
scopeSeparator: " ",
|
|
94
|
+
tokenAuthStyle: "body",
|
|
95
|
+
tokenBodyFormat: "form",
|
|
96
|
+
extraParams: { access_type: "offline", prompt: "consent" },
|
|
97
|
+
setupHints: [
|
|
98
|
+
"Google only returns a refresh token when access_type=offline and prompt=consent are sent — this preset includes both so refresh tokens exist at all.",
|
|
99
|
+
"The default scopes cover identity only. Add the product scopes you actually need (e.g. Gmail: https://www.googleapis.com/auth/gmail.modify).",
|
|
100
|
+
"Create the OAuth client in Google Cloud Console → APIs & Services → Credentials and add the swarm redirect URI to the client's authorized redirect URIs.",
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "slack",
|
|
105
|
+
displayName: "Slack",
|
|
106
|
+
provider: "slack",
|
|
107
|
+
authorizeUrl: "https://slack.com/oauth/v2/authorize",
|
|
108
|
+
tokenUrl: "https://slack.com/api/oauth.v2.access",
|
|
109
|
+
revocationUrl: "https://slack.com/api/auth.revoke",
|
|
110
|
+
userinfoUrl: "https://slack.com/api/auth.test",
|
|
111
|
+
scopes: ["channels:history", "channels:read", "chat:write"],
|
|
112
|
+
scopeSeparator: ",",
|
|
113
|
+
tokenAuthStyle: "body",
|
|
114
|
+
tokenBodyFormat: "form",
|
|
115
|
+
setupHints: [
|
|
116
|
+
"Slack expects comma-separated scopes in the authorize URL — this preset sets that separator.",
|
|
117
|
+
"The listed scopes are bot-token scopes; add user-token scopes separately in the Slack app config if you need them.",
|
|
118
|
+
"Register the app at api.slack.com/apps and add the swarm redirect URI under OAuth & Permissions.",
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "github",
|
|
123
|
+
displayName: "GitHub",
|
|
124
|
+
provider: "github",
|
|
125
|
+
authorizeUrl: "https://github.com/login/oauth/authorize",
|
|
126
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
127
|
+
userinfoUrl: "https://api.github.com/user",
|
|
128
|
+
scopes: ["repo", "read:org", "read:user"],
|
|
129
|
+
scopeSeparator: " ",
|
|
130
|
+
tokenAuthStyle: "body",
|
|
131
|
+
tokenBodyFormat: "form",
|
|
132
|
+
setupHints: [
|
|
133
|
+
"Classic GitHub OAuth apps issue long-lived tokens with no expiry and no refresh token; use a GitHub App instead if you need rotation.",
|
|
134
|
+
"GitHub's token endpoint returns form-encoded data unless the request sends Accept: application/json.",
|
|
135
|
+
"Register at github.com/settings/developers → OAuth Apps and set the swarm redirect URI as the Authorization callback URL.",
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "jira",
|
|
140
|
+
displayName: "Jira (Atlassian)",
|
|
141
|
+
provider: "jira",
|
|
142
|
+
authorizeUrl: "https://auth.atlassian.com/authorize",
|
|
143
|
+
tokenUrl: "https://auth.atlassian.com/oauth/token",
|
|
144
|
+
scopes: ["read:jira-work", "write:jira-work", "read:jira-user", "offline_access"],
|
|
145
|
+
scopeSeparator: " ",
|
|
146
|
+
tokenAuthStyle: "body",
|
|
147
|
+
tokenBodyFormat: "form",
|
|
148
|
+
requiresRefreshTokenRotation: true,
|
|
149
|
+
extraParams: { audience: "api.atlassian.com", prompt: "consent" },
|
|
150
|
+
setupHints: [
|
|
151
|
+
"Atlassian 3LO rotates refresh tokens on every refresh — this preset enables rotation enforcement so a refresh that omits a new token fails loudly instead of silently invalidating the grant.",
|
|
152
|
+
"Include the offline_access scope or you won't receive a refresh token; audience=api.atlassian.com is required and set here.",
|
|
153
|
+
"Refresh tokens expire after 90 days of inactivity — the swarm keep-alive sweep refreshes idle grants to stay ahead of this.",
|
|
154
|
+
"Jira is a reserved provider: configure it through the dedicated tracker integration flow, not the generic OAuth apps route.",
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "linear",
|
|
159
|
+
displayName: "Linear",
|
|
160
|
+
provider: "linear",
|
|
161
|
+
authorizeUrl: "https://linear.app/oauth/authorize",
|
|
162
|
+
tokenUrl: "https://api.linear.app/oauth/token",
|
|
163
|
+
revocationUrl: "https://api.linear.app/oauth/revoke",
|
|
164
|
+
scopes: ["read", "write"],
|
|
165
|
+
scopeSeparator: ",",
|
|
166
|
+
tokenAuthStyle: "body",
|
|
167
|
+
tokenBodyFormat: "form",
|
|
168
|
+
extraParams: { actor: "app" },
|
|
169
|
+
setupHints: [
|
|
170
|
+
"Linear requires comma-separated scopes in the authorize URL — this preset sets that separator.",
|
|
171
|
+
"actor=app makes the swarm act as the application; set actor=user to act on behalf of the authorizing user.",
|
|
172
|
+
"Linear is a reserved provider: configure it through the dedicated tracker integration flow, not the generic OAuth apps route.",
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: "notion",
|
|
177
|
+
displayName: "Notion",
|
|
178
|
+
provider: "notion",
|
|
179
|
+
authorizeUrl: "https://api.notion.com/v1/oauth/authorize",
|
|
180
|
+
tokenUrl: "https://api.notion.com/v1/oauth/token",
|
|
181
|
+
scopes: [],
|
|
182
|
+
scopeSeparator: " ",
|
|
183
|
+
tokenAuthStyle: "basic",
|
|
184
|
+
tokenBodyFormat: "json",
|
|
185
|
+
extraParams: { owner: "user" },
|
|
186
|
+
setupHints: [
|
|
187
|
+
"Notion's token endpoint requires HTTP Basic client authentication and a JSON request body — this preset sets tokenAuthStyle=basic and tokenBodyFormat=json.",
|
|
188
|
+
"Notion has no OAuth scopes; capability is configured on the integration in Notion's developer settings.",
|
|
189
|
+
"owner=user is required to receive a user-scoped token and is set here.",
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
/** All curated presets (stable order). */
|
|
195
|
+
export function listOAuthPresets(): OAuthPreset[] {
|
|
196
|
+
return OAUTH_PRESETS.map((preset) => ({ ...preset }));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** All valid preset ids, for validation error messages + pickers. */
|
|
200
|
+
export function listOAuthPresetIds(): string[] {
|
|
201
|
+
return OAUTH_PRESETS.map((preset) => preset.id);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Look up a preset by id, or `null` when unknown. */
|
|
205
|
+
export function getOAuthPreset(id: string): OAuthPreset | null {
|
|
206
|
+
return OAUTH_PRESETS.find((preset) => preset.id === id) ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function mergeExtraParams(
|
|
210
|
+
base: Record<string, string> | undefined,
|
|
211
|
+
override: Record<string, string> | undefined,
|
|
212
|
+
): Record<string, string> | undefined {
|
|
213
|
+
if (!base && !override) return undefined;
|
|
214
|
+
return { ...(base ?? {}), ...(override ?? {}) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Merge a preset with explicit caller overrides. Explicit fields always win;
|
|
219
|
+
* `extraParams` merges per-key (override keys replace preset keys). The result
|
|
220
|
+
* is always tagged `source: 'curated-prefill'`.
|
|
221
|
+
*/
|
|
222
|
+
export function hydrateOAuthAppFromPreset(
|
|
223
|
+
preset: OAuthPreset,
|
|
224
|
+
overrides: OAuthPresetOverrides = {},
|
|
225
|
+
): HydratedOAuthApp {
|
|
226
|
+
return {
|
|
227
|
+
provider: overrides.provider ?? preset.provider,
|
|
228
|
+
authorizeUrl: overrides.authorizeUrl ?? preset.authorizeUrl,
|
|
229
|
+
tokenUrl: overrides.tokenUrl ?? preset.tokenUrl,
|
|
230
|
+
revocationUrl: overrides.revocationUrl ?? preset.revocationUrl,
|
|
231
|
+
userinfoUrl: overrides.userinfoUrl ?? preset.userinfoUrl,
|
|
232
|
+
scopes: overrides.scopes ?? [...preset.scopes],
|
|
233
|
+
scopeSeparator: overrides.scopeSeparator ?? preset.scopeSeparator,
|
|
234
|
+
tokenAuthStyle: overrides.tokenAuthStyle ?? preset.tokenAuthStyle,
|
|
235
|
+
tokenBodyFormat: overrides.tokenBodyFormat ?? preset.tokenBodyFormat,
|
|
236
|
+
requiresRefreshTokenRotation:
|
|
237
|
+
overrides.requiresRefreshTokenRotation ?? preset.requiresRefreshTokenRotation,
|
|
238
|
+
extraParams: mergeExtraParams(preset.extraParams, overrides.extraParams),
|
|
239
|
+
source: "curated-prefill",
|
|
240
|
+
setupHints: [...preset.setupHints],
|
|
241
|
+
};
|
|
242
|
+
}
|
package/src/oauth/wrapper.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import * as oauth from "oauth4webapi";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
consumeOAuthPending,
|
|
4
|
+
createOAuthPending,
|
|
5
|
+
getOAuthAppIdByProvider,
|
|
6
|
+
type OAuthPendingFlow,
|
|
7
|
+
storeOAuthTokens,
|
|
8
|
+
updateOAuthTokensAfterRefresh,
|
|
9
|
+
} from "../be/db-queries/oauth";
|
|
3
10
|
|
|
4
11
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
5
12
|
|
|
@@ -45,46 +52,50 @@ export interface OAuthProviderConfig {
|
|
|
45
52
|
tokenBodyFormat?: "form" | "json";
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const now = Date.now();
|
|
62
|
-
for (const [key, entry] of pendingStates) {
|
|
63
|
-
if (now - entry.createdAt > STATE_TTL_MS) {
|
|
64
|
-
pendingStates.delete(key);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
55
|
+
/**
|
|
56
|
+
* Options controlling how the pending PKCE session is keyed. When `appId` is
|
|
57
|
+
* omitted it is resolved from `config.provider` (single-app-per-provider
|
|
58
|
+
* callers). `label` selects which authorization the callback upserts into,
|
|
59
|
+
* enabling N labeled authorizations per app.
|
|
60
|
+
*/
|
|
61
|
+
export interface BuildAuthorizationUrlOptions {
|
|
62
|
+
appId?: string;
|
|
63
|
+
label?: string;
|
|
64
|
+
flow?: OAuthPendingFlow;
|
|
65
|
+
finalRedirect?: string | null;
|
|
66
|
+
userId?: string | null;
|
|
67
|
+
nonce?: string | null;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
70
71
|
|
|
71
72
|
/**
|
|
72
|
-
* Build an OAuth 2.0 authorization URL with PKCE (S256).
|
|
73
|
-
*
|
|
73
|
+
* Build an OAuth 2.0 authorization URL with PKCE (S256). Persists a DB-backed
|
|
74
|
+
* pending row (encrypted code verifier) keyed by `state` for later exchange by
|
|
75
|
+
* the static callback handler — survives process restarts.
|
|
74
76
|
*/
|
|
75
77
|
export async function buildAuthorizationUrl(
|
|
76
78
|
config: OAuthProviderConfig,
|
|
79
|
+
options: BuildAuthorizationUrlOptions = {},
|
|
77
80
|
): Promise<{ url: string; state: string; codeVerifier: string }> {
|
|
78
|
-
cleanupExpiredStates();
|
|
79
|
-
|
|
80
81
|
const state = oauth.generateRandomState();
|
|
81
82
|
const codeVerifier = oauth.generateRandomCodeVerifier();
|
|
82
83
|
const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
|
|
83
84
|
|
|
84
|
-
|
|
85
|
+
const appId = options.appId ?? getOAuthAppIdByProvider(config.provider);
|
|
86
|
+
if (!appId) {
|
|
87
|
+
throw new Error(`OAuth app ${config.provider} is not configured`);
|
|
88
|
+
}
|
|
89
|
+
createOAuthPending({
|
|
90
|
+
state,
|
|
91
|
+
appId,
|
|
92
|
+
label: options.label ?? "default",
|
|
93
|
+
flow: options.flow ?? "generic",
|
|
85
94
|
codeVerifier,
|
|
86
|
-
|
|
87
|
-
|
|
95
|
+
nonce: options.nonce ?? null,
|
|
96
|
+
redirectUri: config.redirectUri,
|
|
97
|
+
finalRedirect: options.finalRedirect ?? null,
|
|
98
|
+
userId: options.userId ?? null,
|
|
88
99
|
});
|
|
89
100
|
|
|
90
101
|
const url = new URL(config.authorizeUrl);
|
|
@@ -106,11 +117,6 @@ export async function buildAuthorizationUrl(
|
|
|
106
117
|
return { url: url.toString(), state, codeVerifier };
|
|
107
118
|
}
|
|
108
119
|
|
|
109
|
-
/**
|
|
110
|
-
* Exchange an authorization code for tokens.
|
|
111
|
-
* Validates the state against our pending map, calls the token endpoint,
|
|
112
|
-
* and persists tokens via storeOAuthTokens().
|
|
113
|
-
*/
|
|
114
120
|
/**
|
|
115
121
|
* Build headers + body for a token-endpoint request, honoring the provider's
|
|
116
122
|
* client-auth style (body params vs HTTP Basic) and body encoding (form vs JSON).
|
|
@@ -123,7 +129,11 @@ function tokenRequestInit(
|
|
|
123
129
|
const bodyParams = useBasic
|
|
124
130
|
? params
|
|
125
131
|
: { ...params, client_id: config.clientId, client_secret: config.clientSecret };
|
|
126
|
-
|
|
132
|
+
// Always request a JSON token response. RFC 6749 responses are JSON, but some
|
|
133
|
+
// providers (notably GitHub) default to form-encoded and only return JSON when
|
|
134
|
+
// Accept: application/json is sent — without this the unconditional
|
|
135
|
+
// response.json() in exchange/refresh would throw on their token payload.
|
|
136
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
127
137
|
if (useBasic) {
|
|
128
138
|
headers.Authorization = `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64")}`;
|
|
129
139
|
}
|
|
@@ -135,71 +145,158 @@ function tokenRequestInit(
|
|
|
135
145
|
return { headers, body: new URLSearchParams(bodyParams).toString() };
|
|
136
146
|
}
|
|
137
147
|
|
|
148
|
+
export interface OAuthTokenResponse {
|
|
149
|
+
accessToken: string;
|
|
150
|
+
refreshToken?: string;
|
|
151
|
+
expiresIn?: number;
|
|
152
|
+
scope?: string;
|
|
153
|
+
tokenType?: string;
|
|
154
|
+
/** OIDC id_token, when the provider returns one (used for identity capture). */
|
|
155
|
+
idToken?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Exchange an authorization code for tokens. Pure protocol mechanics — does NOT
|
|
160
|
+
* touch the DB. The caller (static callback handler) consumes the pending row,
|
|
161
|
+
* passes the stored `codeVerifier` + `redirectUri`, and persists the tokens
|
|
162
|
+
* onto the target authorization.
|
|
163
|
+
*/
|
|
164
|
+
export async function exchangeAuthorizationCode(
|
|
165
|
+
config: OAuthProviderConfig,
|
|
166
|
+
params: { code: string; codeVerifier: string; redirectUri: string },
|
|
167
|
+
): Promise<OAuthTokenResponse> {
|
|
168
|
+
const response = await fetch(config.tokenUrl, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
...tokenRequestInit(config, {
|
|
171
|
+
grant_type: "authorization_code",
|
|
172
|
+
redirect_uri: params.redirectUri,
|
|
173
|
+
code: params.code,
|
|
174
|
+
code_verifier: params.codeVerifier,
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
const errorText = await response.text();
|
|
180
|
+
throw new Error(`Token exchange failed (${response.status}): ${errorText}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const data = (await response.json()) as {
|
|
184
|
+
access_token: string;
|
|
185
|
+
token_type?: string;
|
|
186
|
+
expires_in?: number;
|
|
187
|
+
scope?: string;
|
|
188
|
+
refresh_token?: string;
|
|
189
|
+
id_token?: string;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
accessToken: data.access_token,
|
|
194
|
+
refreshToken: data.refresh_token,
|
|
195
|
+
expiresIn: data.expires_in,
|
|
196
|
+
scope: data.scope,
|
|
197
|
+
tokenType: data.token_type,
|
|
198
|
+
idToken: data.id_token,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Backward-compatible code exchange for provider-string callers (tracker
|
|
204
|
+
* callbacks). Consumes the DB-backed pending row by `state`, exchanges the
|
|
205
|
+
* code, and persists onto the provider's `default` authorization. New
|
|
206
|
+
* multi-authorization flows use {@link exchangeAuthorizationCode} directly.
|
|
207
|
+
*/
|
|
138
208
|
export async function exchangeCode(
|
|
139
209
|
config: OAuthProviderConfig,
|
|
140
210
|
code: string,
|
|
141
211
|
state: string,
|
|
142
|
-
): Promise<
|
|
143
|
-
const pending =
|
|
212
|
+
): Promise<OAuthTokenResponse> {
|
|
213
|
+
const pending = consumeOAuthPending(state);
|
|
144
214
|
if (!pending) {
|
|
145
215
|
throw new Error("Invalid or expired OAuth state");
|
|
146
216
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
217
|
+
const tokens = await exchangeAuthorizationCode(config, {
|
|
218
|
+
code,
|
|
219
|
+
codeVerifier: pending.codeVerifier,
|
|
220
|
+
redirectUri: pending.redirectUri,
|
|
221
|
+
});
|
|
222
|
+
// No `expires_in` → non-expiring token: persist NULL (never proactively
|
|
223
|
+
// refresh) rather than fabricating an expiry. See oauth-callback.ts.
|
|
224
|
+
const expiresAt = tokens.expiresIn
|
|
225
|
+
? new Date(Date.now() + tokens.expiresIn * 1000).toISOString()
|
|
226
|
+
: null;
|
|
227
|
+
storeOAuthTokens(config.provider, {
|
|
228
|
+
accessToken: tokens.accessToken,
|
|
229
|
+
refreshToken: tokens.refreshToken ?? null,
|
|
230
|
+
expiresAt,
|
|
231
|
+
scope: tokens.scope ?? null,
|
|
232
|
+
});
|
|
233
|
+
return tokens;
|
|
234
|
+
}
|
|
150
235
|
|
|
151
|
-
|
|
236
|
+
/**
|
|
237
|
+
* Refresh a token using the `refresh_token` grant. Pure protocol mechanics — no
|
|
238
|
+
* DB writes. The caller persists onto the target authorization (id-keyed). Used
|
|
239
|
+
* by the per-authorization refresh endpoint.
|
|
240
|
+
*/
|
|
241
|
+
export async function refreshTokenGrant(
|
|
242
|
+
config: OAuthProviderConfig,
|
|
243
|
+
refreshToken: string,
|
|
244
|
+
): Promise<OAuthTokenResponse> {
|
|
152
245
|
const response = await fetch(config.tokenUrl, {
|
|
153
246
|
method: "POST",
|
|
154
247
|
...tokenRequestInit(config, {
|
|
155
|
-
grant_type: "
|
|
156
|
-
|
|
157
|
-
code,
|
|
158
|
-
code_verifier: codeVerifier,
|
|
248
|
+
grant_type: "refresh_token",
|
|
249
|
+
refresh_token: refreshToken,
|
|
159
250
|
}),
|
|
160
251
|
});
|
|
161
252
|
|
|
162
253
|
if (!response.ok) {
|
|
163
254
|
const errorText = await response.text();
|
|
164
|
-
throw new Error(`Token
|
|
255
|
+
throw new Error(`Token refresh failed (${response.status}): ${errorText}`);
|
|
165
256
|
}
|
|
166
257
|
|
|
167
258
|
const data = (await response.json()) as {
|
|
168
259
|
access_token: string;
|
|
169
|
-
token_type
|
|
260
|
+
token_type?: string;
|
|
170
261
|
expires_in?: number;
|
|
171
262
|
scope?: string;
|
|
172
263
|
refresh_token?: string;
|
|
173
264
|
};
|
|
174
265
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); // default 24h
|
|
179
|
-
|
|
180
|
-
storeOAuthTokens(config.provider, {
|
|
181
|
-
accessToken: data.access_token,
|
|
182
|
-
refreshToken: data.refresh_token ?? null,
|
|
183
|
-
expiresAt,
|
|
184
|
-
scope: data.scope ?? null,
|
|
185
|
-
});
|
|
266
|
+
if (typeof data.access_token !== "string" || data.access_token.length === 0) {
|
|
267
|
+
throw new Error(`Token refresh failed: ${config.provider} response missing access_token`);
|
|
268
|
+
}
|
|
186
269
|
|
|
187
270
|
return {
|
|
188
271
|
accessToken: data.access_token,
|
|
189
272
|
refreshToken: data.refresh_token,
|
|
190
273
|
expiresIn: data.expires_in,
|
|
191
274
|
scope: data.scope,
|
|
275
|
+
tokenType: data.token_type,
|
|
192
276
|
};
|
|
193
277
|
}
|
|
194
278
|
|
|
195
279
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
280
|
+
* Call the token endpoint to exchange a refresh token, validate the response
|
|
281
|
+
* (access token present; rotated refresh token present when the provider
|
|
282
|
+
* requires rotation), and return the normalized result WITHOUT persisting.
|
|
283
|
+
*
|
|
284
|
+
* Persistence is the caller's job — provider-string callers persist via
|
|
285
|
+
* {@link refreshAccessToken}; authorization-keyed callers
|
|
286
|
+
* (src/oauth/ensure-token.ts) persist against a specific authorization id.
|
|
287
|
+
* Throws on any HTTP error, missing access token, or a missing rotated refresh
|
|
288
|
+
* token.
|
|
198
289
|
*/
|
|
199
|
-
export async function
|
|
290
|
+
export async function performTokenRefreshRequest(
|
|
200
291
|
config: OAuthProviderConfig,
|
|
201
292
|
refreshToken: string,
|
|
202
|
-
): Promise<{
|
|
293
|
+
): Promise<{
|
|
294
|
+
accessToken: string;
|
|
295
|
+
refreshToken?: string;
|
|
296
|
+
expiresIn?: number;
|
|
297
|
+
scope?: string;
|
|
298
|
+
expiresAt: string | null;
|
|
299
|
+
}> {
|
|
203
300
|
const response = await fetch(config.tokenUrl, {
|
|
204
301
|
method: "POST",
|
|
205
302
|
...tokenRequestInit(config, {
|
|
@@ -234,17 +331,42 @@ export async function refreshAccessToken(
|
|
|
234
331
|
);
|
|
235
332
|
}
|
|
236
333
|
|
|
334
|
+
// No `expires_in` → non-expiring token stays non-expiring across refreshes:
|
|
335
|
+
// NULL, not a fabricated 24h expiry that would re-arm the sweep against a
|
|
336
|
+
// token the provider never expires.
|
|
237
337
|
const expiresAt = data.expires_in
|
|
238
338
|
? new Date(Date.now() + data.expires_in * 1000).toISOString()
|
|
239
|
-
:
|
|
339
|
+
: null;
|
|
240
340
|
|
|
241
|
-
|
|
341
|
+
return {
|
|
342
|
+
accessToken: data.access_token,
|
|
343
|
+
refreshToken: data.refresh_token,
|
|
344
|
+
expiresIn: data.expires_in,
|
|
345
|
+
scope: data.scope,
|
|
346
|
+
expiresAt,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Refresh an access token using a stored refresh token.
|
|
352
|
+
* Persists the new tokens via updateOAuthTokensAfterRefresh() (provider-keyed
|
|
353
|
+
* default-authorization path).
|
|
354
|
+
*/
|
|
355
|
+
export async function refreshAccessToken(
|
|
356
|
+
config: OAuthProviderConfig,
|
|
357
|
+
refreshToken: string,
|
|
358
|
+
expectedTokenVersion?: number,
|
|
359
|
+
): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number; scope?: string }> {
|
|
360
|
+
const refreshed = await performTokenRefreshRequest(config, refreshToken);
|
|
361
|
+
|
|
362
|
+
const nextRefreshToken = refreshed.refreshToken ?? refreshToken;
|
|
242
363
|
try {
|
|
243
364
|
updateOAuthTokensAfterRefresh(config.provider, refreshToken, {
|
|
244
|
-
accessToken:
|
|
365
|
+
accessToken: refreshed.accessToken,
|
|
245
366
|
refreshToken: nextRefreshToken,
|
|
246
|
-
expiresAt,
|
|
247
|
-
scope:
|
|
367
|
+
expiresAt: refreshed.expiresAt,
|
|
368
|
+
scope: refreshed.scope ?? null,
|
|
369
|
+
...(expectedTokenVersion !== undefined ? { expectedTokenVersion } : {}),
|
|
248
370
|
});
|
|
249
371
|
} catch (err) {
|
|
250
372
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -255,19 +377,18 @@ export async function refreshAccessToken(
|
|
|
255
377
|
}
|
|
256
378
|
|
|
257
379
|
return {
|
|
258
|
-
accessToken:
|
|
259
|
-
refreshToken:
|
|
260
|
-
expiresIn:
|
|
261
|
-
scope:
|
|
380
|
+
accessToken: refreshed.accessToken,
|
|
381
|
+
refreshToken: refreshed.refreshToken,
|
|
382
|
+
expiresIn: refreshed.expiresIn,
|
|
383
|
+
scope: refreshed.scope,
|
|
262
384
|
};
|
|
263
385
|
}
|
|
264
386
|
|
|
265
387
|
// ─── Test helpers (exported for unit tests only) ─────────────────────────────
|
|
266
388
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
389
|
+
/**
|
|
390
|
+
* Deprecated no-op retained for backward compatibility. Pending PKCE state now
|
|
391
|
+
* lives in the `oauth_pending` table (per-test DBs isolate it), so there is no
|
|
392
|
+
* in-memory map to clear.
|
|
393
|
+
*/
|
|
394
|
+
export function _clearPendingStates(): void {}
|