Thanks to visit codestin.com
Credit goes to github.com

Skip to content

protondrive: re-read stored credentials before refreshing so a second Fs can't break login - #9881

Open
jomplox wants to merge 1 commit into
rclone:masterfrom
jomplox:protondrive-shared-session
Open

protondrive: re-read stored credentials before refreshing so a second Fs can't break login#9881
jomplox wants to merge 1 commit into
rclone:masterfrom
jomplox:protondrive-shared-session

Conversation

@jomplox

@jomplox jomplox commented Sep 7, 2026

Copy link
Copy Markdown

What does this change do?

Fixes a Proton Drive session wipe that happens reliably about 25 minutes into
any sync that uses --backup-dir on the same remote. Full analysis is in the
linked issue; the short version:

Proton refresh tokens are single use. --backup-dir on the same remote makes
rclone build a second Fs, and each Fs opens its own Proton session from the
same cached client_refresh_token. When the access tokens expire the two
sessions race. The winner rotates the token; the loser refreshes with the token
that was just spent and gets 400 Invalid refresh token (Code=10013).
go-proton-api treats that as permanent and calls the de-auth handler, which
blanks all four client_* keys in the config file. Every later operation then
falls back to a password login, which Proton's anti-abuse limiter answers with
429 Code=2011. On a 2FA account the run cannot recover without a human.

This follows the approach @ncw suggested in review, the same one
lib/oauthutil uses in reReadToken: before a client refreshes, and again if
the refresh is rejected, it re-reads the stored credentials and adopts them if
another Fs (or another rclone process, since the config file is re-read when
it changes) has rotated them since. It only de-auths when nothing newer exists.

The hook lives in the two libraries, because the backend never sees the
proton.Client:

Here the backend sets config.AuthRefreshHook to a closure that reads the
config, and the auth and de-auth handlers now close over each Fs's config
instead of the _mapper / _saltedKeyPass package globals. Disconnect is
unchanged. The clearConfigMap on a failed cached login in newProtonDrive is
deliberately untouched; that is #8135 and needs its own decision.

Sequencing: this PR needs a go.mod bump to tagged releases of the two
library PRs once they land. I tested it with local replace directives that
are not in the commit.

Linked issue

Fixes #9880

Related: #8135 (same symptom, different trigger, not fixed here)

For new or changed backends

I have not run test_all against Proton Drive. I only have a production account
with live data on it and no test account to offer, so I did not want to point the
integration suite at it. Happy to be told how you would like this covered.

What I did run, all in golang:1.26 with the two libraries pointed at the PR
branches:

  • go build ./... and make quicktest pass. (One vfs test,
    TestWriteFileHandleReadonly, fails only when run as root in Docker and
    passes as a normal user; unrelated.)
  • go test -race ./backend/protondrive/ passes, including the new
    auth_hooks_test.go, which covers the closures and the two-Fs case where
    one persists a rotated token and the other's refresh hook must see it.
  • go-proton-api: full suite with -race, plus four new tests against the fake
    server: adopt before refresh, recover after a rejected refresh, still de-auth
    when the token was revoked, and a control showing a hook-less second client is
    still de-authed.
  • Proton-API-Bridge: full suite with -race, plus a test that logs two clients
    in from one session, lets the first rotate, and checks the second keeps working
    and is not de-authed.

The earlier version of this PR (shared session per remote) ran my nightly
production backup through the exact point where the bug used to fire, with zero
Code=10013. This version has the same unit-level coverage of that scenario but
I have not yet had a production run on it; I will report back after the next
nightly.

Checklist

  • This change is trivial OR it has been discussed and agreed in the linked issue.
  • I have read the contribution guidelines.
  • (If I used AI tools to help write this code) I have read and understood the AI-assisted contributions guidance, and I have tested and take ownership of this change myself.
  • I have added tests for all changes in this PR if appropriate.
  • I have added documentation for the changes if appropriate. (No user-facing option or behaviour change to document - happy to add a note if you would like one.)
  • All commit messages are in house style.
  • (Backend changes only) test_all passes for this backend - see above, I have no test account.
  • This Pull Request is ready for review.

@ncw

ncw commented Sep 7, 2026

Copy link
Copy Markdown
Member

Thank you for this and for the analysis in #9880.

However I think the fix could be improved. We hit exactly this problem years ago with the OAuth backends and the fix we settled on is in lib/oauthutil - see reReadToken(). The idea is simple, when a token needs refreshing, first re-read the config in case something else (another Fs in this process, or another rclone process entirely) has already rotated it. If the config holds a newer token, adopt it and carry on. Only if it doesn't do we do our own refresh.

