Skip to content

feat(oauth): request read-only Gmail access with --readonly - #567

Open
quetza wants to merge 9 commits into
kenn-io:mainfrom
quetza:feat/add-account-readonly
Open

feat(oauth): request read-only Gmail access with --readonly#567
quetza wants to merge 9 commits into
kenn-io:mainfrom
quetza:feat/add-account-readonly

Conversation

@quetza

@quetza quetza commented Aug 8, 2026

Copy link
Copy Markdown

Adds a --readonly flag to add-account that requests Gmail read access (gmail.readonly) instead of the default read + modify pair.

What changed

  • add-account --readonly authorizes an account with read-only Gmail access. Sync, search, and the TUI work as normal; deletion prompts to re-authorize with write access.
  • An account that already has write access can't be narrowed in place, so --readonly alone fails and points at --readonly --force, which revokes the old grant at Google, deletes the stored token, and re-authorizes with the narrower scopes. Revoking at Google means copies of the old token (backups, other machines) lose write access too.
  • If revocation fails, nothing is changed. If msgvault has no working credential to revoke with — the stored token is already dead, or missing for a registered account — it asks you to revoke access at myaccount.google.com/permissions and confirm before continuing.
  • Any Gmail scope other than gmail.readonly counts as write access, including scopes msgvault never requests that could appear in a token created by another tool.
  • Calendar and Drive grants survive narrowing, and re-authorization paths (expired tokens, add-calendar) no longer quietly restore write access to a narrowed account.
  • Recovery hints and headless instructions keep --readonly, so following them doesn't undo the narrowing. A plain add-account on a read-only account warns before requesting write access.
  • The deletion pre-flight accepts mail.google.com as sufficient for trashing; it previously demanded gmail.modify even from accounts with broader access.

Why

Sync only reads mail, but add-account always requested gmail.modify, so there was no way to run msgvault with a read-only grant. Restricting scopes in the Google Cloud Console doesn't help — that page is a declaration for verification review, not a limit on what gets granted — so changing what the client requests is the only lever.

Usage

msgvault add-account you@gmail.com --readonly          # new account
msgvault add-account you@gmail.com --readonly --force  # narrow an existing account

Default behavior without --readonly is unchanged.

Limitations

  • --readonly is rejected for service accounts; their scopes come from the domain-wide delegation grant in the Admin Console.
  • Narrowing needs a browser. On a headless server, --headless --readonly prints steps to narrow on a browser machine and copy the token across.

Also expands the Google Cloud Console setup walkthrough in docs/guides/oauth-setup.md.

## What changed

- add `--readonly` to `add-account`, requesting `gmail.readonly` alone instead of the default read + modify pair
- treat Gmail write access as a set, `gmail.modify` and `mail.google.com`, so a check no longer misses an account holding only the broader scope
- refuse `--readonly` against an account that already holds write access, pointing at `--force`, rather than reporting success while changing nothing
- preserve non-Gmail grants across narrowing, so Calendar and Drive survive `--readonly --force`
- warn before a plain `add-account` re-widens an account that currently has no write access
- stop sync's expired-token re-auth and `add-calendar` from silently unioning write scopes back into a narrowed grant
- accept `mail.google.com` in the trash deletion pre-flight, which previously demanded `gmail.modify` and prompted accounts that already exceeded it

## Why

Sync only reads mail, but `add-account` has always requested `gmail.modify` alongside `gmail.readonly`, so there was no way to run msgvault against Gmail with a read-only grant.

Restricting scopes on the Cloud Console's Data Access page does not achieve this. That page is a declaration used for verification review, not a request-time restriction and the authorization server grants whatever the client requests. Declining a scope at the consent screen fails too, because `authorize` rejects a token that comes back narrower than requested. Changing what the client requests is the only lever.

Re-authorization deliberately carries granted scopes forward so replacement consent cannot drop Calendar or Drive from the shared token file. That is also why an existing grant cannot be narrowed in place: without `--force` the stored token is reused and no narrower request is ever sent. `--force` deletes the token, which makes the narrower request real and narrowing subtracts only the write scopes from what is carried forward.

