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
5 changes: 5 additions & 0 deletions .changeset/calm-snails-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Avoid deleting refreshed OAuth credentials when parallel CLI processes race to refresh the same expired session.
26 changes: 26 additions & 0 deletions packages/cli-core/src/lib/credential-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,32 @@ describe("credential-store", () => {
});
});

test("getValidToken recovers from a concurrent refresh race when another process completes the refresh first (invalid_grant)", async () => {
const session = {
accessToken: "expired-access-token",
refreshToken: "refresh-token",
expiresAt: Date.now() - 60_000,
tokenType: "Bearer",
};
const refreshedSession = {
accessToken: "other-process-access-token",
refreshToken: "other-process-refresh-token",
expiresAt: Date.now() + 60_000,
tokenType: "Bearer",
};
await storeToken(session);

mockRefreshAccessToken.mockImplementation(async () => {
setTimeout(() => {
void storeToken(refreshedSession);
}, 5);
throw new ApiError(400, "invalid_grant");
});

expect(await getValidToken()).toBe("other-process-access-token");
expect(await getStoredSession()).toEqual(refreshedSession);
});

test("getValidToken deletes stored credentials when refresh returns invalid_grant", async () => {
const session = {
accessToken: "expired-access-token",
Expand Down
60 changes: 55 additions & 5 deletions packages/cli-core/src/lib/credential-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* File fallback: "credentials.<envName>"
*/

import { setTimeout as sleep } from "node:timers/promises";
import { dirname, join } from "node:path";
import { mkdir, chmod, writeFile, unlink } from "node:fs/promises";
import { CREDENTIALS_FILE } from "./constants.ts";
Expand All @@ -27,6 +28,7 @@ export const KEYCHAIN_ACCOUNT = "oauth-access-token";
const RELEASE_MACOS_TEAM_ID = "L8SD6SB282";
const RELEASE_MACOS_IDENTIFIER = "clerk";
const JWT_EXPIRY_LEEWAY_MS = 30_000;
const INVALID_GRANT_RETRY_DELAYS_MS = [25, 50, 100];

Comment thread
coderabbitai[bot] marked this conversation as resolved.
export interface OAuthSession {
accessToken: string;
Expand Down Expand Up @@ -309,13 +311,65 @@ async function readStoredValue(): Promise<string | null> {
return fileGet();
}

async function getValidAccessToken(session: OAuthSession): Promise<string> {
if (!isExpiredSession(session)) {
return session.accessToken;
}

return refreshStoredSession(session);
}

/**
* Detect whether a sibling process has already refreshed the OAuth session
* after our own refresh failed with `invalid_grant`. Polls the credential
* store on a short retry budget; returns the new access token if a different
* (non-expired) session appears, otherwise returns `null`.
*
* Race window: two CLI invocations whose stored session is expired will both
* try to redeem the same refresh token. The first wins and rotates; the
* second sees `invalid_grant`. We wait briefly for the winner's persisted
* session to become visible and reuse it instead of forcing a re-auth.
*
* Detection compares refresh tokens because the OAuth server rotates them on
* every successful exchange, so a different refresh token implies a new
* session was written by another process.
*/
async function awaitConcurrentRefresh(session: OAuthSession): Promise<string | null> {
for (const delayMs of [0, ...INVALID_GRANT_RETRY_DELAYS_MS]) {
if (delayMs > 0) {
await sleep(delayMs);
}

const storedSession = await getStoredSession();
if (!storedSession || storedSession.refreshToken === session.refreshToken) {
continue;
}

log.debug("credentials: detected a newer stored session after invalid_grant");
if (isExpiredSession(storedSession)) {
continue;
}
return storedSession.accessToken;
}

return null;
}

async function refreshStoredSession(session: OAuthSession): Promise<string> {
let tokenResponse: TokenResponse;
try {
log.debug("credentials: refreshing OAuth session");
tokenResponse = await refreshAccessToken(session.refreshToken);
} catch (error) {
if (isInvalidGrant(error)) {
try {
const recoveredToken = await awaitConcurrentRefresh(session);
if (recoveredToken) {
return recoveredToken;
}
} catch {
log.debug("credentials: recovery from invalid_grant failed, cleaning up");
}
await deleteToken();
throw sessionExpiredError();
}
Expand Down Expand Up @@ -391,11 +445,7 @@ export async function getValidToken(): Promise<string | null> {
return null;
}

if (!isExpiredSession(session)) {
return session.accessToken;
}

return refreshStoredSession(session);
return getValidAccessToken(session);
}

export async function deleteToken(): Promise<void> {
Expand Down
Loading