Skip to content

Conversation

@tusharpandey13
Copy link
Contributor

@tusharpandey13 tusharpandey13 commented Oct 30, 2025

The auth code callback flow was missing the DPoP nonce retry pattern that already exists for:

  • Refresh token requests (refreshTokenGrantRequest)
  • Connection token exchanges (genericTokenEndpointRequest)

When a server requires a DPoP nonce, it responds with HTTP 400 + use_dpop_nonce error + DPoP-Nonce header. The client must retry with the provided nonce. Without this retry logic, the error propagates to the user and login fails.
endpoint flows.

Changes

Wrapped the authorizationCodeGrantRequestCall with withDPoPNonceRetry() when DPoP is enabled, matching the existing patterns for other token.

  • src/server/auth-client.ts (lines 784-789): Added conditional DPoP nonce retry wrapper in handleCallback() method
  • src/server/dpop-authcode-nonce-retry.test.ts: Added MSW-based HTTP flow tests validating:
    • First request without nonce receives 400 + use_dpop_nonce error
    • Automatic retry with server-provided nonce succeeds
    • Second request returns 200 + valid tokens

References

  • RFC 9449

@tusharpandey13 tusharpandey13 requested a review from a team as a code owner October 30, 2025 07:12
@tusharpandey13
Copy link
Contributor Author

@claude please review this pr

@github-actions
Copy link
Contributor

github-actions bot commented Oct 30, 2025

Claude finished @tusharpandey13's task —— View job


PR Review Complete

I've completed the review of PR #2390. Here's my assessment:

Overall Assessment

❌ This PR is not ready to be merged. While the core fix is correct and RFC 9449 compliant, there are several issues that need to be addressed before merging.

Critical Issues

  1. Unintended version/changelog revert - The .version file was reverted from v4.12.0 to v4.11.0, and recent CHANGELOG entries were removed. This appears to be an accidental rebase issue.

  2. Documentation files included - Four internal planning/documentation files are included in the PR that should not be committed:

    • DPOP_NONCE_VERIFICATION.md
    • PLAN-dpop-authcode-retry-pr.md
    • PLAN-dpop-bugfix.md
    • REACT_AUTH0_DPOP_COMPARISON.md
  3. Missing retry configuration - The auth code callback doesn't pass this.dpopOptions?.retry to withDPoPNonceRetry(), creating inconsistency with other DPoP flows (refresh token at line 1240, connection token at line 1782).

  4. Incomplete test coverage - Tests only verify HTTP mock behavior but don't test the actual handleCallback() method with DPoP enabled, leaving no proof the fix works in the real code path.

Code Quality Notes

  • Architecture: The fix correctly follows the established pattern used for refresh and connection token flows
  • RFC Compliance: Implementation aligns with RFC 9449 Section 5 and 8 requirements
  • Security: No security vulnerabilities introduced; the fix actually improves security by enabling proper DPoP nonce handling
  • Performance: Minimal performance impact; retry only occurs on nonce error with configurable delay (default 100ms with jitter)

Recommendations

  1. Revert the .version and CHANGELOG.md changes
  2. Remove the four documentation markdown files from the commit
  3. Add this.dpopOptions?.retry parameter to the withDPoPNonceRetry call (see inline comment)
  4. Add integration test that actually calls handleCallback() with DPoP enabled to verify end-to-end behavior

Once these issues are resolved, the core fix itself is solid and should be merged.


Job Run | Branch: bugfix/dpop-retry-authcode

@codecov-commenter
Copy link

codecov-commenter commented Oct 30, 2025

Codecov Report

❌ Patch coverage is 92.40506% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.86%. Comparing base (958259f) to head (6488a4a).

Files with missing lines Patch % Lines
src/utils/dpopUtils.ts 86.20% 4 Missing ⚠️
src/server/auth-client.ts 96.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2390      +/-   ##
==========================================
+ Coverage   87.65%   87.86%   +0.20%     
==========================================
  Files          37       37              
  Lines        4084     4128      +44     
  Branches      809      822      +13     
