fix(ui): unblock cold-load 401 refresh (renewer registration race) - #31644
Conversation
…d 401 refresh Follow-up to #31597. The hotfix added TokenService.awaitRenewerReady so the axios 401 interceptor's refresh call could wait briefly for the renewer to be registered, but the underlying race — the ref-deps useEffect that owned the registration — was never addressed. On any provider where the lazy authenticator finishes mounting AFTER the first /users/loggedInUser call has already 401'd (confidential + Google is where this reliably reproduces, but every provider is susceptible on cold-load with a slow chunk), the useEffect never re-runs (ref changes don't schedule re-renders), TokenService.renewToken stays null, awaitRenewerReady times out at 10s, and the interceptor force-logs the user out — no /api/v1/auth/refresh call is ever fired. Fix: each authenticator wrapper (BasicAuthAuthenticator, GenericAuthenticator, OidcAuthenticator, MsalAuthenticator, OktaAuthenticator, Auth0Authenticator) now calls TokenService.updateRenewToken from its own mount useEffect. That effect runs the moment useImperativeHandle populates the ref, with no dependence on the parent re-rendering — no race, deterministic registration. AuthProvider.tsx: the ref-deps useEffect is gone. updateRefreshSuccessCallback(startTokenExpiryTimer) moves to the main mount effect (that callback lives in AuthProvider's closure). TokenService.updateRenewToken now accepts `null` on cleanup, and RenewTokenCallback widens to `() => Promise<unknown>` so each provider's renewer can return its own SDK-native shape (Auth0 RenewTokenResponse, MSAL AuthenticationResult, oidc-client User, …) without a per-provider cast at the registration call site. Renewers wrapped in useCallback where they weren't already (GenericAuthenticator handleSilentSignIn, MsalAuthenticator renewIdToken, OktaAuthenticator renewToken, Auth0Authenticator renewIdToken extracted from useImperativeHandle) so the register effect's dep is stable and doesn't thrash. Tests: each authenticator's test file now mocks TokenService via jest.mock and asserts (a) the mount effect calls updateRenewToken with a function, and (b) unmount calls updateRenewToken(null). 5 new regression tests, 138/138 total across the auth surface. No behavior change on the happy path (renewer already registered in the same render cycle). Only cold-load / slow-lazy-mount scenarios where the old race lost. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
❌ PR checklist incompleteThis PR cannot be merged until the following are addressed on its linked issue:
The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically. Maintainers can bypass this check by adding the |
🔴 Playwright Results — workflow failedValidated commit ✅ 0 passed · ❌ 0 failed · 🟡 0 flaky · ⏭️ 0 skipped · 🧰 0 lifecycle flaky Pipeline and setup failures (6)
Performance⚪ Performance metrics unavailable; see the CI and reporting failures above.
How to debug locally# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip # view trace |
| // Timer restart after a successful cross-tab refresh — the callback | ||
| // itself lives in this component's closure, so we register it here | ||
| // rather than from each authenticator. | ||
| tokenService.current.updateRefreshSuccessCallback(startTokenExpiryTimer); |
There was a problem hiding this comment.
Avoid capturing initial timer state
Registering startTokenExpiryTimer in the mount-only effect retains the initial authConfig and timeoutId. Later refresh successes therefore treat Basic/LDAP as refresh-capable and cannot replace timers created after the first render, resulting in unnecessary refresh requests or overlapping expiry timers.
|
| Count | Rule |
|---|---|
| 22 | i18next/no-literal-string |
| 11 | react-hooks/exhaustive-deps |
| 7 | sonarjs/no-duplicate-string |
| 5 | sonarjs/no-nested-functions |
| 1 | openmetadata-imports/no-lower-layer-page-imports |
| 1 | openmetadata-imports/no-internal-barrel-imports |
| 1 | openmetadata-imports/no-api-calls-in-iteration |
| 1 | sonarjs/cyclomatic-complexity |
All findings
| Location | Rule | Message | |
|---|---|---|---|
| 🟡 | src/components/Auth/AppAuthenticators/Auth0Authenticator.test.tsx:27:28 |
sonarjs/no-duplicate-string |
Define a constant instead of duplicating this literal 4 times. |
| 🟡 | src/components/Auth/AppAuthenticators/Auth0Authenticator.test.tsx:64:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/Auth0Authenticator.test.tsx:75:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/Auth0Authenticator.test.tsx:89:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/Auth0Authenticator.test.tsx:107:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/Auth0Authenticator.test.tsx:128:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/Auth0Authenticator.test.tsx:144:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/Auth0Authenticator.test.tsx:158:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.test.tsx:54:29 |
i18next/no-literal-string |
disallow literal string: Loader |
| 🟡 | src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.test.tsx:72:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.test.tsx:85:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.test.tsx:99:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.test.tsx:117:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.test.tsx:137:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.test.tsx:162:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.test.tsx:189:14 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/BasicAuthAuthenticator.tsx:64:10 |
react-hooks/exhaustive-deps |
React Hook useCallback has unnecessary dependencies: 'setOidcToken' and 'setRefreshToken'. Either exclude them or remove the dependency array. Outer scope value |
| 🟡 | src/components/Auth/AppAuthenticators/GenericAuthenticator.test.tsx:65:16 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/GenericAuthenticator.test.tsx:83:16 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/GenericAuthenticator.test.tsx:103:16 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/GenericAuthenticator.test.tsx:122:16 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/GenericAuthenticator.test.tsx:143:16 |
i18next/no-literal-string |
disallow literal string: Child |
| 🟡 | src/components/Auth/AppAuthenticators/MsalAuthenticator.test.tsx:35:15 |
sonarjs/no-duplicate-string |
Define a constant instead of duplicating this literal 3 times. |
| 🟡 | src/components/Auth/AppAuthenticators/MsalAuthenticator.test.tsx:61:18 |
i18next/no-literal-string |
disallow literal string: Test Children |
| 🟡 | src/components/Auth/AppAuthenticators/MsalAuthenticator.test.tsx:84:30 |
sonarjs/no-duplicate-string |
Define a constant instead of duplicating this literal 5 times. |
| 🟡 | src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx:116:8 |
react-hooks/exhaustive-deps |
React Hook useCallback has a missing dependency: 'fetchIdToken'. Either include it or remove the dependency array. |
| 🟡 | src/components/Auth/AppAuthenticators/MsalAuthenticator.tsx:153:8 |
react-hooks/exhaustive-deps |
React Hook useEffect has a missing dependency: 'handleRedirect'. Either include it or remove the dependency array. |
| 🟡 | src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx:30:1 |
openmetadata-imports/no-lower-layer-page-imports |
Pages are route-level composition modules. Move the shared implementation/type to a lower layer instead of importing a page from here. |
| 🟡 | src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx:120:24 |
sonarjs/no-nested-functions |
Refactor this code to not nest functions more than 4 levels deep. |
| 🟡 | src/components/Auth/AppAuthenticators/OidcAuthenticator.tsx:125:30 |
sonarjs/no-nested-functions |
Refactor this code to not nest functions more than 4 levels deep. |
| 🟡 | src/components/Auth/AppAuthenticators/OktaAuthenticator.test.tsx:70:18 |
i18next/no-literal-string |
disallow literal string: Test Children |
| 🟡 | src/components/Auth/AppAuthenticators/OktaAuthenticator.test.tsx:152:38 |
sonarjs/no-duplicate-string |
Define a constant instead of duplicating this literal 3 times. |
| 🟡 | src/components/Auth/AppAuthenticators/OktaAuthenticator.test.tsx:153:46 |
sonarjs/no-duplicate-string |
Define a constant instead of duplicating this literal 4 times. |
| 🟡 | src/components/Auth/AppAuthenticators/OktaAuthenticator.test.tsx:184:38 |
sonarjs/no-duplicate-string |
Define a constant instead of duplicating this literal 3 times. |
| 🟡 | src/components/Auth/AppAuthenticators/OktaAuthenticator.test.tsx:195:51 |
sonarjs/no-duplicate-string |
Define a constant instead of duplicating this literal 3 times. |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:80:1 |
openmetadata-imports/no-internal-barrel-imports |
Import the internal module directly instead of its index barrel so unrelated siblings do not enter the bundle graph. |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:280:9 |
react-hooks/exhaustive-deps |
The 'onLoginHandler' function makes the dependencies of useMemo Hook (at line 941) change on every render. Move it inside the useMemo callback. Alternatively, w |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:357:6 |
react-hooks/exhaustive-deps |
React Hook useCallback has missing dependencies: 'navigate', 'setApplicationLoading', 'setCurrentUser', and 'setIsAuthenticated'. Either include them or remove |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:415:9 |
react-hooks/exhaustive-deps |
The 'resetUserDetails' function makes the dependencies of useMemo Hook (at line 941) change on every render. To fix this, wrap the definition of 'resetUserDetai |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:559:6 |
react-hooks/exhaustive-deps |
React Hook useEffect has missing dependencies: 'getLoggedInUserDetails' and 'startTokenExpiryTimer'. Either include them or remove the dependency array. |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:569:9 |
react-hooks/exhaustive-deps |
The 'handleFailedLogin' function makes the dependencies of useMemo Hook (at line 941) change on every render. Move it inside the useMemo callback. Alternatively |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:627:5 |
react-hooks/exhaustive-deps |
React Hook useCallback has missing dependencies: 'authConfig?.provider', 'handledVerifiedUser', 'navigate', 'resetUserDetails', and 'startTokenExpiryTimer'. Eit |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:672:9 |
react-hooks/exhaustive-deps |
The 'initializeAxiosInterceptors' function makes the dependencies of useMemo Hook (at line 941) change on every render. To fix this, wrap the definition of 'ini |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:740:67 |
sonarjs/no-nested-functions |
Refactor this code to not nest functions more than 4 levels deep. |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:747:23 |
openmetadata-imports/no-api-calls-in-iteration |
Avoid issuing one API request per item. Fetch at the data owner, use a bulk endpoint, or use useQueries with an intentional concurrency policy. |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:759:37 |
sonarjs/no-nested-functions |
Refactor this code to not nest functions more than 4 levels deep. |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:768:27 |
sonarjs/no-nested-functions |
Refactor this code to not nest functions more than 4 levels deep. |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:837:30 |
sonarjs/cyclomatic-complexity |
{"message":"Function has a complexity of 17 which is greater than 10 authorized.","cost":7,"secondaryLocations":[{"line":837,"column":29,"endLine":837,"endColum |
| 🟡 | src/components/Auth/AuthProviders/AuthProvider.tsx:930:6 |
react-hooks/exhaustive-deps |
React Hook useEffect has missing dependencies: 'cleanup', 'fetchAuthConfig', 'initializeAxiosInterceptors', and 'startTokenExpiryTimer'. Either include them or |
Fix locally (fast - only checks files changed in this branch):
make ui-checkstyle-changed
|
Code Review
|
| Compact |
|
Was this helpful? React with 👍 / 👎 | Powered by Gitar — free for open source
…d 401 refresh (#31644) Follow-up to #31597. The hotfix added TokenService.awaitRenewerReady so the axios 401 interceptor's refresh call could wait briefly for the renewer to be registered, but the underlying race — the ref-deps useEffect that owned the registration — was never addressed. On any provider where the lazy authenticator finishes mounting AFTER the first /users/loggedInUser call has already 401'd (confidential + Google is where this reliably reproduces, but every provider is susceptible on cold-load with a slow chunk), the useEffect never re-runs (ref changes don't schedule re-renders), TokenService.renewToken stays null, awaitRenewerReady times out at 10s, and the interceptor force-logs the user out — no /api/v1/auth/refresh call is ever fired. Fix: each authenticator wrapper (BasicAuthAuthenticator, GenericAuthenticator, OidcAuthenticator, MsalAuthenticator, OktaAuthenticator, Auth0Authenticator) now calls TokenService.updateRenewToken from its own mount useEffect. That effect runs the moment useImperativeHandle populates the ref, with no dependence on the parent re-rendering — no race, deterministic registration. AuthProvider.tsx: the ref-deps useEffect is gone. updateRefreshSuccessCallback(startTokenExpiryTimer) moves to the main mount effect (that callback lives in AuthProvider's closure). TokenService.updateRenewToken now accepts `null` on cleanup, and RenewTokenCallback widens to `() => Promise<unknown>` so each provider's renewer can return its own SDK-native shape (Auth0 RenewTokenResponse, MSAL AuthenticationResult, oidc-client User, …) without a per-provider cast at the registration call site. Renewers wrapped in useCallback where they weren't already (GenericAuthenticator handleSilentSignIn, MsalAuthenticator renewIdToken, OktaAuthenticator renewToken, Auth0Authenticator renewIdToken extracted from useImperativeHandle) so the register effect's dep is stable and doesn't thrash. Tests: each authenticator's test file now mocks TokenService via jest.mock and asserts (a) the mount effect calls updateRenewToken with a function, and (b) unmount calls updateRenewToken(null). 5 new regression tests, 138/138 total across the auth surface. No behavior change on the happy path (renewer already registered in the same render cycle). Only cold-load / slow-lazy-mount scenarios where the old race lost. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> (cherry picked from commit 1511b12)



Summary
Follow-up to #31597. Production users on confidential + Google (and any other provider on a slow first-load) still see
401 Expired token!on cold tab reopen with no/api/v1/auth/refreshcall ever firing — they land on/signinafter ~10 s hang.The first hotfix (#31597) added
TokenService.awaitRenewerReadyso the axios 401 interceptor's refresh call could wait briefly for the renewer to be registered, but it never addressed the underlying race that made the wait pointless.Root cause
The renewer was registered by a
useEffectinAuthProvider.tsxwith deps[authenticatorRef.current?.renewIdToken]— a ref access. Ref changes don't schedule React re-renders. If the lazy authenticator chunk finishes loading afterAuthProvider's first render pass (which is the norm on cold-load), the effect never re-runs,TokenService.renewTokenstaysnull,awaitRenewerReadytimes out at 10 s,fetchNewTokenreturnsnull, and the interceptor's else branch force-logs the user out. No HTTP/refreshcall is ever fired — matches the reported symptom.The race was timing-dependent. Repeat visits (chunk cached) usually won; first-load / cache-busted deploys lose. Recent perf work that shrank config-fetch latency or grew auth-chunk size likely tipped it into "usually loses" in production.
Fix
Move renewer registration out of
AuthProvider.tsx's ref-depsuseEffectand into each authenticator wrapper's own mount effect.useImperativeHandleruns before the same component's ownuseEffect, so registration happens the instant the wrapper mounts — no race, no dependence on the parent re-rendering.Every authenticator (
BasicAuthAuthenticator,GenericAuthenticator,OidcAuthenticator,MsalAuthenticator,OktaAuthenticator,Auth0Authenticator) now does:The parent-side effect is gone.
updateRefreshSuccessCallback(startTokenExpiryTimer)moves toAuthProvider's main mount effect (that callback lives in its closure).Files changed
AppAuthenticators/BasicAuthAuthenticator.tsx— added mount effect,useEffectimportAppAuthenticators/GenericAuthenticator.tsx— added mount effect, wrappedhandleSilentSignIninuseCallbackAppAuthenticators/OidcAuthenticator.tsx— added mount effect, wrappedsignInSilentlyinuseCallbackAppAuthenticators/MsalAuthenticator.tsx— added mount effect, wrappedrenewIdTokeninuseCallbackAppAuthenticators/OktaAuthenticator.tsx— added mount effect, wrappedrenewTokeninuseCallbackAppAuthenticators/Auth0Authenticator.tsx— extractedrenewIdTokenout ofuseImperativeHandleinline, wrapped inuseCallback, added mount effectAuthProviders/AuthProvider.tsx— removed the ref-deps effect, movedupdateRefreshSuccessCallbackinto the main mount effectutils/Auth/TokenService/TokenServiceUtil.ts— widenedRenewTokenCallbackto() => Promise<unknown>(so each provider's SDK-native return shape fits without a cast) and letupdateRenewTokenacceptnullfor cleanupTests
5 new regression tests (one per authenticator that has a test file on
main):Each mocks
TokenService.updateRenewToken, mounts the authenticator, asserts the mount effect registered a function, then unmounts and assertsupdateRenewToken(null)fired.yarn jest src/components/Auth src/utils/Auth→ 138/138 pass (5 new + 133 pre-existing)yarn prettier/yarn eslint— 0 errorsnpx tsc --noEmit— 0 new errors (only pre-existing MSAL readonly-tuple andROUTESliteral-type warnings that exist onmain)Non-goals
awaitRenewerReady— those are strict no-ops now on the happy path (renewer already registered when the first 401 lands).Manual smoke matrix
Per-provider cold-load / tab-reopen scenarios reviewers can tick off:
/api/v1/auth/refreshcall in DevTools).Non-regression check
Active-tab mid-session refresh should be unchanged (renewer already registered by first 401). Quick check: log in, wait past id-token TTL while the tab is active, click something — refresh fires and the app stays authenticated.
Follow-up
This fix will be superseded by the AuthCoordinator refactor in the follow-up PR (
azure-oidc-session-invalidated-on-restart), which already applies the same fix by construction (Phase 2a of that branch).🤖 Generated with Claude Code
Greptile Summary
The PR moves token-renewer registration into each authenticator’s lifecycle so cold-load 401 handling can refresh after lazy authenticators mount.
Confidence Score: 4/5
The PR is not yet safe to merge because the outstanding refresh-success callback still captures initial timer and authentication state.
The mount-only registration retains the first-render startTokenExpiryTimer closure, so later refresh successes can bypass the Basic/LDAP refresh-token guard and create replacement timers without clearing the active timer.
Files Needing Attention: openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx
Important Files Changed
Sequence Diagram
Reviews (2): Last reviewed commit: "Merge branch 'main' into hotfix/renewer-..." | Re-trigger Greptile