Skip to content

fix(auth): prevent synchronous re-entrant recursion in compareOAuthExpiry - #2719

Open
cs-raj wants to merge 3 commits into
developmentfrom
fix/DX-10477
Open

fix(auth): prevent synchronous re-entrant recursion in compareOAuthExpiry#2719
cs-raj wants to merge 3 commits into
developmentfrom
fix/DX-10477

Conversation

@cs-raj

@cs-raj cs-raj commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem

When a user is authenticated via OAuth and their access token has expired (after 59+ minutes idle), running any `csdx` command that initialises the management SDK triggers an unbounded recursive loop that crashes the process with `RangeError: Maximum call stack size exceeded`.

The call chain is entirely synchronous:

```
compareOAuthExpiry()
→ refreshToken()
→ initSDK()
→ managementSDKClient()
→ createAPIClient()
→ compareOAuthExpiry() ← re-enters before guard is set
→ refreshToken()
→ ... ← infinite recursion → stack overflow
```

Root cause

`compareOAuthExpiry` used a mutex (`oauthRefreshInFlight`) to prevent duplicate refreshes, but the guard was assigned using:

```typescript
this.oauthRefreshInFlight = (async () => {
// ...
})();
```

In JavaScript, the async IIFE's synchronous body executes before the assignment completes. The entire call chain from `compareOAuthExpiry → refreshToken → initSDK → managementSDKClient → createAPIClient → compareOAuthExpiry` is synchronous — zero `await` points yield before the second call. By the time the second `compareOAuthExpiry` checks the guard, it is still `null`. The mutex never fires.

Fix

Three changes in this PR:

1. Set the guard before the async work starts (`auth-handler.ts`)

Replace the IIFE self-assignment with a `new Promise` whose executor runs synchronously, assigning `oauthRefreshInFlight` before any nested code can re-enter:

```typescript
let _resolve: () => void;
let _reject: (err: unknown) => void;
this.oauthRefreshInFlight = new Promise((res, rej) => {
_resolve = res;
_reject = rej;
});
(async () => {
try {
await this.refreshToken();
_resolve();
} catch (error) {
_reject(error);
} finally {
this.oauthRefreshInFlight = null;
}
})();
```

2. Skip token validity check inside `initSDK` (`auth-handler.ts`)

`initSDK` calls `managementSDKClient` only to obtain an `oauthHandler` instance — it does not need a valid access token at construction time. Adding `skipTokenValidity: true` prevents `createAPIClient` from calling `compareOAuthExpiry` again, which would otherwise deadlock (the inner call would await the same in-flight promise that is waiting for `initSDK` to complete).

```typescript
this.managementAPIClient = await managementSDKClient({ host, skipTokenValidity: true });
```

The outer management client created by the command is built after `compareOAuthExpiry` resolves, so it always receives the fresh token.

3. Cap 401 retry count in `refreshAccessToken` (`authentication-handler.ts`)

The 401 branch was recursing with the same stale error object and an unincremented counter, allowing unbounded retries. Added the same `maxRetryCount` guard used by the 429/408 branch: attempt one token refresh, then print a clear error and exit if the 401 persists.

What is not affected

  • Concurrent async callers: still share a single in-flight promise and receive the fresh token on resolution
  • Login flow (`auth:login --oauth`): `initSDK` is called before any OAuth session exists; `skipTokenValidity: true` produces the same empty-auth client as the original code did when `authorisationType` was unset
  • Token valid path: unchanged

Tests

Added a regression test that simulates the synchronous re-entrant call pattern — `refreshToken` stub calls `compareOAuthExpiry` synchronously, verifying the guard is already set and that `refreshToken` and the "Token expired" print each happen exactly once.

…piry

The oauthRefreshInFlight mutex was assigned via an async IIFE:

  this.oauthRefreshInFlight = (async () => { ... })();

In JavaScript the IIFE body executes synchronously before the assignment
completes. The full chain compareOAuthExpiry → refreshToken → initSDK →
managementSDKClient → createAPIClient → compareOAuthExpiry has no await
yields, so the second call sees oauthRefreshInFlight as null and starts
another refresh cycle — leading to unbounded recursion and a RangeError:
Maximum call stack size exceeded.

Fix 1: replace the IIFE assignment with new Promise so the guard is set
synchronously before any nested code can re-enter compareOAuthExpiry.

Fix 2: add skipTokenValidity: true in initSDK so createAPIClient does not
call compareOAuthExpiry again (which would deadlock on the in-flight
promise while that promise is waiting for initSDK to complete).

The outer management client used by commands is created after
compareOAuthExpiry resolves, so it always receives the fresh access token.
Concurrent async callers and the login flow are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cs-raj
cs-raj requested a review from a team as a code owner September 1, 2026 09:11
@snyk-io

snyk-io Bot commented Sep 1, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🔒 Security Scan Results

ℹ️ Note: Only vulnerabilities with available fixes (upgrades or patches) are counted toward thresholds.

Check Type Count (with fixes) Without fixes Threshold Result
🔴 Critical Severity 0 0 10 ✅ Passed
🟠 High Severity 0 152 25 ✅ Passed
🟡 Medium Severity 78 4 500 ✅ Passed
🔵 Low Severity 0 0 1000 ✅ Passed

⏱️ SLA Breach Summary

⚠️ Warning: The following vulnerabilities have exceeded their SLA thresholds (days since publication).

