Skip to content

fix(sso): bound SSO poll loop without IPython; bind cached-token swap to user_id (#1721 follow-up) - #2083

Draft
vaimdev wants to merge 3 commits into
fix/site-wide-org-dataset-errorfrom
fix/pr1721-poll-loop-and-token-cache
Draft

vaimdev wants to merge 3 commits into
fix/site-wide-org-dataset-errorfrom
fix/pr1721-poll-loop-and-token-cache

Conversation

@vaimdev

@vaimdev vaimdev commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #1721 (targets fix/site-wide-org-dataset-error, intended to merge into it). Fixes two blockers found while reviewing that branch. Both are client-only and independent of Hub version.

B2 — Blocking SSO poll loop spins with no sleep and no timeout when IPython is not installed

_handle_auth_url()'s countdown did from IPython.core.display import HTML before the per-iteration time.sleep(1) / elapsed_time += 1, inside a try whose except Exception: token = None swallowed the ImportError. With IPython absent (it's only in the jupyter extra), elapsed_time stayed at 1, 1 % 10 == 1 was true every iteration, and the loop re-polled GET /api/v2/o/sso/oidc/jwt/<state>/ with zero delay forever — sso_timeout never fired. Measured: 201 polls in 3 ms.

Newly reachable in #1721: on master _sso_get_token() raised a plain Exception on the first "State is invalid" poll, so the countdown branch was effectively dead; the new SsoPendingException retry makes it live.

Fix: sleep / increment / timeout now run unconditionally each iteration; the notebook HTML countdown is gated on in_ipython() and wrapped in its own try/except. SsoRetrieveTokenTimeoutException was only used as local control flow here, so the import is dropped.

B3 — switch_org() fast path 2 reinstated a token belonging to a different user

_verified_org_tokens is keyed by org only and is reset only by register() (fresh ClientSession). Public login(), pkey_login(), sso_login() and the api_token() setter never touch it. So:

graphistry.login(userA, ...); graphistry.switch_org("shared")   # cached (shared -> tokenA)
graphistry.login(userB, ...); graphistry.switch_org("shared")   # no request; session.api_token = tokenA

Subsequent uploads ran as user A. Same-user variant: a rotated/revoked old token got reinstated.

Fix: ClientSession.get_verified_token() gains an opt-in same_user_as guard that returns the cached token only when both it and the current token carry a readable Hub user_id claim and they match; tokens without the claim (personal keys, opaque tokens) never qualify. switch_org() passes the current token. The multi-org same-user swap-in that #1721 introduced still works (existing test updated to give both tokens the same user_id).

refresh()'s unguarded get_verified_token() call is left as-is — that's B1 (server resets the active org on refresh), which is a separate decision.

Tests

  • TestSsoPollErrorHandling::test_pending_state_stops_at_timeout_without_ipython — blocks IPython* in sys.modules; asserts 3 polls and 4 sleeps for sso_timeout=3. Hangs on the pre-fix code.
  • test_switch_org_does_not_reuse_cached_token_of_another_user, test_switch_org_does_not_reuse_cached_token_without_user_claim — fall through to a real switch_org_request, active token untouched.
  • TestVerifiedOrgTokenCache::test_same_user_guard_* — guard semantics incl. int/str user_id equivalence and opt-in behavior.

ruff / mypy / type-hygiene / comment-density guards clean; 109 tests pass across the four touched test files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TyjxnzaqeB3SeirofUQhSj

vaimdev and others added 3 commits September 14, 2026 22:48
…swap to user_id

Two follow-ups to #1721 (fix/site-wide-org-dataset-error):

- sso_login() blocking poll: the notebook countdown imported IPython before
  the per-iteration sleep/timeout, and the surrounding `except Exception`
  swallowed the ImportError, so with IPython absent (it is only in the
  `jupyter` extra) the loop re-polled the token endpoint with zero delay
  and `sso_timeout` never fired. Newly reachable since SsoPendingException
  is now retried. Sleep/increment/timeout now run unconditionally; the
  display is gated on in_ipython() and wrapped in its own try/except.

- switch_org() fast path 2 swapped in any unexpired token cached for the
  org, but the cache is keyed by org only and login()/pkey_login()/
  api_token() change the active principal without resetting the session,
  so a switch could silently reinstate a previous user's token.
  get_verified_token() gains an opt-in `same_user_as` guard comparing the
  Hub `user_id` claim of both tokens; tokens without a readable claim never
  qualify. The same-user multi-org swap-in still works.

Tests: no-IPython poll loop terminates at sso_timeout (hangs on the old
code); cross-user and claim-less tokens fall through to a real switch;
ClientSession guard semantics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TyjxnzaqeB3SeirofUQhSj
B1 follow-up to #1721: refresh() carried the org grant from the
pre-refresh token onto the newly reissued one, but the server resets
the active org on every reissue -- so the carried grant let
_maybe_switch_org() skip the required /switch/ POST, and plot()
silently uploaded to the default org while session.org_name still
showed the requested one.

Deleting the carry-grant lines alone regresses further: switch_org()'s
same-user fast path (added by 5cdd1d6 for B3) would then find the
stale cache entry left over from before the rotation and swap that
superseded, pre-refresh token back in as active -- no real switch
request, and refresh()'s own return value reverted to the old token.

Fix: ClientSession.forget_verified_org() drops the cache entry for the
active org once refresh() confirms the token actually rotated (skipped
on a no-op refresh, so it doesn't discard a still-valid verification),
forcing a real switch_org() call with the new token.

Tests: test_refresh_rotated_token_does_not_skip_switch pins the
mocked-switch_org_request behavior; test_refresh_rotated_token_hits_real_switch_endpoint
is the HTTP-level version (only requests.post mocked, real
ArrowUploader.refresh()/switch_org_request() code paths run) -- both
fail against the pre-fix code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1Pvaw1aZQvtraEWkEvdyt
…n rotation"

This reverts commit 602a260.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

1 participant