feat: store OAuth tokens in OS keyring via credstore (#926)

## 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 <name>` 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 <xiaolunwen@gmail.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-committed-by: Bo-Yi Wu <appleboy.tw@gmail.com>
This commit is contained in:
Bo-Yi Wu
2026-03-12 02:49:14 +00:00
committed by Bo-Yi Wu (吳柏毅)
parent 0346e1cbb5
commit 302c946cb8
13 changed files with 231 additions and 43 deletions

View File

@@ -112,7 +112,7 @@ var CmdLoginHelper = cli.Command{
} }
} }
if len(userConfig.Token) == 0 { if len(userConfig.GetAccessToken()) == 0 {
log.Fatal("User not set") log.Fatal("User not set")
} }
@@ -126,7 +126,7 @@ var CmdLoginHelper = cli.Command{
return err 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 { if err != nil {
return err return err
} }

View File

@@ -44,7 +44,7 @@ func runLoginOAuthRefresh(_ context.Context, cmd *cli.Command) error {
} }
// Check if the login has a refresh token // 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) return fmt.Errorf("login '%s' does not have a refresh token. It may have been created using a different authentication method", loginName)
} }

5
go.mod
View File

@@ -12,6 +12,7 @@ require (
github.com/adrg/xdg v0.5.3 github.com/adrg/xdg v0.5.3
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
github.com/enescakir/emoji v1.0.0 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/go-git/go-git/v5 v5.17.0
github.com/muesli/termenv v0.16.0 github.com/muesli/termenv v0.16.0
github.com/olekukonko/tablewriter v1.1.3 github.com/olekukonko/tablewriter v1.1.3
@@ -27,6 +28,7 @@ require (
) )
require ( require (
al.essio.dev/pkg/shellescape v1.6.0 // indirect
charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbles/v2 v2.0.0 // indirect
charm.land/bubbletea/v2 v2.0.2 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect
dario.cat/mergo v1.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/cloudflare/circl v1.6.3 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // 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/davecgh/go-spew v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dlclark/regexp2 v1.11.5 // 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-fed/httpsig v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // 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/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/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/go-version v1.8.0 // 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark v1.7.16 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // 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/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect

14
go.sum
View File

@@ -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 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= 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/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 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 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 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= 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= 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-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 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= 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 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= 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 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= 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.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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 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= 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 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 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 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-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=

View File

@@ -38,7 +38,7 @@ func NewClient(login *config.Login) *Client {
return &Client{ return &Client{
baseURL: strings.TrimSuffix(login.URL, "/"), baseURL: strings.TrimSuffix(login.URL, "/"),
token: login.Token, token: login.GetAccessToken(),
httpClient: httpClient, httpClient: httpClient,
} }
} }

View File

@@ -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{ login := config.Login{
Name: name, Name: name,
URL: serverURL, URL: serverURL,
Token: token.AccessToken, Token: token.AccessToken, // temporarily set for Client() validation
RefreshToken: token.RefreshToken, AuthMethod: config.AuthMethodOAuth,
Insecure: insecure, Insecure: insecure,
VersionCheck: true, VersionCheck: true,
Created: time.Now().Unix(), Created: time.Now().Unix(),
} }
// Set token expiry if available
if !token.Expiry.IsZero() {
login.TokenExpiry = token.Expiry.Unix()
}
// Validate token by getting user info // Validate token by getting user info
client := login.Client() client := login.Client()
u, _, err := client.GetMyUserInfo() 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) return fmt.Errorf("failed to validate token: %s", err)
} }
// Clear token from YAML fields (will be stored in credstore)
login.Token = ""
// Set user info // Set user info
login.User = u.UserName login.User = u.UserName
@@ -415,6 +413,11 @@ func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure
return err 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) fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name)
return nil return nil
} }
@@ -443,7 +446,11 @@ func ReauthenticateLogin(login *config.Login) error {
return err 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 login.Token = token.AccessToken
if token.RefreshToken != "" { if token.RefreshToken != "" {
login.RefreshToken = token.RefreshToken login.RefreshToken = token.RefreshToken
@@ -451,7 +458,5 @@ func ReauthenticateLogin(login *config.Login) error {
if !token.Expiry.IsZero() { if !token.Expiry.IsZero() {
login.TokenExpiry = token.Expiry.Unix() login.TokenExpiry = token.Expiry.Unix()
} }
// Save updated login
return config.SaveLoginTokens(login) return config.SaveLoginTokens(login)
} }

View File

@@ -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)
}

