Skip to content

Refresh-token race between concurrent processes destroys a valid persisted session (silent sign-out) #13

Description

@SisyphusOfCorinth

Summary

When two processes using ProtonSSO overlap across an access-token refresh, the loser of the refresh race gets HTTP 400 from /auth/refresh and Session.async_refresh() responds with _clear_local_data() — which, via the SSO persistence observer, deletes the account's session from the keyring and drops it from the index. The persisted session at that moment is perfectly valid (the winner just stored a freshly rotated refresh token); it is only the loser's in-memory copy that is stale. The user is silently signed out — no log line, no error surfaced.

This is easy to hit in the real world: any status-bar widget or script that shells out to protonvpn on a timer will eventually run two invocations that straddle a token refresh. Report of it biting users: jkoestinger/omarchy-vpn#23.

Environment

  • python-proton-core 0.7.0, proton-vpn-cli 1.0.1, python-proton-keyring-linux 0.2.2 (Arch Linux packages)
  • Keyring: GNOME keyring via SecretService, unlocked and healthy throughout

Sequence

  1. Processes A and B each instantiate a session via ProtonSSO; both load the same persisted session data, including refresh token R1.
  2. The access token has expired; both get 401 and call async_refresh(only_when_refresh_revision_is=...). The __refresh_revision guard is per-process in-memory state, so it cannot detect the other process's refresh.
  3. _requests_lock()ProtonSSO._acquire_session_lock() takes the per-account fcntl advisory lock — but only caches the in-memory data passed to it. The persisted session is never re-read after the lock is acquired.
  4. A wins: trades R1 for R2, persists R2 on unlock. The API invalidates R1.
  5. B acquires the lock still holding R1, posts /auth/refresh with R1 → 400.
  6. In async_refresh(), the http_code in (400, 422) branch calls self._clear_local_data() and returns False. On _requests_unlock(), ProtonSSO._release_session_lock() receives empty session data, deletes proton-sso-account-<name> from the keyring, and removes the account from proton-sso-accounts.
  7. R2 — a valid, never-used refresh token — is destroyed along with the session. Every subsequent invocation is signed out. Nothing is logged.

Observed evidence

On the affected machine (a bar widget polling protonvpn status/config list/countries list every 15 s): the CLI log shows three simultaneous /vpn/v1/loads requests with only two responses logged, then permanently signed-out behavior; the keyring afterwards still contains the proton-sso-accounts index item but no proton-sso-account-<name> item — the exact end-state of step 6. Serializing all CLI invocations with an external flock wrapper fully resolves the sign-outs, confirming the race.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions