From e95d4f32c548c35786cc8c10ad6e5d2fda64839d Mon Sep 17 00:00:00 2001 From: Paul Johnson Date: Tue, 7 Apr 2026 14:13:29 +0100 Subject: [PATCH 1/3] fix: improve auth session sync reliability - Add configurable JWT validation leeway (default 60s) to handle clock drift between Clerk servers and the backend - Catch ExpiredTokenError alongside InvalidClaimError/MissingClaimError to prevent crashes on expired tokens - Rewrite ClerkSessionSynchronizer JS for reliability: - Use useRef to deduplicate rapid calls while remaining reconnect-safe - Request fresh tokens with skipCache to avoid near-expiry cached tokens - Handle token retrieval failures gracefully (clear session instead of hang) - Include all dependencies in useEffect array ([isLoaded, isSignedIn, addEvents, getToken]) - Add unit tests for expired token handling and JS code correctness - Add pythonpath config for pytest to find custom_components --- .../reflex_clerk_api/clerk_provider.py | 98 ++++++++++++++----- pyproject.toml | 1 + tests/test_clerk_provider_unit.py | 50 ++++++++++ 3 files changed, 126 insertions(+), 23 deletions(-) create mode 100644 tests/test_clerk_provider_unit.py diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index eccc235..0b0da98 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -59,6 +59,8 @@ class ClerkState(rx.State): "nbf": {"essential": True}, # "azp": {"essential": False, "values": ["http://localhost:3000", "https://example.com"]}, } + _jwt_validate_leeway_seconds: ClassVar[int] = 60 + """Clock-skew leeway (seconds) for validating JWT claims like exp/nbf.""" @classmethod def register_dependent_handler(cls, handler: EventCallback) -> None: @@ -85,6 +87,29 @@ def set_claims_options(cls, claims_options: dict[str, Any]) -> None: """Set the claims options for the JWT claims validation.""" cls._claims_options = claims_options + @classmethod + def set_jwt_validate_leeway_seconds(cls, seconds: int) -> None: + """Set clock-skew leeway (seconds) for JWT exp/nbf validation. + + Default is 60 seconds. Increase if you see intermittent ExpiredTokenError + due to clock drift between Clerk servers and your backend. + + Args: + seconds: Non-negative integer, max 3600 (1 hour). + + Raises: + ValueError: If seconds is negative or exceeds 3600. + """ + if not isinstance(seconds, int) or isinstance(seconds, bool) or seconds < 0: + raise ValueError( + f"jwt_validate_leeway_seconds must be a non-negative integer, got {seconds!r}" + ) + if seconds > 3600: + raise ValueError( + f"jwt_validate_leeway_seconds exceeds maximum of 3600 (1 hour), got {seconds}" + ) + cls._jwt_validate_leeway_seconds = seconds + @property def client(self) -> clerk_backend_api.Clerk: if self._client is None: @@ -116,9 +141,13 @@ async def set_clerk_session(self, token: str) -> EventType: return ClerkState.clear_clerk_session try: # Validate the token according to the claim options (e.g. iss, exp, nbf, azp.) - decoded.validate() - except (jose_errors.InvalidClaimError, jose_errors.MissingClaimError) as e: - logging.warning(f"JWT token is invalid: {e}") + decoded.validate(leeway=self._jwt_validate_leeway_seconds) + except ( + jose_errors.ExpiredTokenError, + jose_errors.InvalidClaimError, + jose_errors.MissingClaimError, + ) as e: + logging.warning(f"JWT token validation failed: {type(e).__name__}: {e}") return ClerkState.clear_clerk_session async with self: @@ -364,7 +393,7 @@ def add_imports( ) -> rx.ImportDict: addl_imports: rx.ImportDict = { "@clerk/clerk-react": ["useAuth"], - "react": ["useContext", "useEffect"], + "react": ["useContext", "useEffect", "useRef"], "$/utils/context": ["EventLoopContext"], "$/utils/state": ["ReflexEvent"], } @@ -375,28 +404,51 @@ def add_custom_code(self) -> list[str]: return [ """ -function ClerkSessionSynchronizer({ children }) { - const { getToken, isLoaded, isSignedIn } = useAuth() - const [ addEvents, connectErrors ] = useContext(EventLoopContext) - - useEffect(() => { - if (isLoaded && !!addEvents) { - if (isSignedIn) { - getToken().then(token => { - addEvents([ReflexEvent("%s.set_clerk_session", {token})]) - }) - } else { - addEvents([ReflexEvent("%s.clear_clerk_session")]) - } - } - }, [isSignedIn]) +function ClerkSessionSynchronizer({{ children }}) {{ + const {{ getToken, isLoaded, isSignedIn }} = useAuth() + const [ addEvents ] = useContext(EventLoopContext) + const lastSentRef = useRef({{ stateKey: null, addEvents: null }}) + + useEffect(() => {{ + // Wait for all dependencies to be ready. + if (!isLoaded || !addEvents) return + + // Deduplicate rapid calls, but remain reconnect-safe: + // addEvents identity changes across websocket reconnects, so include it in the key. + const stateKey = isSignedIn ? "signed_in" : "signed_out" + if ( + lastSentRef.current?.stateKey === stateKey && + lastSentRef.current?.addEvents === addEvents + ) return + lastSentRef.current = {{ stateKey, addEvents }} + + if (isSignedIn) {{ + // Prefer a fresh token; cached tokens can be close to expiry. + // If this Clerk version doesn't support skipCache, fall back to the default call. + Promise.resolve() + .then(() => getToken({{ skipCache: true }})) + .catch(() => getToken()) + .then(token => {{ + if (token) {{ + addEvents([ReflexEvent("{state}.set_clerk_session", {{token}})]) + }} else {{ + // Token unavailable despite isSignedIn - clear to avoid stuck auth state. + addEvents([ReflexEvent("{state}.clear_clerk_session")]) + }} + }}).catch(() => {{ + // Token retrieval failed - clear to avoid stuck auth state. + addEvents([ReflexEvent("{state}.clear_clerk_session")]) + }}) + }} else {{ + addEvents([ReflexEvent("{state}.clear_clerk_session")]) + }} + }}, [isLoaded, isSignedIn, addEvents, getToken]) return ( - <>{children} + <>{{children}} ) -} -""" - % (clerk_state_name, clerk_state_name) +}} +""".format(state=clerk_state_name) ] diff --git a/pyproject.toml b/pyproject.toml index 85bc7d8..0144cb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ homepage = "https://reflex-clerk-api-demo.adventuresoftim.com" [tool.pytest.ini_options] # addopts = "--headed" +pythonpath = ["custom_components"] [tool.pyright] venvPath = "." diff --git a/tests/test_clerk_provider_unit.py b/tests/test_clerk_provider_unit.py new file mode 100644 index 0000000..8a704c7 --- /dev/null +++ b/tests/test_clerk_provider_unit.py @@ -0,0 +1,50 @@ +import asyncio + +import authlib.jose.errors as jose_errors + + +def test_set_clerk_session_expired_token_clears(monkeypatch): + """Expired tokens should not crash the handler; they should clear session.""" + # Import inside the test so the module is importable in different test layouts. + # We need the actual module object (not just ClerkState) to monkeypatch jwt.decode + # where it's used. importlib is required because reflex_clerk_api.clerk_provider + # resolves to the function via __init__.py re-exports. + import importlib + + clerk_provider_module = importlib.import_module("reflex_clerk_api.clerk_provider") + from reflex_clerk_api.clerk_provider import ClerkState + + # Instantiate state in a framework-safe way for tests. + state = ClerkState(_reflex_internal_init=True) + + async def fake_get_jwk_keys(self): + return {} + + monkeypatch.setattr(ClerkState, "_get_jwk_keys", fake_get_jwk_keys, raising=True) + + validate_calls: dict[str, object] = {} + + class FakeClaims: + def validate(self, leeway=None): + validate_calls["leeway"] = leeway + raise jose_errors.ExpiredTokenError() + + monkeypatch.setattr( + clerk_provider_module.jwt, + "decode", + lambda *args, **kwargs: FakeClaims(), + raising=True, + ) + + result = asyncio.run(ClerkState.set_clerk_session.fn(state, token="fake")) + assert validate_calls["leeway"] == 60 + assert result == ClerkState.clear_clerk_session + + +def test_clerk_session_synchronizer_js_contains_reconnect_safe_deps_and_skipcache(): + """String-based regression test for the generated JS.""" + from reflex_clerk_api.clerk_provider import ClerkSessionSynchronizer + + js = ClerkSessionSynchronizer.create().add_custom_code()[0] + assert "[isLoaded, isSignedIn, addEvents, getToken]" in js + assert "skipCache: true" in js From 90c85f7b96ab4455fdd060d37eeeee1e5facb4fa Mon Sep 17 00:00:00 2001 From: Tim Child Date: Sat, 25 Apr 2026 11:22:48 -0500 Subject: [PATCH 2/3] fix: address review on auth session sync PR JS dedupe + retry fixes (Copilot review): - Only update lastSentRef after a confirmed dispatch so a failed token fetch doesn't poison the dedupe and block later retries. - Add inFlightRef to prevent overlapping getToken calls when the effect re-fires before the in-flight promise resolves. - Retry getToken once after a 500ms delay before clearing the backend session, so transient token-fetch failures don't force a backend logout while Clerk is still signed in. - On final failure, leave lastSentRef unchanged so the next trigger (reconnect, sign-in toggle) re-attempts the sync. Test typecheck fixes: - pyright ignore for the `_reflex_internal_init` Reflex internal init flag. - pyright ignore for accessing `.fn` on the wrapped EventCallback. Co-Authored-By: Paul Johnson Co-Authored-By: Claude Opus 4.7 (1M context) --- .../reflex_clerk_api/clerk_provider.py | 59 ++++++++++++------- tests/test_clerk_provider_unit.py | 6 +- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index 0b0da98..618481d 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -407,7 +407,13 @@ def add_custom_code(self) -> list[str]: function ClerkSessionSynchronizer({{ children }}) {{ const {{ getToken, isLoaded, isSignedIn }} = useAuth() const [ addEvents ] = useContext(EventLoopContext) + // Tracks the last *successfully dispatched* (state, addEvents) pair. Only + // updated after a confirmed dispatch so transient token-fetch failures don't + // poison the dedupe and prevent later retries. const lastSentRef = useRef({{ stateKey: null, addEvents: null }}) + // Guards against overlapping getToken calls if the effect re-fires while one + // is still in flight. + const inFlightRef = useRef(false) useEffect(() => {{ // Wait for all dependencies to be ready. @@ -420,28 +426,41 @@ def add_custom_code(self) -> list[str]: lastSentRef.current?.stateKey === stateKey && lastSentRef.current?.addEvents === addEvents ) return - lastSentRef.current = {{ stateKey, addEvents }} - - if (isSignedIn) {{ - // Prefer a fresh token; cached tokens can be close to expiry. - // If this Clerk version doesn't support skipCache, fall back to the default call. - Promise.resolve() - .then(() => getToken({{ skipCache: true }})) - .catch(() => getToken()) - .then(token => {{ - if (token) {{ - addEvents([ReflexEvent("{state}.set_clerk_session", {{token}})]) - }} else {{ - // Token unavailable despite isSignedIn - clear to avoid stuck auth state. - addEvents([ReflexEvent("{state}.clear_clerk_session")]) - }} - }}).catch(() => {{ - // Token retrieval failed - clear to avoid stuck auth state. - addEvents([ReflexEvent("{state}.clear_clerk_session")]) - }}) - }} else {{ + if (inFlightRef.current) return + + if (!isSignedIn) {{ addEvents([ReflexEvent("{state}.clear_clerk_session")]) + lastSentRef.current = {{ stateKey, addEvents }} + return }} + + // isSignedIn: try to get a fresh token. Retry once after a short delay + // on transient failures before clearing the backend session - clearing + // prematurely forces a logout while Clerk is still signed in. + // Prefer skipCache (avoids near-expiry cached tokens); fall back if the + // installed Clerk version doesn't support that option. + inFlightRef.current = true + const fetchToken = () => + getToken({{ skipCache: true }}).catch(() => getToken()) + fetchToken() + .catch(() => new Promise(resolve => setTimeout(resolve, 500)).then(fetchToken)) + .then(token => {{ + if (token) {{ + addEvents([ReflexEvent("{state}.set_clerk_session", {{token}})]) + lastSentRef.current = {{ stateKey, addEvents }} + }} else {{ + // Final failure: clear backend session but leave lastSentRef + // unchanged so the next trigger (reconnect, sign-in toggle, etc.) + // re-attempts the sync instead of being deduped away. + addEvents([ReflexEvent("{state}.clear_clerk_session")]) + }} + }}) + .catch(() => {{ + addEvents([ReflexEvent("{state}.clear_clerk_session")]) + }}) + .finally(() => {{ + inFlightRef.current = false + }}) }}, [isLoaded, isSignedIn, addEvents, getToken]) return ( diff --git a/tests/test_clerk_provider_unit.py b/tests/test_clerk_provider_unit.py index 8a704c7..9e77572 100644 --- a/tests/test_clerk_provider_unit.py +++ b/tests/test_clerk_provider_unit.py @@ -15,7 +15,7 @@ def test_set_clerk_session_expired_token_clears(monkeypatch): from reflex_clerk_api.clerk_provider import ClerkState # Instantiate state in a framework-safe way for tests. - state = ClerkState(_reflex_internal_init=True) + state = ClerkState(_reflex_internal_init=True) # pyright: ignore[reportCallIssue] async def fake_get_jwk_keys(self): return {} @@ -36,7 +36,9 @@ def validate(self, leeway=None): raising=True, ) - result = asyncio.run(ClerkState.set_clerk_session.fn(state, token="fake")) + result = asyncio.run( + ClerkState.set_clerk_session.fn(state, token="fake") # pyright: ignore[reportAttributeAccessIssue] + ) assert validate_calls["leeway"] == 60 assert result == ClerkState.clear_clerk_session From 1d9764653e978051a55ef18b0ea7d5c078668c42 Mon Sep 17 00:00:00 2001 From: Tim Child Date: Sat, 25 Apr 2026 11:55:54 -0500 Subject: [PATCH 3/3] fix: address Copilot race-condition review on session sync JS - Drop inFlightRef hard gate. It blocked the !isSignedIn branch, so a sign-out occurring during an in-flight token fetch would not dispatch clear_clerk_session and the backend session would stay stale until another trigger arrived. - Replace it with a requestIdRef counter that's incremented on every effect run with a new desired state. The in-flight fetch's then/catch handlers check the captured myRequestId against requestIdRef.current before dispatching - so a getToken() that resolves after sign-out can no longer dispatch a stale set_clerk_session. - Sign-out path now runs unconditionally and immediately. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../reflex_clerk_api/clerk_provider.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/custom_components/reflex_clerk_api/clerk_provider.py b/custom_components/reflex_clerk_api/clerk_provider.py index 618481d..0952524 100644 --- a/custom_components/reflex_clerk_api/clerk_provider.py +++ b/custom_components/reflex_clerk_api/clerk_provider.py @@ -411,9 +411,10 @@ def add_custom_code(self) -> list[str]: // updated after a confirmed dispatch so transient token-fetch failures don't // poison the dedupe and prevent later retries. const lastSentRef = useRef({{ stateKey: null, addEvents: null }}) - // Guards against overlapping getToken calls if the effect re-fires while one - // is still in flight. - const inFlightRef = useRef(false) + // Incremented on every effect run with new desired state. In-flight token + // fetches check this on resolve and bail if a newer effect has superseded + // them - prevents stale set_clerk_session dispatches after sign-out. + const requestIdRef = useRef(0) useEffect(() => {{ // Wait for all dependencies to be ready. @@ -426,9 +427,12 @@ def add_custom_code(self) -> list[str]: lastSentRef.current?.stateKey === stateKey && lastSentRef.current?.addEvents === addEvents ) return - if (inFlightRef.current) return + + const myRequestId = ++requestIdRef.current if (!isSignedIn) {{ + // Always run the sign-out path immediately. Any in-flight token fetch + // will see myRequestId !== requestIdRef.current on resolve and drop. addEvents([ReflexEvent("{state}.clear_clerk_session")]) lastSentRef.current = {{ stateKey, addEvents }} return @@ -439,12 +443,13 @@ def add_custom_code(self) -> list[str]: // prematurely forces a logout while Clerk is still signed in. // Prefer skipCache (avoids near-expiry cached tokens); fall back if the // installed Clerk version doesn't support that option. - inFlightRef.current = true const fetchToken = () => getToken({{ skipCache: true }}).catch(() => getToken()) fetchToken() .catch(() => new Promise(resolve => setTimeout(resolve, 500)).then(fetchToken)) .then(token => {{ + // Drop if a newer effect run (e.g., sign-out) has superseded us. + if (myRequestId !== requestIdRef.current) return if (token) {{ addEvents([ReflexEvent("{state}.set_clerk_session", {{token}})]) lastSentRef.current = {{ stateKey, addEvents }} @@ -456,11 +461,9 @@ def add_custom_code(self) -> list[str]: }} }}) .catch(() => {{ + if (myRequestId !== requestIdRef.current) return addEvents([ReflexEvent("{state}.clear_clerk_session")]) }}) - .finally(() => {{ - inFlightRef.current = false - }}) }}, [isLoaded, isSignedIn, addEvents, getToken]) return (