==========================================
+ Hits         3580     3627      +47     
+ Misses        501      498       -3     
  Partials        3        3              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@tusharpandey13 tusharpandey13 changed the title DPoP nonce retry on auth code callback bugfix: DPoP nonce retry on auth code callback Oct 30, 2025
@tusharpandey13 tusharpandey13 force-pushed the bugfix/dpop-retry-authcode branch 2 times, most recently from 7fd3cbd to 751e55d Compare October 30, 2025 14:21
@tusharpandey13
Copy link
Contributor Author

@claude review this pr

@github-actions
Copy link
Contributor

github-actions bot commented Oct 30, 2025

Claude finished @tusharpandey13's task —— View job


Overall Assessment

❌ This PR has a critical bug that breaks DPoP nonce retry for refresh token and connection token flows. The refactored withDPoPNonceRetry() function only works with raw Response objects, but refresh/connection flows pass processed TokenEndpointResponse objects, causing retry logic to be silently skipped. Additionally, the shared DPoP handle approach may cause issues with concurrent requests and nonce rotation. The auth code callback implementation works correctly but is inconsistent with other flows.


Job Run | Branch: bugfix/dpop-retry-authcode

Comment on lines 110 to 146
@@ -110,11 +137,12 @@
// Delay before retry to avoid rapid successive requests
await new Promise((resolve) => setTimeout(resolve, actualDelay));

// The RS-signalled nonce is now cached, retrying
// Retry the request - the DPoP handle automatically learned the nonce
return await fn();
}
throw error;
}

return response;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refactored withDPoPNonceRetry function has a critical flaw: it doesn't properly handle the case where fn() returns a processed token response object (not a raw Response).

Problem: When fn() wraps both the HTTP request AND response processing (like in refresh token flow at line 1241-1243), the returned value is a TokenEndpointResponse object, not a Response. The check response instanceof Response will be false, causing the function to skip retry logic entirely.

Example failure scenario:

// Refresh token flow (lines 1241-1243)
oauthRes = await withDPoPNonceRetry(
  async () => {
    const refreshTokenRes = await refreshTokenGrantRequestCall();
    return await processRefreshTokenResponseCall(refreshTokenRes); // Returns TokenEndpointResponse
  },
  { isDPoPEnabled: true }
);

Here, processRefreshTokenResponseCall returns a TokenEndpointResponse object, not a Response. The retry logic will never trigger even if the server returns a DPoP nonce error.

Root cause: The previous implementation caught exceptions using isDPoPNonceError(), which worked for both Response and processed objects because oauth4webapi throws on errors. The new implementation only checks raw Response status codes.

Fix needed: Either:

  1. Revert to try-catch with isDPoPNonceError() to handle both cases, OR
  2. Ensure fn() always returns raw Response objects and processing happens after retry (requires refactoring all call sites)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a try catch , removed extends Response | object


private dpopKeyPair?: DpopKeyPair;
private readonly useDPoP: boolean;
private defaultDPoPHandle?: ReturnType<typeof oauth.DPoP>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a single shared DPoP handle that's reused across multiple token requests is architecturally problematic for nonce management.

Issue: oauth4webapi's DPoP handle maintains internal state (learned nonces). When multiple concurrent token requests use the same handle:

  1. Request A fails with nonce error, handle learns nonce "ABC"
  2. Request B (concurrent) uses nonce "ABC" but server may have rotated to nonce "XYZ"
  3. Request B fails unnecessarily

RFC 9449 Section 8: "A new nonce value MUST be provisioned for each use" - the server can provide different nonces for different resources/endpoints.

Impact: While this approach works for sequential requests, it may cause issues with:

  • Concurrent token refreshes (multiple tabs/windows)
  • Parallel API calls with token refresh
  • Server-side nonce rotation policies

Recommendation: Consider creating a fresh DPoP handle per token request (as the original code did), or implement per-endpoint nonce caching if performance is a concern.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed the global nonce id, reverted to original code

Comment on lines +119 to +147
}

