Skip to content

feat(auth): harden auth add --readonly for least-privilege consumers - #54

Merged
namastex888 merged 3 commits into
mainfrom
feat/auth-readonly-hygiene
Sep 26, 2026
Merged

namastex888 merged 3 commits into
mainfrom
feat/auth-readonly-hygiene

Conversation

@automagik-genie

Copy link
Copy Markdown
Collaborator

Change

Read-only auth hygiene for wk auth add ... --readonly:

  1. No include_granted_scopes on a read-only authorization. authURLParams(forceConsent, readonly) leaves the parameter out when AuthorizeOptions.Readonly / HeadlessOptions.Readonly is set. This applies to the browser, manual, remote and headless paths. Without it, Google could fold an earlier full drive grant into the new "read-only" refresh token. auth manage is unchanged.
  2. Refuse to merge over a write-scoped token. New secrets.CheckReadOnlyMerge / secrets.MergeReadOnlyToken. A read-only auth add (and auth poll --readonly) refuses when the token stored for the same client+email records write scopes. The check runs before the OAuth flow starts (browser/manual, remote step 1, headless) and again at store time. The command exits 2 with: ... use a dedicated --client (e.g. --client brain-ro) for read-only access. The existing token is left untouched. Merging into a read-only token works as before. A keyring read error propagates and is never treated as "no token".
  3. --no-relay on auth add. ResolveAuthMode(ctx, headless, manual, noRelay, callbackServer): config auth_mode: headless and no-TTY auto-detection resolve to the loopback browser flow, and the relay's /health is never contacted. --headless, --no-poll and --callback-server together with --no-relay are usage errors. --manual/--remote still work.

Also:

  • Documented under "Read-only access for agents" in docs/auth.md.
  • .deadcode-baseline.txt is updated for the line shift of waitPostSuccess in oauth_flow.go (308 → 316). No new dead code.

Tests

  • oauth_flow_more_test.go: TestAuthURLParams_ReadonlyOmitsIncludeGrantedScopes and TestHeadlessAuthorize_ReadonlyOmitsIncludeGrantedScopes. The existing TestAuthURLParams still asserts include_granted_scopes=true for the full flow.
  • detect_test.go: --no-relay overrides config headless; it skips auto-detect headless without calling /health; manual is kept.
  • internal/secrets/readonly_merge_test.go: first authorization, merge into a read-only token, refusal over a write-scoped token (token untouched), a dedicated client being independent, and read-error propagation.
  • internal/cmd/auth_readonly_hygiene_test.go: Readonly is plumbed to the consent URL; refusal before OAuth (exit 2, fix text); --client brain-ro accepted alongside a write-scoped default; refusal in remote step 1, headless and auth poll --readonly; --no-relay forces loopback over config headless (with a relay control run); --no-relay rejects relay flags; --no-relay --manual.

Gate: make ci exits 0 (fmt-check, golangci-lint --new-from-rev=origin/main, test, deadcode, race, coverage).

Note: a named client without its own credentials-<client>.json falls back to the same embedded OAuth client ID as default. --client brain-ro then separates the tokens in the local keyring, but Google still sees one OAuth client. The docs say to register a second OAuth client for Google-side separation.

internal/googleauth/readonly_scopes.go (+ test) is byte-identical to the copy in the companion wk auth scopes PR.

Consumer: khal/brain GSuite connector (read-only). brain authorizes with wk --client brain-ro auth add <email> --readonly --no-relay.

🤖 Generated with Claude Code

Three gaps let a read-only authorization end up write-capable or pass
through the relay:

- include_granted_scopes: the consent URL always asked Google to fold
  earlier grants into the new token, so `--readonly` after a full login
  could return a refresh token that still carried full `drive`. A
  read-only authorization (browser, manual, remote and headless) now
  leaves the parameter out. `auth manage` keeps it.
- Merge: a read-only `auth add` (and `auth poll --readonly`) merged into
  the token already stored for the same client+email. Over a
  write-scoped token that replaced a working write login with a
  read-only one while the recorded scopes still claimed write access.
  It now refuses before the OAuth flow starts, and again at store time
  (secrets.MergeReadOnlyToken), exits 2 and says to use a dedicated
  --client (e.g. --client brain-ro). Merging into a read-only token is
  unchanged.
- Relay: `auth add --no-relay` never uses the headless callback relay.
  Config auth_mode=headless and no-TTY auto-detection resolve to the
  local loopback flow, and --headless, --no-poll and --callback-server
  are rejected. --manual and --remote still work.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 4cfa3e12-9bac-47f9-a83f-9ec225131899


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

namastex888 and others added 2 commits September 26, 2026 20:29
…giene

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…x client docs

A stored token that records no scopes (for example one brought in by
`auth tokens import`) is not provably read-only. A read-only `auth add`
or `auth poll --readonly` now refuses it with exit 2 and the same
dedicated --client hint, instead of overwriting a token that may hold
write access.

docs/auth.md wrongly said a named client without its own credentials
file uses default's OAuth client ID. ReadClientCredentialsFor never
reads default's credentials.json: it tries the client's own file, then
the built-in client ID, then WK_CLIENT_ID/WK_CLIENT_SECRET, and fails
otherwise. The docs now say that, and that two clients resolving to the
built-in client ID are one OAuth client to Google.

Co-Authored-By: Claude Opus 5.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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants