Skip to content

Support multiple OAuth apps for Google Workspace orgs#215

Merged
wesm merged 31 commits intomainfrom
multiple-oauth-apps
Mar 25, 2026
Merged

Support multiple OAuth apps for Google Workspace orgs#215
wesm merged 31 commits intomainfrom
multiple-oauth-apps

Conversation

@wesm
Copy link
Copy Markdown
Owner

@wesm wesm commented Mar 23, 2026

Summary

  • Add --oauth-app flag to add-account for binding accounts to named OAuth apps
  • Support [oauth.apps.<name>] config sections alongside the existing default [oauth].client_secrets
  • Per-account OAuth credential resolution across all commands (sync, serve, verify, deletions)
  • Schema migration adds nullable oauth_app column to sources table

Closes #201

Motivation

Some Google Workspace organizations require OAuth apps to live within their org. A personal OAuth app cannot authorize accounts in those orgs. This adds support for multiple named OAuth client secrets so users with accounts across different Workspace orgs can archive all of them.

Usage

[oauth]
client_secrets = "/path/to/default_secret.json"   # optional default

[oauth.apps.acme]
client_secrets = "/path/to/acme_workspace_secret.json"
msgvault add-account you@acme.com --oauth-app acme
msgvault add-account personal@gmail.com              # uses default
msgvault add-account you@acme.com --oauth-app acme   # re-authorizes if binding changed

🤖 Generated with Claude Code

wesm and others added 16 commits March 22, 2026 16:27
Specifies backward-compatible config format, schema migration,
CLI changes (add-account --oauth-app), and sync-time resolution
for supporting multiple Google Workspace OAuth client secrets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add path normalization note for Apps map entries, clarify serve
command scheduler integration, and note deletions scope handling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Token/binding mismatch: re-auth on oauth_app change instead of
   trying to detect client identity from token files
2. Serve fallback: fall back to default app when no source row exists
3. Default app non-mandatory: defer OAuth checks to per-account
   resolution, add HasAnyConfig helper

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Headless: PrintHeadlessInstructions must include --oauth-app
   in both browser-side and server-side commands
2. Sync bootstrap: unknown-email path falls back to default app;
   named-app-only configs require add-account first

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10 tasks covering config, schema, CLI, sync commands, serve,
verify, deletions, and documentation. Addresses reviewer feedback
on IMAP nil-manager call site and deletions variable scoping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Expand OAuthConfig with a named-app map (map[string]OAuthApp) and two
helper methods:
- ClientSecretsFor(name): returns default secrets path when name is
  empty, or the named app's path; errors with actionable messages.
- HasAnyConfig(): true when any secrets path is configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extend Load() to apply expandPath and resolveRelative to each named app's
client_secrets path, matching the existing treatment of the top-level
client_secrets. Adds tests covering tilde expansion, relative path resolution
against the config dir, and named-apps-only configs with no default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add nullable oauth_app TEXT column to sources, wired through the Source
struct, scanSource, all five SELECT queries, and a new UpdateSourceOAuthApp
method. NULL means "use default OAuth app"; a non-null value maps to a
named app in [oauth.apps.<name>] config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Supports named OAuth apps for Google Workspace orgs. Detects
binding changes and re-authorizes when switching apps.
Headless instructions include --oauth-app when specified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace single-manager pattern with lazy cache keyed by app name.
Sync commands now resolve the correct OAuth credentials per account.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Look up source's oauth_app binding for each scheduled sync.
Fall back to default app when no source row exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both commands now look up the source's oauth_app binding before
creating an OAuth manager. Error messages mention named apps
when configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. oauthManagerCache: add sync.Mutex for concurrent serve syncs
2. add-account rebind: authorize first, persist binding only on
   success (prevents broken state on cancelled auth)
3. Inherit stored oauth_app binding on re-add without --oauth-app
   (not just --force); support clearing binding back to default
   via explicit --oauth-app "" or cmd.Flags().Changed detection