// Second request: Return 200 with tokens
const idToken = await new jose.SignJWT({
sid: DEFAULT.sid,
sub: DEFAULT.sub,
nonce: DEFAULT.nonce,
auth_time: Math.floor(Date.now() / 1000),
iss: `https://${DEFAULT.domain}/`,
aud: DEFAULT.clientId
})
.setProtectedHeader({ alg: DEFAULT.alg })
.setIssuedAt()
.setExpirationTime("1h")
.sign(keyPairParam.privateKey);

return HttpResponse.json({
access_token: DEFAULT.accessToken,
refresh_token: DEFAULT.refreshToken,
id_token: idToken,
token_type: "Bearer",
expires_in: 86400
} as oauth.TokenEndpointResponse);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test validates HTTP-level retry behavior but doesn't verify the actual integration with handleCallback(). While the test confirms MSW mocking works, it doesn't prove the DPoP handle actually learns and applies the nonce.

Gap: The test creates request tracking state but never validates that:

  1. The DPoP JWT payload actually contains the nonce claim on the second request
  2. The nonce value matches what the server provided ("server_nonce_value_123")

Current validation at lines 286-307:

expect(tokenHandlerState.requests[1]).toMatchObject({
  attempt: 2,
  hasDPoP: true,
  hasNonce: true  // Only checks nonce EXISTS, not that it's correct
});

Missing validation: The test should decode the DPoP JWT from the second request and verify:

const dpopJwt = tokenHandlerState.requests[1].dpopHeader;
const payload = jose.decodeJwt(dpopJwt);
expect(payload.nonce).toBe("server_nonce_value_123");

This would prove the oauth4webapi DPoP handle correctly learned the nonce from the error response and injected it on retry.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

