Skip to content
Closed
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
34 changes: 15 additions & 19 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/cli/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@
import { run } from "@stricli/core";
import { app } from "./app.js";
import { buildContext } from "./context.js";
import { maybeRefreshTokenInBackground } from "./lib/token-refresh.js";

// Fire-and-forget: proactively refresh token if expiring soon
// Runs in parallel with the command, never throws, never blocks
maybeRefreshTokenInBackground();

await run(app, process.argv.slice(2), buildContext(process));
5 changes: 0 additions & 5 deletions packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,6 @@ export const loginCommand = buildCommand({

process.stdout.write("✓ Authentication successful!\n");
process.stdout.write(` Config saved to: ${getConfigPath()}\n`);

if (tokenResponse.expires_in) {
const hours = Math.round(tokenResponse.expires_in / 3600);
process.stdout.write(` Token expires in: ${hours} hours\n`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Authentication failed: ${message}`);
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,41 @@ export async function completeOAuthFlow(
export async function setApiToken(token: string): Promise<void> {
await setAuthToken(token);
}

/**
* Refresh an access token using the refresh token
*
* Used to proactively refresh tokens before they expire, so users
* don't get logged out unexpectedly.
*
* Per RFC 6749 §6 and RFC 8628 §5.6, public clients (like CLIs) can refresh
* tokens with just client_id, without requiring client_secret.
*
* @see https://docs.sentry.io/api/auth/#refreshing-tokens
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-6
* @see https://datatracker.ietf.org/doc/html/rfc8628#section-5.6
*/
export async function refreshAccessToken(
refreshToken: string
): Promise<TokenResponse> {
if (!SENTRY_CLIENT_ID) {
throw new Error("SENTRY_CLIENT_ID is required for token refresh");
}

const response = await fetch(`${SENTRY_URL}/oauth/token/`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: SENTRY_CLIENT_ID,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});

if (!response.ok) {
const error = await response.text();
throw new Error(`Token refresh failed: ${error}`);
}

return response.json() as Promise<TokenResponse>;
}
65 changes: 65 additions & 0 deletions packages/cli/src/lib/token-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Token Refresh
*
* Proactively refreshes OAuth tokens before they expire.
* This runs in the background on every CLI command to ensure
* users don't get unexpectedly logged out.
*/

import { readConfig, setAuthToken } from "./config.js";
import { refreshAccessToken } from "./oauth.js";

// Threshold: 5 days in milliseconds
// If token expires within this time, we'll refresh it
const REFRESH_THRESHOLD_MS = 5 * 24 * 60 * 60 * 1000;

/**
* Check if token needs refresh and refresh it in the background.
*
* This function is designed to be called fire-and-forget.
* It NEVER throws - all errors are silently ignored.
*
* @example
* // At CLI startup (fire-and-forget, no .catch() needed)
* maybeRefreshTokenInBackground();
*/
export async function maybeRefreshTokenInBackground(): Promise<void> {
try {
const config = await readConfig();

// Skip if no auth configured
if (!config.auth?.token) {
return;
}

// Skip if no refresh token (e.g., API token auth, not OAuth)
if (!config.auth.refreshToken) {
return;
}

// Skip if no expiration info (shouldn't happen, but be safe)
if (!config.auth.expiresAt) {
return;
}

// Check if token expires within 5 days
const timeUntilExpiry = config.auth.expiresAt - Date.now();
if (timeUntilExpiry > REFRESH_THRESHOLD_MS) {
return; // Token is still fresh, no action needed
}

// Token needs refresh - do it
const tokenResponse = await refreshAccessToken(config.auth.refreshToken);

// Save the new token
await setAuthToken(
tokenResponse.access_token,
tokenResponse.expires_in,
tokenResponse.refresh_token
);
} catch {
// Silently ignore all errors - this is fire-and-forget
// If refresh fails, the token will expire naturally and
// the user will need to re-login with `sentry auth login`
}
}
14 changes: 14 additions & 0 deletions packages/cli/test/lib/token-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Token Refresh Tests
*
* Verifies the background token refresh behavior.
*/

import { expect, test } from "bun:test";
import { maybeRefreshTokenInBackground } from "../../src/lib/token-refresh.js";

test("maybeRefreshTokenInBackground never throws", async () => {
// Call directly - it should handle missing/invalid config gracefully
// and never throw, regardless of the environment state
await expect(maybeRefreshTokenInBackground()).resolves.toBeUndefined();
});