4. subset.go: explicit column list instead of SELECT * to avoid
   breakage when copying from pre-oauth_app databases
5. verify/deletions: capture findGmailSource errors instead of
   silently falling back to default app
6. Hint message references add-account specifically instead of
   suggesting --oauth-app on commands that don't have it

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. add-account rebind: stop deleting token before auth succeeds.
   Authorize atomically writes the new token on success; if auth
   fails or is cancelled, the old token remains intact.

2. subset.go: try copying with oauth_app column first, fall back
   to NULL for source databases created before the column existed.
   Fixes CopySubset against pre-oauth_app databases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. TestAddAccount_RebindPreservesTokenOnFailure: verifies that
   when OAuth app rebinding fails (auth cancelled), the original
   token file and DB binding remain intact.

2. TestCopySubset_LegacySourceWithoutOAuthApp: verifies CopySubset
   succeeds when the source DB predates the oauth_app column,
   with NULL in the destination.

3. browserFlow: bail early if context is already cancelled to
   avoid starting an HTTP server or opening a browser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 23, 2026

roborev: Combined Review (6f0f7a6)

Verdict: The PR successfully introduces multi-app OAuth support, but contains a medium-severity logic flaw in the add-account token binding flow.

Medium

  • Location: cmd/msgvault/cmd/addaccount.go (around the HasToken short-circuit)

  • Problem: add-account --oauth-app <name> can incorrectly skip re-authorization when a token file already exists but there is no existing sources row yet. In that case, bindingChanged stays false, HasToken(email) returns true, and the command binds the account to
    the new OAuth app without proving the token was minted by that client. The next refresh can then fail because the token still belongs to a different OAuth app.

  • Fix: When --oauth-app is explicitly provided and no existing source binding exists, do not trust HasToken alone. Force a fresh
    authorization or otherwise require an explicit binding match before short-circuiting.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

wesm and others added 2 commits March 22, 2026 20:47
Move the --headless early return below the DB lookup and binding
inheritance, so re-running add-account --headless for a named-app
account prints instructions with the correct --oauth-app flag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Headless only prints instructions, so DB errors should not block
it. The DB lookup to inherit the stored oauth_app binding is now
best-effort: if --oauth-app was provided explicitly, skip the
lookup entirely; otherwise try to read the binding but fall back
silently on any DB/schema/lookup error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 23, 2026

roborev: Combined Review (e2f3668)

The changes look solid overall, with one medium-severity issue regarding database schema initialization during the verify step.

Medium

  • Location:
    verify.go
  • Problem: verify now queries sources via findGmailSource immediately after opening the DB, but it never initializes the schema first.
    On a fresh home/token-first setup where the database exists without the sources table yet, this regresses verify into failing with no such table: sources before it even attempts OAuth verification.
  • Fix: Initialize the schema before the lookup, or treat a missing sources table
    the same as “no source row” and fall back to the default app resolution path.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

Fixes: --headless --oauth-app "" would silently re-inherit the
stored binding instead of clearing to default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 23, 2026

roborev: Combined Review (e9dab9d)

The proposed changes introduce multi-app OAuth support but contain a medium severity issue with implicit token binding during account setup.

Medium

  • Location: cmd/msgvault/cmd/addaccount.go around the oauthMgr.HasToken (email) short-circuit block
  • Problem: The new token-first registration path can silently bind an account to the wrong OAuth app. If a token file was copied from another machine for a named app, but there is no existing sources.oauth_app row yet and the user runs
    add-account without --oauth-app, the code resolves the default client secrets, sees the token file exists, and records the source as default-bound. Since HasToken only checks file presence, not which client minted the token, future refreshes will fail against the wrong OAuth client.

Fix: When no stored binding exists, do not treat token presence as sufficient unless the app choice is explicit or otherwise unambiguous. Require --oauth-app, or persist/validate client identity metadata before skipping re-authorization.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