The deletion pre-flight change matters only once accounts can be read-only. Narrowing an account and later escalating it for permanent deletion yields `mail.google.com` without `gmail.modify`, which the old check read as insufficient for trashing.

## Usage

```console
msgvault add-account you@gmail.com --readonly
msgvault add-account you@gmail.com --readonly --force   # narrow an existing grant
```

Default behaviour is unchanged. Nothing is persisted about how an account was set up, the no-op and warning behaviours derive from its current grant.

## Limitations

- narrowing an existing grant needs `--force`, which needs a browser so cannot be done with only a headless host
- `--readonly` is rejected for service accounts whose scope comes from the domain-wide delegation grant in the Admin Console rather than from msgvault flags

Also expands the Google Cloud Console setup walkthrough in `docs/guides/oauth-setup.md`.

Co-Authored-By: Claude Opus 5
@roborev-ci

roborev-ci Bot commented Aug 8, 2026

Copy link
Copy Markdown

roborev: Combined Review (4cdfc42)

Verdict: Two medium-severity issues prevent reliable read-only Gmail authorization.

Medium

  • Metadata-less tokens bypass read-only enforcementcmd/msgvault/cmd/addaccount.go:521, cmd/msgvault/cmd/addaccount.go:570
    Legacy tokens commonly retain the historical gmail.modify grant but lack scope metadata. Running add-account --readonly without --force reuses these credentials and reports success, even though access was not narrowed. Conservatively reject metadata-less existing tokens in read-only mode unless --force is supplied, with instructions to reauthorize for an explicitly recorded gmail.readonly grant.

  • Service-account documentation claims unsupported read-only authorizationdocs/guides/oauth-setup.md:202
    The guide says service-account deployments can authorize only gmail.readonly, but Gmail service-account paths construct NewServiceAccountManager with oauth.Scopes, including gmail.modify. A domain-wide delegation grant limited to read-only therefore cannot satisfy those token requests. Use ScopesGmailReadonly for routine validation and synchronization, escalating only for deletion operations, or remove the unsupported claim.


Reviewers: 2 done | Synthesis: codex, 13s | Total: 7m31s

A token predating scope recording carries no scopes array, so the write-access
check found nothing to object to, the token was reused, and `add-account
--readonly` reported success over an account that still held `gmail.modify`.
Tokens that old were minted when read + modify was the only scope set, so
"no recorded scopes" has to mean "possibly write-capable" rather than "already
narrow".

Such tokens are now refused under `--readonly` unless `--force` is supplied,
with instructions to reauthorize for an explicitly recorded read-only grant.
The token-reuse check refuses them too, so an unverifiable grant cannot be
reused as though it were narrow by either route. Only `--readonly` is affected;
default runs keep their existing tolerance for legacy tokens.

Also corrects the service-account setup guidance. It claimed a delegation grant
limited to `gmail.readonly` was sufficient for a read-only archive, but the
Gmail service-account paths request `gmail.readonly` and `gmail.modify`
together, so such a grant fails the token exchange and breaks `add-account`,
`sync`, `serve`, and `verify`. Narrowing what those paths request is noted as a
possible future change rather than attempted here, since it cannot be verified
without a Workspace domain configured for domain-wide delegation.

Co-Authored-By: Claude Opus 5
@roborev-ci

roborev-ci Bot commented Aug 8, 2026

Copy link
Copy Markdown

roborev: Combined Review (309a626)

Scope narrowing is incomplete: existing write-capable tokens remain valid, and recovery guidance can unintentionally restore write access.

High

  • Existing Gmail write tokens are not revokedcmd/msgvault/cmd/addaccount.go:208, cmd/msgvault/cmd/addaccount.go:391

    --readonly --force deletes only the local token file. The underlying Google refresh token can remain valid, so copied or previously exposed credentials may retain gmail.modify or mail.google.com access after narrowing appears successful.

    Revoke the existing refresh token through Google’s revocation endpoint before deleting it, fail closed if revocation fails, and then reauthorize with the reduced Gmail scope plus preserved non-Gmail scopes.

