Skip to content

Commit b82fa49

Browse files
wesmclaude
andauthored
Support multiple OAuth apps for Google Workspace orgs (#215)
## 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 ```toml [oauth] client_secrets = "/path/to/default_secret.json" # optional default [oauth.apps.acme] client_secrets = "/path/to/acme_workspace_secret.json" ``` ```bash 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](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d2d538f commit b82fa49

File tree

22 files changed

+1458
-118
lines changed

22 files changed

+1458
-118
lines changed

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ When a task involves multiple steps (e.g., implement + commit + PR), complete AL
66

77
Always commit after every turn. Don't wait for the user to ask — if you made changes, commit them before responding. Do not ask "shall I commit?" or "want me to commit?" — just commit. Committing is not a destructive or risky action; it is the expected default after every change.
88

9+
PR descriptions should be concise and changelog-oriented: what changed, why, and how to use it. Do not include test plans, design decisions, or implementation details — those belong in specs and commit messages.
10+
911
## Project Overview
1012

1113
msgvault is an offline Gmail archive tool that exports and stores email data locally with full-text search capabilities. The goal is to archive 20+ years of Gmail data from multiple accounts, make it searchable, and eventually delete emails from Gmail once safely archived.
@@ -46,6 +48,7 @@ make lint # Run linter
4648
./msgvault init-db # Initialize database
4749
./msgvault add-account you@gmail.com # Browser OAuth
4850
./msgvault add-account you@gmail.com --headless # Device flow
51+
./msgvault add-account you@acme.com --oauth-app acme # Named OAuth app
4952
./msgvault sync-full you@gmail.com --limit 100 # Sync with limit
5053
./msgvault sync-full you@gmail.com --after 2024-01-01 # Sync date range
5154
./msgvault sync-incremental you@gmail.com # Incremental sync
@@ -256,6 +259,10 @@ Override with `MSGVAULT_HOME` environment variable.
256259
[oauth]
257260
client_secrets = "/path/to/client_secret.json"
258261

262+
# Named OAuth apps for Google Workspace orgs
263+
# [oauth.apps.acme]
264+
# client_secrets = "/path/to/acme_secret.json"
265+
259266
[sync]
260267
rate_limit_qps = 5
261268
```

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,32 @@ rate_limit_qps = 5
128128

129129
See the [Configuration Guide](https://msgvault.io/configuration/) for all options.
130130

131+
### Multiple OAuth Apps (Google Workspace)
132+
133+
Some Google Workspace organizations require OAuth apps within their org.
134+
To use multiple OAuth apps, add named apps to `config.toml`:
135+
136+
```toml
137+
[oauth]
138+
client_secrets = "/path/to/default_secret.json" # for personal Gmail
139+
140+
[oauth.apps.acme]
141+
client_secrets = "/path/to/acme_workspace_secret.json"
142+
```
143+
144+
Then specify the app when adding accounts:
145+
146+
```bash
147+
msgvault add-account you@acme.com --oauth-app acme
148+
msgvault add-account personal@gmail.com # uses default
149+
```
150+
151+
To switch an existing account to a different OAuth app:
152+
153+
```bash
154+
msgvault add-account you@acme.com --oauth-app acme # re-authorizes
155+
```
156+
131157
## MCP Server
132158

133159
msgvault includes an MCP server that lets AI assistants search, analyze, and read your archived messages. Connect it to Claude Desktop or any MCP-capable agent and query your full message history conversationally. See the [MCP documentation](https://msgvault.io/usage/chat/) for setup instructions.

cmd/msgvault/cmd/addaccount.go

Lines changed: 106 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"database/sql"
45
"errors"
56
"fmt"
67

@@ -13,6 +14,7 @@ var (
1314
headless bool
1415
accountDisplayName string
1516
forceReauth bool
17+
oauthAppName string
1618
)
1719

1820
var addAccountCmd = &cobra.Command{
@@ -24,33 +26,48 @@ By default, opens a browser for authorization. Use --headless to see instruction
2426
for authorizing on headless servers (Google does not support Gmail in device flow).
2527
2628
If a token already exists, the command skips authorization. Use --force to delete
27-
the existing token and start a fresh OAuth flow (most token issues are handled
28-
automatically during sync and verify).
29+
the existing token and start a fresh OAuth flow.
30+
31+
For Google Workspace orgs that require their own OAuth app, use --oauth-app to
32+
specify a named app from config.toml.
2933
3034
Examples:
3135
msgvault add-account you@gmail.com
3236
msgvault add-account you@gmail.com --headless
3337
msgvault add-account you@gmail.com --force
38+
msgvault add-account you@acme.com --oauth-app acme
3439
msgvault add-account you@gmail.com --display-name "Work Account"`,
3540
Args: cobra.ExactArgs(1),
3641
RunE: func(cmd *cobra.Command, args []string) error {
3742
email := args[0]
3843

39-
// Reject incompatible flag combination
4044
if headless && forceReauth {
4145
return fmt.Errorf("--headless and --force cannot be used together: --force requires browser-based OAuth which is not available in headless mode")
4246
}
4347

44-
// For --headless, just show instructions (no OAuth config needed)
48+
// For --headless, try to inherit the stored binding so the
49+
// printed instructions include --oauth-app. DB errors are
50+
// non-fatal since headless only prints instructions.
4551
if headless {
46-
oauth.PrintHeadlessInstructions(email, cfg.TokensDir())
52+
app := oauthAppName
53+
if !cmd.Flags().Changed("oauth-app") {
54+
if s, openErr := store.Open(cfg.DatabaseDSN()); openErr == nil {
55+
defer func() { _ = s.Close() }()
56+
if initErr := s.InitSchema(); initErr == nil {
57+
if src, _ := findGmailSource(s, email); src != nil {
58+
app = sourceOAuthApp(src)
59+
}
60+
}
61+
}
62+
}
63+
oauth.PrintHeadlessInstructions(email, cfg.TokensDir(), app)
4764
return nil
4865
}
4966

50-
// Validate config
51-
if cfg.OAuth.ClientSecrets == "" {
52-
return errOAuthNotConfigured()
53-
}
67+
// Resolve which client secrets to use
68+
resolvedApp := oauthAppName
69+
oauthAppExplicit := cmd.Flags().Changed("oauth-app")
70+
var clientSecretsPath string
5471

5572
// Initialize database (in case it's new)
5673
dbPath := cfg.DatabaseDSN()
@@ -64,8 +81,43 @@ Examples:
6481
return fmt.Errorf("init schema: %w", err)
6582
}
6683

84+
// Look up existing source to detect binding changes
85+
existingSource, err := findGmailSource(s, email)
86+
if err != nil {
87+
return fmt.Errorf("look up existing source: %w", err)
88+
}
89+
90+
// Inherit stored binding when --oauth-app is not specified.
91+
// This ensures re-running add-account on a named-app account
92+
// (e.g., after token loss) uses the correct credentials.
93+
if !oauthAppExplicit && existingSource != nil && existingSource.OAuthApp.Valid {
94+
resolvedApp = existingSource.OAuthApp.String
95+
}
96+
97+
// Detect binding change: --oauth-app was explicitly set and
98+
// differs from the stored value (including clearing to default).
99+
bindingChanged := false
100+
if oauthAppExplicit && existingSource != nil {
101+
currentApp := ""
102+
if existingSource.OAuthApp.Valid {
103+
currentApp = existingSource.OAuthApp.String
104+
}
105+
if currentApp != oauthAppName {
106+
bindingChanged = true
107+
}
108+
}
109+
110+
// Resolve client secrets path
111+
clientSecretsPath, err = cfg.OAuth.ClientSecretsFor(resolvedApp)
112+
if err != nil {
113+
if !cfg.OAuth.HasAnyConfig() {
114+
return errOAuthNotConfigured()
115+
}
116+
return err
117+
}
118+
67119
// Create OAuth manager
68-
oauthMgr, err := oauth.NewManager(cfg.OAuth.ClientSecrets, cfg.TokensDir(), logger)
120+
oauthMgr, err := oauth.NewManager(clientSecretsPath, cfg.TokensDir(), logger)
69121
if err != nil {
70122
return wrapOAuthError(fmt.Errorf("create oauth manager: %w", err))
71123
}
@@ -82,45 +134,58 @@ Examples:
82134
}
83135
}
84136

85-
// Check if already authorized (e.g., token copied from another machine)
86-
if oauthMgr.HasToken(email) {
87-
// Still create the source record - needed for headless setup
88-
// where token was copied but account not yet registered
137+
// If a valid token exists, check if we can reuse it.
138+
// Validate the token's client identity when any named app is
139+
// involved — whether from an explicit flag, a binding change,
140+
// or inherited from the DB. A mismatched token would fail on
141+
// next refresh.
142+
needsClientCheck := bindingChanged || oauthAppExplicit ||
143+
resolvedApp != ""
144+
tokenReusable := !forceReauth && oauthMgr.HasToken(email) &&
145+
(!needsClientCheck || oauthMgr.TokenMatchesClient(email))
146+
if tokenReusable {
89147
source, err := s.GetOrCreateSource("gmail", email)
90148
if err != nil {
91149
return fmt.Errorf("create source: %w", err)
92150
}
151+
// Update oauth_app binding if it changed or was newly specified
152+
if bindingChanged || (resolvedApp != "" && !source.OAuthApp.Valid) {
153+
newApp := sql.NullString{String: resolvedApp, Valid: resolvedApp != ""}
154+
if err := s.UpdateSourceOAuthApp(source.ID, newApp); err != nil {
155+
return fmt.Errorf("update oauth app binding: %w", err)
156+
}
157+
}
93158
if accountDisplayName != "" {
94159
if err := s.UpdateSourceDisplayName(source.ID, accountDisplayName); err != nil {
95160
return fmt.Errorf("set display name: %w", err)
96161
}
97162
}
98-
fmt.Printf("Account %s is already authorized.\n", email)
163+
if bindingChanged {
164+
fmt.Printf("Account %s: OAuth app binding updated to %q.\n", email, resolvedApp)
165+
} else {
166+
fmt.Printf("Account %s is already authorized.\n", email)
167+
}
99168
fmt.Println("Next step: msgvault sync-full", email)
100169
return nil
101170
}
102171

103172
// Perform authorization
104-
fmt.Println("Starting browser authorization...")
173+
if bindingChanged {
174+
fmt.Printf("Switching OAuth app for %s to %q. Authorizing...\n", email, oauthAppName)
175+
} else {
176+
fmt.Println("Starting browser authorization...")
177+
}
105178

106179
if err := oauthMgr.Authorize(cmd.Context(), email); err != nil {
107180
var mismatch *oauth.TokenMismatchError
108181
if errors.As(err, &mismatch) {
109-
// Only suggest add-account with the primary
110-
// address when no Gmail source exists yet. If one
111-
// already exists (--force re-auth, or token was
112-
// manually deleted), the suggestion would create
113-
// a duplicate and orphan the existing source.
114182
existing, lookupErr := findGmailSource(s, email)
115183
if lookupErr != nil {
116-
return fmt.Errorf(
117-
"authorization failed: %w (also: %v)",
118-
err, lookupErr)
184+
return fmt.Errorf("authorization failed: %w (also: %v)", err, lookupErr)
119185
}
120186
if existing == nil {
121187
return fmt.Errorf(
122-
"%w\nIf %s is the primary address, "+
123-
"re-add with:\n"+
188+
"%w\nIf %s is the primary address, re-add with:\n"+
124189
" msgvault add-account %s",
125190
err, mismatch.Actual, mismatch.Actual,
126191
)
@@ -129,13 +194,25 @@ Examples:
129194
return fmt.Errorf("authorization failed: %w", err)
130195
}
131196

132-
// Create source record in database
197+
// Authorization succeeded — now persist the binding and source.
133198
source, err := s.GetOrCreateSource("gmail", email)
134199
if err != nil {
135200
return fmt.Errorf("create source: %w", err)
136201
}
137202

138-
// Set display name if provided
203+
// Update oauth_app binding (set or clear)
204+
if resolvedApp != "" {
205+
newApp := sql.NullString{String: resolvedApp, Valid: true}
206+
if err := s.UpdateSourceOAuthApp(source.ID, newApp); err != nil {
207+
return fmt.Errorf("update oauth app binding: %w", err)
208+
}
209+
} else if bindingChanged {
210+
// Clearing the binding (switching back to default)
211+
if err := s.UpdateSourceOAuthApp(source.ID, sql.NullString{}); err != nil {
212+
return fmt.Errorf("clear oauth app binding: %w", err)
213+
}
214+
}
215+
139216
if accountDisplayName != "" {
140217
if err := s.UpdateSourceDisplayName(source.ID, accountDisplayName); err != nil {
141218
return fmt.Errorf("set display name: %w", err)
@@ -168,5 +245,6 @@ func init() {
168245
addAccountCmd.Flags().BoolVar(&headless, "headless", false, "Show instructions for headless server setup")
169246
addAccountCmd.Flags().BoolVar(&forceReauth, "force", false, "Delete existing token and re-authorize")
170247
addAccountCmd.Flags().StringVar(&accountDisplayName, "display-name", "", "Display name for the account (e.g., \"Work\", \"Personal\")")
248+
addAccountCmd.Flags().StringVar(&oauthAppName, "oauth-app", "", "Named OAuth app from config (for Google Workspace orgs)")
171249
rootCmd.AddCommand(addAccountCmd)
172250
}

0 commit comments

Comments
 (0)