From 79f463bc3acad48dfa869ef0089ce01b2a426771 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Thu, 14 May 2026 22:54:20 +0200 Subject: [PATCH 1/9] keyring: add GetClientSecret/SetClientSecret/DeleteClientSecret helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds clientSecretKey(), GetClientSecret(), SetClientSecret(), and DeleteClientSecret() for OAuth2 client secrets, stored under account//oauth2_client_secret — separate from the password slot. --- internal/keyring/keyring.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go index 6438d2c..6d1fafa 100644 --- a/internal/keyring/keyring.go +++ b/internal/keyring/keyring.go @@ -49,6 +49,38 @@ func DeletePassword(accountName string) error { return err } +// clientSecretKey returns the keyring key for an OAuth2 client secret. +func clientSecretKey(accountName string) string { + return fmt.Sprintf("account/%s/oauth2_client_secret", accountName) +} + +// SetClientSecret stores an OAuth2 client secret in the OS keyring. +func SetClientSecret(accountName, secret string) error { + return keyring.Set(serviceName, clientSecretKey(accountName), secret) +} + +// GetClientSecret retrieves an OAuth2 client secret from the OS keyring. +// Returns ErrNotFound if no entry exists for this account. +func GetClientSecret(accountName string) (string, error) { + secret, err := keyring.Get(serviceName, clientSecretKey(accountName)) + if err == keyring.ErrNotFound { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("keyring get client secret: %w", err) + } + return secret, nil +} + +// DeleteClientSecret removes an OAuth2 client secret from the OS keyring. +func DeleteClientSecret(accountName string) error { + err := keyring.Delete(serviceName, clientSecretKey(accountName)) + if err == keyring.ErrNotFound { + return nil + } + return err +} + // SetOAuth2Token stores an OAuth2 token in the OS keyring as JSON. func SetOAuth2Token(accountName string, token *oauth2.Token) error { data, err := json.Marshal(token) From cd30e0aa2dda51e8e26cba2fa759fbfe4e639c08 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Thu, 14 May 2026 22:55:24 +0200 Subject: [PATCH 2/9] config: resolve oauth2_client_secret from OS keyring on load Adds resolveKeyringClientSecret() mirroring resolveKeyringPassword(). Setting oauth2_client_secret = "keyring" in config.toml now causes Load() to fetch the secret from the OS keyring instead of using the sentinel as a literal value. --- internal/config/config.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 487de82..c6415df 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -540,11 +540,13 @@ func Load(path string) (*Config, error) { cfg.Accounts[i].User = expandEnv(cfg.Accounts[i].User) cfg.Accounts[i].TLSCertFile = expandPath(expandEnv(cfg.Accounts[i].TLSCertFile)) cfg.Accounts[i].Password = resolveKeyringPassword(cfg.Accounts[i].Name, cfg.Accounts[i].Password) + cfg.Accounts[i].OAuth2ClientSecret = resolveKeyringClientSecret(cfg.Accounts[i].Name, cfg.Accounts[i].OAuth2ClientSecret) } cfg.Account.Password = expandEnv(cfg.Account.Password) cfg.Account.User = expandEnv(cfg.Account.User) cfg.Account.TLSCertFile = expandPath(expandEnv(cfg.Account.TLSCertFile)) cfg.Account.Password = resolveKeyringPassword(cfg.Account.Name, cfg.Account.Password) + cfg.Account.OAuth2ClientSecret = resolveKeyringClientSecret(cfg.Account.Name, cfg.Account.OAuth2ClientSecret) cfg.Listmonk.APIToken = expandEnv(cfg.Listmonk.APIToken) @@ -726,6 +728,24 @@ func resolveKeyringPassword(accountName, password string) string { return password // leave sentinel for downstream } +// resolveKeyringClientSecret turns the "keyring" sentinel into the actual +// OAuth2 client secret stored in the OS keyring. Mirrors resolveKeyringPassword. +func resolveKeyringClientSecret(accountName, secret string) string { + if secret != keyringSentinel || accountName == "" { + return secret + } + resolved, err := keyring.GetClientSecret(accountName) + if err == nil { + return resolved + } + if err == keyring.ErrNotFound { + fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2 client secret not in keyring — run :set-oauth2-secret %s\n", accountName, accountName) + } else { + fmt.Fprintf(os.Stderr, "neomd: account %q: keyring unavailable: %v\n", accountName, err) + } + return secret // leave sentinel for downstream +} + // expandEnv resolves a value that is entirely a single env var reference // ($VAR or ${VAR}). If the value contains other text or multiple $ signs // it is returned as-is, so passwords containing $ are never mangled. From 8dccfc8f3e64a7ef40b0804be90fc003bd944bf3 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Thu, 14 May 2026 22:56:01 +0200 Subject: [PATCH 3/9] config: add tests for keyring resolution of oauth2_client_secret Adds TestResolveKeyringClientSecret (four sub-tests: resolved, missing, passthrough, empty account) and TestLoad_KeyringResolvesClientSecret (end-to-end Load() coverage) mirroring the existing password tests. --- internal/config/config_test.go | 76 ++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 94f7670..5c96ee8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -565,6 +565,82 @@ account = "Personal" } } +func TestResolveKeyringClientSecret(t *testing.T) { + zalandokeyring.MockInit() + + t.Run("resolved when entry exists", func(t *testing.T) { + const acct = "TestAcctOAuth" + if err := keyring.SetClientSecret(acct, "the-client-secret"); err != nil { + t.Fatalf("SetClientSecret: %v", err) + } + got := resolveKeyringClientSecret(acct, "keyring") + if got != "the-client-secret" { + t.Errorf("got %q, want resolved client secret", got) + } + _ = keyring.DeleteClientSecret(acct) + }) + + t.Run("sentinel preserved when entry missing", func(t *testing.T) { + got := resolveKeyringClientSecret("MissingAcct", "keyring") + if got != "keyring" { + t.Errorf("got %q, want sentinel preserved", got) + } + }) + + t.Run("non-sentinel passthrough", func(t *testing.T) { + got := resolveKeyringClientSecret("any", "literal-secret") + if got != "literal-secret" { + t.Errorf("got %q, want passthrough", got) + } + }) + + t.Run("empty account name passthrough", func(t *testing.T) { + got := resolveKeyringClientSecret("", "keyring") + if got != "keyring" { + t.Errorf("got %q, want passthrough for empty account", got) + } + }) +} + +func TestLoad_KeyringResolvesClientSecret(t *testing.T) { + zalandokeyring.MockInit() + const acctName = "GMail" + const realSecret = "the-real-client-secret" + if err := keyring.SetClientSecret(acctName, realSecret); err != nil { + t.Fatalf("SetClientSecret: %v", err) + } + defer keyring.DeleteClientSecret(acctName) + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + cfgBody := ` +[[accounts]] +name = "GMail" +imap = "imap.gmail.com:993" +smtp = "smtp.gmail.com:587" +user = "me@gmail.com" +from = "Me " +oauth2_client_id = "client-id.apps.googleusercontent.com" +oauth2_client_secret = "keyring" +oauth2_issuer_url = "https://accounts.google.com" +oauth2_scopes = ["https://mail.google.com/"] +` + if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Accounts) != 1 { + t.Fatalf("expected 1 account, got %d", len(cfg.Accounts)) + } + if got := cfg.Accounts[0].OAuth2ClientSecret; got != realSecret { + t.Errorf("Accounts[0].OAuth2ClientSecret = %q, want resolved %q", got, realSecret) + } +} + func TestLoad_AIConfigDefaultUsesInteractiveClaude(t *testing.T) { // Regression for two prior bugs: // 1. Defaults must NOT include `-p` (claude's print mode bills against From ab71a870810db7fd683d7dd76d569ecfff6688f5 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Thu, 14 May 2026 22:57:19 +0200 Subject: [PATCH 4/9] ui: add promptOAuth2Secret type to password prompt Extends the promptType enum with promptOAuth2Secret and adds matching title/hint text in View() so the same masked-input dialog can be used for OAuth2 client secrets. --- internal/ui/password_prompt.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/ui/password_prompt.go b/internal/ui/password_prompt.go index 00b3b7b..cc06903 100644 --- a/internal/ui/password_prompt.go +++ b/internal/ui/password_prompt.go @@ -20,9 +20,10 @@ type passwordPromptModel struct { type promptType int const ( - promptNewAccount promptType = iota // new account setup - promptAuthFailed // auth failed, need to re-enter - promptManualSet // user ran :set-password command + promptNewAccount promptType = iota // new account setup + promptAuthFailed // auth failed, need to re-enter + promptManualSet // user ran :set-password command + promptOAuth2Secret // user ran :set-oauth2-secret command ) // newPasswordPromptModel creates a new password prompt with masked input. @@ -106,6 +107,8 @@ func (p passwordPromptModel) View(width, height int) string { title = fmt.Sprintf("🔐 Authentication failed for %s", p.account) case promptManualSet: title = fmt.Sprintf("🔐 Update password for %s", p.account) + case promptOAuth2Secret: + title = fmt.Sprintf("🔐 Set OAuth2 client secret for %s", p.account) } var hint string @@ -116,6 +119,8 @@ func (p passwordPromptModel) View(width, height int) string { hint = "Your password may be incorrect or have expired.\nEnter the correct password to continue." case promptManualSet: hint = "Enter the new password to store in the OS keyring." + case promptOAuth2Secret: + hint = "Enter the OAuth2 client secret to store in the OS keyring." } // Build content From 3f264f0d782d1f4b940ab627d88634c71fd9fe2b Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Thu, 14 May 2026 22:57:46 +0200 Subject: [PATCH 5/9] ui: wire passwordPromptModel into TUI state machine Adds statePasswordPrompt viewState, passwordPrompt field, and handlers for passwordSubmittedMsg and passwordCancelledMsg. On submit, dispatches to keyring.SetClientSecret or keyring.SetPassword depending on prompt type. --- internal/ui/model.go | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index 34e948b..4bc8927 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -26,6 +26,7 @@ import ( "github.com/sspaeti/neomd/internal/config" "github.com/sspaeti/neomd/internal/editor" "github.com/sspaeti/neomd/internal/imap" + "github.com/sspaeti/neomd/internal/keyring" "github.com/sspaeti/neomd/internal/listmonk" "github.com/sspaeti/neomd/internal/notify" "github.com/sspaeti/neomd/internal/render" @@ -44,6 +45,7 @@ const ( stateHelp // help overlay stateWelcome // first-run welcome popup stateReaction // emoji reaction picker + statePasswordPrompt // secure input prompt (:set-password / :set-oauth2-secret) ) // async message types @@ -505,10 +507,11 @@ type Model struct { notifier *notify.Notifier notifyState *notify.State - state viewState - width int - height int - loading bool + state viewState + passwordPrompt passwordPromptModel + width int + height int + loading bool // Bulk operation progress — shared pointer, written by goroutines, read by view. bulkProgress *bulkOp @@ -687,6 +690,7 @@ func New(cfg *config.Config, clients []*imap.Client, sc *screener.Screener, mail cmdHistory: loadCmdHistory(config.HistoryPath()), cmdHistI: -1, // Note: Spam is intentionally excluded from tabs — use :go-spam to visit. + passwordPrompt: newPasswordPromptModel(), compose: compose, spinner: sp, markedUIDs: make(map[uint32]bool), @@ -2561,6 +2565,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case passwordSubmittedMsg: + var err error + switch m.passwordPrompt.promptType { + case promptOAuth2Secret: + err = keyring.SetClientSecret(msg.account, msg.password) + default: + err = keyring.SetPassword(msg.account, msg.password) + } + m.state = m.prevState + if err != nil { + m.status = fmt.Sprintf("keyring error: %v", err) + m.isError = true + } else { + m.status = "Saved to keyring." + m.isError = false + } + return m, nil + + case passwordCancelledMsg: + m.state = m.prevState + m.status = "Cancelled." + return m, nil + case tea.KeyMsg: // ? opens help from any state; q/esc/? closes it if msg.String() == "?" { @@ -2592,6 +2619,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case stateReaction: return m.updateReaction(msg) + case statePasswordPrompt: + var cmd tea.Cmd + m.passwordPrompt, cmd = m.passwordPrompt.Update(msg) + return m, cmd } } @@ -5406,6 +5437,8 @@ func (m Model) View() string { return m.viewWelcome() case stateReaction: return m.viewReaction() + case statePasswordPrompt: + return m.passwordPrompt.View(m.width, m.height) } return "" } From 66b7944a9119500f41e390117f9c4626553a9cfa Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Thu, 14 May 2026 22:58:18 +0200 Subject: [PATCH 6/9] ui: register :set-oauth2-secret command (alias: sos) Wires the new command to the password prompt in promptOAuth2Secret mode, allowing users to store the OAuth2 client secret in the OS keyring from within the TUI. --- internal/ui/cmdline.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/ui/cmdline.go b/internal/ui/cmdline.go index 85508ed..7dc036f 100644 --- a/internal/ui/cmdline.go +++ b/internal/ui/cmdline.go @@ -255,6 +255,18 @@ func init() { return m, nil }, }, + { + name: "set-oauth2-secret", + aliases: []string{"sos"}, + desc: "store OAuth2 client secret in OS keyring for the current account", + run: func(m *Model) (tea.Model, tea.Cmd) { + accountName := m.activeAccount().Name + m.passwordPrompt.setPrompt(accountName, promptOAuth2Secret, "") + m.prevState = m.state + m.state = statePasswordPrompt + return m, nil + }, + }, { name: "quit", aliases: []string{"q"}, From 726731a1109ef80ad97c045a2447e6b8e6258a3e Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Fri, 15 May 2026 00:36:22 +0200 Subject: [PATCH 7/9] Updated documentation --- docs/content/docs/configuration/_index.md | 33 ++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/content/docs/configuration/_index.md b/docs/content/docs/configuration/_index.md index b6e2e7a..a515923 100644 --- a/docs/content/docs/configuration/_index.md +++ b/docs/content/docs/configuration/_index.md @@ -116,7 +116,7 @@ Values containing other text or multiple `$` signs are left as-is, so passwords Credentials are stored only in `~/.config/neomd/config.toml` (mode 0600) and never written elsewhere; all IMAP connections use TLS (port 993) or STARTTLS (port 143). -### Storing passwords in the OS keyring (Linux) +### Storing passwords in the OS keyring (Linux/MacOS) Set `password = "keyring"` to fetch the password from your OS keyring (macOS Keychain, GNOME Keyring / KDE Wallet via Secret Service on Linux, Windows Credential Manager) at startup. The lookup uses the `[[accounts]].name` as the account identifier under service `neomd`. @@ -127,6 +127,15 @@ password = "keyring" # resolved at startup; see below for setup # ...rest of the account ``` +If you use OAuth2 authentication, set `oauth2_client_secret = "keyring"` instead of the password field. + +```toml +[[accounts]] +name = "Personal" +oauth2_client_secret = "keyring" # resolved at startup; see below for setup +# ...rest of the account +``` + **Setup before first launch (Linux, using `secret-tool` from `libsecret`):** `zalando/go-keyring` writes Secret Service entries with two attributes — `service` (always `neomd`) and `username` (`account//password`, where `` is the `[[accounts]].name` from your config). The `--label` is display-only; entries are identified by their **attributes**. @@ -134,15 +143,21 @@ password = "keyring" # resolved at startup; see below for setup ```sh # Add the entry (you'll be prompted for the password on stdin): secret-tool store --label "neomd Personal" service neomd username account/Personal/password + # or +secret-tool store --label "neomd Personal" service neomd username account/Personal/oauth2_client_secret # Verify it was stored (read-only): secret-tool lookup service neomd username account/Personal/password + # or +secret-tool lookup service neomd username account/Personal/oauth2_client_secret # Audit every neomd entry currently in your keyring: secret-tool search --all service neomd # Remove a specific entry if you want to start over: secret-tool clear service neomd username account/Personal/password + # or +secret-tool clear service neomd username account/Personal/oauth2_client_secret ``` > [!IMPORTANT] @@ -162,6 +177,22 @@ If the keyring entry is missing or the keyring service is unavailable, neomd pri OAuth2 tokens are also stored in the keyring under `username = account//oauth2` with the same `service = neomd`, falling back to `~/.config/neomd/tokens/.json` (mode `0600`) when no keyring is available (headless / SSH systems). +**Setup before first launch (MacOS, using `security`)** + +```sh +# Add the entry (add-generic-password does not prompt by default so we use read instead) +read -rsp "Password: " pw && eho && security add-generic-passowrd -s neomd -a account/Personal/password -w "$pw" + +# Verify it was stored (read-only): +security find-generic-password -s neomd -a account/Personal/password -w + +# Remove a specific entry if you want to start over: +security delete-generic-passowrd -s neomd -a account/Personal/password + +``` + +The rest of details are the same as for Linux. + ## TLS and STARTTLS Configuration Neomd automatically determines the correct encryption method based on the port and the optional `starttls` config field: From 7949489ebd3ecbcf07bcf38df385c0499617d806 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Fri, 15 May 2026 21:42:19 +0200 Subject: [PATCH 8/9] oauth2: log keyring storage errors instead of silently falling back --- internal/oauth2/oauth2.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/oauth2/oauth2.go b/internal/oauth2/oauth2.go index d0bd72d..cdc82e1 100644 --- a/internal/oauth2/oauth2.go +++ b/internal/oauth2/oauth2.go @@ -13,6 +13,7 @@ import ( _ "embed" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net" @@ -169,7 +170,9 @@ func TokenSource(ctx context.Context, cfg Config) (func() (string, error), error if err != nil { return "", err } - _ = storage.Save(t) + if err := storage.Save(t); err != nil { + fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2 token refresh save failed: %v\n", cfg.AccountName, err) + } return t.AccessToken, nil }, nil } @@ -303,7 +306,9 @@ func (s *tokenStorage) Load() (*oauth2.Token, error) { if err == nil { return tok, nil } - // Any keyring error (including ErrNotFound) — try file fallback. + if !errors.Is(err, keyring.ErrNotFound) { + fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2 keyring load failed, falling back to file: %v\n", s.account, err) + } } if s.path == "" { return nil, fmt.Errorf("oauth2: no token storage available (no account name and no token file path)") @@ -313,10 +318,11 @@ func (s *tokenStorage) Load() (*oauth2.Token, error) { func (s *tokenStorage) Save(tok *oauth2.Token) error { if s.account != "" { - if err := keyring.SetOAuth2Token(s.account, tok); err == nil { + err := keyring.SetOAuth2Token(s.account, tok) + if err == nil { return nil } - // Keyring failed — fall back to file. + fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2 keyring save failed, falling back to file: %v\n", s.account, err) } if s.path == "" { return fmt.Errorf("oauth2: no token storage available") From d834de9a410a7fd3a34ebfe4dea638b4662d433b Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Fri, 15 May 2026 21:42:19 +0200 Subject: [PATCH 9/9] keyring: strip access_token on macOS to fit keychain size limit --- internal/keyring/keyring.go | 26 +++++++++++++++- internal/keyring/keyring_test.go | 51 ++++++++++++++++++++++++++++++++ internal/oauth2/oauth2_test.go | 48 ++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go index 6d1fafa..30bf5af 100644 --- a/internal/keyring/keyring.go +++ b/internal/keyring/keyring.go @@ -5,6 +5,7 @@ package keyring import ( "encoding/json" "fmt" + "runtime" "github.com/zalando/go-keyring" "golang.org/x/oauth2" @@ -82,14 +83,37 @@ func DeleteClientSecret(accountName string) error { } // SetOAuth2Token stores an OAuth2 token in the OS keyring as JSON. +// +// On macOS, only the refresh-relevant fields (RefreshToken, TokenType, +// Expiry) are persisted; the access_token is dropped. Google access tokens +// can exceed 2 KB which, once base64-encoded and wrapped in the `security` +// CLI command zalando/go-keyring uses on macOS, breaches its 4096-byte +// command-length limit. With AccessToken empty, oauth2.Token.Valid() returns +// false, so the library refreshes on first use using the stored +// RefreshToken. Other platforms store the full token unchanged. func SetOAuth2Token(accountName string, token *oauth2.Token) error { - data, err := json.Marshal(token) + data, err := json.Marshal(prepareTokenForStorage(token, runtime.GOOS)) if err != nil { return fmt.Errorf("marshal oauth2 token: %w", err) } return keyring.Set(serviceName, oauth2Key(accountName), string(data)) } +// prepareTokenForStorage returns the token shape to persist for a given OS. +// On darwin, AccessToken is dropped to stay under zalando/go-keyring's +// 4096-byte `security` command-length limit. Other platforms keep the token +// unchanged. +func prepareTokenForStorage(token *oauth2.Token, goos string) *oauth2.Token { + if goos != "darwin" { + return token + } + return &oauth2.Token{ + TokenType: token.TokenType, + RefreshToken: token.RefreshToken, + Expiry: token.Expiry, + } +} + // GetOAuth2Token retrieves an OAuth2 token from the OS keyring. // Returns ErrNotFound if no token exists for this account. func GetOAuth2Token(accountName string) (*oauth2.Token, error) { diff --git a/internal/keyring/keyring_test.go b/internal/keyring/keyring_test.go index fce8418..f289b91 100644 --- a/internal/keyring/keyring_test.go +++ b/internal/keyring/keyring_test.go @@ -134,6 +134,57 @@ func TestKeyFormats(t *testing.T) { } } +func TestPrepareTokenForStorage_DarwinStripsAccessToken(t *testing.T) { + tok := &oauth2.Token{ + AccessToken: "ya29." + string(make([]byte, 2500)), + RefreshToken: "1//0gRefresh", + TokenType: "Bearer", + Expiry: time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC), + } + + got := prepareTokenForStorage(tok, "darwin") + if got.AccessToken != "" { + t.Errorf("AccessToken should be stripped on darwin, got len=%d", len(got.AccessToken)) + } + if got.RefreshToken != tok.RefreshToken { + t.Errorf("RefreshToken = %q, want %q", got.RefreshToken, tok.RefreshToken) + } + if got.TokenType != tok.TokenType { + t.Errorf("TokenType = %q, want %q", got.TokenType, tok.TokenType) + } + if !got.Expiry.Equal(tok.Expiry) { + t.Errorf("Expiry = %v, want %v", got.Expiry, tok.Expiry) + } + + // Marshaled size must fit comfortably under the 4096-byte security CLI limit + // (after base64 expansion ~4/3 and ~80 bytes of command wrapping). + data, _ := json.Marshal(got) + if len(data) > 1024 { + t.Errorf("stripped token JSON unexpectedly large: %d bytes", len(data)) + } +} + +func TestPrepareTokenForStorage_OtherOSPreservesToken(t *testing.T) { + tok := &oauth2.Token{ + AccessToken: "access123", + RefreshToken: "refresh456", + TokenType: "Bearer", + Expiry: time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC), + } + + for _, goos := range []string{"linux", "windows", "freebsd"} { + t.Run(goos, func(t *testing.T) { + got := prepareTokenForStorage(tok, goos) + if got.AccessToken != tok.AccessToken { + t.Errorf("AccessToken = %q, want %q (no stripping on %s)", got.AccessToken, tok.AccessToken, goos) + } + if got.RefreshToken != tok.RefreshToken { + t.Errorf("RefreshToken = %q, want %q", got.RefreshToken, tok.RefreshToken) + } + }) + } +} + func TestClear(t *testing.T) { mock := NewMockBackend() diff --git a/internal/oauth2/oauth2_test.go b/internal/oauth2/oauth2_test.go index fd325a3..86ae97c 100644 --- a/internal/oauth2/oauth2_test.go +++ b/internal/oauth2/oauth2_test.go @@ -125,6 +125,54 @@ func TestLoadToken_InvalidJSON(t *testing.T) { } } +// --- tokenStorage (file-only paths) --- + +// When no account name is configured, tokenStorage must go straight to the file. +// This covers the headless/SSH fallback case where keyring is intentionally unused. +func TestTokenStorage_FileOnlyRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tok.json") + s := newTokenStorage("", path) + + tok := &oauth2.Token{ + AccessToken: "access123", + RefreshToken: "refresh456", + TokenType: "Bearer", + Expiry: time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC), + } + if err := s.Save(tok); err != nil { + t.Fatalf("Save: %v", err) + } + loaded, err := s.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.AccessToken != tok.AccessToken { + t.Errorf("AccessToken = %q, want %q", loaded.AccessToken, tok.AccessToken) + } + if loaded.RefreshToken != tok.RefreshToken { + t.Errorf("RefreshToken = %q, want %q", loaded.RefreshToken, tok.RefreshToken) + } +} + +func TestTokenStorage_NoBackendsConfigured(t *testing.T) { + s := newTokenStorage("", "") + if _, err := s.Load(); err == nil { + t.Error("Load with no account and no path should error") + } + if err := s.Save(&oauth2.Token{AccessToken: "x"}); err == nil { + t.Error("Save with no account and no path should error") + } +} + +func TestTokenStorage_LoadMissingFile(t *testing.T) { + dir := t.TempDir() + s := newTokenStorage("", filepath.Join(dir, "missing.json")) + if _, err := s.Load(); err == nil { + t.Error("expected error loading missing file") + } +} + // --- Config helpers --- func TestConfig_RedirectPort(t *testing.T) {