Medium

  • Recovery instructions lose read-only intentcmd/msgvault/cmd/addaccount.go:154, cmd/msgvault/cmd/root.go:475

    Account-mismatch and expired-token remediation suggests commands that omit --readonly. Following them can silently request Gmail write access, particularly through the headless flow.

    Preserve the requested or recorded grant mode in remediation commands, adding --readonly and --force where appropriate. Add behavioral tests for mismatch and non-interactive recovery paths.


Reviewers: 2 done | Synthesis: codex, 10s | Total: 9m18s

@wesm

wesm commented Aug 9, 2026

Copy link
Copy Markdown
Member

will review thank you

@roborev-ci

roborev-ci Bot commented Aug 9, 2026

Copy link
Copy Markdown

roborev: Combined Review (c898a29)

Verdict: Two high-severity and two medium-severity OAuth credential-narrowing issues need resolution.

High

  • internal/oauth/oauth.go:67 — Incomplete Gmail mutation-scope detection

    ScopesGmailWrite recognizes only gmail.modify and mail.google.com, overlooking mutation scopes such as gmail.send, gmail.compose, gmail.insert, gmail.labels, and gmail.settings.basic. A token containing gmail.readonly plus one of these scopes can be incorrectly reused as read-only. Even with --force, WithoutGmailWriteScopes carries the unrecognized scope into the replacement authorization.

    Fix: Remove every Gmail scope except the explicitly permitted read-only scope while preserving non-Gmail scopes. Treat any other Gmail scope as write-capable or unknown and require revocation.

  • internal/oauth/oauth.go:1191invalid_token is incorrectly treated as successful grant revocation

    An invalid_token response establishes only that the submitted credential is expired or already revoked, not that the entire Google grant and associated tokens were revoked. The command can delete the local credential and report successful narrowing while a refresh token stored elsewhere remains write-capable.

    Fix: Fail closed on invalid_token, retain the local token, and require verified successful revocation or authoritative account-level revocation before deleting it and issuing a narrower grant.

Medium

  • cmd/msgvault/cmd/addaccount.go:692--readonly --force proceeds without a local token

    For an existing account whose local token is missing, the command cannot revoke previous write-capable credentials. Copies held in backups or on other hosts can retain Gmail write access even though narrowing is reported as successful.

    Fix: Fail closed and require manual Google grant revocation before authorization, or reliably verify that the account is genuinely new.

  • cmd/msgvault/cmd/root.go:496 — Headless recovery instructions cannot reliably replace an expired read-only token

    The browser-machine command omits transfer of the existing token metadata and does not force reauthorization. Without that token, Calendar and Drive scopes are omitted; with the stale token copied over, add-account may consider it reusable without validating it and produce no replacement.

    Fix: Instruct users to copy the existing token to the browser machine, run add-account --readonly --force, copy the replacement token back, and register it without --force.


Reviewers: 2 done | Synthesis: codex, 16s | Total: 13m50s

@roborev-ci

roborev-ci Bot commented Aug 9, 2026

Copy link
Copy Markdown

roborev: Combined Review (8ffd30b)

Verdict: One high-severity authorization issue and one medium-severity headless workflow defect require changes.

High

  • Registered accounts with missing tokens bypass read-only revocationcmd/msgvault/cmd/addaccount.go:304, cmd/msgvault/cmd/addaccount.go:599

    decideAddAccountGrant treats a missing or unreadable local token as a new account, even when the Gmail source is already registered. Consequently, add-account <email> --readonly can proceed without --force, revocation, or manual confirmation, potentially leaving an older write-capable refresh token valid. The headless path likewise prints fresh-account instructions without ensuring account-level revocation.

    Fix: Pass whether the source exists into the grant decision. For registered accounts without a valid local token, require --force and use the existing manual-revocation confirmation flow before authorization instructions are printed.

Medium

  • Headless narrowing rejects the token it needs to replacecmd/msgvault/cmd/addaccount.go:307

    --headless --readonly rejects an existing write-capable token before printing the copy-and-reauthorize instructions, making the documented narrowing workflow unusable precisely when narrowing is necessary.

    Fix: Treat the headless browser-machine flow as force-enabled so it can print the reauthorization instructions, while retaining widening warnings for non-read-only authorization.


Reviewers: 2 done | Synthesis: codex, 15s | Total: 10m28s

## What changed

