protondrive: re-read stored credentials before refreshing so a second Fs can't break login - #9881
protondrive: re-read stored credentials before refreshing so a second Fs can't break login#9881jomplox wants to merge 1 commit into
Conversation
|
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 That approach has two advantages over sharing one session per remote name:
The reason this needs to go a little deeper than the backend is that go-proton-api's In go-proton-api (
That's roughly 25 lines. In the backend, register a hook that calls 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
8df03cd to
ec82fbe
Compare
|
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
Tested with local Once the two library PRs are tagged I will bump |
|
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:
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. |
What does this change do?
Fixes a Proton Drive session wipe that happens reliably about 25 minutes into
any sync that uses
--backup-diron the same remote. Full analysis is in thelinked issue; the short version:
Proton refresh tokens are single use.
--backup-diron the same remote makesrclone build a second
Fs, and eachFsopens its own Proton session from thesame cached
client_refresh_token. When the access tokens expire the twosessions 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 thenfalls 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/oauthutiluses inreReadToken: before a client refreshes, and again ifthe 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 whenit 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:Client.AddAuthRefreshHookand theconsult-before-refresh / consult-after-rejection logic.
Config.AuthRefreshHookand registers itright after the client is created, on both login paths.
Here the backend sets
config.AuthRefreshHookto a closure that reads theconfig, and the auth and de-auth handlers now close over each
Fs's configinstead of the
_mapper/_saltedKeyPasspackage globals.Disconnectisunchanged. The
clearConfigMapon a failed cached login innewProtonDriveisdeliberately untouched; that is #8135 and needs its own decision.
Sequencing: this PR needs a
go.modbump to tagged releases of the twolibrary PRs once they land. I tested it with local
replacedirectives thatare 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_allagainst Proton Drive. I only have a production accountwith 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.26with the two libraries pointed at the PRbranches:
go build ./...andmake quicktestpass. (Onevfstest,TestWriteFileHandleReadonly, fails only when run as root in Docker andpasses as a normal user; unrelated.)
go test -race ./backend/protondrive/passes, including the newauth_hooks_test.go, which covers the closures and the two-Fscase whereone persists a rotated token and the other's refresh hook must see it.
-race, plus four new tests against the fakeserver: 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.
-race, plus a test that logs two clientsin 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 butI have not yet had a production run on it; I will report back after the next
nightly.
Checklist
test_allpasses for this backend - see above, I have no test account.