That approach has two advantages over sharing one session per remote name:

  1. It covers multiple processes as well as multiple Fs objects. A shared session only fixes --backup-dir and friends within one process. An rclone rcd plus a cron job, or two overlapping syncs, still race on the same refresh token and the loser still gets de-authed and wipes the credentials. Re-reading the config (which rclone reloads when the file changes on disk) handles both cases.
  2. It doesn't need a process-wide session cache, which brings its own lifecycle problems - Disconnect on one Fs logging out the client every other Fs holds, or two Fs with different options such as replace_existing_draft silently sharing whichever client happened to be built first.

The reason this needs to go a little deeper than the backend is that go-proton-api's Client.authRefresh keeps the refresh token private and has no hook before it refreshes, so the losing client can't recover once its token has been spent. But github.com/rclone/go-proton-api is our fork, so we can add one. Concretely:

In go-proton-api (client.go), add an optional hook, e.g. AddAuthRefreshHook(func() (uid, acc, ref string, ok bool)), and in authRefresh:

  • Before calling m.authRefresh, call the hook. If it returns a refresh token different from c.ref, adopt the new uid/access/refresh tokens, call the auth handlers, and return nil so the caller retries the request with the fresh access token.
  • If our own refresh fails with 400/422, call the hook again before de-authing. If the token has changed adopt it as above; only if nothing newer exists do we run the deauth handlers.

That's roughly 25 lines.

In the backend, register a hook that calls getConfigMap(m), and turn authHandler/deAuthHandler into closures over the Fs's own mapper and salted key pass instead of the _mapper/_saltedKeyPass package globals. That also fixes the "last Fs constructed owns the write-back for every remote" bug you spotted, without needing the authSession struct. The de-auth guard becomes unnecessary because by the time we reach de-auth we have already re-read the config and found nothing newer.

Are you happy to do the go-proton-api and the rclone sides of this?

… Fs can't break login

Proton rotates the refresh token on every use. A second Fs on the same
remote (--backup-dir, --compare-dest, --copy-dest) starts from the same
stored token, so whichever refreshes second presents a spent token, is
de-authed, and clears the config. The next run then has to log in with
the password and trips Proton's 429 rate limit.

Register an auth refresh hook that re-reads the config before refreshing
and again after a rejected refresh, and adopts credentials another Fs
(or another rclone process, since the config file is re-read when it
changes) has stored since. The client only de-auths when nothing newer
exists. The handlers now close over each Fs's config instead of package
globals.

Needs go-proton-api and Proton-API-Bridge changes that add the hook.

Fixes rclone#9880
@jomplox
jomplox force-pushed the protondrive-shared-session branch from 8df03cd to ec82fbe Compare September 7, 2026 16:40
@jomplox jomplox changed the title protondrive: share one session per remote so --backup-dir can't break login protondrive: re-read stored credentials before refreshing so a second Fs can't break login Sep 7, 2026
@jomplox

jomplox commented Sep 7, 2026

Copy link
Copy Markdown
Author

Thanks for the review. Agreed on all three points, and the "only fixes it in-process" one is the real gap: my nightly runs restic and rclone from the same config, so another process rotating the token is a case I actually hit. I have reworked it your way.

The client is created inside Proton-API-Bridge's common.Login and never exposed, so it took three changes:

Tested with local replace directives that are not committed: go build ./... and make quicktest here, the full -race suites in both libraries, and new tests against the go-proton-api fake server for adopt-before-refresh, recover-after-rejected-refresh, still-de-auth-when-revoked, and a two-client control. Details are in the updated description.

Once the two library PRs are tagged I will bump go.mod here. Happy to reorder or squash however you prefer.

@jomplox

jomplox commented Sep 12, 2026

Copy link
Copy Markdown
Author

I’ve now tested this against a real Proton Drive account, using a separate folder for all test files.

The login fix worked in the live check: one client refreshed the login, and a second client picked up the new credentials without getting logged out. I triggered the refresh deliberately rather than waiting for the login to expire. This did not test two separate processes running at the same time.

The backup-folder sync tests passed too. The broader file tests needed a few follow-ups:

  • Some took longer than the time allowed, so I ran the unfinished tests separately.
  • A test deliberately interrupted an upload and left a draft behind. Allowing that draft to be replaced let the backend tests pass. I enabled that setting only for the test run.
  • One upload lost its connection and then retried with an empty body. The test passed on a later run, but that first failure is still worth tracking.

All selected file tests now have a passing result or a normal skip, across those runs. I removed the test folder afterward.

I tested the three proposed changes together locally. GitHub still needs the supporting library changes to be released before I can update this PR to use them. Could you review go-proton-api#10 and Proton-API-Bridge#9 when convenient? Once they’re released, I can update the dependencies here and check GitHub’s results.

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.

protondrive: --backup-dir on the same remote wipes the saved session 25 minutes into a sync

2 participants