- repeat `--readonly` in the account-mismatch hint printed by `add-account`, so following it does not request write access on the retry
- carry the account's recorded grant into the expired-token reauthorization hint, which suggested `add-account <addr> --force` and would have re-widened a narrowed account
- carry it into the alias-mismatch remove-and-re-add recipe as well
- document that narrowing replaces msgvault's stored token without revoking the previous authorization at Google

## Why

Remediation text is followed at the moment an operator is least likely to scrutinise it: something has already failed and they are copying a suggested command to fix it. Guidance that silently widens a grant is worse than no guidance at all.

The reauthorization hint reads the recorded grant through an optional interface, before any reauthorization replaces it. A manager that cannot report scopes, or a grant with no Gmail scopes at all, counts as not narrowed, so every case predating `--readonly` keeps its existing text unchanged.

`--force` deletes msgvault's stored token and obtains a narrower one. It does not ask Google to revoke the old authorization, so a copy of the previous token file keeps whatever it was granted. Revoking through Google clears every scope for the account, Calendar and Drive included, so revocation cannot be folded into narrowing without destroying the grants narrowing exists to preserve. Operators are pointed at the revocation page instead. Revoking automatically after a successful narrowing would avoid that, and is left as a possible follow-up rather than attempted here, where a failed reauthorization would leave the account with no access at all.

Co-Authored-By: Claude Opus 5
@quetza
quetza force-pushed the feat/add-account-readonly branch from 8ffd30b to e1e01fe Compare August 9, 2026 09:51
@roborev-ci

roborev-ci Bot commented Aug 9, 2026

Copy link
Copy Markdown

roborev: Combined Review (e1e01fe)

The read-only authorization flow has one high-severity scope-validation flaw and two medium-severity narrowing/headless-flow issues.

High

  • --readonly can retain unrecognized Gmail write scopes
    Location: internal/oauth/oauth.go:67
    ScopesGmailWrite recognizes only gmail.modify and mail.google.com, omitting write-capable scopes such as gmail.send, gmail.compose, gmail.insert, gmail.labels, and gmail.settings.basic. A token containing gmail.readonly plus one of these scopes may be reused as read-only, and --readonly --force may preserve the write scope during reauthorization.
    Fix: Classify and remove every write-capable Gmail scope when narrowing, refuse non-forced reuse whenever one is present, and reject newly issued --readonly tokens whose returned scopes retain write authority.

Medium

  • Forced narrowing does not revoke the original write-capable grant
    Locations: cmd/msgvault/cmd/addaccount.go:218, cmd/msgvault/cmd/addaccount.go:401
    --readonly --force deletes only the local token, leaving the original Google refresh token valid. Backups or copies can therefore retain Gmail write access after the command reports successful narrowing.
    Fix: Revoke the existing Google grant before deleting the token, abort if revocation fails, and then authorize using the captured narrowed scope set.

  • Headless authorization bypasses the existing-token grant decision
    Location: cmd/msgvault/cmd/addaccount.go:302
    The headless branch returns before applyAddAccountGrantDecision, while HTTP preflight also skips headless runs. This allows plain headless instructions to widen a narrowed account without warning and lets --headless --readonly bypass the refusal applied to existing write-capable tokens.
    Fix: Inspect the existing token and apply the grant decision before printing headless instructions.


Reviewers: 2 done | Synthesis: codex, 15s | Total: 10m57s

@wesm

wesm commented Aug 9, 2026

Copy link
Copy Markdown
Member

I can take the wheel from here to get this merge-ready, thank you

Reapplies the revocation and scope-hardening work on top of the
remediation-guidance rework, which had replaced it.

Narrowing with --readonly --force now revokes the existing grant at
Google before deleting the local token, when that grant is
write-capable or has no recorded scopes. Deleting only the file leaves
the refresh token valid server-side, so copies of it kept write access
while the command reported a successful narrowing. Revocation failure
aborts with nothing changed. A grant already verified as read-only
skips revocation, so replacing an expired read-only token does not
depend on a dead credential being revocable.

When msgvault cannot revoke anything itself — the credential is
already invalid (revoking it proves nothing about other tokens under
the grant), or a registered account has no local token — the operator
must confirm having revoked msgvault's access in their Google account
settings; EOF or anything but yes aborts.

