mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
* chore(token-refresh): extract rotation/cas/circuit-breaker refresh logic into tokenRefresh/* leaves * test(oauth): follow isUnrecoverableRefreshError to tokenRefresh/shared.ts cad2c7285 moved isUnrecoverableRefreshError out of tokenRefresh.ts into tokenRefresh/shared.ts. This suite asserts on source *text* (it regex-matches the function body to prove the unrecoverable sentinel is returned), so the move made it fail to find the definition — the only red test across the 23 tokenRefresh-related suites. Repoint the read() at the file that now defines the body. The public surface is unchanged: tokenRefresh.ts still re-exports the symbol, verified by import. * docs(changelog): add fragment for this PR * docs(auth): correct the #7338 attribution wording in the tokenRefresh header The header claimed credit for KooshaPari's #7338 was "preserved via co-authorship on the extraction commits", but none of the commits carries a Co-authored-by trailer -- and adding one would be inaccurate, since this is an independent implementation against the current tip rather than a reuse of that diff. The by-name credit for proposing the split stays; only the false claim about the mechanism is removed.
86 lines
3.2 KiB
TypeScript
86 lines
3.2 KiB
TypeScript
// @ts-nocheck
|
|
//
|
|
// Token Rotation Map (codex-multi-auth pattern) — extracted from
|
|
// open-sse/services/tokenRefresh.ts. See ../shared.ts for provenance notes.
|
|
//
|
|
// 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 }
|
|
import { pbkdf2Sync } from "node:crypto";
|
|
|
|
const CACHE_SECRET = "omniroute-token-cache";
|
|
|
|
/**
|
|
* Build the dedup/rotation cache key for a (provider, refreshToken) pair.
|
|
* Hashed so a raw refresh_token never sits in a Map key in plaintext.
|
|
*/
|
|
export function getRefreshCacheKey(provider, refreshToken) {
|
|
const tokenHash = pbkdf2Sync(refreshToken, CACHE_SECRET, 1000, 32, "sha256").toString("hex");
|
|
return `${provider}:${tokenHash}`;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
export 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;
|
|
}
|
|
|
|
export 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();
|
|
}
|