Prevent token pollution during sync re-auth#203
Conversation
Three changes to prevent OAuth tokens being stored for the wrong account when sync triggers re-authorization for multiple accounts: 1. Don't auto-launch browser during sync re-auth. Instead, print the authorization URL with clear account context so the user knows exactly which account to select. (AuthorizeManual) 2. Add login_hint parameter to the OAuth URL so Google pre-selects the correct account in the chooser. 3. Validate token email after authorization by calling the Gmail profile API. If the authorized account doesn't match the expected email, the token is deleted and an actionable error is returned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address two review findings: 1. Validate the token *before* persisting it to disk. If validation fails for any reason (network error, API error, email mismatch), the token is never saved, so an existing valid token is never overwritten with an unverified one. 2. Wrap the Gmail profile validation call in a 10s context timeout to prevent indefinite hangs if the API stalls after the browser callback completes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nual auth Finding 1: Don't delete the old token before re-auth succeeds. The authorize method now validates and saves atomically — if validation fails (timeout, API error, mismatch), the existing token is untouched. Removed DeleteToken from the tokenReauthorizer interface and from getTokenSourceWithReauth since it's no longer needed there. Finding 2: Accept Gmail aliases and googlemail.com addresses. Renamed validateTokenEmail to resolveTokenEmail which returns the canonical email from the profile API. Adds sameGoogleAccount() which handles case-insensitive match, dot-insensitive gmail.com, and googlemail.com ↔ gmail.com equivalence. The token is saved under the canonical address. Finding 3: Track Authorize and AuthorizeManual calls separately in the mock. Tests now assert that getTokenSourceWithReauth specifically calls AuthorizeManual (not Authorize) for the invalid_grant reauth path, so a regression to browser auto-launch would fail the test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous commit saved tokens under the canonical email returned by the Gmail profile API. This broke token lookup: the rest of the app (HasToken, TokenSource, source records) keys on the user-supplied identifier. If the canonical address differed (case, dots, googlemail), the token file would be unreachable. Fix: always save under the original identifier after validation confirms the account matches. sameGoogleAccount() still rejects wrong-account selection — it just no longer renames the file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
Verifies that when the canonical Gmail address differs from the user-supplied identifier (dotted alias, case, googlemail.com), the token is saved under the original identifier — not the canonical one. This is the key invariant that prevents breaking HasToken/TokenSource lookups elsewhere in the app. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
The previous regression test manually called sameGoogleAccount() and saveToken(inputEmail, ...), so it would still pass even if authorize() switched back to saving under canonicalEmail. Fix: add a profileURL field to Manager (defaults to the real Gmail endpoint, overridden in tests). The test now stands up an httptest server returning a different canonical email, calls resolveTokenEmail through it, and verifies the token is saved under the original identifier. Also adds TestResolveTokenEmail_RejectsMismatch to cover the wrong-account rejection path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
Review #9805 found TestTokenSavedUnderOriginalIdentifier called resolveTokenEmail + saveToken manually, so a regression in authorize() saving under the canonical email would go undetected. Fix: add browserFlowFn field to Manager for test injection, then rewrite both persistence-key and mismatch tests to call Authorize() directly. The mismatch test also verifies no token is persisted on rejection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Google Workspace accounts may return a primary address from the profile API that differs from the alias the user added. Since we can't verify Workspace aliases without admin API access, accept same-domain mismatches with a logged warning. Gmail/googlemail domains are excluded from this relaxation since their aliases are fully handled by normalizeGmailAddress. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Revert the auto-accept for same-domain Workspace mismatches — it allowed token pollution (bob@company.com saved under alice's identifier). Instead, reject all unverifiable mismatches and suggest re-adding with the primary address shown by the profile API. Also assert no token exists under the profile address on mismatch rejection (#9806). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
The error message no longer suggests `add-account` since that advice is wrong during re-auth (would orphan the existing source). Callers can provide context-appropriate remediation. Also add no-persist assertions to TestAuthorize_WorkspaceAliasMismatch for both the alias and primary addresses. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
resolveTokenEmail now returns *TokenMismatchError with Expected and Actual fields instead of a formatted string. This lets each caller provide context-appropriate remediation: - add-account: suggests re-adding with the primary address - sync re-auth: wraps with neutral "re-authorize" message - deletions: wraps with neutral "authorize" message Tests verify the error type via errors.As and check both Expected and Actual fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When --force re-auth hits a mismatch, suggesting add-account with the primary address would create a second source and orphan the existing one. Only show the add-account guidance for brand-new accounts where no source exists yet. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
roborev: Combined Review (
|
The add-account suggestion on mismatch should only appear when no Gmail source exists for the identifier. Using --force as a proxy missed cases where the token was deleted externally but the source still exists, and suppressed the hint for legitimate --force on brand-new accounts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GetSourcesByIdentifier returns all source types. A non-Gmail source (mbox, imap, apple-mail) sharing the same identifier would suppress the add-account hint even though no Gmail source exists. Filter to Gmail sources specifically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two fixes for alias handling: 1. verify: look up source by user-supplied email, not canonical profile address. The source is keyed under the original identifier from add-account. 2. re-auth mismatch: show remove + re-add instructions with the primary address so Workspace alias accounts have a clear recovery path instead of a dead-end error. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Covers three cases: no sources (hint shown), non-Gmail source only (hint shown), and Gmail source exists (hint suppressed). This logic regressed across multiple commits so it deserves explicit coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1. verify: use findGmailSource() instead of GetSourceByIdentifier() to avoid counting the wrong archive when Gmail and mbox/imap sources share the same identifier. 2. re-auth hint: include --type gmail in the remove-account suggestion since the identifier may be shared across source types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
A query failure in GetSourcesByIdentifier was silently converted to "account not found" with exit code 0. Now findGmailSource returns an error, and verify propagates it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The mismatch handler now calls findGmailSource directly and propagates lookup errors instead of swallowing them via hasGmailSource. Remove the now-unused hasGmailSource wrapper and update the test to exercise findGmailSource. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
Gmail ignores +suffix in the local part, so user+tag@gmail.com is the same account as user@gmail.com. normalizeGmailAddress now strips the +suffix before comparing, preventing false TokenMismatchError for users who use tagged Gmail addresses as their account identifier. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
roborev: Combined Review (
|
|
"⏺ This is a theoretical concern about a narrow edge case, and the suggested fix (add a The scenario: someone added alias@company.com, it worked, token expires, re-auth fails because A rename-account migration command is a reasonable future feature but not a prerequisite for this |
Summary
login_hintparameter: The OAuth URL now includeslogin_hint=<email>so Google pre-selects the correct account in the chooser.Test plan
TestGetTokenSourceWithReauthtests updated and passing — now assertAuthorizeManualis called (notAuthorize) for the reauth pathTestSameGoogleAccountandTestNormalizeGmailAddresstests for alias matchingmsgvault syncwith expired token shows URL + account context without opening browser🤖 Generated with Claude Code