Treat any Gmail scope other than gmail.readonly as write-capable.
The fixed modify/full list missed mutation scopes msgvault never
requests (gmail.send, gmail.compose, ...) that can appear in tokens
minted outside msgvault; detection is now prefix-based.

Apply the grant decision in the --headless branch before printing
instructions, so a plain headless run over a narrowed account warns
instead of printing silently-widening instructions, and
--headless --readonly over a write-capable account is refused. The
read-only headless instructions copy any existing token to the browser
machine first (preserving Calendar/Drive), authorize there with
--readonly --force — a copied stale token would otherwise be reused
unvalidated — and register on the server without --force.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Aug 9, 2026

Copy link
Copy Markdown

roborev: Combined Review (e629e20)

OAuth read-only narrowing has three medium-severity workflow and recovery issues; no high or critical findings were reported.

Medium

  • Headless narrowing is unreachable for existing write-capable tokenscmd/msgvault/cmd/addaccount.go:315
    --headless --readonly rejects the token through the normal non---force path before displaying the documented copy-and-revoke workflow. Because --headless and --force are mutually exclusive, users cannot proceed as instructed. Give headless mode a dedicated path that prints the safe copy-and-revoke steps.

  • Headless narrowing can skip revocation confirmation when no local token existscmd/msgvault/cmd/addaccount.go:315
    A registered account without a local token bypasses the normal force-path confirmation, allowing browser authorization without confirming revocation of potentially active write-capable credentials. When readonlyGrant, existingSource != nil, and no token exists, require account-level revocation confirmation before printing instructions.

  • Failed forced narrowing can lose previously recorded scopescmd/msgvault/cmd/addaccount.go:753
    The flow deletes persisted scope metadata before browser authorization completes. If authorization is cancelled or fails, a retry may request only Gmail read-only and silently omit previously recorded Calendar or Drive scopes. Preserve the intended narrowed scope set until replacement authorization succeeds, then clear the recovery state atomically.


Reviewers: 2 done | Synthesis: codex, 13s | Total: 11m30s

…retries

Headless read-only runs no longer refuse a write-capable account.
--headless and --force are mutually exclusive, so the refusal pointing
at --force made the documented copy-and-revoke workflow unreachable.
The instructions now print with a note explaining that the
browser-machine --readonly --force step revokes the grant. A registered
account with no local token gets a warning to revoke msgvault's access
at Google before authorizing — printed rather than gated, because the
headless command performs no authorization to guard and the
daemon-routed subprocess has no stdin for a prompt.

Narrowing no longer deletes the token file before authorizing. It
revokes the grant, rewrites the stored scope metadata to the narrowed
set, and lets the successful authorization replace the file. Deleting
first meant a failed or cancelled authorization left no scope record,
so a retry requested bare gmail.readonly and silently dropped
Calendar/Drive. Token reuse is now gated on --force in the daemon
preflight, matching the local path, so the kept (revoked) token is
never reported as already authorized.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Aug 9, 2026

Copy link
Copy Markdown

roborev: Combined Review (d9733d2)

Narrowing Gmail access has two significant credential-safety issues: one High and one Medium.

High

  • Equivalent Gmail aliases can bypass revocationcmd/msgvault/cmd/addaccount.go:745

    The missing-token branch treats --readonly --force as a new account when the exact email spelling has no token or source. Because OAuth validation considers case, dot, plus-address, and googlemail.com variants equivalent, narrowing through an alias can create a read-only token without revoking an existing write-capable refresh token.

    Fix: Canonicalize Gmail identifiers before token/source lookup, or locate existing sources using the same account-equivalence rules as authorization. If equivalence cannot be established, fail closed and require confirmed account-level revocation.

Medium

  • Failed reauthorization leaves a revoked credential looking reusablecmd/msgvault/cmd/addaccount.go:780

    After revocation, the old credential remains stored with narrowed scope metadata. If browser authorization fails, a later add-account <email> --readonly may report “already authorized,” while an unexpired cached access token can delay detection until an API call fails.

    Fix: Store retained scopes in an explicit pending-reauthorization state that token reuse and TokenSource reject. Require successful authorization to replace that state, and add a retry test covering failed authorization.


