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/docs/CLI.md b/docs/CLI.md index e9a57e65..2363574b 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -109,6 +109,12 @@ Return the stored token for a URL (git credential protocol) Refresh an OAuth token +### status + +Show authentication status for Gitea logins + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + ## logout Log out from a Gitea server 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") + }) +}