wesm and others added 2 commits March 23, 2026 08:04
Verifies that --headless --oauth-app "" does not re-inherit the
stored binding and prints instructions without --oauth-app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR descriptions should be concise and changelog-oriented,
not include test plans or implementation details.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 23, 2026

roborev: Combined Review (de00c48)

The code changes introduce multi-OAuth-app support but contain a couple of medium-severity issues regarding schema initialization during verification and headless account rebinding.

Medium

  • Location: cmd/msg vault/cmd/verify.go:44
    Problem: verify now calls findGmailSource() before running InitSchema(). On an existing database created before the oauth_app migration, that lookup will fail with no such column: oauth_app, so msg vault verify can break immediately after upgrade until some other command happens to run the migration first.
    Fix: Call s.InitSchema() right after opening the store, before any source lookup, the same way the other commands do.

  • Location: cmd/msgvault/cmd/add account.go:136
    Problem: Rebinding an existing account to a different OAuth app now always skips the HasToken fast path and forces Authorize(). That makes the documented headless flow unusable for app switches: after copying a freshly authorized token onto the server, msgvault add -account <email> --oauth-app <name> still tries to open a browser instead of just registering the copied token and updating sources.oauth_app.
    Fix: Preserve a non-browser registration path for rebinding when a token file is already present, or add an explicit trusted/headless
    rebind mode that updates the binding without forcing interactive OAuth on the server.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

1. verify: call InitSchema() before source lookup so the oauth_app
   column exists when querying pre-migration databases.

2. add-account: when rebinding with an existing token, update the
   binding without forcing re-authorization. This makes headless
   rebind work: copy token to server, then run add-account --oauth-app
   to update the binding without needing a browser.

3. Tests updated: RebindWithExistingToken verifies binding update
   without auth, ForceRebindPreservesBindingOnFailure verifies
   --force with failed auth leaves old binding intact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 23, 2026

roborev: Combined Review (ebc73bc)

Verdict: The PR successfully implements multi-app OAuth support but requires fixes for token reuse when switching apps and silent error swallowing during scheduled syncs.

High Severity

  • Location: cmd/msgvault/cmd/addaccount.go:145
  • Problem: The new bindingChanged flow still short-circuits on oauthMgr.HasToken(email) and updates sources.oauth_app without forcing a new OAuth flow
    . HasToken only proves that <email>.json exists; it does not prove the token was minted by the newly selected client. As a result, switching an existing account from the default app (or another named app) to a different --oauth-app can leave the old token in place and break
    refreshes later, even though the command reports success.
  • Fix: When bindingChanged is true, do not accept an existing token file as sufficient. Force reauthorization (or store and validate client identity in the token metadata before reusing the token), and add a regression test that covers switching apps locally with
    an existing token.

Medium Severity

  • Location: cmd/msgvault/cmd/serve.go:274
  • Problem: runScheduledSync now ignores all errors from findGmailSource by using if src, _ := findGmailSource(s, email ); src != nil { ... }. A real store/query failure is therefore treated the same as “source not found”, which silently falls back to the default OAuth app. For accounts bound to a named app, this can select the wrong client secrets and turn a database problem into a confusing auth failure.
  • Fix
    :
    Handle lookup errors explicitly and return/log them. Only fall back to appName == "" when the source is genuinely absent.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

When switching an account's OAuth app, the existing token may have
been minted by the old app's client and won't refresh against the
new one. Store the OAuth client_id in token file metadata and
check it on rebind:
- Token from same client (headless copy scenario): reuse it
- Token from different client or legacy token without client_id:
  force re-authorization

Also fix serve.go to propagate findGmailSource errors instead of
silently falling back to the default app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 23, 2026

roborev: Combined Review (24c43c6)

Verdict: The PR successfully implements multi-OAuth-app support, but requires a fix for a token validation edge case during new account registration.

Medium Severity

  • Location: cmd/msgvault/cmd/addaccount. go:140
    • Problem: Reusing an existing token only checks TokenMatchesClient when an existing source row is present and the binding changes. If the user runs add-account --oauth-app <name> with a copied token file but no existing sources row yet
      , any token file is accepted based on HasToken() alone, so the account can be bound to the wrong OAuth app and then fail on the next refresh/sync.
    • Fix: When --oauth-app is explicitly set and an existing token is being reused, validate TokenMatchesClient( email) even if no source row exists yet; add a regression test for the token-first registration path.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

When --oauth-app is explicitly set and no source row exists yet,
validate the existing token's client_id against the named app.
A mismatched token (from a different OAuth client) is rejected
instead of silently accepted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 23, 2026

roborev: Combined Review (2c469bf)

Verdict: Changes generally look good, but there is one medium severity issue regarding OAuth token validation that needs addressing.

Medium

  • Location: cmd /msgvault/cmd/addaccount.go (around the needsClientCheck / tokenReusable logic)
  • Problem: The new client-ID validation only runs when --oauth-app was explicitly passed or the binding is changing. If an account already has a stored named-app
    binding and the user re-runs add-account without --oauth-app, the command inherits that binding but will still accept any existing token file without checking that it was minted by the same OAuth client. A copied/restored token from the wrong app will be reported as reusable here and then fail on the
    next refresh.
  • Fix: Require client validation when reusing a token for an account whose resolved app comes from the stored binding as well, not just when the flag was explicitly set. At minimum, validate whenever resolvedApp is non-empty and an existing token is being reused.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

The API's tokenFile struct was missing the client_id field, so
tokens uploaded to a headless server had their client_id stripped.
This broke the headless registration flow for named OAuth apps
since add-account --oauth-app would reject the token as mismatched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 23, 2026

roborev: Combined Review (51c73e0)

Overall verdict: The PR successfully introduces per-account OAuth app binding with one medium-severity issue regarding token validation for the
default client.

Medium

  • Location: cmd/msgvault/cmd/addaccount.go:144
    Problem: Existing tokens are only validated against client_id when --oauth-app is set to a non-default app or when the binding changes. If the resolved
    app is the default client, add-account will still reuse any token file for that email without checking whether it was minted by the default OAuth client, which can register the account successfully and then fail on the next refresh/sync.
    Fix: Validate TokenMatchesClient(email) for any reused
    token whose resolved client is known, including the default app path, and fall back to re-authorization when the stored client_id is missing or mismatched.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 24, 2026

roborev: Combined Review (ce0b874)

Summary Verdict: The PR successfully introduces per-account OAuth app bindings, but requires a fix for a medium-severity regression where token validation
is incorrectly skipped when rebinding to the default app.

Medium

  • Location: cmd/msgvault/cmd/addaccount.go:140
    Problem: The new token client validation is skipped when the user explicitly clears the binding back to the default app with --oauth-app "" . needsClientCheck only becomes true for binding changes or explicit named apps, so a preexisting token minted by a different client can be silently accepted and rebound to the default app. The next refresh will then fail because the stored token does not belong to the default client.
    Fix: Treat any explicit -- oauth-app value, including the empty string, as requiring TokenMatchesClient(email) before reusing an existing token. Add a regression test for first-time registration and rebind-to-default with a mismatched token.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

wesm and others added 2 commits March 24, 2026 08:58
Including --oauth-app "" (clear to default). Previously the empty
string skipped the client check on first-time registration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Covers both paths: mismatched token rejected, matching token
accepted when explicitly clearing to default app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 24, 2026

roborev: Combined Review (fe560dd)

The PR successfully implements per-account OAuth app bindings, but contains one medium-severity issue regarding token validation during account updates.

