From 302c946cb8866d0fd28ee798e749bf7be40e72a6 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Thu, 12 Mar 2026 02:49:14 +0000 Subject: [PATCH] feat: store OAuth tokens in OS keyring via credstore (#926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Introduce `github.com/go-authgate/sdk-go/credstore` to store OAuth tokens securely in the OS keyring (macOS Keychain / Linux Secret Service / Windows Credential Manager), with automatic fallback to an encrypted JSON file - Add `AuthMethod` field to `Login` struct; new OAuth logins are marked `auth_method: oauth` and no longer write `token`/`refresh_token`/`token_expiry` to `config.yml` - Add `GetAccessToken()` / `GetRefreshToken()` / `GetTokenExpiry()` accessors that transparently read from credstore for OAuth logins, with fallback to YAML fields for legacy logins - Update all token reference sites across the codebase to use the new accessors - Non-OAuth logins (token, SSH) are completely unaffected; no migration of existing tokens ## Key files | File | Role | |------|------| | `modules/config/credstore.go` | **New** — credstore wrapper (Load/Save/Delete) | | `modules/config/login.go` | Login struct, token accessors, refresh logic | | `modules/auth/oauth.go` | OAuth flow, token creation / re-authentication | | `modules/api/client.go`, `cmd/login/helper.go`, `cmd/login/oauth_refresh.go` | Token reference updates | | `modules/task/pull_*.go`, `modules/task/repo_clone.go` | Git operation token reference updates | ## Test plan - [x] `go build ./...` compiles successfully - [x] `go test ./...` all tests pass - [x] `tea login add --oauth` completes OAuth flow; verify config.yml has `auth_method: oauth` but no token/refresh_token/token_expiry - [x] `tea repos ls` API calls work (token read from credstore) - [x] `tea login delete ` credstore token is also removed - [x] Existing non-OAuth logins continue to work unchanged 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://gitea.com/gitea/tea/pulls/926 Reviewed-by: Lunny Xiao Co-authored-by: Bo-Yi Wu Co-committed-by: Bo-Yi Wu --- cmd/login/helper.go | 4 +- cmd/login/oauth_refresh.go | 2 +- go.mod | 5 ++ go.sum | 14 ++++ modules/api/client.go | 2 +- modules/auth/oauth.go | 27 ++++--- modules/config/credstore.go | 65 ++++++++++++++++ modules/config/login.go | 135 +++++++++++++++++++++++++++++----- modules/task/pull_checkout.go | 2 +- modules/task/pull_clean.go | 2 +- modules/task/pull_create.go | 2 +- modules/task/repo_clone.go | 2 +- modules/theme/theme.go | 12 +-- 13 files changed, 231 insertions(+), 43 deletions(-) create mode 100644 modules/config/credstore.go diff --git a/cmd/login/helper.go b/cmd/login/helper.go index dee092d..f2efcce 100644 --- a/cmd/login/helper.go +++ b/cmd/login/helper.go @@ -112,7 +112,7 @@ var CmdLoginHelper = cli.Command{ } } - if len(userConfig.Token) == 0 { + if len(userConfig.GetAccessToken()) == 0 { log.Fatal("User not set") } @@ -126,7 +126,7 @@ var CmdLoginHelper = cli.Command{ return err } - _, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.Token) + _, err = fmt.Fprintf(os.Stdout, "protocol=%s\nhost=%s\nusername=%s\npassword=%s\n", host.Scheme, host.Host, userConfig.User, userConfig.GetAccessToken()) if err != nil { return err } diff --git a/cmd/login/oauth_refresh.go b/cmd/login/oauth_refresh.go index 4369cdb..653882e 100644 --- a/cmd/login/oauth_refresh.go +++ b/cmd/login/oauth_refresh.go @@ -44,7 +44,7 @@ func runLoginOAuthRefresh(_ context.Context, cmd *cli.Command) error { } // Check if the login has a refresh token - if login.RefreshToken == "" { + if login.GetRefreshToken() == "" { return fmt.Errorf("login '%s' does not have a refresh token. It may have been created using a different authentication method", loginName) } diff --git a/go.mod b/go.mod index 39cc4fb..1ac8ea8 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/adrg/xdg v0.5.3 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/enescakir/emoji v1.0.0 + github.com/go-authgate/sdk-go v0.2.0 github.com/go-git/go-git/v5 v5.17.0 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.3 @@ -27,6 +28,7 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.6.0 // indirect charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect dario.cat/mergo v1.0.2 // indirect @@ -52,6 +54,7 @@ require ( github.com/cloudflare/circl v1.6.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect @@ -61,6 +64,7 @@ require ( github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.8.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-version v1.8.0 // indirect @@ -87,6 +91,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect + github.com/zalando/go-keyring v0.2.6 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/go.sum b/go.sum index 508dcc1..69c02c9 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= @@ -87,6 +89,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -106,6 +110,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-authgate/sdk-go v0.2.0 h1:w22f+sAg/YMqnLOcS/4SAuMZXTbPurzkSQBsjb1hcbw= +github.com/go-authgate/sdk-go v0.2.0/go.mod h1:RGqvrFdrPnOumndoQQV8qzu8zP1KFUZPdhX0IkWduho= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -116,10 +122,14 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= @@ -190,6 +200,8 @@ github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqR github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -208,6 +220,8 @@ github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/modules/api/client.go b/modules/api/client.go index 80e2d46..853a2a0 100644 --- a/modules/api/client.go +++ b/modules/api/client.go @@ -38,7 +38,7 @@ func NewClient(login *config.Login) *Client { return &Client{ baseURL: strings.TrimSuffix(login.URL, "/"), - token: login.Token, + token: login.GetAccessToken(), httpClient: httpClient, } } diff --git a/modules/auth/oauth.go b/modules/auth/oauth.go index 2861da6..82f0b43 100644 --- a/modules/auth/oauth.go +++ b/modules/auth/oauth.go @@ -377,22 +377,17 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure } } - // Create login object + // Create login object with OAuth auth method login := config.Login{ Name: name, URL: serverURL, - Token: token.AccessToken, - RefreshToken: token.RefreshToken, + Token: token.AccessToken, // temporarily set for Client() validation + AuthMethod: config.AuthMethodOAuth, Insecure: insecure, VersionCheck: true, Created: time.Now().Unix(), } - // Set token expiry if available - if !token.Expiry.IsZero() { - login.TokenExpiry = token.Expiry.Unix() - } - // Validate token by getting user info client := login.Client() u, _, err := client.GetMyUserInfo() @@ -400,6 +395,9 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure return fmt.Errorf("failed to validate token: %s", err) } + // Clear token from YAML fields (will be stored in credstore) + login.Token = "" + // Set user info login.User = u.UserName @@ -415,6 +413,11 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure return err } + // Save tokens to credstore + if err := config.SaveOAuthToken(login.Name, token.AccessToken, token.RefreshToken, token.Expiry); err != nil { + return fmt.Errorf("failed to save token to secure store: %s", err) + } + fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name) return nil } @@ -443,7 +446,11 @@ func ReauthenticateLogin(login *config.Login) error { return err } - // Update the existing login with new token data + if login.IsOAuth() { + return config.SaveOAuthTokenFromOAuth2(login.Name, token, login) + } + + // Legacy path for non-OAuth logins login.Token = token.AccessToken if token.RefreshToken != "" { login.RefreshToken = token.RefreshToken @@ -451,7 +458,5 @@ func ReauthenticateLogin(login *config.Login) error { if !token.Expiry.IsZero() { login.TokenExpiry = token.Expiry.Unix() } - - // Save updated login return config.SaveLoginTokens(login) } diff --git a/modules/config/credstore.go b/modules/config/credstore.go new file mode 100644 index 0000000..18f8791 --- /dev/null +++ b/modules/config/credstore.go @@ -0,0 +1,65 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package config + +import ( + "path/filepath" + "sync" + "time" + + "github.com/adrg/xdg" + "github.com/go-authgate/sdk-go/credstore" + "golang.org/x/oauth2" +) + +var ( + tokenStore *credstore.SecureStore[credstore.Token] + tokenStoreOnce sync.Once +) + +func getTokenStore() *credstore.SecureStore[credstore.Token] { + tokenStoreOnce.Do(func() { + filePath := filepath.Join(xdg.ConfigHome, "tea", "credentials.json") + tokenStore = credstore.DefaultTokenSecureStore("tea-cli", filePath) + }) + return tokenStore +} + +// LoadOAuthToken loads OAuth tokens from the secure store. +func LoadOAuthToken(loginName string) (*credstore.Token, error) { + tok, err := getTokenStore().Load(loginName) + if err != nil { + return nil, err + } + return &tok, nil +} + +// SaveOAuthToken saves OAuth tokens to the secure store. +func SaveOAuthToken(loginName, accessToken, refreshToken string, expiresAt time.Time) error { + return getTokenStore().Save(loginName, credstore.Token{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresAt: expiresAt, + ClientID: loginName, + }) +} + +// DeleteOAuthToken removes tokens from the secure store. +func DeleteOAuthToken(loginName string) error { + return getTokenStore().Delete(loginName) +} + +// SaveOAuthTokenFromOAuth2 saves an oauth2.Token to credstore, falling back to +// the existing login's values for empty refresh token or zero expiry. +func SaveOAuthTokenFromOAuth2(loginName string, token *oauth2.Token, login *Login) error { + refreshToken := token.RefreshToken + if refreshToken == "" { + refreshToken = login.GetRefreshToken() + } + expiry := token.Expiry + if expiry.IsZero() { + expiry = login.GetTokenExpiry() + } + return SaveOAuthToken(loginName, token.AccessToken, refreshToken, expiry) +} diff --git a/modules/config/login.go b/modules/config/login.go index fd3200d..d6ac4c2 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -33,11 +33,14 @@ const TokenRefreshThreshold = 5 * time.Minute // DefaultClientID is the default OAuth2 client ID included in most Gitea instances const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4" +// AuthMethodOAuth marks a login as using OAuth with secure credential storage. +const AuthMethodOAuth = "oauth" + // Login represents a login to a gitea server, you even could add multiple logins for one gitea server type Login struct { Name string `yaml:"name"` URL string `yaml:"url"` - Token string `yaml:"token"` + Token string `yaml:"token,omitempty"` Default bool `yaml:"default"` SSHHost string `yaml:"ssh_host"` // optional path to the private key @@ -52,10 +55,66 @@ type Login struct { User string `yaml:"user"` // Created is auto created unix timestamp Created int64 `yaml:"created"` + // AuthMethod indicates the authentication method ("oauth" for OAuth with credstore) + AuthMethod string `yaml:"auth_method,omitempty"` // RefreshToken is used to renew the access token when it expires - RefreshToken string `yaml:"refresh_token"` + RefreshToken string `yaml:"refresh_token,omitempty"` // TokenExpiry is when the token expires (unix timestamp) - TokenExpiry int64 `yaml:"token_expiry"` + TokenExpiry int64 `yaml:"token_expiry,omitempty"` +} + +// IsOAuth returns true if this login uses OAuth with secure credential storage. +func (l *Login) IsOAuth() bool { + return l.AuthMethod == AuthMethodOAuth +} + +// loadOAuthToken loads the OAuth token from credstore, returning nil if +// this is not an OAuth login or if the load fails (caller should fallback). +func (l *Login) loadOAuthToken() *OAuthToken { + if !l.IsOAuth() { + return nil + } + tok, err := LoadOAuthToken(l.Name) + if err != nil { + return nil + } + return &OAuthToken{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + ExpiresAt: tok.ExpiresAt, + } +} + +// OAuthToken holds the token fields loaded from credstore. +type OAuthToken struct { + AccessToken string + RefreshToken string + ExpiresAt time.Time +} + +// GetAccessToken returns the effective access token. +// For OAuth logins, reads from credstore. For others, returns l.Token directly. +func (l *Login) GetAccessToken() string { + if tok := l.loadOAuthToken(); tok != nil { + return tok.AccessToken + } + return l.Token +} + +// GetRefreshToken returns the refresh token. +func (l *Login) GetRefreshToken() string { + if tok := l.loadOAuthToken(); tok != nil { + return tok.RefreshToken + } + return l.RefreshToken +} + +// GetTokenExpiry returns the token expiry time. +func (l *Login) GetTokenExpiry() time.Time { + if tok := l.loadOAuthToken(); tok != nil { + return tok.ExpiresAt + } + return time.Unix(l.TokenExpiry, 0) } // GetLogins return all login available by config @@ -180,8 +239,14 @@ func DeleteLogin(name string) error { return fmt.Errorf("can not delete login '%s', does not exist", name) } + isOAuth := config.Logins[idx].IsOAuth() config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...) + // Clean up credstore tokens for OAuth logins + if isOAuth { + _ = DeleteOAuthToken(name) + } + return saveConfigUnsafe() }) } @@ -207,6 +272,9 @@ func AddLogin(login *Login) error { // SaveLoginTokens updates the token fields for an existing login. // This is used after browser-based re-authentication to save new tokens. func SaveLoginTokens(login *Login) error { + if login.IsOAuth() { + return SaveOAuthToken(login.Name, login.GetAccessToken(), login.GetRefreshToken(), login.GetTokenExpiry()) + } return withConfigLock(func() error { for i, l := range config.Logins { if strings.EqualFold(l.Name, login.Name) { @@ -223,11 +291,21 @@ func SaveLoginTokens(login *Login) error { // RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry. // Returns nil without doing anything if no refresh is needed. func (l *Login) RefreshOAuthTokenIfNeeded() error { + // Load once to avoid multiple credstore reads + if tok := l.loadOAuthToken(); tok != nil { + if tok.RefreshToken == "" || tok.ExpiresAt.IsZero() { + return nil + } + if time.Now().Add(TokenRefreshThreshold).After(tok.ExpiresAt) { + return l.RefreshOAuthToken() + } + return nil + } + // Non-OAuth path: use YAML fields if l.RefreshToken == "" || l.TokenExpiry == 0 { return nil } - expiryTime := time.Unix(l.TokenExpiry, 0) - if time.Now().Add(TokenRefreshThreshold).After(expiryTime) { + if time.Now().Add(TokenRefreshThreshold).After(time.Unix(l.TokenExpiry, 0)) { return l.RefreshOAuthToken() } return nil @@ -238,7 +316,7 @@ func (l *Login) RefreshOAuthTokenIfNeeded() error { // Uses double-checked locking to avoid unnecessary refresh calls when multiple // processes race to refresh the same token. func (l *Login) RefreshOAuthToken() error { - if l.RefreshToken == "" { + if l.GetRefreshToken() == "" { return fmt.Errorf("no refresh token available") } @@ -248,13 +326,17 @@ func (l *Login) RefreshOAuthToken() error { for i, login := range config.Logins { if login.Name == l.Name { // Check if token was refreshed by another process - if login.TokenExpiry != l.TokenExpiry && login.TokenExpiry > 0 { - expiryTime := time.Unix(login.TokenExpiry, 0) - if time.Now().Add(TokenRefreshThreshold).Before(expiryTime) { + currentExpiry := login.GetTokenExpiry() + ourExpiry := l.GetTokenExpiry() + if currentExpiry != ourExpiry && !currentExpiry.IsZero() { + if time.Now().Add(TokenRefreshThreshold).Before(currentExpiry) { // Token was refreshed by another process, update our copy - l.Token = login.Token - l.RefreshToken = login.RefreshToken - l.TokenExpiry = login.TokenExpiry + if !login.IsOAuth() { + l.Token = login.Token + l.RefreshToken = login.RefreshToken + l.TokenExpiry = login.TokenExpiry + } + // For OAuth logins, credstore already has the latest tokens return nil } } @@ -265,7 +347,12 @@ func (l *Login) RefreshOAuthToken() error { return err } - // Update login with new token information + if l.IsOAuth() { + // Save tokens to credstore; no YAML changes needed + return SaveOAuthTokenFromOAuth2(l.Name, newToken, l) + } + + // Update login with new token information (legacy path) l.Token = newToken.AccessToken if newToken.RefreshToken != "" { l.RefreshToken = newToken.RefreshToken @@ -273,8 +360,6 @@ func (l *Login) RefreshOAuthToken() error { if !newToken.Expiry.IsZero() { l.TokenExpiry = newToken.Expiry.Unix() } - - // Update in config slice and save config.Logins[i] = *l return saveConfigUnsafe() } @@ -286,10 +371,22 @@ func (l *Login) RefreshOAuthToken() error { // doOAuthRefresh performs the actual OAuth token refresh API call. func doOAuthRefresh(l *Login) (*oauth2.Token, error) { + // Build current token from credstore (single load) or YAML fields + var accessToken, refreshToken string + var expiry time.Time + if tok := l.loadOAuthToken(); tok != nil { + accessToken = tok.AccessToken + refreshToken = tok.RefreshToken + expiry = tok.ExpiresAt + } else { + accessToken = l.Token + refreshToken = l.RefreshToken + expiry = time.Unix(l.TokenExpiry, 0) + } currentToken := &oauth2.Token{ - AccessToken: l.Token, - RefreshToken: l.RefreshToken, - Expiry: time.Unix(l.TokenExpiry, 0), + AccessToken: accessToken, + RefreshToken: refreshToken, + Expiry: expiry, } ctx := context.Background() @@ -341,7 +438,7 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client { options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...) } - options = append(options, gitea.SetToken(l.Token), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent())) + options = append(options, gitea.SetToken(l.GetAccessToken()), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent())) if debug.IsDebug() { options = append(options, gitea.SetDebugMode()) } diff --git a/modules/task/pull_checkout.go b/modules/task/pull_checkout.go index 2e6e8b1..d73a2ec 100644 --- a/modules/task/pull_checkout.go +++ b/modules/task/pull_checkout.go @@ -85,7 +85,7 @@ func doPRFetch( if err != nil { return "", err } - auth, err := local_git.GetAuthForURL(url, login.Token, login.SSHKey, callback) + auth, err := local_git.GetAuthForURL(url, login.GetAccessToken(), login.SSHKey, callback) if err != nil { return "", err } diff --git a/modules/task/pull_clean.go b/modules/task/pull_clean.go index c09647c..0f1ad82 100644 --- a/modules/task/pull_clean.go +++ b/modules/task/pull_clean.go @@ -96,7 +96,7 @@ call me again with the --ignore-sha flag`, remoteBranch) if urlErr != nil { return urlErr } - auth, authErr := local_git.GetAuthForURL(url, login.Token, login.SSHKey, callback) + auth, authErr := local_git.GetAuthForURL(url, login.GetAccessToken(), login.SSHKey, callback) if authErr != nil { return authErr } diff --git a/modules/task/pull_create.go b/modules/task/pull_create.go index 33e2f5a..58c56bb 100644 --- a/modules/task/pull_create.go +++ b/modules/task/pull_create.go @@ -211,7 +211,7 @@ func CreateAgitFlowPull(ctx *context.TeaContext, remote, head, base, topic strin return err } - auth, err := local_git.GetAuthForURL(url, ctx.Login.Token, ctx.Login.SSHKey, callback) + auth, err := local_git.GetAuthForURL(url, ctx.Login.GetAccessToken(), ctx.Login.SSHKey, callback) if err != nil { return err } diff --git a/modules/task/repo_clone.go b/modules/task/repo_clone.go index 1a56147..b23af21 100644 --- a/modules/task/repo_clone.go +++ b/modules/task/repo_clone.go @@ -35,7 +35,7 @@ func RepoClone( return nil, err } - auth, err := local_git.GetAuthForURL(originURL, login.Token, login.SSHKey, callback) + auth, err := local_git.GetAuthForURL(originURL, login.GetAccessToken(), login.SSHKey, callback) if err != nil { return nil, err } diff --git a/modules/theme/theme.go b/modules/theme/theme.go index 89790bb..6819835 100644 --- a/modules/theme/theme.go +++ b/modules/theme/theme.go @@ -9,9 +9,11 @@ import ( "charm.land/lipgloss/v2/compat" ) -type myTheme struct{} +// TeaTheme implements the huh.Theme interface with tea-cli styling. +type TeaTheme struct{} -func (t myTheme) Theme(isDark bool) *huh.Styles { +// Theme implements the huh.Theme interface. +func (t TeaTheme) Theme(isDark bool) *huh.Styles { theme := huh.ThemeCharm(isDark) title := compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")} @@ -20,8 +22,8 @@ func (t myTheme) Theme(isDark bool) *huh.Styles { return theme } -// GetTheme returns default theme -func GetTheme() myTheme { - var t myTheme +// GetTheme returns the default theme for huh prompts. +func GetTheme() TeaTheme { + var t TeaTheme return t }