Reviewers: 2 done | Synthesis: codex, 13s | Total: 9m50s

Fail closed when narrowing an email spelling that has no stored token
but is a Gmail alias of one that does. Authorization accepts case,
dot, plus-address, and googlemail.com variants as the same account, so
narrowing through an alias minted a read-only token while the stored
spelling kept an unrevoked write-capable credential. The error points
at the stored spelling to re-run against.

Mark the credential a narrowing run revokes as pending
re-authorization. It is kept for its scope metadata, but nothing may
use it as a live token: TokenSource refuses it — a cached access token
could otherwise defer the failure to a later API call — and add-account
no longer reports it as already authorized, proceeding to a fresh
authorization instead. A successful authorization replaces the file
and clears the mark.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Aug 10, 2026

Copy link
Copy Markdown

roborev: Combined Review (37328b6)

The review found two medium-severity issues affecting Gmail read-only grant narrowing and headless token transfer.

Medium

  • Equivalent Gmail aliases can bypass read-only narrowing checkscmd/msgvault/cmd/addaccount.go:750
    Equivalent addresses using dot, plus, or googlemail aliases are checked only during --force when the exact token is absent. A normal or headless --readonly call can therefore create or reuse a read-only token while an equivalent write-capable token remains valid, falsely reporting successful narrowing. This also affects cases where exact and equivalent token files coexist.
    Fix: Check equivalent token spellings before every read-only decision. Refuse normal/headless reuse and direct the user to narrow using the stored spelling. Add behavioral coverage for non-force, headless, and duplicate-token cases.

  • Headless token-copy instructions assume identical directory layoutsinternal/oauth/oauth.go:313
    Step 0 uses the server’s absolute token directory as the destination on the browser machine. If the machines have different home or data directories, Step 1 will not read the copied token and therefore cannot preserve non-Gmail scopes or revoke the existing write grant as promised.
    Fix: Copy through a neutral path and explicitly install the token into the browser machine’s locally configured token directory, or provide an import command that resolves the directory locally.


Reviewers: 2 done | Synthesis: codex, 21s | Total: 11m29s

…s paths

Move the alias-equivalence check from the forced-reauth preparation
into the grant decision, so every read-only path hits it: a plain or
headless --readonly under a dot, plus, or googlemail variant of a
stored token could still mint or reuse a read-only token while the
stored spelling's credential kept its access, and the same held when
exact and alias token files coexisted. Any --readonly run whose
spelling is a Gmail alias of a stored token is now refused — with or
without --force, browser or headless — and pointed at the stored
spelling. The decision inputs move into a struct, and
FindEquivalentTokenEmail gains an os.SameFile guard so case variants
that resolve to the account's own file on case-insensitive filesystems
are not misread as a second credential.

Print browser-machine paths in the headless instructions as
~/.msgvault/tokens rather than reusing this server's absolute tokens
directory. A different user or MSGVAULT_HOME on the browser machine
put the Step 0 copy where its msgvault never looks, silently skipping
scope preservation and revocation; ~ now expands locally, with a note
for custom homes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Aug 10, 2026

Copy link
Copy Markdown

roborev: Combined Review (b911255)

Verdict: Changes require fixes for one high-severity authorization issue and one medium-severity scope-preservation issue.

High

  • cmd/msgvault/cmd/addaccount.go:799--readonly --force can claim successful narrowing without revoking existing write-capable tokens. When no local token exists, revocation confirmation depends on an exact registered source match. This safeguard is bypassed if the source was removed without revoking Google’s grant or if the account is registered under a Gmail-equivalent spelling such as username@gmail.com versus user.name@gmail.com. Older token copies may consequently retain write access. Require account-level revocation confirmation whenever --readonly --force lacks a revocable local token, resolving Gmail-equivalent identifiers where applicable rather than relying on local source existence.

Medium

  • internal/oauth/oauth.go:319 — Normal headless reauthorization can discard Calendar or Drive scopes. The instructions copy the existing token to the browser machine only for --readonly. Other headless reauthorization flows therefore construct authorization without the recorded optional scopes and may replace the grant with Gmail-only access. Include the token-copy step for every headless reauthorization, construct the scope-preserving manager from that token, and then use browser-side --force.

