From a9651d6bc5e484f3f1878e1f53b20b73037b5090 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 07:04:10 -0600 Subject: [PATCH 01/14] Fix headless OAuth: proper error handling and scope fix Fixes #31. The device code flow had multiple issues: 1. No HTTP status code checking - errors were silently ignored 2. No error field checking in device response - if Google returned an error, we'd get empty URL/code and ExpiresIn=0, causing immediate "authorization timed out" 3. Hardcoded Scopes instead of m.config.Scopes - device flow ignored the manager's configured scopes Now provides clear error messages, including specific guidance when the OAuth client isn't configured for device flow (requires "TVs and Limited Input devices" type in Google Cloud Console). Co-Authored-By: Claude Opus 4.5 --- internal/oauth/oauth.go | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index ee72ab69..2c29bf41 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -176,24 +176,54 @@ func (m *Manager) deviceFlow(ctx context.Context) (*oauth2.Token, error) { // Request device code resp, err := http.PostForm(deviceEndpoint, map[string][]string{ "client_id": {m.config.ClientID}, - "scope": {scopesToString(Scopes)}, + "scope": {scopesToString(m.config.Scopes)}, }) if err != nil { return nil, fmt.Errorf("request device code: %w", err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + // Try to read error details from body + var errResp struct { + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + } + if json.NewDecoder(resp.Body).Decode(&errResp) == nil && errResp.Error != "" { + if errResp.Error == "invalid_client" { + return nil, fmt.Errorf("device flow not supported: %s\n\nTo use --headless, your OAuth client must be configured as 'TVs and Limited Input devices' type in Google Cloud Console.\nAlternatively, use the browser flow without --headless from a machine with a browser", errResp.ErrorDesc) + } + return nil, fmt.Errorf("device code request failed (HTTP %d): %s - %s", resp.StatusCode, errResp.Error, errResp.ErrorDesc) + } + return nil, fmt.Errorf("device code request failed with HTTP %d", resp.StatusCode) + } + var deviceResp struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` VerificationURL string `json:"verification_url"` ExpiresIn int `json:"expires_in"` Interval int `json:"interval"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` } if err := json.NewDecoder(resp.Body).Decode(&deviceResp); err != nil { return nil, fmt.Errorf("parse device response: %w", err) } + // Check for error in response + if deviceResp.Error != "" { + if deviceResp.Error == "invalid_client" { + return nil, fmt.Errorf("device flow not supported: %s\n\nTo use --headless, your OAuth client must be configured as 'TVs and Limited Input devices' type in Google Cloud Console.\nAlternatively, use the browser flow without --headless from a machine with a browser", deviceResp.ErrorDesc) + } + return nil, fmt.Errorf("device code request failed: %s - %s", deviceResp.Error, deviceResp.ErrorDesc) + } + + // Validate required fields + if deviceResp.VerificationURL == "" || deviceResp.UserCode == "" { + return nil, fmt.Errorf("device code response missing required fields (verification_url or user_code)") + } + // Display instructions to user fmt.Printf("\n") fmt.Printf("To authorize msgvault, visit:\n") From ee9efcdf932472290cb898a71c99d8a8cacbfd80 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 07:09:23 -0600 Subject: [PATCH 02/14] Validate device_code and expires_in in device flow response Extend required field validation to prevent empty device_code (which would cause polling failures) and expires_in <= 0 (which would cause immediate timeout). Co-Authored-By: Claude Opus 4.5 --- internal/oauth/oauth.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 2c29bf41..2244d279 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -220,8 +220,11 @@ func (m *Manager) deviceFlow(ctx context.Context) (*oauth2.Token, error) { } // Validate required fields - if deviceResp.VerificationURL == "" || deviceResp.UserCode == "" { - return nil, fmt.Errorf("device code response missing required fields (verification_url or user_code)") + if deviceResp.VerificationURL == "" || deviceResp.UserCode == "" || deviceResp.DeviceCode == "" { + return nil, fmt.Errorf("device code response missing required fields (verification_url, user_code, or device_code)") + } + if deviceResp.ExpiresIn <= 0 { + return nil, fmt.Errorf("device code response has invalid expires_in: %d", deviceResp.ExpiresIn) } // Display instructions to user From 73f88a7ae524e98e782f0c14343a04ced2a94efb Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 07:15:51 -0600 Subject: [PATCH 03/14] Handle TV/device OAuth clients without redirect URIs Google's "TVs and Limited Input devices" OAuth clients don't include redirect_uris in their client secrets JSON, causing google.ConfigFromJSON to fail. Now we fall back to manual parsing for these clients. This allows users to use --headless with TV-type OAuth credentials without needing to also maintain Desktop app credentials. Co-Authored-By: Claude Opus 4.5 --- internal/oauth/oauth.go | 54 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 2244d279..c2a80ae9 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -442,7 +442,7 @@ func NewManagerWithScopes(clientSecretsPath, tokensDir string, logger *slog.Logg return nil, fmt.Errorf("read client secrets: %w", err) } - config, err := google.ConfigFromJSON(data, scopes...) + config, err := parseClientSecrets(data, scopes) if err != nil { return nil, fmt.Errorf("parse client secrets: %w", err) } @@ -458,6 +458,58 @@ func NewManagerWithScopes(clientSecretsPath, tokensDir string, logger *slog.Logg }, nil } +// parseClientSecrets parses Google OAuth client secrets JSON. +// Handles both "Desktop application" (with redirect_uris) and +// "TVs and Limited Input devices" (without redirect_uris) client types. +func parseClientSecrets(data []byte, scopes []string) (*oauth2.Config, error) { + // Try standard parsing first (works for Desktop apps with redirect URIs) + config, err := google.ConfigFromJSON(data, scopes...) + if err == nil { + return config, nil + } + + // If that fails, try parsing manually for TV/device clients without redirect URIs + var secrets struct { + Installed *struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthURI string `json:"auth_uri"` + TokenURI string `json:"token_uri"` + } `json:"installed"` + Web *struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthURI string `json:"auth_uri"` + TokenURI string `json:"token_uri"` + } `json:"web"` + } + + if jsonErr := json.Unmarshal(data, &secrets); jsonErr != nil { + return nil, fmt.Errorf("invalid client secrets JSON: %w", jsonErr) + } + + // Check installed (Desktop/TV) clients first, then web + creds := secrets.Installed + if creds == nil { + creds = secrets.Web + } + if creds == nil || creds.ClientID == "" || creds.ClientSecret == "" { + return nil, fmt.Errorf("client secrets missing client_id or client_secret") + } + + // Build config manually - use empty redirect URI for device flow clients + return &oauth2.Config{ + ClientID: creds.ClientID, + ClientSecret: creds.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: creds.AuthURI, + TokenURL: creds.TokenURI, + }, + RedirectURL: "urn:ietf:wg:oauth:2.0:oob", // Out-of-band redirect for device flow + Scopes: scopes, + }, nil +} + // DeleteToken removes the token file for the given email. func (m *Manager) DeleteToken(email string) error { path := m.tokenPath(email) From 45e0f6944f728174988e2c1126b5b0f2926466a6 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 07:18:46 -0600 Subject: [PATCH 04/14] Validate auth_uri/token_uri and remove deprecated OOB redirect - Validate that auth_uri and token_uri are present in fallback parsing - Remove deprecated urn:ietf:wg:oauth:2.0:oob redirect URL (unused for device flow anyway) Co-Authored-By: Claude Opus 4.5 --- internal/oauth/oauth.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index c2a80ae9..b268a879 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -496,8 +496,11 @@ func parseClientSecrets(data []byte, scopes []string) (*oauth2.Config, error) { if creds == nil || creds.ClientID == "" || creds.ClientSecret == "" { return nil, fmt.Errorf("client secrets missing client_id or client_secret") } + if creds.AuthURI == "" || creds.TokenURI == "" { + return nil, fmt.Errorf("client secrets missing auth_uri or token_uri") + } - // Build config manually - use empty redirect URI for device flow clients + // Build config manually - no redirect URI needed for device flow return &oauth2.Config{ ClientID: creds.ClientID, ClientSecret: creds.ClientSecret, @@ -505,8 +508,7 @@ func parseClientSecrets(data []byte, scopes []string) (*oauth2.Config, error) { AuthURL: creds.AuthURI, TokenURL: creds.TokenURI, }, - RedirectURL: "urn:ietf:wg:oauth:2.0:oob", // Out-of-band redirect for device flow - Scopes: scopes, + Scopes: scopes, }, nil } From f85d52343449575f0f51bc55b8595f18bf61f86c Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 07:25:11 -0600 Subject: [PATCH 05/14] Use full mail.google.com scope for device flow Google's device flow only supports the full mail.google.com scope, not granular gmail.readonly/gmail.modify scopes. This causes "invalid_scope" errors when trying to use device flow with standard scopes. - Add ScopesDeviceFlow constant with mail.google.com scope - Use ScopesDeviceFlow in device flow instead of manager's configured scopes - Update saveToken to accept scopes parameter - Preserve original scopes when refreshing tokens Co-Authored-By: Claude Opus 4.5 --- internal/oauth/oauth.go | 37 +++++++++++++++++++++++++----------- internal/oauth/oauth_test.go | 2 +- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index b268a879..1deda2c0 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -33,6 +33,13 @@ var ScopesDeletion = []string{ "https://mail.google.com/", } +// ScopesDeviceFlow is used for headless device authorization. +// Google's device flow only supports the full mail.google.com scope, +// not granular gmail.readonly/gmail.modify scopes. +var ScopesDeviceFlow = []string{ + "https://mail.google.com/", +} + // Manager handles OAuth2 token acquisition and storage. type Manager struct { config *oauth2.Config @@ -48,13 +55,13 @@ func NewManager(clientSecretsPath, tokensDir string, logger *slog.Logger) (*Mana // TokenSource returns a token source for the given email. // If a valid token exists, it will be reused and auto-refreshed. func (m *Manager) TokenSource(ctx context.Context, email string) (oauth2.TokenSource, error) { - token, err := m.loadToken(email) + tf, err := m.loadTokenFile(email) if err != nil { return nil, fmt.Errorf("no valid token for %s: %w", email, err) } // Create a token source that auto-refreshes - ts := m.config.TokenSource(ctx, token) + ts := m.config.TokenSource(ctx, &tf.Token) // Save refreshed token if it changed newToken, err := ts.Token() @@ -62,8 +69,13 @@ func (m *Manager) TokenSource(ctx context.Context, email string) (oauth2.TokenSo return nil, fmt.Errorf("refresh token: %w", err) } - if newToken.AccessToken != token.AccessToken { - if err := m.saveToken(email, newToken); err != nil { + if newToken.AccessToken != tf.Token.AccessToken { + // Preserve the original scopes when saving refreshed token + scopes := tf.Scopes + if len(scopes) == 0 { + scopes = m.config.Scopes // fallback for legacy tokens + } + if err := m.saveToken(email, newToken, scopes); err != nil { m.logger.Warn("failed to save refreshed token", "email", email, "error", err) } } @@ -81,19 +93,22 @@ func (m *Manager) HasToken(email string) bool { // If headless is true, uses device code flow; otherwise opens browser. func (m *Manager) Authorize(ctx context.Context, email string, headless bool) error { var token *oauth2.Token + var scopes []string var err error if headless { token, err = m.deviceFlow(ctx) + scopes = ScopesDeviceFlow // Device flow requires full mail.google.com scope } else { token, err = m.browserFlow(ctx) + scopes = m.config.Scopes } if err != nil { return err } - return m.saveToken(email, token) + return m.saveToken(email, token, scopes) } const ( @@ -173,10 +188,11 @@ func (m *Manager) deviceFlow(ctx context.Context) (*oauth2.Token, error) { // Device flow endpoint deviceEndpoint := "https://oauth2.googleapis.com/device/code" - // Request device code + // Request device code - device flow requires full mail.google.com scope, + // not granular gmail.readonly/gmail.modify scopes resp, err := http.PostForm(deviceEndpoint, map[string][]string{ "client_id": {m.config.ClientID}, - "scope": {scopesToString(m.config.Scopes)}, + "scope": {scopesToString(ScopesDeviceFlow)}, }) if err != nil { return nil, fmt.Errorf("request device code: %w", err) @@ -369,16 +385,15 @@ func (m *Manager) HasScope(email string, scope string) bool { return false } -// saveToken saves a token for the given email, including the scopes from -// the manager's config. -func (m *Manager) saveToken(email string, token *oauth2.Token) error { +// saveToken saves a token for the given email with the specified scopes. +func (m *Manager) saveToken(email string, token *oauth2.Token, scopes []string) error { if err := os.MkdirAll(m.tokensDir, 0700); err != nil { return err } tf := tokenFile{ Token: *token, - Scopes: m.config.Scopes, + Scopes: scopes, } data, err := json.MarshalIndent(tf, "", " ") diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index d996fb60..ef27e5be 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -139,7 +139,7 @@ func TestTokenFileScopesRoundTrip(t *testing.T) { TokenType: "Bearer", } - if err := mgr.saveToken("test@gmail.com", token); err != nil { + if err := mgr.saveToken("test@gmail.com", token, ScopesDeletion); err != nil { t.Fatal(err) } From ffd9bfba09a3e9051ef185312b7682516111c27d Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 07:54:48 -0600 Subject: [PATCH 06/14] Remove device flow code: Gmail scopes not supported Google's OAuth device code flow does not support Gmail scopes - only OpenID Connect, Drive, and YouTube are allowed. Rather than keeping broken code, `--headless` now prints instructions for the workaround: authorize on a machine with a browser and copy the token file. Changes: - Remove deviceFlow() and pollForToken() from oauth.go - Remove ScopesDeviceFlow constant - Add PrintHeadlessInstructions() to display setup steps - Simplify Authorize() to browser flow only (remove headless param) - Update addaccount.go to call instructions function for --headless The token file is portable and auto-refreshes, so the copy-token approach works reliably for headless servers. Fixes #31 Co-Authored-By: Claude Opus 4.5 --- cmd/msgvault/cmd/addaccount.go | 19 ++-- cmd/msgvault/cmd/deletions.go | 2 +- internal/oauth/oauth.go | 186 +++++---------------------------- 3 files changed, 38 insertions(+), 169 deletions(-) diff --git a/cmd/msgvault/cmd/addaccount.go b/cmd/msgvault/cmd/addaccount.go index e9b571ac..51c529c7 100644 --- a/cmd/msgvault/cmd/addaccount.go +++ b/cmd/msgvault/cmd/addaccount.go @@ -17,8 +17,8 @@ var addAccountCmd = &cobra.Command{ Short: "Add a Gmail account via OAuth", Long: `Add a Gmail account by completing the OAuth2 authorization flow. -By default, opens a browser for authorization. Use --headless for environments -without a display (e.g., SSH sessions) to use device code flow instead. +By default, opens a browser for authorization. Use --headless to see instructions +for authorizing on headless servers (Google does not support Gmail in device flow). Example: msgvault add-account you@gmail.com @@ -27,6 +27,12 @@ Example: RunE: func(cmd *cobra.Command, args []string) error { email := args[0] + // For --headless, just show instructions (no OAuth config needed) + if headless { + oauth.PrintHeadlessInstructions(email) + return nil + } + // Validate config if cfg.OAuth.ClientSecrets == "" { return errOAuthNotConfigured() @@ -58,14 +64,9 @@ Example: } // Perform authorization - ctx := cmd.Context() - if headless { - fmt.Println("Starting device code flow...") - } else { - fmt.Println("Starting browser authorization...") - } + fmt.Println("Starting browser authorization...") - if err := oauthMgr.Authorize(ctx, email, headless); err != nil { + if err := oauthMgr.Authorize(cmd.Context(), email); err != nil { return fmt.Errorf("authorization failed: %w", err) } diff --git a/cmd/msgvault/cmd/deletions.go b/cmd/msgvault/cmd/deletions.go index e768003d..2f49848e 100644 --- a/cmd/msgvault/cmd/deletions.go +++ b/cmd/msgvault/cmd/deletions.go @@ -650,7 +650,7 @@ func promptScopeEscalation(ctx context.Context, oauthMgr *oauth.Manager, account return fmt.Errorf("create oauth manager: %w", err) } - if err := newMgr.Authorize(ctx, account, false); err != nil { + if err := newMgr.Authorize(ctx, account); err != nil { return fmt.Errorf("authorize: %w", err) } diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 1deda2c0..88a716fc 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -15,7 +15,6 @@ import ( "path/filepath" "runtime" "strings" - "time" "golang.org/x/oauth2" "golang.org/x/oauth2/google" @@ -33,13 +32,6 @@ var ScopesDeletion = []string{ "https://mail.google.com/", } -// ScopesDeviceFlow is used for headless device authorization. -// Google's device flow only supports the full mail.google.com scope, -// not granular gmail.readonly/gmail.modify scopes. -var ScopesDeviceFlow = []string{ - "https://mail.google.com/", -} - // Manager handles OAuth2 token acquisition and storage. type Manager struct { config *oauth2.Config @@ -89,26 +81,38 @@ func (m *Manager) HasToken(email string) bool { return err == nil } -// Authorize performs the OAuth flow for a new account. -// If headless is true, uses device code flow; otherwise opens browser. -func (m *Manager) Authorize(ctx context.Context, email string, headless bool) error { - var token *oauth2.Token - var scopes []string - var err error - - if headless { - token, err = m.deviceFlow(ctx) - scopes = ScopesDeviceFlow // Device flow requires full mail.google.com scope - } else { - token, err = m.browserFlow(ctx) - scopes = m.config.Scopes - } +// PrintHeadlessInstructions prints setup instructions for headless servers. +// Google's device flow does not support Gmail scopes, so users must authorize +// on a machine with a browser and copy the token file. +func PrintHeadlessInstructions(email string) { + fmt.Println() + fmt.Println("=== Headless Server Setup ===") + fmt.Println() + fmt.Println("Google's OAuth device flow does not support Gmail scopes, so --headless") + fmt.Println("cannot directly authorize. Instead, authorize on a machine with a browser") + fmt.Println("and copy the token to your server.") + fmt.Println() + fmt.Println("Step 1: On a machine with a browser, run:") + fmt.Println() + fmt.Printf(" msgvault add-account %s\n", email) + fmt.Println() + fmt.Println("Step 2: Copy the token file to your headless server:") + fmt.Println() + fmt.Printf(" scp ~/.msgvault/tokens/%s.json user@server:~/.msgvault/tokens/\n", email) + fmt.Println() + fmt.Println("The token will work on the server and auto-refresh as needed.") + fmt.Println("All msgvault commands (sync, tui, etc.) will work normally.") + fmt.Println() +} +// Authorize performs the browser OAuth flow for a new account. +func (m *Manager) Authorize(ctx context.Context, email string) error { + token, err := m.browserFlow(ctx) if err != nil { return err } - return m.saveToken(email, token, scopes) + return m.saveToken(email, token, m.config.Scopes) } const ( @@ -183,142 +187,6 @@ func (m *Manager) browserFlow(ctx context.Context) (*oauth2.Token, error) { } } -// deviceFlow uses the device authorization grant for headless environments. -func (m *Manager) deviceFlow(ctx context.Context) (*oauth2.Token, error) { - // Device flow endpoint - deviceEndpoint := "https://oauth2.googleapis.com/device/code" - - // Request device code - device flow requires full mail.google.com scope, - // not granular gmail.readonly/gmail.modify scopes - resp, err := http.PostForm(deviceEndpoint, map[string][]string{ - "client_id": {m.config.ClientID}, - "scope": {scopesToString(ScopesDeviceFlow)}, - }) - if err != nil { - return nil, fmt.Errorf("request device code: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // Try to read error details from body - var errResp struct { - Error string `json:"error"` - ErrorDesc string `json:"error_description"` - } - if json.NewDecoder(resp.Body).Decode(&errResp) == nil && errResp.Error != "" { - if errResp.Error == "invalid_client" { - return nil, fmt.Errorf("device flow not supported: %s\n\nTo use --headless, your OAuth client must be configured as 'TVs and Limited Input devices' type in Google Cloud Console.\nAlternatively, use the browser flow without --headless from a machine with a browser", errResp.ErrorDesc) - } - return nil, fmt.Errorf("device code request failed (HTTP %d): %s - %s", resp.StatusCode, errResp.Error, errResp.ErrorDesc) - } - return nil, fmt.Errorf("device code request failed with HTTP %d", resp.StatusCode) - } - - var deviceResp struct { - DeviceCode string `json:"device_code"` - UserCode string `json:"user_code"` - VerificationURL string `json:"verification_url"` - ExpiresIn int `json:"expires_in"` - Interval int `json:"interval"` - Error string `json:"error"` - ErrorDesc string `json:"error_description"` - } - if err := json.NewDecoder(resp.Body).Decode(&deviceResp); err != nil { - return nil, fmt.Errorf("parse device response: %w", err) - } - - // Check for error in response - if deviceResp.Error != "" { - if deviceResp.Error == "invalid_client" { - return nil, fmt.Errorf("device flow not supported: %s\n\nTo use --headless, your OAuth client must be configured as 'TVs and Limited Input devices' type in Google Cloud Console.\nAlternatively, use the browser flow without --headless from a machine with a browser", deviceResp.ErrorDesc) - } - return nil, fmt.Errorf("device code request failed: %s - %s", deviceResp.Error, deviceResp.ErrorDesc) - } - - // Validate required fields - if deviceResp.VerificationURL == "" || deviceResp.UserCode == "" || deviceResp.DeviceCode == "" { - return nil, fmt.Errorf("device code response missing required fields (verification_url, user_code, or device_code)") - } - if deviceResp.ExpiresIn <= 0 { - return nil, fmt.Errorf("device code response has invalid expires_in: %d", deviceResp.ExpiresIn) - } - - // Display instructions to user - fmt.Printf("\n") - fmt.Printf("To authorize msgvault, visit:\n") - fmt.Printf(" %s\n\n", deviceResp.VerificationURL) - fmt.Printf("And enter code: %s\n\n", deviceResp.UserCode) - fmt.Printf("Waiting for authorization...\n") - - // Poll for token - interval := time.Duration(deviceResp.Interval) * time.Second - if interval < 5*time.Second { - interval = 5 * time.Second - } - - deadline := time.Now().Add(time.Duration(deviceResp.ExpiresIn) * time.Second) - - for time.Now().Before(deadline) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(interval): - } - - token, err := m.pollForToken(ctx, deviceResp.DeviceCode) - if err == nil { - fmt.Printf("Authorization successful!\n") - return token, nil - } - - // Check if we should continue polling - errStr := err.Error() - if errStr == "oauth error: authorization_pending" || errStr == "oauth error: slow_down" { - continue - } - - return nil, err - } - - return nil, fmt.Errorf("authorization timed out") -} - -// pollForToken polls the token endpoint during device flow. -func (m *Manager) pollForToken(ctx context.Context, deviceCode string) (*oauth2.Token, error) { - resp, err := http.PostForm("https://oauth2.googleapis.com/token", map[string][]string{ - "client_id": {m.config.ClientID}, - "client_secret": {m.config.ClientSecret}, - "device_code": {deviceCode}, - "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, - }) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var tokenResp struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - TokenType string `json:"token_type"` - Error string `json:"error"` - } - if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { - return nil, err - } - - if tokenResp.Error != "" { - return nil, fmt.Errorf("oauth error: %s", tokenResp.Error) - } - - return &oauth2.Token{ - AccessToken: tokenResp.AccessToken, - RefreshToken: tokenResp.RefreshToken, - TokenType: tokenResp.TokenType, - Expiry: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second), - }, nil -} - // tokenFile wraps an OAuth2 token with metadata about the scopes it was // authorized with. This enables proactive scope checking (e.g., detecting // that deletion requires re-authorization) without making an API call first. From 44879032b431ffecf4d98f9c66415c6fe6689230 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 07:58:26 -0600 Subject: [PATCH 07/14] Complete headless setup: add step 3 to register account After copying the token file, users need to run `add-account` again on the headless server to register the account in the database. Changes: - Add Step 3 to headless instructions - Fix add-account to create source record when token already exists (needed for headless workflow where token was copied) - Update --headless flag help text Co-Authored-By: Claude Opus 4.5 --- cmd/msgvault/cmd/addaccount.go | 14 ++++++++++---- internal/oauth/oauth.go | 6 +++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/cmd/msgvault/cmd/addaccount.go b/cmd/msgvault/cmd/addaccount.go index 51c529c7..8012cd54 100644 --- a/cmd/msgvault/cmd/addaccount.go +++ b/cmd/msgvault/cmd/addaccount.go @@ -56,10 +56,16 @@ Example: return wrapOAuthError(fmt.Errorf("create oauth manager: %w", err)) } - // Check if already authorized + // Check if already authorized (e.g., token copied from another machine) if oauthMgr.HasToken(email) { - fmt.Printf("Account %s is already authorized.\n", email) - fmt.Println("To re-authorize, delete the token file and try again.") + // Still create the source record - needed for headless setup + // where token was copied but account not yet registered + _, err = s.GetOrCreateSource("gmail", email) + if err != nil { + return fmt.Errorf("create source: %w", err) + } + fmt.Printf("Account %s is ready.\n", email) + fmt.Println("You can now run: msgvault sync-full", email) return nil } @@ -84,6 +90,6 @@ Example: } func init() { - addAccountCmd.Flags().BoolVar(&headless, "headless", false, "Use device code flow for headless environments") + addAccountCmd.Flags().BoolVar(&headless, "headless", false, "Show instructions for headless server setup") rootCmd.AddCommand(addAccountCmd) } diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 88a716fc..c323b196 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -100,7 +100,11 @@ func PrintHeadlessInstructions(email string) { fmt.Println() fmt.Printf(" scp ~/.msgvault/tokens/%s.json user@server:~/.msgvault/tokens/\n", email) fmt.Println() - fmt.Println("The token will work on the server and auto-refresh as needed.") + fmt.Println("Step 3: On the headless server, register the account:") + fmt.Println() + fmt.Printf(" msgvault add-account %s\n", email) + fmt.Println() + fmt.Println("The token will be detected and the account registered. No browser needed.") fmt.Println("All msgvault commands (sync, tui, etc.) will work normally.") fmt.Println() } From b49db003b33025c74c8d53ee8092b4d49e950499 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 08:03:29 -0600 Subject: [PATCH 08/14] Use actual token path in headless instructions Address review feedback: headless instructions now use the configured tokens directory instead of hardcoding ~/.msgvault/tokens/. This ensures correct paths for users with MSGVAULT_HOME or custom data_dir. - Extract sanitizeEmail() function for reuse - Pass tokensDir to PrintHeadlessInstructions() - Add test for sanitizeEmail() Co-Authored-By: Claude Opus 4.5 --- cmd/msgvault/cmd/addaccount.go | 2 +- internal/oauth/oauth.go | 23 ++++++++++++++++------- internal/oauth/oauth_test.go | 22 ++++++++++++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/cmd/msgvault/cmd/addaccount.go b/cmd/msgvault/cmd/addaccount.go index 8012cd54..f76bfd4c 100644 --- a/cmd/msgvault/cmd/addaccount.go +++ b/cmd/msgvault/cmd/addaccount.go @@ -29,7 +29,7 @@ Example: // For --headless, just show instructions (no OAuth config needed) if headless { - oauth.PrintHeadlessInstructions(email) + oauth.PrintHeadlessInstructions(email, cfg.TokensDir()) return nil } diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index c323b196..c3e8f2da 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -84,7 +84,12 @@ func (m *Manager) HasToken(email string) bool { // PrintHeadlessInstructions prints setup instructions for headless servers. // Google's device flow does not support Gmail scopes, so users must authorize // on a machine with a browser and copy the token file. -func PrintHeadlessInstructions(email string) { +// tokensDir should be the configured tokens directory (e.g., cfg.TokensDir()). +func PrintHeadlessInstructions(email, tokensDir string) { + // Use same sanitization as tokenPath for consistency + tokenFile := sanitizeEmail(email) + ".json" + tokenPath := filepath.Join(tokensDir, tokenFile) + fmt.Println() fmt.Println("=== Headless Server Setup ===") fmt.Println() @@ -98,7 +103,7 @@ func PrintHeadlessInstructions(email string) { fmt.Println() fmt.Println("Step 2: Copy the token file to your headless server:") fmt.Println() - fmt.Printf(" scp ~/.msgvault/tokens/%s.json user@server:~/.msgvault/tokens/\n", email) + fmt.Printf(" scp %s user@server:%s\n", tokenPath, tokenPath) fmt.Println() fmt.Println("Step 3: On the headless server, register the account:") fmt.Println() @@ -109,6 +114,14 @@ func PrintHeadlessInstructions(email string) { fmt.Println() } +// sanitizeEmail sanitizes an email for use in a filename. +func sanitizeEmail(email string) string { + safe := strings.ReplaceAll(email, "/", "_") + safe = strings.ReplaceAll(safe, "\\", "_") + safe = strings.ReplaceAll(safe, "..", "_") + return safe +} + // Authorize performs the browser OAuth flow for a new account. func (m *Manager) Authorize(ctx context.Context, email string) error { token, err := m.browserFlow(ctx) @@ -280,11 +293,7 @@ func (m *Manager) saveToken(email string, token *oauth2.Token, scopes []string) // tokenPath returns the path to the token file for an email. // The email is sanitized to prevent path traversal attacks. func (m *Manager) tokenPath(email string) string { - // Sanitize email to prevent path traversal - // Replace characters that could be used for path traversal - safe := strings.ReplaceAll(email, "/", "_") - safe = strings.ReplaceAll(safe, "\\", "_") - safe = strings.ReplaceAll(safe, "..", "_") + safe := sanitizeEmail(email) // Ensure the final path is within tokensDir path := filepath.Join(m.tokensDir, safe+".json") diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index ef27e5be..f61619c5 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -205,6 +205,28 @@ func TestHasScopeMetadata(t *testing.T) { } } +func TestSanitizeEmail(t *testing.T) { + tests := []struct { + email string + want string + }{ + {"user@gmail.com", "user@gmail.com"}, + {"user/slash@gmail.com", "user_slash@gmail.com"}, + {"user\\backslash@gmail.com", "user_backslash@gmail.com"}, + {"user..dots@gmail.com", "user_dots@gmail.com"}, + {"../../../etc/passwd", "______etc_passwd"}, + } + + for _, tt := range tests { + t.Run(tt.email, func(t *testing.T) { + got := sanitizeEmail(tt.email) + if got != tt.want { + t.Errorf("sanitizeEmail(%q) = %q, want %q", tt.email, got, tt.want) + } + }) + } +} + func TestNewCallbackHandler(t *testing.T) { mgr := setupTestManager(t, Scopes) From 7b5becfe763384b1590b2df928077dbe549bc1f6 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 08:05:23 -0600 Subject: [PATCH 09/14] Quote paths in headless scp command Wrap paths in single quotes to handle spaces and shell-sensitive characters in token directory paths. Co-Authored-By: Claude Opus 4.5 --- internal/oauth/oauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index c3e8f2da..6e656072 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -103,7 +103,7 @@ func PrintHeadlessInstructions(email, tokensDir string) { fmt.Println() fmt.Println("Step 2: Copy the token file to your headless server:") fmt.Println() - fmt.Printf(" scp %s user@server:%s\n", tokenPath, tokenPath) + fmt.Printf(" scp '%s' user@server:'%s'\n", tokenPath, tokenPath) fmt.Println() fmt.Println("Step 3: On the headless server, register the account:") fmt.Println() From 2af330945fff026e29d8d2dd32b4cc220cc08445 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 08:06:41 -0600 Subject: [PATCH 10/14] Use proper shell escaping for scp paths Add shellQuote() that handles embedded single quotes using the standard POSIX technique: ' -> '\'' This ensures the scp command works even if the token path contains single quotes (unlikely but possible). Co-Authored-By: Claude Opus 4.5 --- internal/oauth/oauth.go | 9 ++++++++- internal/oauth/oauth_test.go | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 6e656072..25dd424f 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -103,7 +103,7 @@ func PrintHeadlessInstructions(email, tokensDir string) { fmt.Println() fmt.Println("Step 2: Copy the token file to your headless server:") fmt.Println() - fmt.Printf(" scp '%s' user@server:'%s'\n", tokenPath, tokenPath) + fmt.Printf(" scp %s user@server:%s\n", shellQuote(tokenPath), shellQuote(tokenPath)) fmt.Println() fmt.Println("Step 3: On the headless server, register the account:") fmt.Println() @@ -122,6 +122,13 @@ func sanitizeEmail(email string) string { return safe } +// shellQuote returns a shell-safe quoted string using single quotes. +// Handles embedded single quotes by ending the quoted string, adding an +// escaped single quote, and starting a new quoted string: ' -> '\” +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + // Authorize performs the browser OAuth flow for a new account. func (m *Manager) Authorize(ctx context.Context, email string) error { token, err := m.browserFlow(ctx) diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index f61619c5..f36744f9 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -205,6 +205,28 @@ func TestHasScopeMetadata(t *testing.T) { } } +func TestShellQuote(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"/path/to/file", "'/path/to/file'"}, + {"/path with spaces/file", "'/path with spaces/file'"}, + {"/path/with'quote/file", "'/path/with'\\''quote/file'"}, + {"simple", "'simple'"}, + {"", "''"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := shellQuote(tt.input) + if got != tt.want { + t.Errorf("shellQuote(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + func TestSanitizeEmail(t *testing.T) { tests := []struct { email string From 68955019aff9f7eb0fd7ad3a5c2b39a64b39a85c Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 09:08:20 -0600 Subject: [PATCH 11/14] Reject TV/device OAuth clients with clear error TV/device clients don't work with browser auth flow (no redirect_uris). Now parseClientSecrets detects this and returns a helpful error message instead of failing later at runtime. Added tests for parseClientSecrets covering valid desktop client, TV/device client rejection, and malformed JSON. Co-Authored-By: Claude Opus 4.5 --- internal/oauth/oauth.go | 62 ++++++++-------------------------- internal/oauth/oauth_test.go | 65 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 48 deletions(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 25dd424f..32b85f2e 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -362,57 +362,23 @@ func NewManagerWithScopes(clientSecretsPath, tokensDir string, logger *slog.Logg } // parseClientSecrets parses Google OAuth client secrets JSON. -// Handles both "Desktop application" (with redirect_uris) and -// "TVs and Limited Input devices" (without redirect_uris) client types. +// Requires "Desktop application" credentials with redirect_uris. +// TV/device clients are not supported (device flow doesn't work with Gmail). func parseClientSecrets(data []byte, scopes []string) (*oauth2.Config, error) { - // Try standard parsing first (works for Desktop apps with redirect URIs) config, err := google.ConfigFromJSON(data, scopes...) - if err == nil { - return config, nil - } - - // If that fails, try parsing manually for TV/device clients without redirect URIs - var secrets struct { - Installed *struct { - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - AuthURI string `json:"auth_uri"` - TokenURI string `json:"token_uri"` - } `json:"installed"` - Web *struct { - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` - AuthURI string `json:"auth_uri"` - TokenURI string `json:"token_uri"` - } `json:"web"` - } - - if jsonErr := json.Unmarshal(data, &secrets); jsonErr != nil { - return nil, fmt.Errorf("invalid client secrets JSON: %w", jsonErr) - } - - // Check installed (Desktop/TV) clients first, then web - creds := secrets.Installed - if creds == nil { - creds = secrets.Web - } - if creds == nil || creds.ClientID == "" || creds.ClientSecret == "" { - return nil, fmt.Errorf("client secrets missing client_id or client_secret") - } - if creds.AuthURI == "" || creds.TokenURI == "" { - return nil, fmt.Errorf("client secrets missing auth_uri or token_uri") + if err != nil { + // Check if it's a TV/device client (missing redirect_uris) + var secrets struct { + Installed *struct { + RedirectURIs []string `json:"redirect_uris"` + } `json:"installed"` + } + if json.Unmarshal(data, &secrets) == nil && secrets.Installed != nil && len(secrets.Installed.RedirectURIs) == 0 { + return nil, fmt.Errorf("TV/device OAuth clients are not supported (Gmail doesn't work with device flow). Please create a 'Desktop application' OAuth client in Google Cloud Console") + } + return nil, err } - - // Build config manually - no redirect URI needed for device flow - return &oauth2.Config{ - ClientID: creds.ClientID, - ClientSecret: creds.ClientSecret, - Endpoint: oauth2.Endpoint{ - AuthURL: creds.AuthURI, - TokenURL: creds.TokenURI, - }, - Scopes: scopes, - }, nil + return config, nil } // DeleteToken removes the token file for the given email. diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index f36744f9..5b67d1ea 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -249,6 +249,71 @@ func TestSanitizeEmail(t *testing.T) { } } +func TestParseClientSecrets(t *testing.T) { + // Valid Desktop application credentials + validDesktop := `{ + "installed": { + "client_id": "123.apps.googleusercontent.com", + "client_secret": "secret", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "redirect_uris": ["http://localhost"] + } + }` + + // TV/device client (no redirect_uris) + tvClient := `{ + "installed": { + "client_id": "123.apps.googleusercontent.com", + "client_secret": "secret", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + } + }` + + // Malformed JSON + malformedJSON := `{not valid json` + + tests := []struct { + name string + data string + wantErr string + }{ + { + name: "valid desktop client", + data: validDesktop, + wantErr: "", + }, + { + name: "TV/device client rejected", + data: tvClient, + wantErr: "TV/device OAuth clients are not supported", + }, + { + name: "malformed JSON", + data: malformedJSON, + wantErr: "invalid character", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseClientSecrets([]byte(tt.data), Scopes) + if tt.wantErr == "" { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } else { + if err == nil { + t.Error("expected error, got nil") + } else if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %q, want to contain %q", err.Error(), tt.wantErr) + } + } + }) + } +} + func TestNewCallbackHandler(t *testing.T) { mgr := setupTestManager(t, Scopes) From e954d99fa2dc85159e9b46849734d44cdab41ab6 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 10:19:33 -0600 Subject: [PATCH 12/14] Add mkdir command to headless setup instructions The tokens directory must exist before scp can copy the token file. Add ssh mkdir -p command to step 2. Co-Authored-By: Claude Opus 4.5 --- internal/oauth/oauth.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 32b85f2e..c294835f 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -103,6 +103,7 @@ func PrintHeadlessInstructions(email, tokensDir string) { fmt.Println() fmt.Println("Step 2: Copy the token file to your headless server:") fmt.Println() + fmt.Printf(" ssh user@server mkdir -p %s\n", shellQuote(tokensDir)) fmt.Printf(" scp %s user@server:%s\n", shellQuote(tokenPath), shellQuote(tokenPath)) fmt.Println() fmt.Println("Step 3: On the headless server, register the account:") From 4206d987ec8bb299ccd31db013d12239c97b26d0 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 10:21:37 -0600 Subject: [PATCH 13/14] Check both installed and web clients for missing redirect_uris Extend parseClientSecrets to detect missing redirect_uris in both installed and web client types, not just installed. Added tests for valid web client and web client without redirect_uris. Co-Authored-By: Claude Opus 4.5 --- internal/oauth/oauth.go | 15 +++++++++++---- internal/oauth/oauth_test.go | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index c294835f..ba53189e 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -363,19 +363,26 @@ func NewManagerWithScopes(clientSecretsPath, tokensDir string, logger *slog.Logg } // parseClientSecrets parses Google OAuth client secrets JSON. -// Requires "Desktop application" credentials with redirect_uris. +// Requires credentials with redirect_uris (Desktop app or Web app). // TV/device clients are not supported (device flow doesn't work with Gmail). func parseClientSecrets(data []byte, scopes []string) (*oauth2.Config, error) { config, err := google.ConfigFromJSON(data, scopes...) if err != nil { - // Check if it's a TV/device client (missing redirect_uris) + // Check if it's a client missing redirect_uris (TV/device or misconfigured) var secrets struct { Installed *struct { RedirectURIs []string `json:"redirect_uris"` } `json:"installed"` + Web *struct { + RedirectURIs []string `json:"redirect_uris"` + } `json:"web"` } - if json.Unmarshal(data, &secrets) == nil && secrets.Installed != nil && len(secrets.Installed.RedirectURIs) == 0 { - return nil, fmt.Errorf("TV/device OAuth clients are not supported (Gmail doesn't work with device flow). Please create a 'Desktop application' OAuth client in Google Cloud Console") + if json.Unmarshal(data, &secrets) == nil { + missingRedirects := (secrets.Installed != nil && len(secrets.Installed.RedirectURIs) == 0) || + (secrets.Web != nil && len(secrets.Web.RedirectURIs) == 0) + if missingRedirects { + return nil, fmt.Errorf("OAuth client is missing redirect_uris (TV/device clients are not supported - Gmail doesn't work with device flow). Please create a 'Desktop application' OAuth client in Google Cloud Console") + } } return nil, err } diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go index 5b67d1ea..13cce2bb 100644 --- a/internal/oauth/oauth_test.go +++ b/internal/oauth/oauth_test.go @@ -261,7 +261,18 @@ func TestParseClientSecrets(t *testing.T) { } }` - // TV/device client (no redirect_uris) + // Valid Web application credentials + validWeb := `{ + "web": { + "client_id": "123.apps.googleusercontent.com", + "client_secret": "secret", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "redirect_uris": ["http://localhost:8080/callback"] + } + }` + + // TV/device client (no redirect_uris in installed) tvClient := `{ "installed": { "client_id": "123.apps.googleusercontent.com", @@ -271,6 +282,16 @@ func TestParseClientSecrets(t *testing.T) { } }` + // Web client missing redirect_uris + webNoRedirects := `{ + "web": { + "client_id": "123.apps.googleusercontent.com", + "client_secret": "secret", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + } + }` + // Malformed JSON malformedJSON := `{not valid json` @@ -284,10 +305,20 @@ func TestParseClientSecrets(t *testing.T) { data: validDesktop, wantErr: "", }, + { + name: "valid web client", + data: validWeb, + wantErr: "", + }, { name: "TV/device client rejected", data: tvClient, - wantErr: "TV/device OAuth clients are not supported", + wantErr: "missing redirect_uris", + }, + { + name: "web client without redirect_uris rejected", + data: webNoRedirects, + wantErr: "missing redirect_uris", }, { name: "malformed JSON", From 9ee8a06c6e06e5ab668fa26f05a6b30665aeeba5 Mon Sep 17 00:00:00 2001 From: Wes McKinney Date: Tue, 3 Feb 2026 10:23:15 -0600 Subject: [PATCH 14/14] Fix error message to mention both Desktop and Web app types Co-Authored-By: Claude Opus 4.5 --- internal/oauth/oauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index ba53189e..414b1d2f 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -381,7 +381,7 @@ func parseClientSecrets(data []byte, scopes []string) (*oauth2.Config, error) { missingRedirects := (secrets.Installed != nil && len(secrets.Installed.RedirectURIs) == 0) || (secrets.Web != nil && len(secrets.Web.RedirectURIs) == 0) if missingRedirects { - return nil, fmt.Errorf("OAuth client is missing redirect_uris (TV/device clients are not supported - Gmail doesn't work with device flow). Please create a 'Desktop application' OAuth client in Google Cloud Console") + return nil, fmt.Errorf("OAuth client is missing redirect_uris (TV/device clients are not supported - Gmail doesn't work with device flow). Please create a 'Desktop application' or 'Web application' OAuth client in Google Cloud Console") } } return nil, err