From 9187a22c2a2ba2680e63302f4c4fa8ff6efb374b Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Thu, 27 Aug 2026 09:44:29 -0700 Subject: [PATCH] feat(login): add status command (#1087) Add `tea login status` to verify the stored token for one or all configured logins. It reports login validity, auth method, token expiry, and whether the git credential helper is configured, and captures OAuth refresh failures instead of exiting. Fixes #1087 --- cmd/login.go | 1 + cmd/login/status.go | 59 ++++++++++++ modules/config/login.go | 8 ++ modules/print/login_status.go | 145 +++++++++++++++++++++++++++++ modules/print/login_status_test.go | 38 ++++++++ modules/task/login_create.go | 23 +++++ modules/task/login_status.go | 67 +++++++++++++ modules/task/login_status_test.go | 75 +++++++++++++++ 8 files changed, 416 insertions(+) create mode 100644 cmd/login/status.go create mode 100644 modules/print/login_status.go create mode 100644 modules/print/login_status_test.go create mode 100644 modules/task/login_status.go create mode 100644 modules/task/login_status_test.go diff --git a/cmd/login.go b/cmd/login.go index fddf6f4b..0a2de65d 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -31,6 +31,7 @@ var CmdLogin = cli.Command{ &login.CmdLoginSetDefault, &login.CmdLoginHelper, &login.CmdLoginOAuthRefresh, + &login.CmdLoginStatus, }, } diff --git a/cmd/login/status.go b/cmd/login/status.go new file mode 100644 index 00000000..9cb852ba --- /dev/null +++ b/cmd/login/status.go @@ -0,0 +1,59 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package login + +import ( + "context" + "fmt" + + "gitea.dev/tea/cmd/flags" + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/print" + "gitea.dev/tea/modules/task" + + "github.com/urfave/cli/v3" +) + +// CmdLoginStatus represents a command to show authentication status for logins. +var CmdLoginStatus = cli.Command{ + Name: "status", + Usage: "Show authentication status for Gitea logins", + Description: `Verify the stored token for one or all Gitea logins and report its validity.`, + ArgsUsage: "[]", + Action: RunLoginStatus, + Flags: []cli.Flag{&flags.OutputFlag}, +} + +// RunLoginStatus verifies one login, or every configured login when no name is +// provided, and prints a short authentication report. +func RunLoginStatus(requestCtx context.Context, cmd *cli.Command) error { + var logins []config.Login + + switch cmd.Args().Len() { + case 0: + var err error + logins, err = config.GetLogins() + if err != nil { + return err + } + case 1: + login, err := config.GetLoginByName(cmd.Args().First()) + if err != nil { + return err + } + if login == nil { + return fmt.Errorf("login '%s' not found", cmd.Args().First()) + } + logins = []config.Login{*login} + default: + return fmt.Errorf("too many arguments") + } + + statuses := make([]print.LoginStatus, 0, len(logins)) + for i := range logins { + statuses = append(statuses, task.CheckLoginStatus(requestCtx, &logins[i])) + } + + return print.LoginStatuses(statuses, cmd.String("output")) +} diff --git a/modules/config/login.go b/modules/config/login.go index 3cd1adb7..7ae7968b 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -446,6 +446,14 @@ func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client { os.Exit(1) } + return l.ClientWithoutRefresh(options...) +} + +// ClientWithoutRefresh returns a client to operate the Gitea API without +// attempting an automatic OAuth token refresh. Commands that need to handle +// token refresh errors themselves (such as 'tea login status') should use this +// instead of Client, which prints to stderr and exits on refresh failure. +func (l *Login) ClientWithoutRefresh(options ...gitea.ClientOption) *gitea.Client { // Configure transport-level timeouts so a stalled or unresponsive server // fails fast instead of hanging forever. These bound connection setup and // time-to-first-response-byte only, so slow-but-progressing transfers (e.g. diff --git a/modules/print/login_status.go b/modules/print/login_status.go new file mode 100644 index 00000000..6ceb00ba --- /dev/null +++ b/modules/print/login_status.go @@ -0,0 +1,145 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "fmt" + "strings" + "time" +) + +// LoginStatus contains the authentication status of a single configured login. +type LoginStatus struct { + Name string + URL string + User string + Valid bool + AuthMethod string + TokenExpiry time.Time + Helper bool + Default bool + Error string +} + +// LoginStatusFields are the available fields to print with LoginStatuses. +var LoginStatusFields = []string{ + "name", + "url", + "user", + "valid", + "auth_method", + "token_expiry", + "helper", + "default", +} + +// LoginStatuses prints authentication status for one or more logins. +func LoginStatuses(statuses []LoginStatus, output string) error { + if output != "" { + printables := make([]printable, len(statuses)) + for i := range statuses { + printables[i] = statuses[i] + } + t := tableFromItems(LoginStatusFields, printables, isMachineReadable(output)) + return t.print(output) + } + + if len(statuses) == 0 { + fmt.Println("No logins configured.") + return nil + } + + for i, status := range statuses { + if i > 0 { + fmt.Println() + } + printLoginStatusReport(status) + } + return nil +} + +func printLoginStatusReport(status LoginStatus) { + name := status.Name + if status.Default { + name += " (default)" + } + fmt.Println(name) + + if status.Valid { + line := " ✔ Logged in to " + status.URL + if status.User != "" { + line += " as " + status.User + } + fmt.Println(line) + + tokenLine := " ✔ Token is valid" + if status.AuthMethod != "" { + tokenLine += " (" + status.AuthMethod + if !status.TokenExpiry.IsZero() { + tokenLine += ", " + formatTokenExpiry(status.TokenExpiry) + } + tokenLine += ")" + } + fmt.Println(tokenLine) + } else { + message := status.Error + if message == "" { + message = "Login failed" + } + fmt.Println(" ✗ " + message) + } + + if status.Helper { + fmt.Println(" ✔ Git credential helper configured") + } else { + fmt.Println(" ✗ Git credential helper not configured") + } +} + +func formatExpiryDuration(t time.Time) string { + d := time.Until(t) + if d < 0 { + return "expired" + } + if d < time.Minute { + return "in less than a minute" + } + return "in " + strings.TrimSuffix(d.Truncate(time.Minute).String(), "0s") +} + +func formatTokenExpiry(t time.Time) string { + if t.Before(time.Now()) { + return "expired" + } + return "expires " + formatExpiryDuration(t) +} + +// FormatField implements the printable interface for LoginStatus. +func (s LoginStatus) FormatField(field string, machineReadable bool) string { + switch field { + case "name": + return s.Name + case "url": + return s.URL + case "user": + return s.User + case "valid": + return formatBoolean(s.Valid, !machineReadable) + case "auth_method": + return s.AuthMethod + case "token_expiry": + if s.TokenExpiry.IsZero() { + return "" + } + if machineReadable { + return FormatTime(s.TokenExpiry, true) + } + return formatExpiryDuration(s.TokenExpiry) + case "helper": + return formatBoolean(s.Helper, !machineReadable) + case "default": + return formatBoolean(s.Default, !machineReadable) + } + return "" +} diff --git a/modules/print/login_status_test.go b/modules/print/login_status_test.go new file mode 100644 index 00000000..0f196e0a --- /dev/null +++ b/modules/print/login_status_test.go @@ -0,0 +1,38 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLoginStatusFormatField(t *testing.T) { + status := LoginStatus{ + Name: "gitea", + URL: "https://gitea.com", + User: "alice", + Valid: true, + AuthMethod: "oauth", + TokenExpiry: time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC), + Helper: true, + Default: true, + } + + assert.Equal(t, "gitea", status.FormatField("name", false)) + assert.Equal(t, "https://gitea.com", status.FormatField("url", false)) + assert.Equal(t, "alice", status.FormatField("user", false)) + assert.Equal(t, "true", status.FormatField("valid", true)) + assert.Equal(t, "✔", status.FormatField("valid", false)) + assert.Equal(t, "oauth", status.FormatField("auth_method", true)) + assert.Equal(t, "2026-08-27T12:00:00Z", status.FormatField("token_expiry", true)) + assert.Equal(t, "true", status.FormatField("helper", true)) + assert.Equal(t, "✔", status.FormatField("default", false)) +} + +func TestFormatExpiryDurationExpired(t *testing.T) { + assert.Equal(t, "expired", formatExpiryDuration(time.Now().Add(-time.Hour))) +} diff --git a/modules/task/login_create.go b/modules/task/login_create.go index 2a0e1050..5b0b6720 100644 --- a/modules/task/login_create.go +++ b/modules/task/login_create.go @@ -48,6 +48,29 @@ func SetupHelper(login config.Login) (ok bool, err error) { return true, nil } +// HasGitCredentialHelper reports whether tea is registered as a git credential +// helper for the given login. It mirrors the global git config lookup used by +// SetupHelper. +func HasGitCredentialHelper(login config.Login) bool { + if login.URL == "" { + return false + } + + helperKey := fmt.Sprintf("credential.%s.helper", login.URL) + currentHelpers, err := exec.Command("git", "config", "--global", "--get-all", helperKey).Output() + if err != nil { + return false + } + + for _, line := range strings.Split(strings.ReplaceAll(string(currentHelpers), "\r", ""), "\n") { + if strings.HasSuffix(strings.TrimSpace(line), "login helper") { + return true + } + } + + return false +} + // CreateLogin create a login to be stored in config func CreateLogin(ctx stdctx.Context, name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint string, insecure, sshAgent, versionCheck, addHelper bool) error { // checks ... diff --git a/modules/task/login_status.go b/modules/task/login_status.go new file mode 100644 index 00000000..b76a8974 --- /dev/null +++ b/modules/task/login_status.go @@ -0,0 +1,67 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "fmt" + "strings" + "time" + + "gitea.dev/tea/modules/config" + "gitea.dev/tea/modules/print" +) + +// CheckLoginStatus verifies the stored token for a login against the server and +// returns a printable status. Unlike config.Login.Client, refresh failures are +// captured in the returned status instead of terminating the process. +func CheckLoginStatus(ctx context.Context, login *config.Login) print.LoginStatus { + status := print.LoginStatus{ + Name: login.Name, + URL: login.URL, + AuthMethod: loginAuthMethod(login), + TokenExpiry: loginTokenExpiry(login), + Helper: HasGitCredentialHelper(*login), + Default: login.Default, + } + + if login.GetAccessToken() == "" { + status.Error = "Login failed: no access token configured" + return status + } + + if err := login.RefreshOAuthTokenIfNeeded(); err != nil { + status.Error = "Token refresh failed: " + strings.TrimPrefix(err.Error(), "failed to refresh token: ") + return status + } + + // A successful refresh updates the token in the secure store, so re-read the + // expiry for the status line. + status.TokenExpiry = loginTokenExpiry(login) + + user, _, err := login.ClientWithoutRefresh().Users.GetMyUserInfo(ctx) + if err != nil { + status.Error = fmt.Sprintf("Login failed: %s", err) + return status + } + + status.Valid = true + status.User = user.UserName + return status +} + +func loginAuthMethod(login *config.Login) string { + if login.IsOAuth() { + return config.AuthMethodOAuth + } + return "token" +} + +func loginTokenExpiry(login *config.Login) time.Time { + expiry := login.GetTokenExpiry() + if expiry.Equal(time.Unix(0, 0)) { + return time.Time{} + } + return expiry +} diff --git a/modules/task/login_status_test.go b/modules/task/login_status_test.go new file mode 100644 index 00000000..8fb4ac9c --- /dev/null +++ b/modules/task/login_status_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "gitea.dev/tea/modules/config" + + "github.com/stretchr/testify/assert" +) + +func TestCheckLoginStatus(t *testing.T) { + // Keep helper detection isolated from the developer's real git config. + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), ".gitconfig")) + + t.Run("valid token", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/user", r.URL.Path) + assert.Equal(t, "token secret-token", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":1,"login":"alice"}`)) + })) + defer server.Close() + + status := CheckLoginStatus(context.Background(), &config.Login{ + Name: "test", + URL: server.URL, + Token: "secret-token", + VersionCheck: false, + }) + + assert.True(t, status.Valid) + assert.Empty(t, status.Error) + assert.Equal(t, "test", status.Name) + assert.Equal(t, server.URL, status.URL) + assert.Equal(t, "alice", status.User) + assert.Equal(t, "token", status.AuthMethod) + assert.False(t, status.Helper) + }) + + t.Run("invalid token", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"token is invalid"}`)) + })) + defer server.Close() + + status := CheckLoginStatus(context.Background(), &config.Login{ + Name: "test", + URL: server.URL, + Token: "expired-token", + VersionCheck: false, + }) + + assert.False(t, status.Valid) + assert.Contains(t, status.Error, "token is invalid") + }) + + t.Run("missing token", func(t *testing.T) { + status := CheckLoginStatus(context.Background(), &config.Login{ + Name: "test", + URL: "https://gitea.example.com", + }) + + assert.False(t, status.Valid) + assert.Contains(t, status.Error, "no access token configured") + }) +}