Reviewers: 2 done | Synthesis: codex, 18s | Total: 11m13s

… all headless flows

Require the manual-revocation confirmation whenever --readonly --force
finds no local token, instead of gating it on an exactly-matching
registered source. That gate could be bypassed by removing the account
before narrowing, or by a source registered under a Gmail alias
spelling, while credentials issued earlier stayed valid at Google. A
forced narrowing of an account with no token is a rare, deliberate
invocation, so the unconditional confirmation costs the common paths
nothing.

Print the copy-existing-token step and use browser-side --force in
every headless flow, not just --readonly. A default headless
re-authorization had the browser machine authorize with no recorded
scopes to preserve, and Google's replacement consent then dropped
Calendar/Drive from the grant — the same hazard the add-calendar
headless instructions already guard against. With the copied token,
--force rebuilds the request from the recorded scopes; without a
token to copy, it changes nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roborev-ci

roborev-ci Bot commented Aug 10, 2026

Copy link
Copy Markdown

roborev: Combined Review (9c8808f)

Verdict: Changes requested — one high-severity credential revocation flaw and three medium-severity authorization-state issues remain.

High

  • cmd/msgvault/cmd/addaccount.go:817--readonly --force does not safely retire existing read-only credentials. When stored scopes are already read-only, the command neither revokes the credential nor marks it pending before reauthorization. Consequently, copied refresh tokens can remain valid after the documented revocation workflow, while a failed browser authorization can leave the stale local token reusable as “already authorized.”

    Fix: Revoke every non-pending stored credential during forced read-only reauthorization, regardless of recorded scopes, and mark it pending while preserving scope metadata. Skip revocation only when PendingReauth confirms it was already revoked; clear pending state only after successful authorization.

Medium

  • internal/oauth/oauth.go:1328 — interrupted narrowing of legacy tokens cannot be retried cleanly. A legacy token without scope metadata is marked pending with an empty Scopes list. After authorization fails, a normal --readonly retry rejects it as legacy, while --force attempts to revoke the already-revoked credential again.

    Fix: Record gmail.readonly plus preserved non-Gmail scopes when marking the token pending, or explicitly represent pending narrowing records as having read-only intent.

  • cmd/msgvault/cmd/addaccount.go:664 — equivalent canonical and Gmail-alias tokens can deadlock read-only authorization. If both token files exist, each spelling detects the other and refuses while directing the user back to it, leaving no actionable read-only path.

    Fix: Prevent equivalent duplicates from being created, or detect the conflict and provide an explicit cleanup/revocation workflow.

  • cmd/msgvault/cmd/addaccount.go:669 — registered accounts without a local token bypass revocation confirmation. A missing token is always treated as a fresh account, so plain --readonly can issue a new token even though older write-capable credentials may remain valid elsewhere.

    Fix: Include registered-source existence in the grant decision and require the forced/manual-revocation workflow when an existing account lacks a local token.


Reviewers: 2 done | Synthesis: codex, 15s | Total: 23m53s

…token gaps

Revoke the stored credential on --readonly --force regardless of its
recorded scopes. Skipping verified read-only grants contradicted the
documented contract — copies of the old refresh token stayed valid —
and a failed authorization left the old token reusable as already
authorized. The only skip is a record already marked pending
re-authorization, which is only ever set after a successful or
operator-confirmed revocation, so retries proceed without re-revoking
or prompting.

Record gmail.readonly on the pending record of a legacy token. With no
scopes at all, an interrupted narrowing could not be retried cleanly:
a plain --readonly retry bounced off the legacy refusal and --force
tried to revoke the dead credential again.

Give duplicate alias token files a cleanup path instead of a deadlock.
When both spellings hold tokens, each previously refused by pointing
at the other; the refusal now names the remove-account cleanup.

Refuse plain --readonly on a registered account whose token file is
missing. It was treated as a fresh account and minted a new read-only
token while credentials issued earlier may have stayed valid; the
refusal points at --readonly --force, which routes through the
manual-revocation confirmation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants