Files
OmniRoute/open-sse/services/tokenRefresh.ts
Diego Rodrigues de Sa e Souza 555b21d296 Release v3.8.37 (#5053)
* 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>
2026-06-26 02:51:06 -03:00

2103 lines
71 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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;
}