Comment on lines 786 to 802
codeGrantResponse = await withDPoPNonceRetry(
authorizationCodeGrantRequestCall,
{
isDPoPEnabled: !!(this.useDPoP && this.dpopKeyPair),
...this.dpopOptions?.retry
}
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auth code callback flow passes a wrapper function that includes both HTTP request AND response processing to withDPoPNonceRetry(). This creates an inconsistency with how other token flows are structured.

Inconsistency:

  • Auth code (lines 786-792): Passes only the HTTP request function, response processing happens after (line 807)
  • Refresh token (lines 1240-1249): Passes BOTH HTTP request AND response processing in the wrapper
  • Connection token (lines 1787-1793): Passes BOTH HTTP request AND response processing in the wrapper

Why this matters: The refactored withDPoPNonceRetry() at dpopUtils.ts:110-146 only handles Response objects (checks response instanceof Response). If the wrapper includes response processing, it returns a processed object and retry logic won't work.

Current state: Auth code flow works correctly because it only wraps the HTTP request. But this inconsistency with other flows is confusing and may lead to bugs if someone refactors it to match the pattern of refresh/connection flows.

Recommendation: Document why auth code flow must wrap only the HTTP request (not response processing), or refactor all flows to use the same pattern.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documented


// Check if this is a 400 error response with use_dpop_nonce
if (response instanceof Response && response.status === 400) {
const errorBody = await response.clone().json();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling .json() on the response body without error handling could cause unhandled promise rejections if the response body is not valid JSON.

Scenario: A misconfigured server or proxy might return a 400 status with HTML error page instead of JSON. The .json() call would throw, but this error is not caught, causing the entire token operation to fail with a cryptic JSON parse error instead of the original HTTP error.

Impact: Poor error messages for users when servers misbehave.

Suggested change
const errorBody = await response.clone().json();
// Check if this is a 400 error response with use_dpop_nonce
if (response instanceof Response && response.status === 400) {
try {
const errorBody = await response.clone().json();
if (errorBody.error === "use_dpop_nonce") {
// This is a DPoP nonce error, retry with delay and jitter
const delay = config?.delay ?? 100;
const jitter = config?.jitter ?? true;
let actualDelay = delay;
// Apply jitter if enabled (50-100% of original delay to prevent thundering herd)
if (jitter) {
actualDelay = delay * (0.5 + Math.random() * 0.5);
}
// Delay before retry to avoid rapid successive requests
await new Promise((resolve) => setTimeout(resolve, actualDelay));
// Retry the request - the DPoP handle automatically learned the nonce
return await fn();
}
} catch {
// If JSON parsing fails, it's not a DPoP nonce error - return original response
}
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

used your suggestion

Comment on lines 367 to 373
// Initialize DPoP if enabled. Check useDPoP flag first to avoid timing attacks.
if ((options.useDPoP ?? false) && options.dpopKeyPair) {
this.dpopKeyPair = options.dpopKeyPair;
// Create DPoP handle once for reuse across all token grant operations
// oauth4webapi automatically learns nonces from error responses
this.defaultDPoPHandle = oauth.DPoP(
this.clientMetadata,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DPoP handle is only created when both useDPoP flag AND dpopKeyPair are present, but later code only checks this.defaultDPoPHandle existence. This creates a subtle logic gap.

Issue: If someone mistakenly sets useDPoP: true without providing dpopKeyPair, or provides dpopKeyPair without setting useDPoP: true, the behavior is inconsistent:

  • Constructor: Handle not created (correct)
  • Token operations: Check this.defaultDPoPHandle && { DPoP: this.defaultDPoPHandle } (works correctly, no DPoP used)
  • withDPoPNonceRetry: Checks isDPoPEnabled: !!(this.useDPoP && this.dpopKeyPair) (redundant check)

Redundancy: The spread operator ...(this.defaultDPoPHandle && { DPoP: this.defaultDPoPHandle }) already handles the case where handle doesn't exist. The isDPoPEnabled flag passed to withDPoPNonceRetry is redundant - you could simplify to checking if the response is from a DPoP-enabled request by inspecting whether the handle was actually used.

Simplification opportunity:

isDPoPEnabled: !!this.defaultDPoPHandle

This is clearer because it directly expresses "DPoP is enabled if we have a handle", rather than re-checking the original configuration flags.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed the default dpop handle

@tusharpandey13 tusharpandey13 force-pushed the bugfix/dpop-retry-authcode branch from 751e55d to 3009c49 Compare October 30, 2025 14:46
@tusharpandey13
Copy link
Contributor Author

@claude review this pr

@github-actions
Copy link
Contributor

github-actions bot commented Oct 30, 2025

Claude finished @tusharpandey13's task —— View job


Overall Assessment

✅ This PR successfully addresses the critical bugs from the previous review and implements DPoP nonce retry for auth code callbacks correctly. The core fix is sound and RFC 9449 compliant. The author has made significant improvements: removed the problematic shared DPoP handle, added proper retry configuration, enhanced test coverage to validate actual nonce values, and documented the intentional architectural differences between token flows. While there are minor code quality improvements that could be made (formatting, simplifying redundant checks, using jose for JWT parsing in tests), these are not blocking issues. The PR is ready to merge.


Job Run | Branch: bugfix/dpop-retry-authcode

throw error;
else{
throw error;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Formatting: Missing space before else keyword. Should be } else { instead of }else{.

if (jitter) {
actualDelay = delay * (0.5 + Math.random() * 0.5);
// Check if this is a 400 error response with use_dpop_nonce
if (response instanceof Response && response.status === 400) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dual retry mechanism (checking both Response objects with status 400 AND catching isDPoPNonceError exceptions) creates inconsistent behavior across token flows.

Issue: The refresh token and connection token flows wrap both HTTP request AND response processing (lines 1260-1262 and 1805-1811 in auth-client.ts). When oauth.processRefreshTokenResponse() or oauth.processGenericTokenEndpointResponse() encounters a DPoP nonce error, oauth4webapi throws an exception rather than returning a 400 Response. The exception path (lines 150-159) handles this correctly with isDPoPNonceError().

However, the auth code callback flow only wraps the HTTP request (lines 796-802), so it returns a raw Response object. The Response path (lines 135-146) handles this by checking response.status === 400.

Why this is fragile: The retry logic now depends on how each flow is structured:

  • Auth code: Relies on Response inspection (lines 135-146)
  • Refresh/connection: Relies on exception catching (lines 150-159)

If someone refactors the auth code flow to match refresh/connection flows by including response processing in the wrapper, the Response path would break because processAuthorizationCodeResponse() throws on errors, not returns 400 Response.

Recommendation: Document this dual-path behavior clearly, or refactor all flows to use consistent wrapping (either all wrap only HTTP requests, or all wrap both HTTP + processing).

return { hasNonce: false };
}

try {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nonce extraction logic parses the JWT manually using base64url decoding. While this works for testing purposes, it's fragile because it doesn't validate JWT structure or handle malformed tokens.

Potential issue: If the DPoP header is malformed or contains invalid base64url data, the try-catch will silently return { hasNonce: false } without indicating what went wrong.

Suggestion: Since jose is already imported, use jose.decodeJwt() which provides proper JWT parsing with error handling:

const extractDPoPNonce = (dpopHeader: string | null): { hasNonce: boolean; nonce?: string } => {
  if (!dpopHeader || typeof dpopHeader !== "string") {
    return { hasNonce: false };
  }

  try {
    const payload = jose.decodeJwt(dpopHeader);
    if ("nonce" in payload && typeof payload.nonce === "string") {
      return { hasNonce: true, nonce: payload.nonce };
    }
  } catch {
    // If JWT parsing fails, assume no nonce
  }

  return { hasNonce: false };
};

This is more robust and leverages the existing library.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed

codeGrantResponse = await withDPoPNonceRetry(
authorizationCodeGrantRequestCall,
{
isDPoPEnabled: !!(this.useDPoP && this.dpopKeyPair),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isDPoPEnabled flag is redundantly checking the same conditions used to create dpopHandle just a few lines above (lines 767-770). This creates maintenance burden if the DPoP enablement logic changes.

Simplification: Since dpopHandle is only defined when both useDPoP and dpopKeyPair are truthy, you can simplify to:

codeGrantResponse = await withDPoPNonceRetry(
  authorizationCodeGrantRequestCall,
  {
    isDPoPEnabled: !!dpopHandle,
    ...this.dpopOptions?.retry
  }
);

This is more maintainable because it directly expresses "DPoP is enabled if we created a handle" rather than re-checking the original configuration conditions. Apply the same pattern to lines 1265 and 1819.

@tusharpandey13
Copy link
Contributor Author

@claude review this pr

@github-actions
Copy link
Contributor

github-actions bot commented Oct 30, 2025

Claude finished @tusharpandey13's task —— View job


Overall Assessment

✅ This PR successfully implements DPoP nonce retry for auth code callbacks and is ready to merge. The implementation correctly follows RFC 9449, addresses all critical issues from previous reviews, and includes comprehensive test coverage. The only suggestions are minor code style improvements that don't block merging.


Job Run | Branch: bugfix/dpop-retry-authcode

codeGrantResponse = await withDPoPNonceRetry(
authorizationCodeGrantRequestCall,
{
isDPoPEnabled: !!dpopHandle,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isDPoPEnabled flag redundantly checks the same condition used to create dpopHandle just above (lines 767-770). Since dpopHandle is only defined when both conditions are met, simplify to !!dpopHandle for better maintainability.

Suggested change
isDPoPEnabled: !!dpopHandle,
{
isDPoPEnabled: !!dpopHandle,
...this.dpopOptions?.retry
}

@tusharpandey13 tusharpandey13 force-pushed the bugfix/dpop-retry-authcode branch from b34cfee to 6488a4a Compare October 30, 2025 15:03
@tusharpandey13
Copy link
Contributor Author

replaced by #2391
#2391 contains all the addressed review comments changes in this thread

@tusharpandey13 tusharpandey13 changed the title bugfix: DPoP nonce retry on auth code callback [DO NOT MERGE] bugfix: DPoP nonce retry on auth code callback Oct 30, 2025
@tusharpandey13 tusharpandey13 deleted the bugfix/dpop-retry-authcode branch October 30, 2025 15:36
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