Skip to content

security: clear stale auth state at login entry - #411

Merged
cyberjunky merged 2 commits into
masterfrom
security/login-clear-stale-auth
Aug 10, 2026
Merged

security: clear stale auth state at login entry#411
cyberjunky merged 2 commits into
masterfrom
security/login-clear-stale-auth

Conversation

@cyberjunky

@cyberjunky cyberjunky commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

From the external security audit (medium severity). login() never cleared previous auth state — its only entry guard checked _mfa_pending. A Client carrying an old di_token (from a previous login on the same instance, or a loaded token store) hit two defects:

  1. Failed login didn't clean up. Strategy exhaustion raised without touching tokens, so login() throws but is_authenticated stays True and get_api_headers() keeps returning Bearer <old token>.
  2. Web login didn't displace di_token. get_api_headers() unconditionally prefers di_token over jwt_web, and the web strategies only set jwt_web — so after a successful web login the fresh token was silently ignored and requests went out with the stale bearer. Worse, with verify_login=True (default) _verify_token() validated that same stale token, blessing the login as the wrong identity.

Impact

Identity confusion / confused-deputy under Client-reuse patterns (e.g. a multi-tenant server caching Client objects — requests run as the previous user). Even single-user: a failed re-login leaves the app believing it's authenticated with dead credentials.

Fix

  • login() now calls _clear_auth_state(keep_tokenstore_path=True) at entry (after the _mfa_pending guard): failed logins end unauthenticated, and a web-strategy jwt_web can no longer be shadowed by a stale di_token.
  • _clear_auth_state() gained keep_tokenstore_path: bool = False — the Garmin wrapper sets _tokenstore_path before delegating to client.login(), and wiping it would silently break token persistence on refresh. logout() behavior is unchanged (path still cleared).

Tests

New TestLoginClearsStaleAuth:

  • test_failed_login_clears_stale_token — all strategies fail → is_authenticated False, get_api_headers() raises
  • test_web_login_uses_fresh_jwt_not_stale_di_token — stale di_token cleared; headers carry the fresh JWT_WEB cookie
  • test_login_preserves_tokenstore_path — persistence target survives login

Updated test_mfa_token_rejection_falls_through_to_next_strategy for the new entry-clear call. 182 passed; only the 3 known Windows-symlink-privilege failures remain (pre-existing, unrelated).

Summary by CodeRabbit

  • Bug Fixes
    • Improved login reliability by clearing stale authentication and MFA session state before retrying.
    • Ensured fresh authentication tokens are used instead of expired or rejected credentials.
    • Preserved the configured token-store location during authentication resets.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Login now clears stale authentication and MFA/session state before credential attempts while preserving the configured token-store path. Tests verify cleanup ordering, fresh web JWT usage, failed-login cleanup, and path preservation.

Changes

Authentication cleanup

