1353 lines
42 KiB
TypeScript
1353 lines
42 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const tokenRefresh = await import("../../open-sse/services/tokenRefresh.ts");
|
|
const { PROVIDERS, OAUTH_ENDPOINTS } = await import("../../open-sse/config/constants.ts");
|
|
|
|
const {
|
|
TOKEN_EXPIRY_BUFFER_MS,
|
|
refreshAccessToken,
|
|
refreshClineToken,
|
|
refreshKimiCodingToken,
|
|
refreshClaudeOAuthToken,
|
|
refreshGoogleToken,
|
|
refreshQwenToken,
|
|
refreshCodexToken,
|
|
refreshKiroToken,
|
|
refreshQoderToken,
|
|
refreshGitHubToken,
|
|
refreshCopilotToken,
|
|
supportsTokenRefresh,
|
|
isUnrecoverableRefreshError,
|
|
getAccessToken,
|
|
formatProviderCredentials,
|
|
getAllAccessTokens,
|
|
isProviderBlocked,
|
|
getCircuitBreakerStatus,
|
|
getConnectionRefreshMutexStatus,
|
|
refreshWithRetry,
|
|
} = tokenRefresh;
|
|
|
|
type LogLevel = "debug" | "info" | "warn" | "error";
|
|
type LogEntry = {
|
|
level: LogLevel;
|
|
scope: unknown;
|
|
message: unknown;
|
|
meta: unknown;
|
|
};
|
|
type MockLogger = {
|
|
entries: LogEntry[];
|
|
debug: (...args: [unknown?, unknown?, unknown?]) => void;
|
|
info: (...args: [unknown?, unknown?, unknown?]) => void;
|
|
warn: (...args: [unknown?, unknown?, unknown?]) => void;
|
|
error: (...args: [unknown?, unknown?, unknown?]) => void;
|
|
};
|
|
|
|
type TestFetch = typeof fetch;
|
|
type FastSetTimeout = typeof globalThis.setTimeout & {
|
|
__promisify__?: typeof globalThis.setTimeout.__promisify__;
|
|
};
|
|
|
|
function createLog(): MockLogger {
|
|
const entries: LogEntry[] = [];
|
|
const push = (level: LogLevel, args: [unknown?, unknown?, unknown?]) => {
|
|
const [scope, message, meta] = args;
|
|
entries.push({ level, scope, message, meta });
|
|
};
|
|
|
|
return {
|
|
entries,
|
|
debug: (...args) => push("debug", args),
|
|
info: (...args) => push("info", args),
|
|
warn: (...args) => push("warn", args),
|
|
error: (...args) => push("error", args),
|
|
};
|
|
}
|
|
|
|
function jsonResponse(body: any, status = 200) {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function textResponse(text: any, status = 400) {
|
|
return new Response(text, {
|
|
status,
|
|
headers: { "content-type": "text/plain" },
|
|
});
|
|
}
|
|
|
|
function bodyToString(body: BodyInit | null | undefined) {
|
|
if (typeof body === "string") return body;
|
|
if (body instanceof URLSearchParams) return body.toString();
|
|
return String(body ?? "");
|
|
}
|
|
|
|
async function withMockedFetch<TResult>(fetchImpl: TestFetch, fn: () => Promise<TResult>) {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = fetchImpl;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
}
|
|
|
|
async function withMockedNow<TResult>(now: number, fn: () => Promise<TResult>) {
|
|
const originalNow = Date.now;
|
|
Date.now = () => now;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
Date.now = originalNow;
|
|
}
|
|
}
|
|
|
|
async function withPatchedProperties<TResult>(
|
|
target: object,
|
|
patch: Record<string, unknown>,
|
|
fn: () => Promise<TResult>
|
|
) {
|
|
const previous = new Map<string, unknown>();
|
|
const targetRecord = target as Record<string, unknown>;
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
previous.set(
|
|
key,
|
|
Object.prototype.hasOwnProperty.call(targetRecord, key) ? targetRecord[key] : undefined
|
|
);
|
|
targetRecord[key] = value;
|
|
}
|
|
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
for (const [key] of Object.entries(patch)) {
|
|
const prior = previous.get(key);
|
|
if (prior === undefined) {
|
|
delete targetRecord[key];
|
|
} else {
|
|
targetRecord[key] = prior;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function withFastRetryTimers<TResult>(fn: () => Promise<TResult>) {
|
|
const originalSetTimeout = globalThis.setTimeout as FastSetTimeout;
|
|
const fastSetTimeout: FastSetTimeout = Object.assign(
|
|
((callback: TimerHandler, delay = 0, ...args: unknown[]) =>
|
|
originalSetTimeout(callback, delay === 30_000 ? delay : 0, ...args)) as typeof setTimeout,
|
|
{ __promisify__: originalSetTimeout.__promisify__ }
|
|
);
|
|
globalThis.setTimeout = fastSetTimeout;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
globalThis.setTimeout = originalSetTimeout;
|
|
}
|
|
}
|
|
|
|
test("TOKEN_EXPIRY_BUFFER_MS stays at five minutes", () => {
|
|
assert.equal(TOKEN_EXPIRY_BUFFER_MS, 5 * 60 * 1000);
|
|
});
|
|
|
|
test("refreshAccessToken returns null when no provider refresh endpoint exists", async () => {
|
|
const log = createLog();
|
|
const result = await refreshAccessToken("qoder", "refresh-token", {}, log);
|
|
assert.equal(result, null);
|
|
assert.equal(
|
|
log.entries.some((entry) => entry.level === "warn"),
|
|
true
|
|
);
|
|
});
|
|
|
|
test("refreshAccessToken returns null when refresh token is missing", async () => {
|
|
const log = createLog();
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS,
|
|
{
|
|
"custom-oauth-task-207": { tokenUrl: "https://auth.example.com/token" },
|
|
},
|
|
async () => {
|
|
const result = await refreshAccessToken("custom-oauth-task-207", null, {}, log);
|
|
assert.equal(result, null);
|
|
}
|
|
);
|
|
});
|
|
|
|
test("refreshAccessToken posts form data and returns rotated tokens", async () => {
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS,
|
|
{
|
|
"custom-oauth-task-207": {
|
|
refreshUrl: "https://auth.example.com/token",
|
|
clientId: "client-id",
|
|
clientSecret: "client-secret",
|
|
},
|
|
},
|
|
async () => {
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
access_token: "new-access",
|
|
refresh_token: "new-refresh",
|
|
expires_in: 3600,
|
|
});
|
|
},
|
|
async () => {
|
|
const result = await refreshAccessToken("custom-oauth-task-207", "refresh-123", {}, log);
|
|
|
|
assert.deepEqual(result, {
|
|
accessToken: "new-access",
|
|
refreshToken: "new-refresh",
|
|
expiresIn: 3600,
|
|
});
|
|
}
|
|
);
|
|
}
|
|
);
|
|
|
|
assert.equal(calls[0].url, "https://auth.example.com/token");
|
|
assert.equal(
|
|
bodyToString(calls[0].options.body),
|
|
"grant_type=refresh_token&refresh_token=refresh-123&client_id=client-id&client_secret=client-secret"
|
|
);
|
|
});
|
|
|
|
test("refreshAccessToken returns null on upstream refresh failure", async () => {
|
|
const log = createLog();
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS,
|
|
{
|
|
"custom-oauth-task-207": { tokenUrl: "https://auth.example.com/token" },
|
|
},
|
|
async () => {
|
|
await withMockedFetch(
|
|
async () => textResponse("rate limited", 429),
|
|
async () => {
|
|
const result = await refreshAccessToken("custom-oauth-task-207", "refresh-123", {}, log);
|
|
|
|
assert.equal(result, null);
|
|
assert.equal(
|
|
log.entries.some((entry) => entry.level === "error"),
|
|
true
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
test("refreshClineToken handles nested payloads and computes expiresIn", async () => {
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withMockedNow(1_700_000_000_000, async () => {
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
data: {
|
|
accessToken: "cline-access",
|
|
refreshToken: "cline-refresh",
|
|
expiresAt: new Date(Date.now() + 95_000).toISOString(),
|
|
},
|
|
});
|
|
},
|
|
async () => {
|
|
const result = await refreshClineToken("refresh-cline", log);
|
|
assert.equal(result?.accessToken, "cline-access");
|
|
assert.equal(result?.refreshToken, "cline-refresh");
|
|
assert.equal(result?.expiresIn, 95);
|
|
}
|
|
);
|
|
});
|
|
|
|
assert.equal(calls[0].url, PROVIDERS.cline.refreshUrl);
|
|
assert.deepEqual(JSON.parse(calls[0].options.body), {
|
|
refreshToken: "refresh-cline",
|
|
grantType: "refresh_token",
|
|
clientType: "extension",
|
|
});
|
|
});
|
|
|
|
test("refreshKimiCodingToken adds provider-specific headers and fields", async () => {
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
access_token: "kimi-access",
|
|
refresh_token: "kimi-refresh-next",
|
|
expires_in: 7200,
|
|
token_type: "Bearer",
|
|
scope: "coding offline_access",
|
|
});
|
|
},
|
|
async () => {
|
|
// Pass providerSpecificData with a stable deviceId (second positional arg after signature change)
|
|
const result = await refreshKimiCodingToken(
|
|
"kimi-refresh",
|
|
{ deviceId: "test-stable-device" },
|
|
log
|
|
);
|
|
assert.deepEqual(result, {
|
|
accessToken: "kimi-access",
|
|
refreshToken: "kimi-refresh-next",
|
|
expiresIn: 7200,
|
|
tokenType: "Bearer",
|
|
scope: "coding offline_access",
|
|
});
|
|
}
|
|
);
|
|
|
|
assert.equal(calls[0].url, PROVIDERS["kimi-coding"].refreshUrl);
|
|
// Platform is now "kimi_cli" (matching the real Kimi CLI fingerprint)
|
|
assert.equal(calls[0].options.headers["X-Msh-Platform"], "kimi_cli");
|
|
// Version comes from KIMI_CLI_VERSION env or default "1.36.0"
|
|
assert.ok(calls[0].options.headers["X-Msh-Version"], "X-Msh-Version must be set");
|
|
// Device-Id must NOT be an ephemeral "kimi-refresh-<timestamp>" value
|
|
assert.ok(
|
|
calls[0].options.headers["X-Msh-Device-Id"] &&
|
|
!calls[0].options.headers["X-Msh-Device-Id"].startsWith("kimi-refresh-"),
|
|
"X-Msh-Device-Id must be stable (not ephemeral kimi-refresh-<timestamp>)"
|
|
);
|
|
// When providerSpecificData.deviceId is provided, it should be used directly
|
|
assert.equal(calls[0].options.headers["X-Msh-Device-Id"], "test-stable-device");
|
|
assert.match(bodyToString(calls[0].options.body), /grant_type=refresh_token/);
|
|
});
|
|
|
|
test("refreshClaudeOAuthToken posts the anthropic oauth refresh contract", async () => {
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
access_token: "claude-access",
|
|
refresh_token: "claude-refresh-next",
|
|
expires_in: 1800,
|
|
});
|
|
},
|
|
async () => {
|
|
const result = await refreshClaudeOAuthToken("claude-refresh", log);
|
|
assert.deepEqual(result, {
|
|
accessToken: "claude-access",
|
|
refreshToken: "claude-refresh-next",
|
|
expiresIn: 1800,
|
|
});
|
|
}
|
|
);
|
|
|
|
assert.equal(calls[0].url, OAUTH_ENDPOINTS.anthropic.token);
|
|
assert.equal(calls[0].options.headers["anthropic-beta"], "oauth-2025-04-20");
|
|
assert.match(calls[0].options.body, /grant_type=refresh_token/);
|
|
assert.match(calls[0].options.body, /client_id=/);
|
|
});
|
|
|
|
test("refreshGoogleToken exchanges refresh tokens against the shared google endpoint", async () => {
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
access_token: "google-access",
|
|
refresh_token: "google-refresh-next",
|
|
expires_in: 3600,
|
|
});
|
|
},
|
|
async () => {
|
|
const result = await refreshGoogleToken("google-refresh", "gid", "gsecret", log);
|
|
assert.deepEqual(result, {
|
|
accessToken: "google-access",
|
|
refreshToken: "google-refresh-next",
|
|
expiresIn: 3600,
|
|
});
|
|
}
|
|
);
|
|
|
|
assert.equal(calls[0].url, OAUTH_ENDPOINTS.google.token);
|
|
assert.equal(
|
|
bodyToString(calls[0].options.body),
|
|
"grant_type=refresh_token&refresh_token=google-refresh&client_id=gid&client_secret=gsecret"
|
|
);
|
|
});
|
|
|
|
test("refreshQwenToken maps resource_url into providerSpecificData", async () => {
|
|
const log = createLog();
|
|
|
|
await withMockedFetch(
|
|
async () =>
|
|
jsonResponse({
|
|
access_token: "qwen-access",
|
|
refresh_token: "qwen-refresh-next",
|
|
expires_in: 1500,
|
|
resource_url: "https://chat.qwen.ai/workspace/resource",
|
|
}),
|
|
async () => {
|
|
const result = await refreshQwenToken("qwen-refresh", log);
|
|
assert.deepEqual(result, {
|
|
accessToken: "qwen-access",
|
|
refreshToken: "qwen-refresh-next",
|
|
expiresIn: 1500,
|
|
providerSpecificData: {
|
|
resourceUrl: "https://chat.qwen.ai/workspace/resource",
|
|
},
|
|
});
|
|
}
|
|
);
|
|
});
|
|
|
|
test("refreshQwenToken surfaces invalid_request as unrecoverable", async () => {
|
|
const log = createLog();
|
|
|
|
await withMockedFetch(
|
|
async () => textResponse(JSON.stringify({ error: "invalid_request" }), 400),
|
|
async () => {
|
|
const result = await refreshQwenToken("qwen-refresh", log);
|
|
// Normalized to unrecoverable_refresh_error sentinel (Fix 4)
|
|
assert.deepEqual(result, { error: "unrecoverable_refresh_error", code: "invalid_request" });
|
|
}
|
|
);
|
|
});
|
|
|
|
test("refreshCodexToken recognizes refresh_token_reused responses", async () => {
|
|
const log = createLog();
|
|
|
|
await withMockedFetch(
|
|
async () => textResponse(JSON.stringify({ error: { code: "refresh_token_reused" } }), 400),
|
|
async () => {
|
|
const result = await refreshCodexToken("codex-refresh", log);
|
|
assert.deepEqual(result, {
|
|
error: "unrecoverable_refresh_error",
|
|
code: "refresh_token_reused",
|
|
});
|
|
}
|
|
);
|
|
});
|
|
|
|
// Port from decolua/9router#1821 (sacwooky): a 401 from OpenAI's OAuth token
|
|
// endpoint means the refresh credential itself was rejected (e.g. rotated away
|
|
// or a payload whose error code we do not yet recognize). Retrying with the
|
|
// same refresh token will never succeed — surface re-auth, do not loop.
|
|
test("refreshCodexToken treats any 401 from the token endpoint as unrecoverable", async () => {
|
|
const log = createLog();
|
|
|
|
await withMockedFetch(
|
|
async () =>
|
|
textResponse(
|
|
JSON.stringify({
|
|
error: {
|
|
// A payload variant whose code/type are NOT in the existing
|
|
// unrecoverable set — only the 401 status proves the token is dead.
|
|
message: "Could not validate your token. Please try signing in again.",
|
|
type: "invalid_request_error",
|
|
},
|
|
}),
|
|
401
|
|
),
|
|
async () => {
|
|
const result = await refreshCodexToken("codex-refresh", log);
|
|
assert.equal(
|
|
result?.error,
|
|
"unrecoverable_refresh_error",
|
|
"401 from OpenAI token endpoint must surface re-auth instead of returning null (which triggers retry)"
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
test("refreshKiroToken uses the AWS OIDC flow when client credentials are present", async () => {
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
accessToken: "kiro-aws-access",
|
|
refreshToken: "kiro-aws-refresh-next",
|
|
expiresIn: 900,
|
|
});
|
|
},
|
|
async () => {
|
|
const result = await refreshKiroToken(
|
|
"kiro-refresh",
|
|
{
|
|
authMethod: "idc",
|
|
clientId: "aws-client",
|
|
clientSecret: "aws-secret",
|
|
region: "eu-west-1",
|
|
},
|
|
log
|
|
);
|
|
|
|
assert.deepEqual(result, {
|
|
accessToken: "kiro-aws-access",
|
|
refreshToken: "kiro-aws-refresh-next",
|
|
expiresIn: 900,
|
|
});
|
|
}
|
|
);
|
|
|
|
assert.equal(calls[0].url, "https://oidc.eu-west-1.amazonaws.com/token");
|
|
assert.deepEqual(JSON.parse(calls[0].options.body), {
|
|
clientId: "aws-client",
|
|
clientSecret: "aws-secret",
|
|
refreshToken: "kiro-refresh",
|
|
grantType: "refresh_token",
|
|
});
|
|
});
|
|
|
|
test("refreshKiroToken uses stored region for AWS OIDC refresh without authMethod", async () => {
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
accessToken: "kiro-aws-access",
|
|
refreshToken: "kiro-aws-refresh-next",
|
|
expiresIn: 900,
|
|
});
|
|
},
|
|
async () => {
|
|
const result = await refreshKiroToken(
|
|
"kiro-refresh",
|
|
{
|
|
clientId: "aws-client",
|
|
clientSecret: "aws-secret",
|
|
region: "ap-southeast-1",
|
|
},
|
|
log
|
|
);
|
|
|
|
assert.deepEqual(result, {
|
|
accessToken: "kiro-aws-access",
|
|
refreshToken: "kiro-aws-refresh-next",
|
|
expiresIn: 900,
|
|
});
|
|
}
|
|
);
|
|
|
|
assert.equal(calls[0].url, "https://oidc.ap-southeast-1.amazonaws.com/token");
|
|
assert.deepEqual(JSON.parse(calls[0].options.body), {
|
|
clientId: "aws-client",
|
|
clientSecret: "aws-secret",
|
|
refreshToken: "kiro-refresh",
|
|
grantType: "refresh_token",
|
|
});
|
|
});
|
|
|
|
test("refreshKiroToken falls back to the social-auth refresh endpoint", async () => {
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
accessToken: "kiro-social-access",
|
|
refreshToken: "kiro-social-refresh-next",
|
|
expiresIn: 1200,
|
|
});
|
|
},
|
|
async () => {
|
|
const result = await refreshKiroToken("kiro-refresh", null, log);
|
|
assert.deepEqual(result, {
|
|
accessToken: "kiro-social-access",
|
|
refreshToken: "kiro-social-refresh-next",
|
|
expiresIn: 1200,
|
|
});
|
|
}
|
|
);
|
|
|
|
assert.equal(calls[0].url, PROVIDERS.kiro.tokenUrl);
|
|
assert.deepEqual(JSON.parse(calls[0].options.body), {
|
|
refreshToken: "kiro-refresh",
|
|
});
|
|
});
|
|
|
|
// Issue #2328 — once a social-auth token has clientId/clientSecret stored
|
|
// (because it was imported after v3.8.0), refreshKiroToken must use the AWS OIDC
|
|
// endpoint, not the shared social-auth endpoint, even though authMethod is "google".
|
|
test("refreshKiroToken uses AWS OIDC path for social-auth token when clientId is present (#2328)", async () => {
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
accessToken: "kiro-isolated-access",
|
|
refreshToken: "kiro-isolated-refresh-next",
|
|
expiresIn: 900,
|
|
});
|
|
},
|
|
async () => {
|
|
const result = await refreshKiroToken(
|
|
"kiro-social-refresh",
|
|
{
|
|
authMethod: "google",
|
|
clientId: "isolated-client-id",
|
|
clientSecret: "isolated-client-secret",
|
|
region: "us-east-1",
|
|
},
|
|
log
|
|
);
|
|
|
|
assert.deepEqual(result, {
|
|
accessToken: "kiro-isolated-access",
|
|
refreshToken: "kiro-isolated-refresh-next",
|
|
expiresIn: 900,
|
|
});
|
|
}
|
|
);
|
|
|
|
// Must call the AWS OIDC endpoint — not the shared social-auth tokenUrl
|
|
assert.ok(
|
|
calls[0].url.includes("oidc.us-east-1.amazonaws.com/token"),
|
|
`expected AWS OIDC endpoint but got ${calls[0].url}`
|
|
);
|
|
assert.notEqual(
|
|
calls[0].url,
|
|
PROVIDERS.kiro.tokenUrl,
|
|
"should not call the shared social-auth endpoint when clientId is set"
|
|
);
|
|
assert.deepEqual(JSON.parse(calls[0].options.body), {
|
|
clientId: "isolated-client-id",
|
|
clientSecret: "isolated-client-secret",
|
|
refreshToken: "kiro-social-refresh",
|
|
grantType: "refresh_token",
|
|
});
|
|
});
|
|
|
|
// Issue #2467 — an IMPORTED social token (authMethod === "imported") carries a
|
|
// freshly-registered clientId/clientSecret, but its refresh token is Kiro-social-issued
|
|
// and the isolated OIDC client cannot refresh it. It must use the social-auth endpoint,
|
|
// NOT AWS OIDC (which is what #2328 enabled for authMethod "google").
|
|
test("refreshKiroToken uses social-auth path for imported token even with clientId (#2467)", async () => {
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
accessToken: "kiro-imported-access",
|
|
refreshToken: "kiro-imported-refresh-next",
|
|
expiresIn: 1100,
|
|
});
|
|
},
|
|
async () => {
|
|
const result = await refreshKiroToken(
|
|
"kiro-imported-refresh",
|
|
{
|
|
authMethod: "imported",
|
|
clientId: "isolated-client-id",
|
|
clientSecret: "isolated-client-secret",
|
|
region: "us-east-1",
|
|
},
|
|
log
|
|
);
|
|
assert.equal(result.accessToken, "kiro-imported-access");
|
|
}
|
|
);
|
|
|
|
// Must call the shared social-auth tokenUrl — NOT the AWS OIDC endpoint.
|
|
assert.equal(
|
|
calls[0].url,
|
|
PROVIDERS.kiro.tokenUrl,
|
|
`expected social-auth endpoint but got ${calls[0].url}`
|
|
);
|
|
assert.ok(!calls[0].url.includes("oidc."), "imported token must not use AWS OIDC");
|
|
});
|
|
|
|
test("refreshQoderToken uses basic auth once qoder oauth settings are configured", async () => {
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS.qoder,
|
|
{
|
|
clientId: "qoder-client",
|
|
clientSecret: "qoder-secret",
|
|
},
|
|
async () => {
|
|
await withPatchedProperties(
|
|
OAUTH_ENDPOINTS.qoder,
|
|
{
|
|
token: "https://qoder.example.com/oauth/token",
|
|
},
|
|
async () => {
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
access_token: "qoder-access",
|
|
refresh_token: "qoder-refresh-next",
|
|
expires_in: 2400,
|
|
});
|
|
},
|
|
async () => {
|
|
const result = await refreshQoderToken("qoder-refresh", log);
|
|
assert.deepEqual(result, {
|
|
accessToken: "qoder-access",
|
|
refreshToken: "qoder-refresh-next",
|
|
expiresIn: 2400,
|
|
});
|
|
}
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
|
|
assert.equal(calls[0].url, "https://qoder.example.com/oauth/token");
|
|
assert.match(calls[0].options.headers.Authorization, /^Basic /);
|
|
});
|
|
|
|
test("refreshGitHubToken sends the real public github client_id and no client_secret (port from 9router#442)", async () => {
|
|
// GitHub Copilot's OAuth app is a public device-flow client: it has a client_id but
|
|
// NO client_secret. PROVIDERS.github.clientId must be populated from the embedded public
|
|
// cred so the refresh request actually carries a client_id — a missing one makes GitHub
|
|
// reject the refresh. The previous test patched a fake clientId/clientSecret onto
|
|
// PROVIDERS.github, masking the fact that the real config had neither. This uses the real
|
|
// config and asserts the real client_id is sent and no client_secret leaks out.
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
access_token: "github-access",
|
|
refresh_token: "github-refresh-next",
|
|
expires_in: 3600,
|
|
});
|
|
},
|
|
async () => {
|
|
const result = await refreshGitHubToken("github-refresh", log);
|
|
assert.deepEqual(result, {
|
|
accessToken: "github-access",
|
|
refreshToken: "github-refresh-next",
|
|
expiresIn: 3600,
|
|
});
|
|
}
|
|
);
|
|
|
|
const body = bodyToString(calls[0].options.body);
|
|
assert.equal(calls[0].url, OAUTH_ENDPOINTS.github.token);
|
|
assert.ok(PROVIDERS.github.clientId, "PROVIDERS.github.clientId must be populated from the public cred");
|
|
assert.match(body, /client_id=Iv1\./, "the real public github client_id must be sent on refresh");
|
|
assert.ok(!body.includes("client_secret="), "no client_secret for the public github client");
|
|
});
|
|
|
|
test("refreshCopilotToken returns the short-lived copilot token", async () => {
|
|
const log = createLog();
|
|
const calls: any[] = [];
|
|
|
|
await withMockedFetch(
|
|
async (url, options = {}) => {
|
|
calls.push({ url, options });
|
|
return jsonResponse({
|
|
token: "copilot-session-token",
|
|
expires_at: "2026-01-01T00:00:00.000Z",
|
|
});
|
|
},
|
|
async () => {
|
|
const result = await refreshCopilotToken("github-access-token", log);
|
|
assert.deepEqual(result, {
|
|
token: "copilot-session-token",
|
|
expiresAt: "2026-01-01T00:00:00.000Z",
|
|
});
|
|
}
|
|
);
|
|
|
|
assert.equal(calls[0].url, "https://api.github.com/copilot_internal/v2/token");
|
|
assert.equal(calls[0].options.headers.Authorization, "token github-access-token");
|
|
});
|
|
|
|
test("supportsTokenRefresh, isUnrecoverableRefreshError and formatProviderCredentials cover provider helpers", async () => {
|
|
const log = createLog();
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS,
|
|
{
|
|
"custom-oauth-task-207": { tokenUrl: "https://auth.example.com/token" },
|
|
},
|
|
async () => {
|
|
assert.equal(supportsTokenRefresh("claude"), true);
|
|
assert.equal(supportsTokenRefresh("amazon-q"), true);
|
|
assert.equal(supportsTokenRefresh("custom-oauth-task-207"), true);
|
|
assert.equal(supportsTokenRefresh("missing-provider"), false);
|
|
}
|
|
);
|
|
|
|
assert.equal(isUnrecoverableRefreshError({ error: "refresh_token_reused" }), true);
|
|
assert.equal(isUnrecoverableRefreshError({ error: "invalid_request" }), true);
|
|
assert.equal(isUnrecoverableRefreshError({ error: "temporary_failure" }), false);
|
|
|
|
assert.deepEqual(
|
|
formatProviderCredentials(
|
|
"gemini",
|
|
{
|
|
apiKey: "gemini-key",
|
|
accessToken: "gemini-access",
|
|
projectId: "project-1",
|
|
refreshToken: "ignored",
|
|
},
|
|
log
|
|
),
|
|
{
|
|
apiKey: "gemini-key",
|
|
accessToken: "gemini-access",
|
|
projectId: "project-1",
|
|
}
|
|
);
|
|
|
|
assert.deepEqual(
|
|
formatProviderCredentials(
|
|
"antigravity",
|
|
{
|
|
accessToken: "google-access",
|
|
refreshToken: "google-refresh",
|
|
},
|
|
log
|
|
),
|
|
{
|
|
accessToken: "google-access",
|
|
refreshToken: "google-refresh",
|
|
}
|
|
);
|
|
|
|
assert.equal(formatProviderCredentials("missing-provider", {}, log), null);
|
|
});
|
|
|
|
test("getAccessToken deduplicates concurrent refreshes for the same provider and token", async () => {
|
|
const log = createLog();
|
|
let fetchCount = 0;
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS,
|
|
{
|
|
"custom-oauth-task-207": { tokenUrl: "https://auth.example.com/token" },
|
|
},
|
|
async () => {
|
|
await withMockedFetch(
|
|
async () => {
|
|
fetchCount += 1;
|
|
return jsonResponse({
|
|
access_token: "shared-access",
|
|
refresh_token: "shared-refresh-next",
|
|
expires_in: 600,
|
|
});
|
|
},
|
|
async () => {
|
|
const [first, second] = await Promise.all([
|
|
getAccessToken("custom-oauth-task-207", { refreshToken: "same-refresh" }, log),
|
|
getAccessToken("custom-oauth-task-207", { refreshToken: "same-refresh" }, log),
|
|
]);
|
|
|
|
assert.equal(fetchCount, 1);
|
|
assert.strictEqual(first, second);
|
|
assert.equal(first.accessToken, "shared-access");
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
test("getAccessToken cleans the in-flight cache after resolve and separates different tokens", async () => {
|
|
const log = createLog();
|
|
let fetchCount = 0;
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS,
|
|
{
|
|
"custom-oauth-task-207": { tokenUrl: "https://auth.example.com/token" },
|
|
},
|
|
async () => {
|
|
await withMockedFetch(
|
|
async (_url, options: RequestInit = {}) => {
|
|
fetchCount += 1;
|
|
const refreshToken = new URLSearchParams(bodyToString(options.body)).get("refresh_token");
|
|
return jsonResponse({
|
|
access_token: `access-${refreshToken}`,
|
|
refresh_token: `next-${refreshToken}`,
|
|
expires_in: 600,
|
|
});
|
|
},
|
|
async () => {
|
|
const first = await getAccessToken(
|
|
"custom-oauth-task-207",
|
|
{ refreshToken: "refresh-a" },
|
|
log
|
|
);
|
|
const second = await getAccessToken(
|
|
"custom-oauth-task-207",
|
|
{ refreshToken: "refresh-a" },
|
|
log
|
|
);
|
|
const third = await getAccessToken(
|
|
"custom-oauth-task-207",
|
|
{ refreshToken: "refresh-b" },
|
|
log
|
|
);
|
|
|
|
assert.equal(fetchCount, 3);
|
|
assert.equal(first.accessToken, "access-refresh-a");
|
|
assert.equal(second.accessToken, "access-refresh-a");
|
|
assert.equal(third.accessToken, "access-refresh-b");
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
test("getAccessToken returns null for invalid refresh token input", async () => {
|
|
const log = createLog();
|
|
const result = await getAccessToken("codex", { refreshToken: null }, log);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test("getAllAccessTokens refreshes only active connections with providers", async () => {
|
|
const log = createLog();
|
|
let fetchCount = 0;
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS,
|
|
{
|
|
"custom-oauth-task-207": { tokenUrl: "https://auth.example.com/token" },
|
|
},
|
|
async () => {
|
|
await withMockedFetch(
|
|
async (_url, options: RequestInit = {}) => {
|
|
fetchCount += 1;
|
|
const refreshToken = new URLSearchParams(bodyToString(options.body)).get("refresh_token");
|
|
return jsonResponse({
|
|
access_token: `access-${refreshToken}`,
|
|
refresh_token: `next-${refreshToken}`,
|
|
expires_in: 900,
|
|
});
|
|
},
|
|
async () => {
|
|
const tokens = await getAllAccessTokens(
|
|
{
|
|
connections: [
|
|
{
|
|
provider: "custom-oauth-task-207",
|
|
refreshToken: "active-one",
|
|
isActive: true,
|
|
},
|
|
{
|
|
provider: "custom-oauth-task-207",
|
|
refreshToken: "inactive-one",
|
|
isActive: false,
|
|
},
|
|
{
|
|
provider: null,
|
|
refreshToken: "missing-provider",
|
|
isActive: true,
|
|
},
|
|
],
|
|
},
|
|
log
|
|
);
|
|
|
|
assert.equal(fetchCount, 1);
|
|
assert.deepEqual(tokens, {
|
|
"custom-oauth-task-207": {
|
|
accessToken: "access-active-one",
|
|
refreshToken: "next-active-one",
|
|
expiresIn: 900,
|
|
},
|
|
});
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
test("refreshWithRetry retries to success and clears prior circuit-breaker state", async () => {
|
|
const provider = `retry-success-${Date.now()}`;
|
|
const log = createLog();
|
|
|
|
await refreshWithRetry(async () => null, 1, log, provider);
|
|
assert.equal(getCircuitBreakerStatus()[provider].failures, 1);
|
|
|
|
await withFastRetryTimers(async () => {
|
|
let attempts = 0;
|
|
const result = await refreshWithRetry(
|
|
async () => {
|
|
attempts += 1;
|
|
return attempts === 2 ? { accessToken: "recovered" } : null;
|
|
},
|
|
3,
|
|
log,
|
|
provider
|
|
);
|
|
|
|
assert.deepEqual(result, { accessToken: "recovered" });
|
|
assert.equal(attempts, 2);
|
|
assert.equal(getCircuitBreakerStatus()[provider], undefined);
|
|
});
|
|
});
|
|
|
|
test("refreshWithRetry trips the circuit breaker after repeated failures and blocks new calls", async () => {
|
|
const provider = `retry-blocked-${Date.now()}`;
|
|
const log = createLog();
|
|
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
const result = await refreshWithRetry(async () => null, 1, log, provider);
|
|
assert.equal(result, null);
|
|
}
|
|
|
|
assert.equal(isProviderBlocked(provider), true);
|
|
assert.equal(getCircuitBreakerStatus()[provider].blocked, true);
|
|
|
|
let called = false;
|
|
const blockedResult = await refreshWithRetry(
|
|
async () => {
|
|
called = true;
|
|
return { accessToken: "should-not-run" };
|
|
},
|
|
1,
|
|
log,
|
|
provider
|
|
);
|
|
|
|
assert.equal(blockedResult, null);
|
|
assert.equal(called, false);
|
|
});
|
|
|
|
test("isProviderBlocked clears expired circuit-breaker entries once cooldown passes", async () => {
|
|
const provider = `retry-expiry-${Date.now()}`;
|
|
const log = createLog();
|
|
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
await refreshWithRetry(async () => null, 1, log, provider);
|
|
}
|
|
|
|
const blockedUntil = Date.parse(getCircuitBreakerStatus()[provider].blockedUntil as string);
|
|
|
|
await withMockedNow(blockedUntil + 1, async () => {
|
|
assert.equal(isProviderBlocked(provider), false);
|
|
assert.equal(getCircuitBreakerStatus()[provider], undefined);
|
|
});
|
|
});
|
|
|
|
// ─── Per-connection mutex tests ────────────────────────────────────────────────
|
|
|
|
test("getAccessToken per-connection mutex: 5 concurrent callers fire exactly one upstream call", async () => {
|
|
const log = createLog();
|
|
let upstreamCallCount = 0;
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS,
|
|
{ "custom-oauth-conn-mutex": { tokenUrl: "https://auth.example.com/token" } },
|
|
async () => {
|
|
await withMockedFetch(
|
|
async () => {
|
|
upstreamCallCount++;
|
|
// Simulate 50ms upstream latency so all 5 callers are concurrent
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
return jsonResponse({
|
|
access_token: "new-access-token",
|
|
refresh_token: "new-refresh-token",
|
|
expires_in: 3600,
|
|
});
|
|
},
|
|
async () => {
|
|
const credentials = {
|
|
connectionId: "conn-abc-123",
|
|
refreshToken: "old-refresh-token",
|
|
};
|
|
|
|
const results = await Promise.all([
|
|
getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log),
|
|
getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log),
|
|
getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log),
|
|
getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log),
|
|
getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log),
|
|
]);
|
|
|
|
assert.equal(upstreamCallCount, 1, "upstream called exactly once");
|
|
for (const result of results) {
|
|
assert.equal(result?.accessToken, "new-access-token", "all callers got same token");
|
|
assert.equal(result?.refreshToken, "new-refresh-token");
|
|
}
|
|
// All results are the same object reference (shared promise)
|
|
assert.strictEqual(results[0], results[1]);
|
|
assert.strictEqual(results[1], results[4]);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
test("getAccessToken per-connection mutex: logs concurrent refresh with waiter count", async () => {
|
|
const log = createLog();
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS,
|
|
{ "custom-oauth-conn-mutex": { tokenUrl: "https://auth.example.com/token" } },
|
|
async () => {
|
|
await withMockedFetch(
|
|
async () => {
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
return jsonResponse({ access_token: "tok", refresh_token: "rtok", expires_in: 600 });
|
|
},
|
|
async () => {
|
|
const credentials = { connectionId: "conn-log-test", refreshToken: "rt" };
|
|
await Promise.all([
|
|
getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log),
|
|
getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log),
|
|
getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log),
|
|
]);
|
|
|
|
const concurrentLogs = log.entries.filter(
|
|
(e) =>
|
|
e.level === "info" &&
|
|
e.message === "Concurrent refresh detected — sharing in-flight refresh"
|
|
);
|
|
assert.ok(concurrentLogs.length >= 1, "logged at least one concurrent refresh event");
|
|
assert.ok(
|
|
concurrentLogs.some((e) => e.meta?.connectionId === "conn-log-test"),
|
|
"log includes connectionId"
|
|
);
|
|
assert.ok(
|
|
concurrentLogs.some((e) => typeof e.meta?.waiters === "number" && e.meta.waiters >= 1),
|
|
"log includes waiter count"
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
test("getAccessToken per-connection mutex: failed refresh propagates null to all waiters (idempotent error)", async () => {
|
|
const log = createLog();
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS,
|
|
{ "custom-oauth-conn-mutex": { tokenUrl: "https://auth.example.com/token" } },
|
|
async () => {
|
|
await withMockedFetch(
|
|
async () => {
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
// 400 response causes refreshAccessToken to return null
|
|
return new Response("bad_request", { status: 400 });
|
|
},
|
|
async () => {
|
|
const credentials = { connectionId: "conn-fail-test", refreshToken: "expired-rt" };
|
|
const results = await Promise.all([
|
|
getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log),
|
|
getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log),
|
|
getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log),
|
|
]);
|
|
|
|
for (const result of results) {
|
|
assert.equal(result, null, "failed refresh returns null to all waiters");
|
|
}
|
|
// Mutex cleaned up after failure
|
|
assert.equal(
|
|
getConnectionRefreshMutexStatus()["conn-fail-test"],
|
|
undefined,
|
|
"mutex entry removed after failure"
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
test("getAccessToken per-connection mutex: different connections run independently", async () => {
|
|
const log = createLog();
|
|
let upstreamCallCount = 0;
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS,
|
|
{ "custom-oauth-conn-mutex": { tokenUrl: "https://auth.example.com/token" } },
|
|
async () => {
|
|
await withMockedFetch(
|
|
async () => {
|
|
upstreamCallCount++;
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
return jsonResponse({
|
|
access_token: `access-${upstreamCallCount}`,
|
|
refresh_token: `refresh-${upstreamCallCount}`,
|
|
expires_in: 600,
|
|
});
|
|
},
|
|
async () => {
|
|
const [groupA, groupB] = await Promise.all([
|
|
Promise.all([
|
|
getAccessToken(
|
|
"custom-oauth-conn-mutex",
|
|
{ connectionId: "conn-A", refreshToken: "rt-a" },
|
|
log
|
|
),
|
|
getAccessToken(
|
|
"custom-oauth-conn-mutex",
|
|
{ connectionId: "conn-A", refreshToken: "rt-a" },
|
|
log
|
|
),
|
|
]),
|
|
Promise.all([
|
|
getAccessToken(
|
|
"custom-oauth-conn-mutex",
|
|
{ connectionId: "conn-B", refreshToken: "rt-b" },
|
|
log
|
|
),
|
|
getAccessToken(
|
|
"custom-oauth-conn-mutex",
|
|
{ connectionId: "conn-B", refreshToken: "rt-b" },
|
|
log
|
|
),
|
|
]),
|
|
]);
|
|
|
|
assert.equal(upstreamCallCount, 2, "one upstream call per distinct connection");
|
|
assert.strictEqual(groupA[0], groupA[1], "conn-A callers share same result");
|
|
assert.strictEqual(groupB[0], groupB[1], "conn-B callers share same result");
|
|
assert.notStrictEqual(groupA[0], groupB[0], "conn-A and conn-B got different results");
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
test("getAccessToken per-connection mutex: mutex cleared after success, next call re-fires upstream", async () => {
|
|
const log = createLog();
|
|
let upstreamCallCount = 0;
|
|
|
|
// The rotation map (added for the codex-multi-auth pattern) is process-wide
|
|
// and intentionally redirects a stale-token caller to the cached rotated
|
|
// tokens. Clear it BEFORE and BETWEEN calls so this test exercises the
|
|
// lower-level mutex semantics it was designed for.
|
|
tokenRefresh._clearTokenRotationMap();
|
|
|
|
await withPatchedProperties(
|
|
PROVIDERS,
|
|
{ "custom-oauth-conn-mutex": { tokenUrl: "https://auth.example.com/token" } },
|
|
async () => {
|
|
await withMockedFetch(
|
|
async () => {
|
|
upstreamCallCount++;
|
|
return jsonResponse({
|
|
access_token: `access-${upstreamCallCount}`,
|
|
refresh_token: `refresh-${upstreamCallCount}`,
|
|
expires_in: 600,
|
|
});
|
|
},
|
|
async () => {
|
|
const credentials = { connectionId: "conn-refire", refreshToken: "rt" };
|
|
|
|
const first = await getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log);
|
|
tokenRefresh._clearTokenRotationMap();
|
|
const second = await getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log);
|
|
|
|
assert.equal(upstreamCallCount, 2, "each sequential call fires upstream once");
|
|
assert.equal(first?.accessToken, "access-1");
|
|
assert.equal(second?.accessToken, "access-2");
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
|
|
// ─── Unrecoverable error bail-out tests ──────────────────────────────────────
|
|
|
|
test("refreshWithRetry bails immediately on unrecoverable error without retrying", async () => {
|
|
const provider = `bail-unrecoverable-${Date.now()}`;
|
|
const log = createLog();
|
|
let callCount = 0;
|
|
|
|
const result = await refreshWithRetry(
|
|
async () => {
|
|
callCount++;
|
|
return { error: "unrecoverable_refresh_error", code: "http_400" };
|
|
},
|
|
3,
|
|
log,
|
|
provider
|
|
);
|
|
|
|
assert.equal(callCount, 1, "should only call refreshFn once (no retries)");
|
|
assert.deepEqual(result, { error: "unrecoverable_refresh_error", code: "http_400" });
|
|
const warnMessages = log.entries.filter((e) => e.level === "warn").map((e) => e.message);
|
|
assert.ok(
|
|
warnMessages.some((m) => String(m).includes("Unrecoverable")),
|
|
"should log an unrecoverable warning"
|
|
);
|
|
});
|
|
|
|
test("refreshWithRetry bails immediately on invalid_grant error without retrying", async () => {
|
|
const provider = `bail-invalid-grant-${Date.now()}`;
|
|
const log = createLog();
|
|
let callCount = 0;
|
|
|
|
const result = await refreshWithRetry(
|
|
async () => {
|
|
callCount++;
|
|
return { error: "invalid_grant", code: "http_400" };
|
|
},
|
|
3,
|
|
log,
|
|
provider
|
|
);
|
|
|
|
assert.equal(callCount, 1, "should only call refreshFn once (no retries)");
|
|
assert.deepEqual(result, { error: "invalid_grant", code: "http_400" });
|
|
});
|
|
|
|
test("refreshClaudeOAuthToken returns error object for invalid_grant (expired refresh token)", async () => {
|
|
const log = createLog();
|
|
|
|
await withMockedFetch(
|
|
async () =>
|
|
new Response(JSON.stringify({ error: "invalid_grant", error_description: "Token expired" }), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
async () => {
|
|
const result = await refreshClaudeOAuthToken("expired-token", log);
|
|
assert.ok(result && typeof result === "object", "should return error object, not null");
|
|
// Normalized to unrecoverable_refresh_error sentinel (Fix 6)
|
|
assert.equal((result as any).error, "unrecoverable_refresh_error");
|
|
assert.equal((result as any).code, "invalid_grant");
|
|
assert.ok(isUnrecoverableRefreshError(result), "should be detected as unrecoverable");
|
|
}
|
|
);
|
|
});
|
|
|
|
test("refreshClaudeOAuthToken returns null for transient server errors (not unrecoverable)", async () => {
|
|
const log = createLog();
|
|
|
|
await withMockedFetch(
|
|
async () =>
|
|
new Response(JSON.stringify({ error: "server_error" }), {
|
|
status: 503,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
async () => {
|
|
const result = await refreshClaudeOAuthToken("some-token", log);
|
|
assert.equal(result, null, "transient server errors should return null (retryable)");
|
|
}
|
|
);
|
|
});
|