mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
* chore(release): open v3.8.37 development cycle * chore(ci): harden release flow — ratchet decoupling, fast-path drift gates, build-scope guard, heap default (#5054) Implements improvements 1-4 from the v3.8.36 release benchmark (_tasks/release-bench/v3.8.36/PLANO-MELHORIA.md): 1. Quality Ratchet decoupled from flaky coverage (ci.yml): the shard→coverage→ratchet chain meant a single flaky Coverage Shard SKIPPED the whole Quality Ratchet on the release PR (v3.8.36 #4854), so cycle drift only surfaced post-merge in #5029. The job now runs on !cancelled(); coverage download is continue-on-error and the ratchet runs --allow-missing, so the DETERMINISTIC gates (eslint/complexity/cognitive/duplication/ codeql) stay blocking even when coverage is unavailable. 2. Fast-path drift gates (quality.yml PR→release): added check:complexity, check:cognitive-complexity, and a new lightweight check:pack-policy (pack-artifact unexpected-files check WITHOUT a build, via --policy-only) so drift + stray-tarball-file regressions are caught/rebaselined PER-PR instead of cascading onto the release PR. 3. Build heap default 4096→8192 MB (build-next-isolated.mjs): the clean graph peaks ~3.9 GB and brushed the old 4 GB ceiling; 8 GB gives headroom. Comment notes heap is NOT the fix for a poisoned scope (run check:build-scope instead). 4. check:build-scope gate (new): fails if .ts/.tsx/.js/.jsx files in the tsconfig scope exceed a threshold — catches worktrees/cruft leaking into the build scope (the v3.8.36 OOM root cause: 355,215 vs 4,547 files) BEFORE it detonates next build. Wired into the fast-path. * fix(auth): only trust forwarding headers from loopback TCP peers (#4689) Integrated into release/v3.8.37 — loopback-gated forwarding headers (IP spoofing fix). Cherry-picked onto current release tip; ipUtils.test.ts 9/9 green. * fix(codex): treat OAuth 401 as unrecoverable refresh failure (#4686) Integrated into release/v3.8.37 — codex OAuth 401 treated as unrecoverable refresh. Cherry-picked onto release tip; token-refresh-service.test.ts 38/38 green. * fix(translator): preserve reasoning_effort for non-Copilot Responses clients (#4688) Integrated into release/v3.8.37 — preserve reasoning_effort for non-Copilot Responses clients. Cherry-picked onto release tip; tests 47/47 green. * fix(translator): coerce tool descriptions to strings in OpenAI normalization (#4675) Integrated into release/v3.8.37 — coerce tool descriptions to strings in OpenAI normalization. Cherry-picked onto release tip; tests 3/3 green. * feat(sse): x-omniroute-strip-reasoning header to drop reasoning_content (#4678) Integrated into release/v3.8.37 — x-omniroute-strip-reasoning header. Cherry-picked onto release tip (resolved chatCore.ts/headers.ts adjacency conflict, kept resolveCompressionHeader + isStripReasoningRequested); tests 8/8 green. * fix(combo): flatten Anthropic tool messages + tool history to prevent upstream 503 (#4648) Integrated into release/v3.8.37 — flattenToolHistory helper (combo anti-503). Cherry-picked onto release tip; tests 9/9 green. * feat(headroom): proxy lifecycle management + dashboard UI (Docker sidecar supported) (#4649) Integrated into release/v3.8.37 — headroom proxy lifecycle (status/start/stop, local-only + spawn-capable per Rules #15/#17). Cherry-picked onto release tip; lifecycle 7/7 + route-guard 43/43 + check:cycles green. * feat(cli): multi-model support for Factory Droid CLI (#4682) Integrated into release/v3.8.37 — Factory Droid multi-model support. Cherry-picked onto release tip (kept readJsoncConfig + droidCustomModels imports); droid-custom-models 11/11 green. * fix(providers): require Default Model in compatible-provider API-key setup (#4641) Integrated into release/v3.8.37 — require Default Model in compatible-provider API-key setup. Cherry-picked fix + test-move onto release tip (kept release providerSpecificData + QuotaScrapingFields; fixed moved-test import path; baseline rebaseline unneeded, 865<866); UI test 2/2 green. * fix(dashboard): stop double-masking already-masked API key in list (E2E 3/9 regression) (#4671) Integrated into release/v3.8.37 — render server-masked key verbatim (drop redundant maskKey call). Note: release's maskKey already guards '****' (since v3.8.34), so this is a safe simplification; added a contract test pinning the **** passthrough invariant (2/2 green, would fail against the pre-guard maskKey = the historical double-mask bug). * chore(quality): rebaseline file-size for rc17 PR batch drift Own growth from the merged rc17 PRs (#4678/#4686/#4688) at existing chokepoints — cohesive, not extractable: - open-sse/handlers/responseSanitizer.ts 1103->1122 (SanitizeOpenAIResponseOptions + stripReasoning, #4678) - open-sse/services/tokenRefresh.ts 2070->2090 (codex 401 unrecoverable-refresh guard, #4686) - tests/unit/token-refresh-service.test.ts 1322->1353 (401 regression case, #4686) - tests/unit/translator-openai-responses-req.test.ts 1047->1050 (reasoning_effort assertion, #4688) * docs(env): document HEADROOM_URL in .env.example + ENVIRONMENT.md The headroom proxy lifecycle (#4649) reads HEADROOM_URL (src/lib/headroom/detect.ts, default http://localhost:8787) but it was missing from the env contract, tripping check:env-doc-sync. Adds the var to both .env.example (commented, has a default) and the Proxy Health table in ENVIRONMENT.md. * fix(sse): stream writer mock abort() returns a Promise (#4788) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(cli): fall back to default data dir when DATA_DIR is not writable (#4767) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(oauth): verify Cursor installation on Linux before auto-import (#4770) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): track Ollama streaming usage from raw NDJSON chunks (#4754) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): strip enumDescriptions from antigravity tool schema (#4740) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): include low-level cause details in formatProviderError (#4741) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(translator): strip x-anthropic-billing-header in claude-to-openai (#4728) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): gate Kiro image attachments behind a Claude-capability check (#4763) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): read Antigravity usage from the response.usageMetadata envelope (#4785) Integrated into release/v3.8.37 — Antigravity response.usageMetadata envelope. Cherry-picked onto release tip (resolved test-tail adjacency with #4754 Ollama block); usage-extractor 23/23 green. * fix(api): fall back to existing access token for any OAuth provider on refresh failure (#4786) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(cli): verify launchd registration + skip self-SIGTERM in macOS autostart (#4765) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(executors): anthropic-compatible-* gateways get Bearer alongside x-api-key (#4729) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): json_schema fallback for OpenAI-compatible providers (#4766) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): use workos auth token shape for cline (#4787) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * feat(sse): parse Gemini CLI 429 retryDelay from structured RetryInfo (#4738) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): finalize tool_calls finish_reason on early stream end in OpenAI Responses translator (#4764) Integrated into release/v3.8.37 — computeFinishReason finalizes tool_calls on early stream end (Responses translator). Cherry-picked onto release tip; responses-translation-fixes 29/29 green. * test(sse): golden-lock provider.ts translate-path across all providers (#4734) Integrated into release/v3.8.37 — golden-lock for provider.ts translate-path. Cherry-picked onto release tip; snapshot regenerated against the current provider set (UPDATE_GOLDEN=1, 167 entries); golden test 3/3 deterministic. * chore(quality): rebaseline file-size for rc17 leva2 PR batch drift Own growth from the merged leva2 PRs (cohesive, not extractable): - src/lib/usage/providerLimits.ts 950->955 (#4786) - open-sse/executors/default.ts NEW frozen @828 (#4729 + #4766 + #4787 header branches) - open-sse/translator/request/openai-to-kiro.ts 807->814 (#4763) - open-sse/translator/response/openai-responses.ts 923->937 (#4764) - tests/unit/executor-default-base.test.ts 1339->1440 (#4766) - tests/unit/translator-openai-to-kiro.test.ts 918->980 (#4763) * fix(dashboard): align Engine Combos editor engines with API schema (#4955) (#5062) The named-combos pipeline dropdown offered four engines (headroom, session-dedup, ccr, llmlingua) that stackedPipelineStepSchema rejects, so selecting one made PUT /api/context/combos/[id] return HTTP 400 while saveCombo swallowed the non-OK response (if (!res.ok) return). Editing the default 'Standard Savings' combo and changing an engine reproduced the 400. - Add canonical STACKED_PIPELINE_ENGINE_INTENSITIES next to the schema as the single source of truth; the client dropdown imports it so it can never drift from the discriminated union the API validates against. - Surface save errors and empty-name/empty-pipeline validation in the editor instead of failing silently. - Add a parity unit test asserting the UI engine map equals the schema union and that every (engine, intensity) the UI emits is accepted. * fix(sse): filter nameless hosted tools when converting Responses API to Chat format (#4789) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(dashboard): keep desktop sidebar visible via explicit CSS class (#4812) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip enumDescriptions from Antigravity tool schemas (#4813) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(dashboard): resolve passthrough model aliases by providerId in ModelSelectModal (#4815) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(oauth): allow per-connection refresh lead-time override via providerSpecificData.refreshLeadMs (#4818) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip X-Stainless-* headers and normalize SDK User-Agent for OpenAI-compatible endpoints (#4820) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip Gemini built-in tools when functionDeclarations present in Antigravity envelope (#4821) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(api): surface a Docker-localhost hint on provider-node validation connection errors (#4822) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): resolve bare model names to connection defaultModel before upstream calls (#4825) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(build): trace-include sql.js sql-wasm.wasm in standalone bundle (#4839) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip Composer <|final|> sentinel markers leaking after Composer reasoning (#4842) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(config): sync full SiliconFlow model list into registry (#4844) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): close reasoning before message content in Responses stream (#4848) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): reject unsupported Kiro [1m] context suffix (#4816) Integrated into release/v3.8.37 — cherry-picked onto release tip; test-tail conflict with #4763 resolved (kept both image + [1m] test blocks); CHANGELOG re-merged; 29/29 green. * fix(db): validate HuggingFace tokens via whoami-v2 auth probe (#4819) Integrated into release/v3.8.37 — defining commit re-homed onto the god-file-split validation module (validateHuggingFaceProvider in validation/openaiFormat.ts + map wiring); 115/115 green. * fix(sse): make anthropic-version default-guard case-insensitive (#4823) Integrated into release/v3.8.37 — conflict with #4729 Bearer-fallback resolved (kept both Bearer fallback + case-insensitive anthropic-version guard); 48/48 green. * fix(sse): sanitize Kiro tool schemas to avoid 400 "Improperly formed request" (#4847) Integrated into release/v3.8.37 — conflict in kiro-to-openai.ts resolved (kept release fallbackToolCallId + adopted #1375 toolNameMap remap); 7/7 green. * feat(sse): add GPT-4 to the GitHub Copilot provider (#4798) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(sse): add GPT-4o mini to GitHub Copilot provider (#4797) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(api): add MiniMax-M3 pricing row (#4814) Integrated into release/v3.8.37 — pricing row re-homed onto god-file-split pricing/regional.ts (pricing.ts is now a barrel); 4/4 green. * fix(cli): save runtime deps with --save-exact so a sibling install can't prune them (#4841) Integrated into release/v3.8.37 — trayRuntime conflict resolved (kept release SYSTRAY_SPEC + added --save-exact); 2/2 green. * fix(sse): preserve required fields in antigravity tool schemas (#4843) Integrated into release/v3.8.37 — conflict resolved (kept #4740/#4813 enumDescriptions strip + typed normalizeSchemaTypes, added required-preservation helpers; test-tail merged keeping both enumDescriptions + required tests); 7/7 green. * chore(quality): rebaseline file-size for rc17b leva3 PR batch drift * fix(sse): strip reasoning blobs from agentic context to prevent O(n^2) token growth (#4849) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): unwrap Qoder HTTP 200 SSE error envelope so fallback can trigger (#4850) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip temperature for Claude models with extended thinking (#4853) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): emit valid concatenable kiro tool_calls.arguments deltas (#4855) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(sse): add toggleable tool-source diagnostics (#4856) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): redact api key from the AUTH debug log in the chat handler (#4858) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): forward AI SDK image parts in Responses translator (#4859) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): resolve custom combos by id and case-insensitive name (#4446) (#4869) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): exclude WS bridge controller-closed error from provider breaker (#4602) (#4870) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(providers): add xAI Grok inbound translators and thinking patcher (#4910) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(embeddings): add dimensions override field to embedding combos (#4913) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(oauth): Codex bulk-import endpoint — POST /api/oauth/codex/import (#4914) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(antigravity): retry transient upstream failures (#4941) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): surface malformed HTTP-200 upstream responses (#4942) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): normalize Codex custom tools (apply_patch) to { input: string } schema (#4862) Integrated into release/v3.8.37 — conflict in request/openai-responses.ts resolved (kept #4789 nameless-tool skip + added #1007 custom-tool {input:string} normalization); 48/48 green incl. #4789/#4859 regression. * fix(sse): dense, deterministic output ordering in Responses API response.completed (#4906) Integrated into release/v3.8.37 — manual integration with #4862 in response/openai-responses.ts (custom-tool funcItem + dense recordCompletedItem). Fixed a latent #4848 interaction: the close-reasoning-before-message guard force-closed <think>-tag reasoning prematurely, which dense output (#4906) then snapshotted as a partial buffer ("plan" vs "planning") — scoped the guard to native reasoning_content (!inThinking) in BOTH transformer + translator paths. Full Responses suite 203/203 green incl. #4848/#4862 regression. * feat(sse): auto-promote successful combo model to position #1 (#4852) Integrated into release/v3.8.37 — dropped the stale file-size-baseline.json hunk (re-derived against the rc17b rebaseline); code+test applied clean; 13/13 green. * feat(providers): add Pioneer AI (Fastino Labs) provider (#4909) Integrated into release/v3.8.37 — providers.ts apikey block re-homed onto god-file-split src/shared/constants/providers/apikey/frontier-labs.ts (inline APIKEY_PROVIDERS no longer exists); registry/pioneer + providers/index.ts applied clean; 6/6 green. * add DGrid AI gateway provider (#4931) Integrated into release/v3.8.37 — rebased the contributor's commit onto the release tip; providers.ts god-file-split conflict resolved by relocating the dgrid APIKEY_PROVIDERS entry into apikey/gateways.ts; CHANGELOG added. 7/7 green. Thanks @dgridOP! * chore(quality): rebaseline file-size for rc17b leva4 PR batch drift * docs(routing): sync combo strategy docs for Fusion (17 strategies) (#5067) Fusion (16th strategy, panel fan-out + judge synthesis) and headroom shipped but the strategy-count docs were stale (14/15) and omitted both. Update every combo-strategy reference to the canonical 17, add fusion + headroom to all strategy tables, and add a dedicated Fusion section to AUTO-COMBO.md documenting judgeModel / fusionTuning config + an example. - CLAUDE.md, README.md, FEATURES.md, RESILIENCE_GUIDE.md, ARCHITECTURE.md, OPEN_SSE_ARCHITECTURE.md, OMNIROUTE_VS_ALTERNATIVES.md, docs/README.md, request-pipeline.mmd: 14/15 -> 17, list fusion + headroom - docs/routing/AUTO-COMBO.md: strategy table + new Fusion strategy section - docs/openapi.yaml: add reset-window, headroom, fusion to the strategy enum * fix(oauth): classify /api/oauth/cursor/auto-import as local-only (route-guard) (#5070) The Cursor auto-import route runs execFile("which", ["cursor"]) to verify a local Cursor install before importing credentials — a child-process spawn. The check:route-guard-membership gate (Hard Rules #15/#17) flagged it as an unclassified spawn-capable route: reachable past the loopback gate, an RCE-via-tunnel surface (a leaked JWT over a tunnel could trigger the spawn). Classify the specific path in LOCAL_ONLY_API_PREFIXES so loopback enforcement runs unconditionally before any auth check. Scoped to the exact path — the rest of /api/oauth/ (browser redirect/callback flows) stays remote-reachable. TDD: added a failing-then-passing assertion in route-guard-local-prefix.test.ts (classification + an over-broadening guard proving sibling OAuth paths stay remote). check:route-guard-membership now reports 0 new gaps. * chore(release): v3.8.37 — 2026-06-26 --------- Co-authored-by: dgridOP <dgrid_op@outlook.com>
2103 lines
71 KiB
TypeScript
Executable File
2103 lines
71 KiB
TypeScript
Executable File
// @ts-nocheck
|
||
import { AsyncLocalStorage } from "node:async_hooks";
|
||
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
|
||
import { getGitHubCopilotRefreshHeaders } from "../config/providerHeaderProfiles.ts";
|
||
import { pbkdf2Sync } from "node:crypto";
|
||
import { runWithProxyContext } from "../utils/proxyFetch.ts";
|
||
import { serializeRefresh } from "./refreshSerializer.ts";
|
||
import { WINDSURF_CONFIG } from "@/lib/oauth/constants/oauth";
|
||
import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab";
|
||
|
||
// Default token expiry buffer (refresh if expires within 5 minutes).
|
||
// Used as fallback for providers without an explicit lead time in
|
||
// REFRESH_LEAD_MS below.
|
||
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||
|
||
// Per-provider proactive-refresh lead time.
|
||
//
|
||
// For multi-account OAuth on providers that enforce "single active session per
|
||
// client_id" (notably OpenAI Codex / Auth0), refreshing one account's token
|
||
// can invalidate the refresh_token family of OTHER accounts under the same
|
||
// client. We MINIMIZE refresh frequency for these providers: stay on the
|
||
// original access_token until it is genuinely about to expire, so each account
|
||
// gets the full access_token lifetime without triggering Auth0's family-
|
||
// invalidation logic on its siblings.
|
||
//
|
||
// Trade-off: when refresh finally happens (last 5 min before expiry), Auth0
|
||
// MAY invalidate other accounts' refresh_tokens. The user must re-auth those.
|
||
// This is the upstream limitation documented in openai/codex#9648.
|
||
//
|
||
// Providers with non-rotating tokens (Google, Anthropic) or where multi-
|
||
// account is naturally isolated keep longer lead times.
|
||
export const REFRESH_LEAD_MS: Record<string, number> = {
|
||
// Rotating refresh tokens — minimize refresh frequency to avoid the
|
||
// "refresh-invalidates-siblings" cascade documented for OpenAI Auth0.
|
||
codex: 5 * 60 * 1000, // 5 minutes
|
||
openai: 5 * 60 * 1000, // same Auth0 backend as codex
|
||
claude: 5 * 60 * 1000, // Anthropic OAuth rotates refresh_tokens (user-reported)
|
||
"gitlab-duo": 5 * 60 * 1000, // GitLab token family revocation on misuse
|
||
kiro: 5 * 60 * 1000, // AWS SSO OIDC issues one-time-use refresh tokens
|
||
"kimi-coding": 5 * 60 * 1000, // Moonshot rotates per-refresh
|
||
qwen: 5 * 60 * 1000, // Alibaba device-code path also rotates
|
||
// Non-rotating providers — longer lead is safe.
|
||
iflow: 24 * 60 * 60 * 1000, // 24 hours
|
||
// Google OAuth refresh_tokens are permanent (non-rotating) — longer lead
|
||
// is safe and reduces unnecessary upstream chatter.
|
||
"gemini-cli": 15 * 60 * 1000,
|
||
antigravity: 15 * 60 * 1000,
|
||
agy: 15 * 60 * 1000, // same Google backend as antigravity (non-rotating refresh tokens)
|
||
};
|
||
|
||
/**
|
||
* Get the proactive refresh lead time (ms) for a given provider.
|
||
*
|
||
* Precedence:
|
||
* 1. A per-connection override in `providerSpecificData.refreshLeadMs`
|
||
* (must be a positive finite number), so an operator can tune the lead
|
||
* time for a single connection without touching the provider defaults.
|
||
* 2. The provider default from REFRESH_LEAD_MS.
|
||
* 3. TOKEN_EXPIRY_BUFFER_MS (5 min) when nothing else applies.
|
||
*/
|
||
export function getRefreshLeadMs(
|
||
provider: string,
|
||
providerSpecificData?: { refreshLeadMs?: unknown } | null
|
||
): number {
|
||
const override = providerSpecificData?.refreshLeadMs;
|
||
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
|
||
return override;
|
||
}
|
||
return REFRESH_LEAD_MS[provider] ?? TOKEN_EXPIRY_BUFFER_MS;
|
||
}
|
||
|
||
const CACHE_SECRET = "omniroute-token-cache";
|
||
|
||
// In-flight refresh promise cache to prevent race conditions
|
||
// Key: "provider:sha256(refreshToken)" → Value: Promise<result>
|
||
const refreshPromiseCache = new Map();
|
||
|
||
// Per-connection mutex: prevents parallel OAuth refresh for rotating tokens.
|
||
// Key: connectionId → Value: { promise, waiters }
|
||
// Primary dedup when credentials.connectionId is present; refreshPromiseCache is fallback.
|
||
const connectionRefreshMutex = new Map();
|
||
|
||
// ─── Token Rotation Map (codex-multi-auth pattern) ─────────────────────────
|
||
//
|
||
// When a rotating-token provider (Codex, Kimi, GitLab Duo, etc.) refreshes,
|
||
// the old refresh_token is consumed and a new one is issued. Any subsequent
|
||
// caller arriving with the OLD token would, without protection, hit upstream
|
||
// and trigger "refresh_token_reused" — which Auth0 treats as a security event
|
||
// and invalidates the entire token family.
|
||
//
|
||
// This in-memory map caches RECENT rotations so a stale caller can be redirected
|
||
// to the new tokens WITHOUT touching upstream. The DB staleness check inside
|
||
// the per-connection mutex covers the same scenario when connectionId is known,
|
||
// but not all callers pass connectionId (e.g., legacy code paths, retries that
|
||
// snapshot credentials before the rotation lands in DB).
|
||
//
|
||
// Ported from ndycode/codex-multi-auth (lib/refresh-queue.ts:218-248), the only
|
||
// publicly known tool that reliably sustains multiple Codex OAuth accounts.
|
||
//
|
||
// Key format: `provider:sha256(oldRefreshToken)`
|
||
// Value: { result: tokens, expiresAt: ms_since_epoch }
|
||
type RotationEntry = {
|
||
result: { accessToken: string; refreshToken: string; expiresIn?: number; expiresAt?: string };
|
||
expiresAt: number;
|
||
};
|
||
const tokenRotationMap = new Map<string, RotationEntry>();
|
||
const ROTATION_MAP_TTL_MS = 60 * 1000; // 60 seconds — long enough to catch in-flight stale callers
|
||
|
||
function cleanupRotationMap(now: number = Date.now()): void {
|
||
if (tokenRotationMap.size === 0) return;
|
||
for (const [key, entry] of tokenRotationMap.entries()) {
|
||
if (entry.expiresAt <= now) tokenRotationMap.delete(key);
|
||
}
|
||
}
|
||
|
||
function lookupRotation(provider: string, refreshToken: string): RotationEntry | undefined {
|
||
cleanupRotationMap();
|
||
const key = getRefreshCacheKey(provider, refreshToken);
|
||
const entry = tokenRotationMap.get(key);
|
||
if (!entry) return undefined;
|
||
if (entry.expiresAt <= Date.now()) {
|
||
tokenRotationMap.delete(key);
|
||
return undefined;
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
function recordRotation(
|
||
provider: string,
|
||
oldRefreshToken: string,
|
||
result: { accessToken: string; refreshToken: string; expiresIn?: number; expiresAt?: string }
|
||
): void {
|
||
if (!oldRefreshToken || !result.refreshToken || oldRefreshToken === result.refreshToken) {
|
||
return;
|
||
}
|
||
const key = getRefreshCacheKey(provider, oldRefreshToken);
|
||
tokenRotationMap.set(key, {
|
||
result,
|
||
expiresAt: Date.now() + ROTATION_MAP_TTL_MS,
|
||
});
|
||
}
|
||
|
||
// Exported for tests + diagnostics; not part of the public API surface.
|
||
export function _getTokenRotationMapStats(): { size: number; entries: number } {
|
||
cleanupRotationMap();
|
||
return { size: tokenRotationMap.size, entries: tokenRotationMap.size };
|
||
}
|
||
|
||
export function _clearTokenRotationMap(): void {
|
||
tokenRotationMap.clear();
|
||
}
|
||
|
||
// AsyncLocalStorage for plumbing `onPersist` through executor.refreshCredentials
|
||
// without modifying every executor's signature. The chatCore.ts / base.ts call
|
||
// sites wrap executor.refreshCredentials in `runWithOnPersist(persistFn, () => ...)`
|
||
// and `getAccessToken` reads the active store as a fallback when no explicit
|
||
// onPersist parameter is provided. This keeps Fix A's atomic [refresh + persist]
|
||
// guarantee while avoiding per-executor signature changes.
|
||
type RefreshPersistResult = Record<string, unknown>;
|
||
type RefreshPersistFn = (result: RefreshPersistResult) => Promise<void>;
|
||
const onPersistStore = new AsyncLocalStorage<RefreshPersistFn>();
|
||
|
||
export function runWithOnPersist<T>(
|
||
onPersist: RefreshPersistFn | undefined | null,
|
||
fn: () => Promise<T>
|
||
): Promise<T> {
|
||
if (!onPersist) return fn();
|
||
return onPersistStore.run(onPersist, fn);
|
||
}
|
||
|
||
export function getActiveOnPersist(): RefreshPersistFn | undefined {
|
||
return onPersistStore.getStore();
|
||
}
|
||
|
||
type RefreshLogger = {
|
||
info?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
||
warn?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
||
error?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
||
debug?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
||
} | null;
|
||
|
||
function buildFormParams(entries: Record<string, unknown>): URLSearchParams {
|
||
const params = new URLSearchParams();
|
||
for (const [key, value] of Object.entries(entries)) {
|
||
if (typeof value === "string" && value.length > 0) {
|
||
params.set(key, value);
|
||
}
|
||
}
|
||
return params;
|
||
}
|
||
|
||
function getRefreshCacheKey(provider, refreshToken) {
|
||
const tokenHash = pbkdf2Sync(refreshToken, CACHE_SECRET, 1000, 32, "sha256").toString("hex");
|
||
return `${provider}:${tokenHash}`;
|
||
}
|
||
|
||
/**
|
||
* OAuth2 error codes that mean the refresh token is permanently dead and
|
||
* retrying will never succeed → callers must emit the unrecoverable sentinel
|
||
* so the HealthCheck deactivates the account instead of looping every 60s.
|
||
* Deliberately EXCLUDES transient codes (server_error, temporarily_unavailable,
|
||
* slow_down) so we never deactivate an account over a recoverable blip.
|
||
*/
|
||
const UNRECOVERABLE_OAUTH_ERROR_CODES = new Set([
|
||
"invalid_grant",
|
||
"invalid_request",
|
||
"refresh_token_reused",
|
||
"invalid_token",
|
||
"expired_token",
|
||
"unauthorized_client",
|
||
"access_denied",
|
||
]);
|
||
|
||
/**
|
||
* Extract a canonical OAuth error code from a refresh-endpoint error body of
|
||
* ANY shape. Production proxies/MITMs deliver the same `invalid_grant` 400 in
|
||
* several shapes — a plain object `{error:"invalid_grant"}`, a nested
|
||
* `{error:{code:"invalid_grant"}}`, a JSON **string** (double-encoded body),
|
||
* or the raw JSON text wrapped as `{error:"<json text>"}` by a catch branch.
|
||
* The old `errorBody.error === "invalid_grant"` only matched the first shape,
|
||
* so the others returned `null` → the HealthCheck refresh loop (root cause of
|
||
* the 1352× claude/aa5dd5cf invalidation storm).
|
||
*
|
||
* Returns the matched code (only if it is in UNRECOVERABLE_OAUTH_ERROR_CODES)
|
||
* or null. Never matches loosely — a known code is accepted only when it is a
|
||
* bare code string or the value of an `"error"`/`"error_code"` field, so a 502
|
||
* HTML page or a `server_error` body never becomes a false positive.
|
||
*/
|
||
export function extractOAuthErrorCode(raw: unknown, depth = 0): string | null {
|
||
if (raw == null || depth > 6) return null;
|
||
|
||
if (typeof raw === "string") {
|
||
const s = raw.trim();
|
||
if (!s) return null;
|
||
if (UNRECOVERABLE_OAUTH_ERROR_CODES.has(s)) return s;
|
||
// The string may itself be JSON (a double-encoded body, or the raw text).
|
||
if (s[0] === "{" || s[0] === "[" || s[0] === '"') {
|
||
try {
|
||
const nested = extractOAuthErrorCode(JSON.parse(s), depth + 1);
|
||
if (nested) return nested;
|
||
} catch {
|
||
// not valid JSON — fall through to the field scan
|
||
}
|
||
}
|
||
// Safety net: a known code appearing as the value of an "error"/"error_code"
|
||
// field inside otherwise-unparsed text. Scoped to avoid false positives.
|
||
const m = s.match(/"error(?:_code)?"\s*:\s*"([a-z_]+)"/i);
|
||
if (m && UNRECOVERABLE_OAUTH_ERROR_CODES.has(m[1])) return m[1];
|
||
return null;
|
||
}
|
||
|
||
if (typeof raw === "object") {
|
||
const o = raw as Record<string, unknown>;
|
||
return (
|
||
extractOAuthErrorCode(o.error, depth + 1) ??
|
||
extractOAuthErrorCode(o.code, depth + 1) ??
|
||
extractOAuthErrorCode(o.error_code, depth + 1)
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Read an error response body ONCE and classify it. Returns the raw text (for
|
||
* logging) and the extracted unrecoverable OAuth code (or null). Reading once
|
||
* avoids the double-read bug where `response.json()` consumes the stream and a
|
||
* later `response.text()` returns empty.
|
||
*/
|
||
async function readRefreshErrorBody(
|
||
response: Response
|
||
): Promise<{ rawText: string; code: string | null }> {
|
||
const rawText = await response.text().catch(() => "");
|
||
let parsed: unknown = rawText;
|
||
try {
|
||
parsed = JSON.parse(rawText);
|
||
} catch {
|
||
// keep rawText as-is
|
||
}
|
||
const code = extractOAuthErrorCode(parsed) ?? extractOAuthErrorCode(rawText);
|
||
return { rawText, code };
|
||
}
|
||
|
||
/**
|
||
* Refresh OAuth access token using refresh token
|
||
*/
|
||
export async function refreshAccessToken(
|
||
provider,
|
||
refreshToken,
|
||
credentials,
|
||
log,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
const config = PROVIDERS[provider];
|
||
|
||
const refreshEndpoint = config?.refreshUrl || config?.tokenUrl;
|
||
if (!config || !refreshEndpoint) {
|
||
log?.warn?.("TOKEN_REFRESH", `No refresh endpoint configured for provider: ${provider}`);
|
||
return null;
|
||
}
|
||
|
||
if (!refreshToken) {
|
||
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const params = new URLSearchParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
});
|
||
if (config.clientId) params.set("client_id", config.clientId);
|
||
if (config.clientSecret) params.set("client_secret", config.clientSecret);
|
||
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(refreshEndpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
body: params,
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", `Failed to refresh token for ${provider}`, {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
const code = extractOAuthErrorCode(errorText);
|
||
if (code === "invalid_grant" || code === "invalid_request") {
|
||
return { error: "unrecoverable_refresh_error", code };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", `Successfully refreshed token for ${provider}`, {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
|
||
error: error.message,
|
||
});
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Cline OAuth tokens.
|
||
* Cline refresh endpoint expects JSON body and returns camelCase fields.
|
||
*/
|
||
/**
|
||
* Refresh Windsurf (Devin CLI / Codeium) tokens.
|
||
*
|
||
* Windsurf uses Firebase Secure Token Service (STS) for token refresh.
|
||
* If the token is a long-lived Codeium API key (import flow), it never
|
||
* expires and refresh is a no-op returning the same token.
|
||
* If the token is a Firebase ID token (device-code flow), it expires after
|
||
* ~1 hour and can be refreshed with the stored Firebase refresh token.
|
||
*/
|
||
export async function refreshWindsurfToken(
|
||
refreshToken: string,
|
||
providerSpecificData: Record<string, unknown> | null | undefined,
|
||
log: RefreshLogger,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
if (!refreshToken) {
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
"No refresh token stored for Windsurf — token may be a long-lived API key"
|
||
);
|
||
return null;
|
||
}
|
||
|
||
const authMethod = (providerSpecificData?.authMethod as string) || "import";
|
||
|
||
// Long-lived Codeium API keys (import flow) have no expiry — nothing to refresh.
|
||
if (authMethod === "import") {
|
||
log?.debug?.("TOKEN_REFRESH", "Windsurf import token is long-lived — no refresh needed");
|
||
return null;
|
||
}
|
||
|
||
// Firebase STS refresh for browser-flow tokens.
|
||
// Resolves via WINDSURF_CONFIG.firebaseApiKey, which honors the
|
||
// WINDSURF_FIREBASE_API_KEY env override and falls back to the embedded
|
||
// public default in publicCreds.ts. See docs/security/PUBLIC_CREDS.md.
|
||
const firebaseApiKey = WINDSURF_CONFIG.firebaseApiKey || "";
|
||
if (!firebaseApiKey) {
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
"Windsurf Firebase API key unavailable — skipping Firebase token refresh"
|
||
);
|
||
return null;
|
||
}
|
||
const tokenUrl = `https://securetoken.googleapis.com/v1/token?key=${firebaseApiKey}`;
|
||
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(tokenUrl, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: buildFormParams({ grant_type: "refresh_token", refresh_token: refreshToken }),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Windsurf Firebase token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
|
||
// Firebase STS returns structured errors. Detect unrecoverable token states.
|
||
try {
|
||
const fbError = JSON.parse(errorText);
|
||
const fbCode =
|
||
typeof fbError?.error?.message === "string"
|
||
? fbError.error.message
|
||
: typeof fbError?.error === "string"
|
||
? fbError.error
|
||
: null;
|
||
if (
|
||
typeof fbCode === "string" &&
|
||
(fbCode.includes("USER_DISABLED") ||
|
||
fbCode.includes("TOKEN_EXPIRED") ||
|
||
fbCode.includes("INVALID_REFRESH_TOKEN") ||
|
||
fbCode.includes("USER_NOT_FOUND"))
|
||
) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Windsurf Firebase token is permanently invalid. Re-authentication required.",
|
||
{
|
||
fbCode,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: fbCode };
|
||
}
|
||
} catch {
|
||
// not JSON — fall through
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
const data = await response.json();
|
||
const expiresIn = parseInt(data.expires_in ?? "3600", 10);
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Windsurf Firebase token", {
|
||
expiresIn,
|
||
hasNewIdToken: !!data.id_token,
|
||
});
|
||
|
||
return {
|
||
accessToken: data.id_token,
|
||
refreshToken: data.refresh_token || refreshToken,
|
||
expiresIn,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`Network error refreshing Windsurf token: ${error instanceof Error ? error.message : String(error)}`
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* CodeBuddy CN (Tencent) token refresh — POST /v2/plugin/auth/token/refresh with
|
||
* the refresh token carried in the X-Refresh-Token header (not a form body),
|
||
* matching the official CodeBuddy CLI. Response: { code: 0, data: <token> }.
|
||
*/
|
||
export async function refreshCodebuddyCnToken(
|
||
refreshToken: string,
|
||
log: RefreshLogger,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
if (!refreshToken) return null;
|
||
const { CODEBUDDY_CN_CONFIG } = await import("@/lib/oauth/constants/oauth");
|
||
const oauth = CODEBUDDY_CN_CONFIG;
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(oauth.refreshUrl, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
"User-Agent": oauth.userAgent,
|
||
"X-Requested-With": "XMLHttpRequest",
|
||
"X-Domain": "copilot.tencent.com",
|
||
"X-Refresh-Token": refreshToken,
|
||
"X-Auth-Refresh-Source": "plugin",
|
||
"X-Product": "SaaS",
|
||
},
|
||
body: "{}",
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh CodeBuddy CN token", {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const data = await response.json();
|
||
if (data?.code !== 0 || !data?.data?.accessToken) {
|
||
log?.error?.("TOKEN_REFRESH", "CodeBuddy CN token refresh returned no token", {
|
||
code: data?.code,
|
||
msg: data?.msg,
|
||
});
|
||
return null;
|
||
}
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed CodeBuddy CN token", {
|
||
hasNewAccessToken: !!data.data.accessToken,
|
||
hasNewRefreshToken: !!data.data.refreshToken,
|
||
expiresIn: data.data.expiresIn,
|
||
});
|
||
|
||
return {
|
||
accessToken: data.data.accessToken,
|
||
refreshToken: data.data.refreshToken || refreshToken,
|
||
expiresIn: data.data.expiresIn,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`Network error refreshing CodeBuddy CN token: ${error?.message}`
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function refreshClineToken(refreshToken, log, proxyConfig: unknown = null) {
|
||
const endpoint = PROVIDERS.cline?.refreshUrl;
|
||
if (!endpoint) {
|
||
log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Cline");
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(endpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
refreshToken,
|
||
grantType: "refresh_token",
|
||
clientType: "extension",
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Cline token", {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
const code = extractOAuthErrorCode(errorText);
|
||
if (code === "invalid_grant" || code === "invalid_request") {
|
||
return { error: "unrecoverable_refresh_error", code };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const payload = await response.json();
|
||
const data = payload?.data || payload;
|
||
const expiresAtIso = data?.expiresAt;
|
||
const expiresIn = expiresAtIso
|
||
? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000))
|
||
: undefined;
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Cline token", {
|
||
hasNewAccessToken: !!data?.accessToken,
|
||
hasNewRefreshToken: !!data?.refreshToken,
|
||
expiresIn,
|
||
});
|
||
|
||
return {
|
||
accessToken: data?.accessToken,
|
||
refreshToken: data?.refreshToken || refreshToken,
|
||
expiresIn,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Cline token: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Kimi Coding OAuth tokens.
|
||
* Uses custom X-Msh-* headers required by Kimi OAuth API.
|
||
*
|
||
* Uses a stable device_id from providerSpecificData (stored at login) to avoid
|
||
* anti-bot detection from ephemeral IDs. If absent, derives a deterministic ID
|
||
* from the refresh token hash so it is at least stable across refreshes for the
|
||
* same token.
|
||
*/
|
||
export async function refreshKimiCodingToken(
|
||
refreshToken: string,
|
||
providerSpecificData: Record<string, unknown> | null | undefined,
|
||
log: RefreshLogger,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
const endpoint = PROVIDERS["kimi-coding"]?.refreshUrl || PROVIDERS["kimi-coding"]?.tokenUrl;
|
||
if (!endpoint) {
|
||
log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Kimi Coding");
|
||
return null;
|
||
}
|
||
|
||
// Prefer stable device_id persisted at login time; fall back to a
|
||
// deterministic hash of the refresh token so it is at least consistent
|
||
// across refreshes for the same session.
|
||
const stableDeviceId =
|
||
(providerSpecificData?.deviceId as string) ||
|
||
pbkdf2Sync(refreshToken, "kimi-device-id", 1000, 16, "sha256").toString("hex");
|
||
|
||
const platform = "kimi_cli";
|
||
const version = process.env.KIMI_CLI_VERSION || "1.36.0";
|
||
|
||
// Build device model string matching the format from providers/kimi-coding.ts.
|
||
// open-sse is a portable workspace — use process.platform/arch (always available in Node).
|
||
const osTypeStr = typeof process !== "undefined" ? process.platform : "unknown";
|
||
const archStr = typeof process !== "undefined" ? process.arch : "unknown";
|
||
const deviceModel = [osTypeStr, archStr].filter(Boolean).join(" ");
|
||
|
||
try {
|
||
const params = new URLSearchParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: PROVIDERS["kimi-coding"]?.clientId || "",
|
||
});
|
||
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(endpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
"X-Msh-Platform": platform,
|
||
"X-Msh-Version": version,
|
||
"X-Msh-Device-Model": (providerSpecificData?.deviceModel as string) || deviceModel,
|
||
"X-Msh-Device-Id": stableDeviceId,
|
||
// These headers match getKimiOAuthHeaders() in providers/kimi-coding.ts.
|
||
// They're derived at runtime from os module calls; use safe fallbacks here
|
||
// since open-sse is a portable workspace without direct fs/os access.
|
||
"X-Msh-Device-Name": (providerSpecificData?.deviceName as string) || osTypeStr,
|
||
"X-Msh-Os-Version": (providerSpecificData?.osVersion as string) || osTypeStr,
|
||
},
|
||
body: params,
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
|
||
// Detect unrecoverable errors
|
||
try {
|
||
const parsed = JSON.parse(errorText);
|
||
const errorCode = parsed?.error;
|
||
if (errorCode === "invalid_grant" || errorCode === "invalid_request") {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Kimi Coding refresh token invalid. Re-authentication required.",
|
||
{
|
||
errorCode,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: errorCode };
|
||
}
|
||
} catch {
|
||
// not JSON — fall through
|
||
}
|
||
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kimi Coding token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kimi Coding token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
tokenType: tokens.token_type,
|
||
scope: tokens.scope,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`Network error refreshing Kimi Coding token: ${error instanceof Error ? error.message : String(error)}`
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for GitLab Duo OAuth tokens.
|
||
* Token URL is instance-specific; resolves from providerSpecificData.baseUrl.
|
||
* Uses PKCE authorization_code flow initially but refresh_token grant does NOT
|
||
* require code_verifier — only client_id + refresh_token.
|
||
* On invalid_grant (revoked/expired refresh token) returns the unrecoverable sentinel.
|
||
*/
|
||
export async function refreshGitLabDuoToken(
|
||
refreshToken: string,
|
||
providerSpecificData: Record<string, unknown> | null | undefined,
|
||
log: RefreshLogger,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
if (!refreshToken) {
|
||
log?.warn?.("TOKEN_REFRESH", "No refresh token for GitLab Duo");
|
||
return null;
|
||
}
|
||
|
||
const baseUrl = resolveGitLabOAuthBaseUrl(providerSpecificData);
|
||
const endpoints = buildGitLabOAuthEndpoints(baseUrl);
|
||
const tokenUrl = endpoints.tokenUrl;
|
||
|
||
// client_id from providerSpecificData (stored at login) or fall back to PROVIDERS config
|
||
const clientId =
|
||
(providerSpecificData?.clientId as string) ||
|
||
PROVIDERS["gitlab-duo"]?.clientId ||
|
||
process.env.GITLAB_DUO_OAUTH_CLIENT_ID ||
|
||
process.env.GITLAB_OAUTH_CLIENT_ID ||
|
||
"";
|
||
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(tokenUrl, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
body: buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: clientId,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
|
||
// Detect unrecoverable token — GitLab returns standard OAuth2 error codes.
|
||
try {
|
||
const errorBody = JSON.parse(errorText);
|
||
const errorCode = errorBody.error;
|
||
if (errorCode === "invalid_grant" || errorCode === "invalid_request") {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"GitLab Duo refresh token invalid. Re-authentication required.",
|
||
{
|
||
errorCode,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: errorCode };
|
||
}
|
||
} catch {
|
||
// not JSON — fall through
|
||
}
|
||
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitLab Duo token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitLab Duo token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`Network error refreshing GitLab Duo token: ${error instanceof Error ? error.message : String(error)}`
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Claude OAuth tokens
|
||
*/
|
||
export async function refreshClaudeOAuthToken(refreshToken, log, proxyConfig: unknown = null) {
|
||
try {
|
||
// Standard OAuth2 token refresh uses form-urlencoded (not JSON)
|
||
const params = buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: PROVIDERS.claude.clientId,
|
||
});
|
||
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
"anthropic-beta": "oauth-2025-04-20",
|
||
},
|
||
body: params.toString(),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
// Read + classify the body ONCE, shape-agnostic. A proxy/MITM can deliver
|
||
// the invalid_grant 400 as a JSON string, a double-encoded string, a
|
||
// nested {error:{code}}, or raw text — all must yield the sentinel so the
|
||
// HealthCheck deactivates instead of looping every 60s.
|
||
const { rawText, code } = await readRefreshErrorBody(response);
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", {
|
||
status: response.status,
|
||
error: rawText.slice(0, 300),
|
||
});
|
||
if (code === "invalid_grant" || code === "invalid_request") {
|
||
return { error: "unrecoverable_refresh_error", code };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Google providers (Gemini, Antigravity)
|
||
*/
|
||
export async function refreshGoogleToken(
|
||
refreshToken,
|
||
clientId,
|
||
clientSecret,
|
||
log,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(OAUTH_ENDPOINTS.google.token, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
body: buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: clientId,
|
||
client_secret: clientSecret,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
|
||
// Detect unrecoverable token (invalid_grant = revoked / expired refresh token)
|
||
try {
|
||
const errorBody = JSON.parse(errorText);
|
||
if (errorBody.error === "invalid_grant") {
|
||
log?.error?.("TOKEN_REFRESH", "Google refresh token invalid. Re-authentication required.", {
|
||
provider: "google",
|
||
});
|
||
return { error: "unrecoverable_refresh_error", code: "invalid_grant" };
|
||
}
|
||
} catch {
|
||
// not JSON — fall through
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
}
|
||
|
||
export async function refreshQwenToken(refreshToken, log, proxyConfig: unknown = null) {
|
||
const endpoint = OAUTH_ENDPOINTS.qwen.token;
|
||
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(endpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
body: buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: PROVIDERS.qwen.clientId,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (response.status === 200) {
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
providerSpecificData: tokens.resource_url
|
||
? { resourceUrl: tokens.resource_url }
|
||
: undefined,
|
||
};
|
||
} else {
|
||
const errorText = await response.text().catch(() => "");
|
||
|
||
// Detect unrecoverable invalid_request (expired/revoked refresh token or bad client_id)
|
||
let errorCode = null;
|
||
try {
|
||
const parsed = JSON.parse(errorText);
|
||
errorCode = parsed?.error;
|
||
} catch {
|
||
// not JSON, ignore
|
||
}
|
||
|
||
if (errorCode === "invalid_request" || errorCode === "invalid_grant") {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Qwen refresh token is invalid or expired. Re-authentication required.",
|
||
{
|
||
status: response.status,
|
||
errorCode,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: errorCode };
|
||
}
|
||
|
||
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
|
||
error: error.message,
|
||
});
|
||
}
|
||
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Codex (OpenAI) OAuth tokens.
|
||
* OpenAI uses rotating (one-time-use) refresh tokens.
|
||
* Returns { error: 'unrecoverable_refresh_error', code } when the token has already been
|
||
* consumed or is invalid, so callers can stop retrying and request re-authentication.
|
||
*/
|
||
export async function refreshCodexToken(refreshToken, log, proxyConfig: unknown = null) {
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(OAUTH_ENDPOINTS.openai.token, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
// Body intentionally omits `scope`. RFC 6749 §6 makes scope optional on a
|
||
// refresh_token grant (the server reuses the originally-granted scope when
|
||
// absent). Including `scope` causes Auth0 (which OpenAI Codex OAuth is
|
||
// built on) to treat the request as a re-scope, which can invalidate
|
||
// sibling refresh_token families on the same client_id. Matches the
|
||
// pattern used by ndycode/codex-multi-auth, the only known tool that
|
||
// sustains multiple Codex accounts without cross-invalidation.
|
||
body: buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: PROVIDERS.codex.clientId,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
|
||
// Detect unrecoverable "refresh_token_reused" or "invalid_grant" error from OpenAI
|
||
// This means the token was already consumed or has expired.
|
||
// Retrying with the same token will never succeed.
|
||
let errorCode = null;
|
||
try {
|
||
const parsed = JSON.parse(errorText);
|
||
errorCode =
|
||
parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null);
|
||
} catch {
|
||
// not JSON, ignore
|
||
}
|
||
|
||
if (
|
||
errorCode === "refresh_token_reused" ||
|
||
errorCode === "invalid_grant" ||
|
||
errorCode === "token_expired" ||
|
||
errorCode === "invalid_token"
|
||
) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Codex refresh token already used or invalid. Re-authentication required.",
|
||
{
|
||
status: response.status,
|
||
errorCode,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: errorCode };
|
||
}
|
||
|
||
// Defense-in-depth (port from decolua/9router#1821): any 401 from OpenAI's
|
||
// OAuth token endpoint means the refresh credential itself was rejected
|
||
// (e.g. rotated away, or a payload variant whose code we do not yet
|
||
// recognize — OpenAI has shipped both `token_expired` and the bare
|
||
// "Could not validate your token" message). Retrying with the same dead
|
||
// refresh token will never succeed; surface re-auth instead of looping.
|
||
// 429 / 5xx remain transient and fall through to the retryable branch.
|
||
if (response.status === 401) {
|
||
const code = errorCode || "unauthorized";
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Codex OAuth token endpoint returned 401. Re-authentication required.",
|
||
{
|
||
status: response.status,
|
||
errorCode: code,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code };
|
||
}
|
||
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Kiro (AWS CodeWhisperer) tokens
|
||
* Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub)
|
||
*/
|
||
export async function refreshKiroToken(
|
||
refreshToken,
|
||
providerSpecificData,
|
||
log,
|
||
proxyConfig: unknown = null
|
||
) {
|
||
try {
|
||
const authMethod = providerSpecificData?.authMethod;
|
||
const clientId = providerSpecificData?.clientId;
|
||
const clientSecret = providerSpecificData?.clientSecret;
|
||
const region = providerSpecificData?.region;
|
||
|
||
// AWS SSO OIDC (Builder ID or IDC)
|
||
// If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified).
|
||
// Exception: imported social tokens (authMethod === "imported") carry a freshly-registered
|
||
// clientId/clientSecret but their refresh token is Kiro-social-issued — the isolated OIDC client
|
||
// cannot refresh it, so they must fall through to the social auth path (#2467).
|
||
if (clientId && clientSecret && authMethod !== "imported") {
|
||
const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;
|
||
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(endpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
clientId: clientId,
|
||
clientSecret: clientSecret,
|
||
refreshToken: refreshToken,
|
||
grantType: "refresh_token",
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
|
||
// AWS SSO OIDC uses {"__type": "InvalidGrantException"} error format (not standard OAuth2).
|
||
let awsErrorType: string | undefined;
|
||
try {
|
||
const awsError = JSON.parse(errorText);
|
||
awsErrorType = awsError.__type || awsError.error;
|
||
} catch {
|
||
// not JSON
|
||
}
|
||
|
||
// If the refresh token itself is expired/revoked, no amount of re-registration helps.
|
||
if (
|
||
awsErrorType === "InvalidGrantException" ||
|
||
awsErrorType === "ExpiredTokenException" ||
|
||
awsErrorType === "invalid_grant"
|
||
) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Kiro AWS refresh token expired/invalid. Re-authentication required.",
|
||
{ awsErrorType }
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: awsErrorType };
|
||
}
|
||
|
||
// Client credentials may be expired/invalid (DB import, TTL expiry, browser conflict).
|
||
// Re-register a fresh OIDC client and retry once before giving up (#2524).
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
"Kiro OIDC refresh failed, attempting client re-registration...",
|
||
{ status: response.status, error: errorText.slice(0, 200) }
|
||
);
|
||
|
||
try {
|
||
const resolvedRegion = region || "us-east-1";
|
||
const regEndpoint = `https://oidc.${resolvedRegion}.amazonaws.com/client/register`;
|
||
const regRes = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(regEndpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify({
|
||
clientName: "kiro-oauth-client",
|
||
clientType: "public",
|
||
scopes: [
|
||
"codewhisperer:completions",
|
||
"codewhisperer:analysis",
|
||
"codewhisperer:conversations",
|
||
],
|
||
grantTypes: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
|
||
issuerUrl: "https://identitycenter.amazonaws.com/ssoins-722374e8c3c8e6c6",
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (regRes.ok) {
|
||
const newClient = await regRes.json();
|
||
const retryRes = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(endpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify({
|
||
clientId: newClient.clientId,
|
||
clientSecret: newClient.clientSecret,
|
||
refreshToken: refreshToken,
|
||
grantType: "refresh_token",
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (retryRes.ok) {
|
||
const retryTokens = await retryRes.json();
|
||
log?.info?.("TOKEN_REFRESH", "Kiro refresh recovered via client re-registration", {
|
||
hasNewAccessToken: !!retryTokens.accessToken,
|
||
expiresIn: retryTokens.expiresIn,
|
||
});
|
||
return {
|
||
accessToken: retryTokens.accessToken,
|
||
refreshToken: retryTokens.refreshToken || refreshToken,
|
||
expiresIn: retryTokens.expiresIn,
|
||
_newClientId: newClient.clientId,
|
||
_newClientSecret: newClient.clientSecret,
|
||
_newClientSecretExpiresAt: newClient.clientSecretExpiresAt,
|
||
};
|
||
}
|
||
}
|
||
} catch (reRegErr) {
|
||
log?.warn?.("TOKEN_REFRESH", "Kiro client re-registration fallback failed", {
|
||
error: String(reRegErr),
|
||
});
|
||
}
|
||
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", {
|
||
hasNewAccessToken: !!tokens.accessToken,
|
||
expiresIn: tokens.expiresIn,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.accessToken,
|
||
refreshToken: tokens.refreshToken || refreshToken,
|
||
expiresIn: tokens.expiresIn,
|
||
};
|
||
}
|
||
|
||
// Social Auth (Google/GitHub) - use Kiro's refresh endpoint
|
||
const tokenUrl = PROVIDERS.kiro.tokenUrl;
|
||
if (!tokenUrl) {
|
||
log?.error?.("TOKEN_REFRESH", "Missing Kiro token endpoint");
|
||
return null;
|
||
}
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(tokenUrl, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
refreshToken: refreshToken,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
|
||
// Also check for AWS-style errors on the social auth path (Kiro may relay them)
|
||
try {
|
||
const awsError = JSON.parse(errorText);
|
||
const awsErrorType = awsError.__type || awsError.error;
|
||
if (
|
||
awsErrorType === "InvalidGrantException" ||
|
||
awsErrorType === "ExpiredTokenException" ||
|
||
awsErrorType === "invalid_grant"
|
||
) {
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
"Kiro social refresh token expired/invalid. Re-authentication required.",
|
||
{
|
||
awsErrorType,
|
||
}
|
||
);
|
||
return { error: "unrecoverable_refresh_error", code: awsErrorType };
|
||
}
|
||
} catch {
|
||
// not JSON — fall through
|
||
}
|
||
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", {
|
||
status: response.status,
|
||
error: errorText.slice(0, 200),
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", {
|
||
hasNewAccessToken: !!tokens.accessToken,
|
||
expiresIn: tokens.expiresIn,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.accessToken,
|
||
refreshToken: tokens.refreshToken || refreshToken,
|
||
expiresIn: tokens.expiresIn,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Kiro token: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for Qoder OAuth tokens
|
||
*/
|
||
export async function refreshQoderToken(refreshToken, log, proxyConfig: unknown = null) {
|
||
if (!OAUTH_ENDPOINTS.qoder.token || !PROVIDERS.qoder.clientId || !PROVIDERS.qoder.clientSecret) {
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
"Qoder OAuth refresh skipped: browser OAuth is not configured in this environment"
|
||
);
|
||
return null;
|
||
}
|
||
|
||
const basicAuth = btoa(`${PROVIDERS.qoder.clientId}:${PROVIDERS.qoder.clientSecret}`);
|
||
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(OAUTH_ENDPOINTS.qoder.token, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
Authorization: `Basic ${basicAuth}`,
|
||
},
|
||
body: buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: PROVIDERS.qoder.clientId,
|
||
client_secret: PROVIDERS.qoder.clientSecret,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qoder token", {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
const code = extractOAuthErrorCode(errorText);
|
||
if (code === "invalid_grant" || code === "invalid_request") {
|
||
return { error: "unrecoverable_refresh_error", code };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qoder token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Specialized refresh for GitHub Copilot OAuth tokens
|
||
*/
|
||
export async function refreshGitHubToken(refreshToken, log, proxyConfig: unknown = null) {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch(OAUTH_ENDPOINTS.github.token, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
},
|
||
body: buildFormParams({
|
||
grant_type: "refresh_token",
|
||
refresh_token: refreshToken,
|
||
client_id: PROVIDERS.github.clientId,
|
||
client_secret: PROVIDERS.github.clientSecret,
|
||
}),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
const code = extractOAuthErrorCode(errorText);
|
||
if (code === "invalid_grant" || code === "invalid_request") {
|
||
return { error: "unrecoverable_refresh_error", code };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const tokens = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", {
|
||
hasNewAccessToken: !!tokens.access_token,
|
||
hasNewRefreshToken: !!tokens.refresh_token,
|
||
expiresIn: tokens.expires_in,
|
||
});
|
||
|
||
return {
|
||
accessToken: tokens.access_token,
|
||
refreshToken: tokens.refresh_token || refreshToken,
|
||
expiresIn: tokens.expires_in,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Refresh GitHub Copilot token using GitHub access token
|
||
*/
|
||
export async function refreshCopilotToken(githubAccessToken, log, proxyConfig: unknown = null) {
|
||
try {
|
||
const response = await runWithProxyContext(proxyConfig, () =>
|
||
fetch("https://api.github.com/copilot_internal/v2/token", {
|
||
headers: getGitHubCopilotRefreshHeaders(`token ${githubAccessToken}`),
|
||
})
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
|
||
status: response.status,
|
||
error: errorText,
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", {
|
||
hasToken: !!data.token,
|
||
expiresAt: data.expires_at,
|
||
});
|
||
|
||
return {
|
||
token: data.token,
|
||
expiresAt: data.expires_at,
|
||
};
|
||
} catch (error) {
|
||
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
|
||
error: error.message,
|
||
});
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get access token for a specific provider (internal, does the actual work)
|
||
*/
|
||
async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: unknown = null) {
|
||
switch (provider) {
|
||
case "gemini":
|
||
case "gemini-cli":
|
||
case "antigravity":
|
||
case "agy":
|
||
return await refreshGoogleToken(
|
||
credentials.refreshToken,
|
||
PROVIDERS[provider].clientId,
|
||
PROVIDERS[provider].clientSecret,
|
||
log,
|
||
proxyConfig
|
||
);
|
||
|
||
case "claude":
|
||
return await refreshClaudeOAuthToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
case "codex":
|
||
return await refreshCodexToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
case "qwen":
|
||
return await refreshQwenToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
case "qoder":
|
||
return await refreshQoderToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
case "github":
|
||
return await refreshGitHubToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
case "kiro":
|
||
case "amazon-q":
|
||
return await refreshKiroToken(
|
||
credentials.refreshToken,
|
||
credentials.providerSpecificData,
|
||
log,
|
||
proxyConfig
|
||
);
|
||
|
||
case "cline":
|
||
return await refreshClineToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
case "kimi-coding":
|
||
return await refreshKimiCodingToken(
|
||
credentials.refreshToken,
|
||
credentials.providerSpecificData,
|
||
log,
|
||
proxyConfig
|
||
);
|
||
|
||
case "gitlab-duo":
|
||
return await refreshGitLabDuoToken(
|
||
credentials.refreshToken,
|
||
credentials.providerSpecificData,
|
||
log,
|
||
proxyConfig
|
||
);
|
||
|
||
case "windsurf":
|
||
case "devin-cli":
|
||
return await refreshWindsurfToken(
|
||
credentials.refreshToken,
|
||
credentials.providerSpecificData,
|
||
log,
|
||
proxyConfig
|
||
);
|
||
|
||
case "codebuddy-cn":
|
||
return await refreshCodebuddyCnToken(credentials.refreshToken, log, proxyConfig);
|
||
|
||
default:
|
||
// Fallback to generic OAuth refresh for unknown providers
|
||
return refreshAccessToken(provider, credentials.refreshToken, credentials, log, proxyConfig);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Whether a provider has a supported refresh path in this service.
|
||
*/
|
||
export function supportsTokenRefresh(provider) {
|
||
const explicitlySupported = new Set([
|
||
"gemini",
|
||
"gemini-cli",
|
||
"antigravity",
|
||
"agy",
|
||
"claude",
|
||
"codex",
|
||
"qwen",
|
||
"qoder",
|
||
"github",
|
||
"kiro",
|
||
"amazon-q",
|
||
"cline",
|
||
"kimi-coding",
|
||
"windsurf",
|
||
"devin-cli",
|
||
"gitlab-duo",
|
||
"codebuddy-cn",
|
||
]);
|
||
if (explicitlySupported.has(provider)) return true;
|
||
const config = PROVIDERS[provider];
|
||
return !!(config?.refreshUrl || config?.tokenUrl);
|
||
}
|
||
|
||
/**
|
||
* Check if a refresh result indicates an unrecoverable error
|
||
* (e.g. the refresh token was already consumed and cannot be reused).
|
||
* Callers should stop retrying and request re-authentication.
|
||
*/
|
||
export function isUnrecoverableRefreshError(result) {
|
||
return (
|
||
result &&
|
||
typeof result === "object" &&
|
||
(result.error === "unrecoverable_refresh_error" ||
|
||
result.error === "refresh_token_reused" ||
|
||
result.error === "invalid_request" ||
|
||
result.error === "invalid_grant")
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Get access token for a specific provider (with deduplication).
|
||
*
|
||
* Deduplication strategy (two layers):
|
||
* 1. Per-connection mutex (primary): if credentials.connectionId is present, all concurrent
|
||
* callers for that connection share one in-flight promise regardless of which token they
|
||
* loaded. This prevents refresh_token_reused errors with rotating (one-time-use) tokens,
|
||
* e.g. Codex/OpenAI, where callers that loaded credentials at different times may hold
|
||
* different token strings but refer to the same connection.
|
||
* 2. Token-hash fallback: if no connectionId, dedup by provider+sha256(refreshToken) as before.
|
||
*
|
||
* Additionally, when connectionId is present, the stale-token check reads the DB to detect
|
||
* whether another process already refreshed the token. If the DB token is still valid it is
|
||
* returned immediately without a new upstream call.
|
||
*
|
||
* @param onPersist - Optional callback invoked INSIDE the per-connection mutex closure after a
|
||
* successful refresh, before the mutex releases. Use this to atomically persist the new tokens
|
||
* to the DB within the same lock window. If `onPersist` throws, the error is logged and
|
||
* re-thrown so the caller is aware of the persistence failure.
|
||
*/
|
||
export async function getAccessToken(
|
||
provider,
|
||
credentials,
|
||
log,
|
||
proxyConfig: unknown = null,
|
||
onPersist?: RefreshPersistFn
|
||
) {
|
||
if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") {
|
||
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
|
||
return null;
|
||
}
|
||
|
||
// If the caller did not pass onPersist explicitly, fall back to the active
|
||
// AsyncLocalStorage store. This lets `runWithOnPersist(persistFn, () =>
|
||
// executor.refreshCredentials(creds, log))` plumb the persist callback through
|
||
// executors (e.g. CodexExecutor) without modifying their signature.
|
||
const effectiveOnPersist = onPersist ?? getActiveOnPersist();
|
||
|
||
const connectionId = credentials.connectionId;
|
||
|
||
// ── Layer 1: per-connection mutex ──────────────────────────────────────────
|
||
if (connectionId && typeof connectionId === "string") {
|
||
const existing = connectionRefreshMutex.get(connectionId);
|
||
if (existing) {
|
||
existing.waiters++;
|
||
log?.info?.("TOKEN_REFRESH", "Concurrent refresh detected — sharing in-flight refresh", {
|
||
provider,
|
||
connectionId,
|
||
waiters: existing.waiters,
|
||
});
|
||
return existing.promise;
|
||
}
|
||
|
||
const entry = { promise: null, waiters: 0 };
|
||
entry.promise = (async () => {
|
||
const result = await _getAccessTokenWithStalenessCheck(
|
||
provider,
|
||
credentials,
|
||
log,
|
||
proxyConfig
|
||
);
|
||
// Invoke onPersist INSIDE the mutex so [network call + DB write] are one atomic step.
|
||
// This prevents a concurrent waiter from reading stale credentials before the DB is updated.
|
||
if (result?.accessToken && effectiveOnPersist) {
|
||
try {
|
||
await effectiveOnPersist(result);
|
||
} catch (persistErr) {
|
||
const { sanitizeErrorMessage } = await import("../utils/error.ts");
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`onPersist callback failed for ${provider}/${connectionId}: ${sanitizeErrorMessage(persistErr instanceof Error ? persistErr : new Error(String(persistErr)))}`
|
||
);
|
||
throw persistErr;
|
||
}
|
||
}
|
||
return result;
|
||
})().finally(() => {
|
||
connectionRefreshMutex.delete(connectionId);
|
||
});
|
||
connectionRefreshMutex.set(connectionId, entry);
|
||
return entry.promise;
|
||
}
|
||
|
||
// ── Layer 2: token-hash fallback (no connectionId) ─────────────────────────
|
||
const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken);
|
||
|
||
if (refreshPromiseCache.has(cacheKey)) {
|
||
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
|
||
return refreshPromiseCache.get(cacheKey);
|
||
}
|
||
|
||
// Layer 2 has no per-connection mutex, so callers that pass an onPersist
|
||
// callback expect it to fire after a successful refresh. Without this hook
|
||
// the legacy `connectionId`-less path would silently swallow the callback,
|
||
// leaving DB rows out of sync with rotated tokens (Codex/OpenAI). We still
|
||
// resolve the promise to all waiters with the refreshed credentials.
|
||
const refreshPromise = serializeRefresh(provider, () =>
|
||
_getAccessTokenInternal(provider, credentials, log, proxyConfig)
|
||
)
|
||
.then(async (result) => {
|
||
if (result?.accessToken && effectiveOnPersist) {
|
||
try {
|
||
await effectiveOnPersist(result);
|
||
} catch (persistErr) {
|
||
const { sanitizeErrorMessage } = await import("../utils/error.ts");
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`Layer 2 onPersist callback failed for ${provider}: ${sanitizeErrorMessage(persistErr instanceof Error ? persistErr : new Error(String(persistErr)))}`
|
||
);
|
||
throw persistErr;
|
||
}
|
||
} else if (result?.accessToken && !effectiveOnPersist) {
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
`Layer 2 refresh succeeded for ${provider} without onPersist — DB row will not be updated with rotated token. Callers should pass connectionId for Layer 1 atomicity.`
|
||
);
|
||
}
|
||
return result;
|
||
})
|
||
.finally(() => {
|
||
refreshPromiseCache.delete(cacheKey);
|
||
});
|
||
|
||
refreshPromiseCache.set(cacheKey, refreshPromise);
|
||
return refreshPromise;
|
||
}
|
||
|
||
/**
|
||
* Internal helper: performs the DB staleness check then calls the actual refresh.
|
||
* Only called from the per-connection mutex path (Layer 1 above).
|
||
*/
|
||
async function _getAccessTokenWithStalenessCheck(provider, credentials, log, proxyConfig) {
|
||
// ROTATION MAP CHECK (codex-multi-auth pattern): if this refresh_token was
|
||
// rotated very recently (within ROTATION_MAP_TTL_MS), reuse the cached new
|
||
// tokens INSTEAD of hitting upstream. Auth0 treats re-use of a rotated token
|
||
// as a security event and revokes the entire token family — fatal for
|
||
// multi-account Codex setups. The in-memory rotation map catches this even
|
||
// when the caller bypasses the DB staleness path (no connectionId, stale
|
||
// in-memory credentials in retries, etc.).
|
||
const rotated = lookupRotation(provider, credentials.refreshToken);
|
||
if (rotated) {
|
||
log?.info?.(
|
||
"TOKEN_REFRESH",
|
||
`Rotation map hit for ${provider}. Returning cached rotated tokens (avoids family-revoke).`
|
||
);
|
||
return rotated.result;
|
||
}
|
||
|
||
// RACE CONDITION PREVENTION:
|
||
// If the credentials object in memory is stale (e.g. it waited in a semaphore while another
|
||
// request refreshed the token), using its OLD refreshToken will cause the provider (e.g. OpenAI)
|
||
// to reject it with 'refresh_token_reused' and revoke the new token family.
|
||
// We MUST check if the DB has a newer token before proceeding with a network refresh.
|
||
if (credentials.connectionId) {
|
||
try {
|
||
const { getProviderConnectionById } = await import("../../src/lib/db/providers");
|
||
const dbConnection = await getProviderConnectionById(credentials.connectionId);
|
||
if (dbConnection && dbConnection.refreshToken) {
|
||
const now = Date.now();
|
||
const dbExpiresAt = dbConnection.expiresAt ? new Date(dbConnection.expiresAt).getTime() : 0;
|
||
|
||
if (dbConnection.refreshToken !== credentials.refreshToken) {
|
||
log?.info?.(
|
||
"TOKEN_REFRESH",
|
||
`Stale token detected in memory for ${provider}. Using refreshed token from DB.`
|
||
);
|
||
|
||
// If the DB token is not expired, we can just return it!
|
||
if (dbExpiresAt > now + 60000) {
|
||
// 60 seconds buffer
|
||
log?.info?.("TOKEN_REFRESH", `DB token is still valid. Skipping OAuth refresh.`);
|
||
return {
|
||
accessToken: dbConnection.accessToken,
|
||
refreshToken: dbConnection.refreshToken,
|
||
// Return absolute expiresAt so downstream callers do NOT recompute lifetime
|
||
// from a relative expiresIn value (which would incorrectly extend the TTL).
|
||
// expiresIn intentionally omitted here.
|
||
expiresAt: dbConnection.expiresAt,
|
||
};
|
||
} else {
|
||
// DB token is also expired, but it's the NEWEST one. We must use it to refresh.
|
||
credentials.refreshToken = dbConnection.refreshToken;
|
||
credentials.accessToken = dbConnection.accessToken;
|
||
}
|
||
}
|
||
// NOTE: Fix F (skip when DB == memory and DB > now+60s) was intentionally
|
||
// removed. The caller (checkAndRefreshToken) already decided to refresh
|
||
// because the token is within TOKEN_EXPIRY_BUFFER_MS of expiry. Re-checking
|
||
// with a tighter 60-second window here would skip legitimate refreshes and
|
||
// let near-expired tokens hit the upstream. Layer-1 mutex (per-connection)
|
||
// and Layer-2 dedup (token-hash) already prevent concurrent refreshes for
|
||
// the import-burst scenario.
|
||
}
|
||
} catch (e) {
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
`Failed to check DB for stale token: ${e instanceof Error ? e.message : String(e)}`
|
||
);
|
||
}
|
||
}
|
||
|
||
const oldRefreshToken = credentials.refreshToken;
|
||
// Front 1: serialize the network refresh across all connections of the same
|
||
// rotation group (e.g. Codex+openai share one Auth0 client) so two sibling
|
||
// accounts never refresh concurrently and trip Auth0 family revocation.
|
||
const result = await serializeRefresh(provider, () =>
|
||
_getAccessTokenInternal(provider, credentials, log, proxyConfig)
|
||
);
|
||
|
||
// Record the rotation so subsequent stale callers can be redirected to the
|
||
// new tokens without re-hitting upstream (which would trigger Auth0 family
|
||
// revocation). Only records when the refresh actually rotated the token.
|
||
if (
|
||
result &&
|
||
typeof result === "object" &&
|
||
!("error" in result) &&
|
||
(result as { accessToken?: string }).accessToken &&
|
||
(result as { refreshToken?: string }).refreshToken
|
||
) {
|
||
recordRotation(
|
||
provider,
|
||
oldRefreshToken,
|
||
result as {
|
||
accessToken: string;
|
||
refreshToken: string;
|
||
expiresIn?: number;
|
||
expiresAt?: string;
|
||
}
|
||
);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Refresh token by provider type (alias for getAccessToken)
|
||
* @deprecated Since v0.2.70 — use getAccessToken() directly.
|
||
* Still exported because open-sse/index.js and src/sse wrapper use it.
|
||
* Will be removed in a future major version.
|
||
*/
|
||
export const refreshTokenByProvider = getAccessToken;
|
||
|
||
/**
|
||
* Format credentials for provider
|
||
*/
|
||
export function formatProviderCredentials(provider, credentials, log) {
|
||
const config = PROVIDERS[provider];
|
||
if (!config) {
|
||
log?.warn?.("TOKEN_REFRESH", `No configuration found for provider: ${provider}`);
|
||
return null;
|
||
}
|
||
|
||
switch (provider) {
|
||
case "gemini":
|
||
return {
|
||
apiKey: credentials.apiKey,
|
||
accessToken: credentials.accessToken,
|
||
projectId: credentials.projectId,
|
||
};
|
||
|
||
case "claude":
|
||
return {
|
||
apiKey: credentials.apiKey,
|
||
accessToken: credentials.accessToken,
|
||
};
|
||
|
||
case "codex":
|
||
case "qwen":
|
||
case "qoder":
|
||
case "openai":
|
||
case "openrouter":
|
||
return {
|
||
apiKey: credentials.apiKey,
|
||
accessToken: credentials.accessToken,
|
||
};
|
||
|
||
case "antigravity":
|
||
case "agy":
|
||
case "gemini-cli":
|
||
return {
|
||
accessToken: credentials.accessToken,
|
||
refreshToken: credentials.refreshToken,
|
||
};
|
||
|
||
default:
|
||
return {
|
||
apiKey: credentials.apiKey,
|
||
accessToken: credentials.accessToken,
|
||
refreshToken: credentials.refreshToken,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get all access tokens for a user
|
||
*/
|
||
export async function getAllAccessTokens(userInfo, log) {
|
||
const results = {};
|
||
|
||
if (userInfo.connections && Array.isArray(userInfo.connections)) {
|
||
for (const connection of userInfo.connections) {
|
||
if (connection.isActive && connection.provider) {
|
||
const token = await getAccessToken(
|
||
connection.provider,
|
||
{
|
||
refreshToken: connection.refreshToken,
|
||
},
|
||
log
|
||
);
|
||
|
||
if (token) {
|
||
results[connection.provider] = token;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Refresh token with retry and exponential backoff
|
||
* Retries on failure with increasing delay: 1s, 2s, 3s...
|
||
*
|
||
* Includes:
|
||
* - Per-provider circuit breaker (5 consecutive failures → 30min pause)
|
||
* - 30s timeout per refresh attempt to prevent hanging connections
|
||
*
|
||
* @param {function} refreshFn - Async function that returns token or null
|
||
* @param {number} maxRetries - Max retry attempts (default 3)
|
||
* @param {object} log - Logger instance (optional)
|
||
* @param {string} provider - Provider ID for circuit breaker tracking (optional)
|
||
* @returns {Promise<object|null>} Token result or null if all retries fail
|
||
*/
|
||
|
||
// ─── Circuit Breaker State ──────────────────────────────────────────────────
|
||
const _circuitBreaker: Record<string, { failures: number; blockedUntil: number }> = {};
|
||
const CIRCUIT_BREAKER_THRESHOLD = 5; // consecutive failures before tripping
|
||
const CIRCUIT_BREAKER_COOLDOWN = 30 * 60 * 1000; // 30 minutes
|
||
const REFRESH_TIMEOUT_MS = 30_000; // 30s max per refresh attempt
|
||
|
||
interface CircuitBreakerStatusEntry {
|
||
failures: number;
|
||
blocked: boolean;
|
||
blockedUntil: string | null;
|
||
remainingMs: number;
|
||
}
|
||
|
||
interface RefreshLoggerLike {
|
||
error?: (scope: string, message: string) => void;
|
||
warn?: (scope: string, message: string) => void;
|
||
}
|
||
|
||
/**
|
||
* Check if a provider is circuit-breaker blocked.
|
||
*/
|
||
export function isProviderBlocked(provider: string): boolean {
|
||
const state = _circuitBreaker[provider];
|
||
if (!state) return false;
|
||
if (!state.blockedUntil) return false;
|
||
if (state.blockedUntil > Date.now()) return true;
|
||
// Cooldown expired — reset
|
||
delete _circuitBreaker[provider];
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Get active per-connection mutex entries (for diagnostics/metrics).
|
||
* Returns a snapshot of connections that have an in-flight refresh and their waiter count.
|
||
*/
|
||
export function getConnectionRefreshMutexStatus(): Record<string, { waiters: number }> {
|
||
const result: Record<string, { waiters: number }> = {};
|
||
for (const [connectionId, entry] of connectionRefreshMutex.entries()) {
|
||
result[connectionId] = { waiters: entry.waiters };
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Get circuit breaker status for all providers (for diagnostics).
|
||
*/
|
||
export function getCircuitBreakerStatus(): Record<string, CircuitBreakerStatusEntry> {
|
||
const result: Record<string, CircuitBreakerStatusEntry> = {};
|
||
for (const [provider, state] of Object.entries(_circuitBreaker)) {
|
||
result[provider] = {
|
||
failures: state.failures,
|
||
blocked: state.blockedUntil > Date.now(),
|
||
blockedUntil:
|
||
state.blockedUntil > Date.now() ? new Date(state.blockedUntil).toISOString() : null,
|
||
remainingMs: Math.max(0, state.blockedUntil - Date.now()),
|
||
};
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Record a successful refresh — resets circuit breaker for provider.
|
||
*/
|
||
function recordSuccess(provider: string) {
|
||
if (_circuitBreaker[provider]) {
|
||
delete _circuitBreaker[provider];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Record a failed refresh — increments circuit breaker counter.
|
||
*/
|
||
function recordFailure(provider: string, log: RefreshLoggerLike | null = null) {
|
||
if (!_circuitBreaker[provider]) {
|
||
_circuitBreaker[provider] = { failures: 0, blockedUntil: 0 };
|
||
}
|
||
_circuitBreaker[provider].failures++;
|
||
|
||
if (_circuitBreaker[provider].failures >= CIRCUIT_BREAKER_THRESHOLD) {
|
||
_circuitBreaker[provider].blockedUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN;
|
||
log?.error?.(
|
||
"TOKEN_REFRESH",
|
||
`🔴 Circuit breaker tripped for ${provider}: ${CIRCUIT_BREAKER_THRESHOLD} consecutive failures. ` +
|
||
`Blocked for ${CIRCUIT_BREAKER_COOLDOWN / 60000}min. Provider needs re-authentication.`
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Execute a function with a timeout.
|
||
*/
|
||
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T | null> {
|
||
return await new Promise<T | null>((resolve, reject) => {
|
||
const timer = setTimeout(() => resolve(null), timeoutMs);
|
||
if (typeof timer === "object" && "unref" in timer) {
|
||
(timer as { unref?: () => void }).unref?.();
|
||
}
|
||
|
||
fn().then(
|
||
(result) => {
|
||
clearTimeout(timer);
|
||
resolve(result);
|
||
},
|
||
(error) => {
|
||
clearTimeout(timer);
|
||
reject(error);
|
||
}
|
||
);
|
||
});
|
||
}
|
||
|
||
export async function refreshWithRetry(
|
||
refreshFn,
|
||
maxRetries = 3,
|
||
log: RefreshLogger = null,
|
||
provider = "unknown"
|
||
) {
|
||
// Circuit breaker check
|
||
if (isProviderBlocked(provider)) {
|
||
log?.warn?.("TOKEN_REFRESH", `⚡ Circuit breaker active for ${provider}, skipping refresh`);
|
||
return null;
|
||
}
|
||
|
||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||
if (attempt > 0) {
|
||
const delay = attempt * 1000;
|
||
log?.debug?.("TOKEN_REFRESH", `Retry ${attempt}/${maxRetries} after ${delay}ms`);
|
||
await new Promise((r) => setTimeout(r, delay));
|
||
}
|
||
|
||
try {
|
||
const result = await withTimeout(refreshFn, REFRESH_TIMEOUT_MS);
|
||
if (isUnrecoverableRefreshError(result)) {
|
||
log?.warn?.(
|
||
"TOKEN_REFRESH",
|
||
`Unrecoverable refresh error for ${provider}: ${result.error} — skipping retries`
|
||
);
|
||
return result;
|
||
}
|
||
if (result) {
|
||
recordSuccess(provider);
|
||
return result;
|
||
}
|
||
} catch (error) {
|
||
log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// All retries exhausted — record failure for circuit breaker
|
||
recordFailure(provider, log);
|
||
log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed for ${provider}`);
|
||
return null;
|
||
}
|