Layer / File(s) Summary
Preserve token-store path during cleanup
garminconnect/client.py
_clear_auth_state accepts keep_tokenstore_path and conditionally preserves _tokenstore_path.
Clear stale state during login
garminconnect/client.py, tests/test_garmin_unit.py
login clears prior authentication state before credential attempts. Tests verify cleanup order, MFA and token rejection cleanup, fresh JWT usage, failed-login cleanup, and token-store path preservation.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: tamcore, mannmann2, rifusaki

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: clearing stale authentication state when login starts.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/login-clear-stale-auth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@garminconnect/client.py`:
- Around line 417-424: Update every cleanup call within login’s fallback flow,
including the MFA and strategy token-rejection paths in
garminconnect/client.py:417-424, to pass keep_tokenstore_path=True so later
strategies preserve the configured token-store path. In
tests/test_garmin_unit.py:738-743, update the second expected cleanup call to
call(keep_tokenstore_path=True) and verify that a rejected strategy followed by
a successful strategy retains the path.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 25cf1782-4bc1-4307-93fb-557f8749e9ec

📥 Commits

Reviewing files that changed from the base of the PR and between 108e683 and 887ec40.

📒 Files selected for processing (2)
  • garminconnect/client.py
  • tests/test_garmin_unit.py

Comment thread garminconnect/client.py
@cyberjunky
cyberjunky force-pushed the security/login-clear-stale-auth branch from 887ec40 to 480d747 Compare August 10, 2026 13:06
login()'s only entry guard checked _mfa_pending; nothing cleared prior
auth state. A Client carrying an old di_token (previous login or loaded
token store) hit two defects:

1. Failed login didn't clean up: strategy exhaustion raised without
   touching tokens, so login() throws but is_authenticated stays True
   with the old token intact.
2. Web login didn't displace di_token: get_api_headers() prefers
   di_token over jwt_web and web strategies only set jwt_web, so the
   client kept sending the old bearer - and _verify_token() validated
   that same stale token, blessing the login as the wrong identity.

Under Client-reuse patterns this is identity confusion across accounts;
even single-user, a failed re-login leaves the app believing it is
authenticated with dead credentials.

login() now calls _clear_auth_state(keep_tokenstore_path=True) at
entry. The new keyword preserves the persistence target the Garmin
wrapper sets before delegating, so token refresh keeps dumping to the
same store; logout behavior is unchanged.
@cyberjunky
cyberjunky force-pushed the security/login-clear-stale-auth branch from 480d747 to e7baf99 Compare August 10, 2026 13:18
Addresses CodeRabbit review on PR #411: the MFA token-rejection and
strategy token-rejection paths called _clear_auth_state() without
keep_tokenstore_path=True, wiping the path the wrapper set before login.
A rejected strategy followed by a successful one then left the client
unable to persist refreshed tokens to the configured store.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@garminconnect/client.py`:
- Around line 308-321: Update the token-rejection branch in resume_login() to
call _clear_auth_state(keep_tokenstore_path=True), preserving the configured
token-store path during deferred MFA failures. Add a regression test covering
login(return_on_mfa=True), subsequent MFA token rejection, and a later
successful login that persists refreshed tokens.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d4317cec-4749-410d-af70-d7e5cf2b0a71

📥 Commits

Reviewing files that changed from the base of the PR and between 480d747 and a19a849.

📒 Files selected for processing (2)
  • garminconnect/client.py
  • tests/test_garmin_unit.py

Comment thread garminconnect/client.py
Comment on lines +308 to +321
def _clear_auth_state(self, *, keep_tokenstore_path: bool = False) -> None:
"""Wipe all in-memory auth tokens and session state so the next login starts clean.

``keep_tokenstore_path`` preserves the persistence target: login() sets
it via the Garmin wrapper *before* the credential flow runs, and a
fresh login should keep persisting to the same store.
"""
self.di_token = None
self.di_refresh_token = None
self.di_client_id = None
self.jwt_web = None
self.csrf_token = None
self._tokenstore_path = None
if not keep_tokenstore_path:
self._tokenstore_path = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the token-store path after deferred MFA token rejection.

resume_login() handles MFA attempts created by login(return_on_mfa=True). At Line 1503, it calls _clear_auth_state() without keep_tokenstore_path=True. This clears the configured persistence target when _verify_token() rejects the completed MFA token.

Use keep_tokenstore_path=True in that branch. Add a regression test for deferred MFA followed by token rejection. Otherwise, a later successful login on the same Client cannot persist refreshed tokens.

Proposed fix
             if self.verify_login and not self._verify_token():
-                self._clear_auth_state()
+                self._clear_auth_state(keep_tokenstore_path=True)
                 raise GarminConnectConnectionError(
                     "token rejected by API tier after MFA"
                 )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@garminconnect/client.py` around lines 308 - 321, Update the token-rejection
branch in resume_login() to call _clear_auth_state(keep_tokenstore_path=True),
preserving the configured token-store path during deferred MFA failures. Add a
regression test covering login(return_on_mfa=True), subsequent MFA token
rejection, and a later successful login that persists refreshed tokens.

@cyberjunky
cyberjunky merged commit a5ba3c9 into master Aug 10, 2026
4 checks passed
@cyberjunky
cyberjunky deleted the security/login-clear-stale-auth branch August 10, 2026 17:50
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