Severity Breaches (with fixes) Breaches (no fixes) SLA Threshold (with/no fixes) Status
🔴 Critical 0 0 15 / 30 days ✅ Passed
🟠 High 0 0 30 / 120 days ✅ Passed
🟡 Medium 0 1 90 / 365 days ⚠️ Warning
🔵 Low 0 0 180 / 365 days ✅ Passed

ℹ️ Vulnerabilities Without Available Fixes (Informational Only)

The following vulnerabilities were detected but do not have fixes available (no upgrade or patch). These are excluded from failure thresholds:

  • Critical without fixes: 0
  • High without fixes: 152
  • Medium without fixes: 4
  • Low without fixes: 0

⚠️ BUILD PASSED WITH WARNINGS - SLA breaches detected for issues without available fixes

Consider reviewing these vulnerabilities when fixes become available.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🔒 Security Scan Results

ℹ️ Note: Only vulnerabilities with available fixes (upgrades or patches) are counted toward thresholds.

Check Type Count (with fixes) Without fixes Threshold Result
🔴 Critical Severity 0 0 10 ✅ Passed
🟠 High Severity 0 0 25 ✅ Passed
🟡 Medium Severity 0 4 500 ✅ Passed
🔵 Low Severity 0 0 1000 ✅ Passed

⏱️ SLA Breach Summary

⚠️ Warning: The following vulnerabilities have exceeded their SLA thresholds (days since publication).

Severity Breaches (with fixes) Breaches (no fixes) SLA Threshold (with/no fixes) Status
🔴 Critical 0 0 15 / 30 days ✅ Passed
🟠 High 0 0 30 / 120 days ✅ Passed
🟡 Medium 0 1 90 / 365 days ⚠️ Warning
🔵 Low 0 0 180 / 365 days ✅ Passed

ℹ️ Vulnerabilities Without Available Fixes (Informational Only)

The following vulnerabilities were detected but do not have fixes available (no upgrade or patch). These are excluded from failure thresholds:

  • Critical without fixes: 0
  • High without fixes: 0
  • Medium without fixes: 4
  • Low without fixes: 0

⚠️ BUILD PASSED WITH WARNINGS - SLA breaches detected for issues without available fixes

Consider reviewing these vulnerabilities when fixes become available.

reeshika-h
reeshika-h previously approved these changes Sep 1, 2026
The 401 branch was recursing with the same stale error and an
unincremented counter — allowing unbounded retries. Add the same
maxRetryCount guard used by the 429/408 branch: attempt one token
refresh, then print a clear error and exit if the 401 persists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🔒 Security Scan Results

ℹ️ Note: Only vulnerabilities with available fixes (upgrades or patches) are counted toward thresholds.

Check Type Count (with fixes) Without fixes Threshold Result
🔴 Critical Severity 0 0 10 ✅ Passed
🟠 High Severity 0 0 25 ✅ Passed
🟡 Medium Severity 0 4 500 ✅ Passed
🔵 Low Severity 0 0 1000 ✅ Passed

⏱️ SLA Breach Summary

⚠️ Warning: The following vulnerabilities have exceeded their SLA thresholds (days since publication).

Severity Breaches (with fixes) Breaches (no fixes) SLA Threshold (with/no fixes) Status
🔴 Critical 0 0 15 / 30 days ✅ Passed
🟠 High 0 0 30 / 120 days ✅ Passed
🟡 Medium 0 1 90 / 365 days ⚠️ Warning
🔵 Low 0 0 180 / 365 days ✅ Passed

ℹ️ Vulnerabilities Without Available Fixes (Informational Only)

The following vulnerabilities were detected but do not have fixes available (no upgrade or patch). These are excluded from failure thresholds:

  • Critical without fixes: 0
  • High without fixes: 0
  • Medium without fixes: 4
  • Low without fixes: 0

⚠️ BUILD PASSED WITH WARNINGS - SLA breaches detected for issues without available fixes

Consider reviewing these vulnerabilities when fixes become available.

@aniket-shikhare-cstk

Copy link
Copy Markdown

✅ QA Verification — all test scenarios passed

Tested this PR end-to-end on a local monorepo build (node bin/run against the compiled packages). Token expiry was simulated by back-dating oauthDateTime in the CLI config store.

# Scenario Result
1 Expired OAuth token + CMA command (cm:branches) Token expired, refreshing the token printed exactly once, token refreshed (verified oauthDateTime updated), command completed with exit 0. No RangeError, no loop
2 Valid token + CMA command (happy path) ✅ Completed with no refresh messages
3 Force refresh — compareOAuthExpiry(true) Forcing token refresh... printed exactly once, resolved cleanly
4 auth:login --oauth with a stale/back-dated token ✅ Reached the browser auth step cleanly (previously crashed with RangeError before the browser ever opened)
5 auth:logout -y with an expired token ✅ Logged out cleanly, all OAuth keys cleared from config
6 Basic auth (authtoken) session + CMA command ✅ Unaffected — OAuth expiry path never entered

Scenarios 1, 4, and 5 are the ones that crashed with RangeError: Maximum call stack size exceeded before this fix — all three now behave correctly.

Also confirmed the mechanism in code: the oauthRefreshInFlight guard promise is created synchronously before the async work starts, so the re-entrant compareOAuthExpiry call returns the in-flight promise instead of recursing, and skipTokenValidity: true in initSDK breaks the inner cycle.

Good from QA side 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants