fix(oauth): set tokenExpiresAt at creation + surface no-refresh-token as expired (#5326) (#5347)

Integrated into release/v3.8.41 — Antigravity Token Expired badge: set tokenExpiresAt at creation + surface no-refresh-token as expired (#5326). Tests green (2 + 3).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-29 11:21:57 -03:00
committed by GitHub
parent 98c9f5b136
commit cd9a9abbf5
6 changed files with 235 additions and 38 deletions

View File

@@ -9,7 +9,10 @@ import {
pollForToken,
resolveBrowserOAuthRedirectUri,
} from "@/lib/oauth/providers";
import { persistOAuthConnection } from "@/lib/oauth/connectionPersistence";
import {
persistOAuthConnection,
buildOAuthConnectionCreatePayload,
} from "@/lib/oauth/connectionPersistence";
import { createDeviceFlowTicket, getDeviceFlowTicketStatus } from "@/lib/oauth/deviceFlowTickets";
import {
createProviderConnection,
@@ -497,13 +500,9 @@ export async function POST(
}
}
if (!connection) {
connection = await createProviderConnection({
provider,
authType: "oauth",
...tokenData,
expiresAt,
testStatus: "active",
});
connection = await createProviderConnection(
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt)
);
}
// Auto sync to Cloud if enabled
@@ -589,13 +588,9 @@ export async function POST(
}
}
if (!connection) {
connection = await createProviderConnection({
provider,
authType: "oauth",
...result.tokens,
expiresAt,
testStatus: "active",
});
connection = await createProviderConnection(
buildOAuthConnectionCreatePayload(provider, result.tokens, expiresAt)
);
}
// Auto sync to Cloud if enabled
@@ -722,13 +717,9 @@ export async function POST(
}
}
if (!connection) {
connection = await createProviderConnection({
provider,
authType: "oauth",
...tokenData,
expiresAt,
testStatus: "active",
});
connection = await createProviderConnection(
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt)
);
}
await syncToCloudIfEnabled();
@@ -796,13 +787,9 @@ export async function POST(
}
}
if (!connection) {
connection = await createProviderConnection({
provider,
authType: "oauth",
...tokenData,
expiresAt,
testStatus: "active",
});
connection = await createProviderConnection(
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt)
);
}
await syncToCloudIfEnabled();

View File

@@ -27,6 +27,34 @@ function safeEqual(a: string | null | undefined, b: string | null | undefined):
return timingSafeEqual(ba, bb);
}
/**
* Build the create payload for a brand-new OAuth connection.
*
* #5326: mirror the freshly computed `expiresAt` into `tokenExpiresAt` at creation
* time. The dashboard token-health badge prefers `tokenExpiresAt` over `expiresAt`
* (ConnectionRow.tsx: `connection.tokenExpiresAt || connection.expiresAt`). If
* `tokenExpiresAt` stays null on a freshly created connection, the badge falls back
* to the original grant clock and can flash a false amber/"Token Expired" until the
* first background refresh writes both fields together. All refresh paths already
* persist `expiresAt` and `tokenExpiresAt` in lockstep
* (tokenHealthCheck onPersist, tokenRefresh.updateProviderCredentials); this makes
* creation consistent with them.
*/
export function buildOAuthConnectionCreatePayload(
provider: string,
tokenData: Record<string, any>,
expiresAt: string | null
) {
return {
provider,
authType: "oauth" as const,
...tokenData,
expiresAt,
tokenExpiresAt: expiresAt,
testStatus: "active" as const,
};
}
async function syncToCloudIfEnabled(): Promise<void> {
try {
const cloudEnabled = await isCloudEnabled();
@@ -76,13 +104,9 @@ export async function persistOAuthConnection(
}
}
if (!connection) {
connection = await createProviderConnection({
provider,
authType: "oauth",
...tokenData,
expiresAt,
testStatus: "active",
});
connection = await createProviderConnection(
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt)
);
}
await syncToCloudIfEnabled();

View File

@@ -341,7 +341,36 @@ export async function checkConnection(conn) {
const intervalMin = conn.healthCheckInterval ?? DEFAULT_HEALTH_CHECK_INTERVAL_MIN;
if (intervalMin <= 0) return;
if (!conn.isActive) return;
if (!conn.refreshToken || typeof conn.refreshToken !== "string") return;
if (!conn.refreshToken || typeof conn.refreshToken !== "string") {
// #5326: a refresh-CAPABLE provider (e.g. antigravity/gemini) with no usable
// refresh token can never self-heal via the sweep — it genuinely needs re-auth.
// Silently skipping here left the row at testStatus="active" while the dashboard
// badge (which derives expiry from tokenExpiresAt||expiresAt) showed a confusing
// cosmetic "Token Expired". Surface reality as a terminal "expired" status instead.
// Guard tightly so we do NOT clobber:
// - providers that simply don't use refresh tokens (supportsTokenRefresh=false)
// - connections already in a terminal/specific state (expired/banned/credits_exhausted)
// - transient cooldown state (unavailable) owned by the request path
const refreshCapableNeedsReauth =
supportsTokenRefresh(conn.provider) &&
(!conn.testStatus || conn.testStatus === "active");
if (refreshCapableNeedsReauth) {
const now = new Date().toISOString();
await updateProviderConnection(conn.id, {
testStatus: "expired",
lastHealthCheckAt: now,
lastError: "No refresh token available — re-authenticate this account.",
lastErrorAt: now,
lastErrorType: "no_refresh_token",
lastErrorSource: "oauth",
errorCode: "no_refresh_token",
});
log(
`${LOG_PREFIX} ${conn.provider}/${getConnectionLogLabel(conn)} has no refresh token; marking expired (needs re-auth)`
);
}
return;
}
// Retry expired connections with exponential backoff up to EXPIRED_RETRY_MAX times.
if (conn.testStatus === "expired") {