Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions packages/cli/src/lib/db/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,26 @@ export const REFRESH_THRESHOLD = 0.1;
/** Default token lifetime (1 hour) for tokens without issuedAt */
export const DEFAULT_TOKEN_LIFETIME_MS = 3600 * 1000;

/**
* Fraction of a token's lifetime that remains at `now`.
*
* Uses {@link DEFAULT_TOKEN_LIFETIME_MS} when `issuedAt` is missing or not
* before `expiresAt`. A corrupt row with `issued_at >= expires_at` would
* otherwise divide by zero or flip the sign, and an `Infinity` ratio skips
* proactive refresh.
*/
export function getRemainingLifetimeRatio(
expiresAt: number,
issuedAt: number | null | undefined,
now: number,
): number {
const storedLifetime =
issuedAt === null || issuedAt === undefined ? 0 : expiresAt - issuedAt;
const totalLifetime =
storedLifetime > 0 ? storedLifetime : DEFAULT_TOKEN_LIFETIME_MS;
return (expiresAt - now) / totalLifetime;
}

/**
* The active account row. `host` is NULL only for rows migrated from the
* pre-v16 `auth` table; `migrateNullHost` backfills it on access.
Expand Down Expand Up @@ -1679,10 +1699,12 @@ export async function refreshToken(
return { ...baseResult, token: row.token, refreshed: false };
}

const issuedAt = row.issued_at ?? expiresAt - DEFAULT_TOKEN_LIFETIME_MS;
const totalLifetime = expiresAt - issuedAt;
const remainingLifetime = expiresAt - now;
const remainingRatio = remainingLifetime / totalLifetime;
const remainingRatio = getRemainingLifetimeRatio(
expiresAt,
row.issued_at,
now,
);
const expiresIn = Math.max(0, Math.floor(remainingLifetime / 1000));

if (!force && remainingRatio > REFRESH_THRESHOLD && now < expiresAt) {
Expand Down
17 changes: 6 additions & 11 deletions packages/cli/src/lib/sentry-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ import {
import { applyCustomHeaders } from "./custom-headers.js";
import {
type CredentialContext,
DEFAULT_TOKEN_LIFETIME_MS,
getCredentialContext,
getRemainingLifetimeRatio,
REFRESH_THRESHOLD,
refreshToken,
} from "./db/auth.js";
Expand Down Expand Up @@ -631,19 +631,14 @@ async function fetchWithRetry({
validatedRedirects,
responseBodyMode,
}: RetryContext): Promise<Response> {
const issuedAt =
credential.issuedAt ??
(credential.expiresAt
? credential.expiresAt - DEFAULT_TOKEN_LIFETIME_MS
: undefined);
const shouldRefresh = Boolean(
credential.refreshable &&
credential.expiresAt &&
(Date.now() >= credential.expiresAt ||
(issuedAt &&
(credential.expiresAt - Date.now()) /
(credential.expiresAt - issuedAt) <=
REFRESH_THRESHOLD)),
getRemainingLifetimeRatio(
credential.expiresAt,
credential.issuedAt,
Date.now(),
) <= REFRESH_THRESHOLD,
);
const refreshed = shouldRefresh
? await refreshToken({ expectedCredential: credential })
Expand Down
54 changes: 54 additions & 0 deletions packages/cli/test/lib/db/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ import { describe, expect, test } from "vitest";
import {
ANON_IDENTITY,
clearAuth,
DEFAULT_TOKEN_LIFETIME_MS,
getActiveEnvVarName,
getAuthConfig,
getAuthToken,
getIdentityFingerprint,
getRawEnvToken,
getRemainingLifetimeRatio,
hasStoredAuthCredentials,
isAuthenticated,
isEnvTokenActive,
REFRESH_THRESHOLD,
refreshToken,
resetAuthTokenCache,
resetHasStoredCredsCache,
Expand Down Expand Up @@ -437,6 +440,19 @@ describe("refreshToken row-read memoization", () => {
});
});

describe("refreshToken: corrupt issued_at", () => {
test("proactively refreshes when issued_at equals expires_at", async () => {
// 2 minutes left is under 10% of the default 1h lifetime. A zero stored
// lifetime used to read as an Infinity ratio and skip the refresh path.
setAuthToken("corrupt_access", 120);
getDatabase().query("UPDATE accounts SET issued_at = expires_at").run();
process.env.SENTRY_AUTH_TOKEN = "env_token";

const result = await refreshToken();
expect(result.token).toBe("env_token");
});
});

describe("hasStoredAuthCredentials memoization", () => {
test("returns false and caches when no stored token", () => {
expect(hasStoredAuthCredentials()).toBe(false);
Expand Down Expand Up @@ -479,3 +495,41 @@ describe("hasStoredAuthCredentials memoization", () => {
expect(hasStoredAuthCredentials()).toBe(true);
});
});

describe("getRemainingLifetimeRatio", () => {
const now = 1_000_000_000;

test("uses the stored lifetime when issuedAt precedes expiresAt", () => {
expect(getRemainingLifetimeRatio(now + 500, now - 500, now)).toBe(0.5);
});

test("falls back to the default lifetime when issuedAt is missing", () => {
const expiresAt = now + DEFAULT_TOKEN_LIFETIME_MS / 4;
expect(getRemainingLifetimeRatio(expiresAt, null, now)).toBe(0.25);
expect(getRemainingLifetimeRatio(expiresAt, undefined, now)).toBe(0.25);
});

test.each([
["issued_at equals expires_at", 0],
["issued_at after expires_at", 1000],
])(
"falls back to the default lifetime when %s",
(_label, issuedAfterExpiry) => {
// 5% of the default lifetime remains, so a sane lifetime must trigger
// proactive refresh instead of reading as Infinity or a huge ratio.
const expiresAt = now + DEFAULT_TOKEN_LIFETIME_MS * 0.05;
const ratio = getRemainingLifetimeRatio(
expiresAt,
expiresAt + issuedAfterExpiry,
now,
);
expect(ratio).toBeCloseTo(0.05);
expect(ratio).toBeLessThanOrEqual(REFRESH_THRESHOLD);
},
);

test("is non-positive once the token has expired", () => {
expect(getRemainingLifetimeRatio(now, now, now)).toBe(0);
expect(getRemainingLifetimeRatio(now - 1, now - 1, now)).toBeLessThan(0);
});
});
Loading