Skip to content

@W-22846346@ Refresh and retry on SCAPI 401 instead of a hard error page - #3860

Merged
vcua-mobify merged 3 commits into
developfrom
W-22846346/401-refresh-retry
Jun 8, 2026
Merged

@W-22846346@ Refresh and retry on SCAPI 401 instead of a hard error page#3860
vcua-mobify merged 3 commits into
developfrom
W-22846346/401-refresh-retry

Conversation

@vcua-mobify

Copy link
Copy Markdown
Contributor

Description

The commerce-sdk-react Auth layer decides whether to refresh purely from the expiry indicator — the non-HttpOnly cc-at-expires cookie in HttpOnly mode, or the JWT exp in localStorage mode — and never from a 401 response.

When the access token is invalid-but-unexpired (server-revoked, tampered, or invalidated after a SLAS signing-key rotation), the indicator still reads "valid", so Auth.ready() sends the bad token, SCAPI returns 401, and the SSR render path throws an HTTPError that the PWA Kit error boundary renders as a hard "This page isn't working — HTTPError 401" page — even though a still-valid refresh token (cc-nx) usually exists.

This adds 401-driven refresh-and-retry at the existing choke point, handleInvalidToken. For any 401 that isn't the "Customer credentials changed after token was issued." case, it clears the stale cc-at-expires indicator and refreshes — mirroring the existing 400 access_token_cookie_missing handler. Clearing the expiry first is required so _refreshAccessToken() doesn't short-circuit on the still-"valid" indicator and return the stale token. The caller (useAuthorizationHeader, and the useQuery/useMutation custom-endpoint paths) retries the request exactly once with no .catch on the retry, so a repeat 401 still propagates — no loop.

Fail-closed behavior is preserved (no 5xx, no data leak); this only changes a previously hard-failing 401 into a graceful refresh-and-retry.

Not HttpOnly-specific: the gap was confirmed to reproduce in a non-HttpOnly (localStorage) environment too — it's a pre-existing SDK resilience gap (the SDK refreshes on expiry, not on a 401). HttpOnly mode amplifies it because the client can't validate the opaque token client-side. The fix lives in the shared retry path, so it covers both modes.

No MRT / proxy changes: the refresh round-trip, the cookie rewrite on the refresh response, and the bearer injection on the retried SCAPI call are all already deployed and exercised by the normal expiry-driven refresh. Adoption is a normal SDK-version bump + storefront redeploy.

Types of Changes

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Documentation update
  • Breaking change (could cause existing functionality to not work as expected)
  • Other changes (non-breaking changes that does not fit any of the above)

Changes

  • handleInvalidToken (packages/commerce-sdk-react/src/hooks/helpers.ts): a generic 401 (not "Customer credentials changed…") now calls auth.clearAccessTokenExpiry() then auth.refreshAccessToken() and returns the fresh token for the caller's single retry, instead of re-throwing.
  • If the refresh token is also dead, refreshAccessToken() falls back to a guest token (the same fallback ready() and the 400 handler already rely on) rather than throwing.
  • Tests (helpers.test.ts): rewrote the obsolete "re-throws generic 401" test to assert clear-expiry + refresh + single read of the response body + clear-before-refresh ordering; added a test that a refreshAccessToken() rejection propagates; upgraded the error mock so the one-shot body read is verifiable. Updated handleInvalidToken JSDoc to document all branches.

How to Test-Drive This PR

  1. In a storefront using commerce-sdk-react (HttpOnly or localStorage mode), authenticate so a valid session exists.
  2. Make the access token invalid-but-unexpired: tamper with the cc-at value (or simulate a server-side revocation / SLAS key rotation) while leaving cc-at-expires intact.
  3. Trigger an SSR render that issues a SCAPI request (e.g. reload a PLP/PDP).
  4. Before: a hard "This page isn't working — HTTPError 401" error page. After: the SDK clears the expiry, refreshes off cc-nx, retries once, and the page renders normally.
  5. Confirm a genuinely unrecoverable case (invalid access token and dead refresh token) gracefully falls back to a guest session rather than the hard error page.

Checklists

General

  • Changes are covered by test cases
  • CHANGELOG.md updated with a short description of changes (not required for documentation updates)

Accessibility Compliance

  • There are no changes to UI

Localization

  • Changes include a UI text update in the Retail React App (which requires translation)

The commerce-sdk-react Auth layer decides whether to refresh purely from the
expiry indicator (the cc-at-expires cookie in HttpOnly mode, or the JWT exp in
localStorage mode), never from a 401 response. When the access token is
invalid-but-unexpired (server-revoked, tampered, or after a SLAS signing-key
rotation), the indicator still reads "valid", so ready() sends the bad token,
SCAPI returns 401, and the SSR render path throws an HTTPError that the PWA Kit
error boundary renders as a hard "This page isn't working - HTTPError 401" page
-- even though a still-valid refresh token (cc-nx) usually exists.

handleInvalidToken now adds 401-driven refresh-and-retry: for any 401 that
isn't the "Customer credentials changed after token was issued." case, it clears
the stale cc-at-expires indicator and refreshes, mirroring the existing 400
access_token_cookie_missing handler. Clearing the expiry first is required so
_refreshAccessToken() doesn't short-circuit on the still-"valid" indicator. The
caller (useAuthorizationHeader and the useQuery/useMutation custom-endpoint
paths) retries the request exactly once, so a repeat 401 still propagates. The
fix is mode-agnostic, covering both HttpOnly and localStorage; the gap was
confirmed to reproduce in both.

No MRT/proxy changes: the refresh round-trip, cookie rewrite, and bearer
injection this relies on are already deployed and exercised by the normal
expiry-driven refresh.
@vcua-mobify
vcua-mobify requested a review from a team as a code owner June 5, 2026 21:18
@git2gus

git2gus Bot commented Jun 5, 2026

Copy link
Copy Markdown

Git2Gus App is installed but the .git2gus/config.json doesn't have right values. You should add the required configuration.

@cc-prodsec

cc-prodsec commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

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

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

@vcua-mobify
vcua-mobify requested review from shethj and unandyala June 5, 2026 21:20
@vcua-mobify
vcua-mobify merged commit f1bcad5 into develop Jun 8, 2026
72 of 74 checks passed
@vcua-mobify
vcua-mobify deleted the W-22846346/401-refresh-retry branch June 8, 2026 16:47
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.

4 participants