Medium

  • Location: cmd/msgvault/cmd/addaccount.go:138
  • Problem: add-account only
    calls TokenMatchesClient when --oauth-app was explicitly passed or the binding changed. If the command omits --oauth-app and inherits an existing named-app binding from the source row, any token file is treated as reusable without checking that it was minted by that client. In the headless/
    token-copy workflow, this can silently accept a token from the wrong OAuth app and report the account as authorized even though refresh will fail later.
  • Fix: Also require client validation when resolvedApp comes from an existing stored binding (at least for non-default bindings), and add a regression test for
    rerunning add-account <email> without --oauth-app on a named-app account with a mismatched token.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

Prevents hanging on browser auth if regression makes the token
non-reusable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 24, 2026

roborev: Combined Review (306533d)

Verdict: The PR successfully implements per-account OAuth app bindings, but contains a medium-severity flaw in client validation during token reuse.

Medium

  • Location: cmd/msgvault /cmd/addaccount.go around needsClientCheck := bindingChanged || oauthAppExplicit
  • Problem: Reusing a token skips TokenMatchesClient whenever the OAuth app is only inherited from the existing source row. This means msgvault add-account <email> can silently accept a copied or legacy
    token minted by the wrong client for a named-app account, mark the account as authorized, and only fail later on refresh/sync.
  • Fix: Require client validation whenever resolvedApp comes from a stored non-default binding as well, not just when --oauth-app was passed explicitly or
    the binding is changing.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

Previously, re-running add-account on a named-app account without
--oauth-app skipped client validation because the binding was
inherited, not explicit. A stale token from a different client
would be silently accepted and fail on next refresh.

Now checks whenever resolvedApp is non-empty, regardless of source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 24, 2026

roborev: Combined Review (3d30ef9)

Summary: One Medium severity issue identified regarding OAuth token validation for default apps.

Medium

Location: cmd/msgvault/cmd/addaccount.go:140
Problem: Client-ID validation is skipped for accounts that remain bound to the default OAuth app when
the user reruns add-account without --oauth-app. If the default [oauth].client_secrets is changed or rotated, the command will still treat the old token as reusable and report the account as already authorized, even though the next refresh will fail against the new client.
Fix
:
Validate the stored token’s client_id against the currently configured client for default-app accounts too. If backward compatibility with legacy tokens is needed, make the check conditional on the token having client_id metadata instead of skipping validation entirely for the default app.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

Table-driven test covers both paths: matching token reused,
mismatched token rejected when binding is inherited from DB
without explicit --oauth-app flag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@wesm
Copy link
Copy Markdown
Owner Author

wesm commented Mar 24, 2026

Tested this locally with a Google Workspace account and it's working well. I'll merge this soon and get a 0.11.0 release out

@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 24, 2026

roborev: Combined Review (8b848e5)

Summary Verdict: One Medium severity issue
found regarding the generation of headless instructions for OAuth app bindings.

Medium

  • Location: cmd/msgvault/cmd/addaccount.go#L42, internal/oauth/oauth.go#L117
  • Problem: The headless instruction path loses the distinction between "no --oauth-app flag" and an explicit
    --oauth-app "" meant to clear an existing binding back to the default app. PrintHeadlessInstructions only receives the resolved app name, so it omits the flag entirely for the empty-string case. If the user follows those printed commands on a machine that already has the account bound to a named app,
    add-account will re-inherit the stored binding and keep using the named app instead of clearing it.
  • Fix: Preserve whether --oauth-app was explicitly provided and pass that through to PrintHeadlessInstructions, so the generated command can emit an explicit empty value (or a dedicated clear
    flag) when the user is intentionally switching back to the default app.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@wesm
Copy link
Copy Markdown
Owner Author

wesm commented Mar 25, 2026

ok to merge as is

@wesm wesm merged commit b82fa49 into main Mar 25, 2026
4 checks passed
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.

Support multiple tokens in config.toml for multiple account syncs

1 participant