View File

@@ -33,11 +33,14 @@ const TokenRefreshThreshold = 5 * time.Minute
// DefaultClientID is the default OAuth2 client ID included in most Gitea instances // DefaultClientID is the default OAuth2 client ID included in most Gitea instances
const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4" 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 // Login represents a login to a gitea server, you even could add multiple logins for one gitea server
type Login struct { type Login struct {
Name string `yaml:"name"` Name string `yaml:"name"`
URL string `yaml:"url"` URL string `yaml:"url"`
Token string `yaml:"token"` Token string `yaml:"token,omitempty"`
Default bool `yaml:"default"` Default bool `yaml:"default"`
SSHHost string `yaml:"ssh_host"` SSHHost string `yaml:"ssh_host"`
// optional path to the private key // optional path to the private key
@@ -52,10 +55,66 @@ type Login struct {
User string `yaml:"user"` User string `yaml:"user"`
// Created is auto created unix timestamp // Created is auto created unix timestamp
Created int64 `yaml:"created"` 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 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 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 // 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) 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:]...) config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...)
// Clean up credstore tokens for OAuth logins
if isOAuth {
_ = DeleteOAuthToken(name)
}
return saveConfigUnsafe() return saveConfigUnsafe()
}) })
} }
@@ -207,6 +272,9 @@ func AddLogin(login *Login) error {
// SaveLoginTokens updates the token fields for an existing login. // SaveLoginTokens updates the token fields for an existing login.
// This is used after browser-based re-authentication to save new tokens. // This is used after browser-based re-authentication to save new tokens.
func SaveLoginTokens(login *Login) error { func SaveLoginTokens(login *Login) error {
if login.IsOAuth() {
return SaveOAuthToken(login.Name, login.GetAccessToken(), login.GetRefreshToken(), login.GetTokenExpiry())
}
return withConfigLock(func() error { return withConfigLock(func() error {
for i, l := range config.Logins { for i, l := range config.Logins {
if strings.EqualFold(l.Name, login.Name) { 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. // RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry.
// Returns nil without doing anything if no refresh is needed. // Returns nil without doing anything if no refresh is needed.
func (l *Login) RefreshOAuthTokenIfNeeded() error { 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 { if l.RefreshToken == "" || l.TokenExpiry == 0 {
return nil return nil
} }
expiryTime := time.Unix(l.TokenExpiry, 0) if time.Now().Add(TokenRefreshThreshold).After(time.Unix(l.TokenExpiry, 0)) {
if time.Now().Add(TokenRefreshThreshold).After(expiryTime) {
return l.RefreshOAuthToken() return l.RefreshOAuthToken()
} }
return nil return nil
@@ -238,7 +316,7 @@ func (l *Login) RefreshOAuthTokenIfNeeded() error {
// Uses double-checked locking to avoid unnecessary refresh calls when multiple // Uses double-checked locking to avoid unnecessary refresh calls when multiple
// processes race to refresh the same token. // processes race to refresh the same token.
func (l *Login) RefreshOAuthToken() error { func (l *Login) RefreshOAuthToken() error {
if l.RefreshToken == "" { if l.GetRefreshToken() == "" {
return fmt.Errorf("no refresh token available") return fmt.Errorf("no refresh token available")
} }
@@ -248,13 +326,17 @@ func (l *Login) RefreshOAuthToken() error {
for i, login := range config.Logins { for i, login := range config.Logins {
if login.Name == l.Name { if login.Name == l.Name {
// Check if token was refreshed by another process // Check if token was refreshed by another process
if login.TokenExpiry != l.TokenExpiry && login.TokenExpiry > 0 { currentExpiry := login.GetTokenExpiry()
expiryTime := time.Unix(login.TokenExpiry, 0) ourExpiry := l.GetTokenExpiry()
if time.Now().Add(TokenRefreshThreshold).Before(expiryTime) { if currentExpiry != ourExpiry && !currentExpiry.IsZero() {
if time.Now().Add(TokenRefreshThreshold).Before(currentExpiry) {
// Token was refreshed by another process, update our copy // Token was refreshed by another process, update our copy
if !login.IsOAuth() {
l.Token = login.Token l.Token = login.Token
l.RefreshToken = login.RefreshToken l.RefreshToken = login.RefreshToken
l.TokenExpiry = login.TokenExpiry l.TokenExpiry = login.TokenExpiry
}
// For OAuth logins, credstore already has the latest tokens
return nil return nil
} }
} }
@@ -265,7 +347,12 @@ func (l *Login) RefreshOAuthToken() error {
return err 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 l.Token = newToken.AccessToken
if newToken.RefreshToken != "" { if newToken.RefreshToken != "" {
l.RefreshToken = newToken.RefreshToken l.RefreshToken = newToken.RefreshToken
@@ -273,8 +360,6 @@ func (l *Login) RefreshOAuthToken() error {
if !newToken.Expiry.IsZero() { if !newToken.Expiry.IsZero() {
l.TokenExpiry = newToken.Expiry.Unix() l.TokenExpiry = newToken.Expiry.Unix()
} }
// Update in config slice and save
config.Logins[i] = *l config.Logins[i] = *l
return saveConfigUnsafe() return saveConfigUnsafe()
} }
@@ -286,10 +371,22 @@ func (l *Login) RefreshOAuthToken() error {
// doOAuthRefresh performs the actual OAuth token refresh API call. // doOAuthRefresh performs the actual OAuth token refresh API call.
func doOAuthRefresh(l *Login) (*oauth2.Token, error) { 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{ currentToken := &oauth2.Token{
AccessToken: l.Token, AccessToken: accessToken,
RefreshToken: l.RefreshToken, RefreshToken: refreshToken,
Expiry: time.Unix(l.TokenExpiry, 0), Expiry: expiry,
} }
ctx := context.Background() 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([]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() { if debug.IsDebug() {
options = append(options, gitea.SetDebugMode()) options = append(options, gitea.SetDebugMode())
} }

View File

@@ -85,7 +85,7 @@ func doPRFetch(
if err != nil { if err != nil {
return "", err 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 { if err != nil {
return "", err return "", err
} }

View File

@@ -96,7 +96,7 @@ call me again with the --ignore-sha flag`, remoteBranch)
if urlErr != nil { if urlErr != nil {
return urlErr 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 { if authErr != nil {
return authErr return authErr
} }

View File

@@ -211,7 +211,7 @@ func CreateAgitFlowPull(ctx *context.TeaContext, remote, head, base, topic strin
return err 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 { if err != nil {
return err return err
} }

View File

@@ -35,7 +35,7 @@ func RepoClone(
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }

View File

@@ -9,9 +9,11 @@ import (
"charm.land/lipgloss/v2/compat" "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) theme := huh.ThemeCharm(isDark)
title := compat.AdaptiveColor{Light: lipgloss.Color("#02BA84"), Dark: lipgloss.Color("#02BF87")} 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 return theme
} }
// GetTheme returns default theme // GetTheme returns the default theme for huh prompts.
func GetTheme() myTheme { func GetTheme() TeaTheme {
var t myTheme var t TeaTheme
return t return t
} }