Files
gitea-tea/internal/app/git_credentials.go
T
bircni a2bea8da23 feat(auth): guide login, readable status and switch without --host
tea auth login without a method flag asks on a terminal for the instance
(prefilled from the repository, default_host or the only instance), how
to log in (token, browser, SSH key), the pasted token without echo with
a link to the applications page, where to keep it and whether git
should use tea for the instance. Bare hosts are accepted. Logging in to
an account tea already has replaces its credential after a
confirmation, or with --yes; a named account is confirmed before the
token is asked for. Every login ends with "Logged in to HOST as USER
(token, keyring)". OAuth requests write scopes for everything but
administration by default, and --scopes chooses others; --web is an
alias of --oauth.

auth status prints a block per instance with the active account, how
each account authenticates, its expiry, verification and token scopes,
whether git uses tea, and the GITEA_TOKEN of the environment. Piped it
prints tab-separated fields. It fails with exit code 4 when no account
or no usable credential exists instead of printing nothing.

auth switch takes the instance of the repository, --host, default_host
or the sole instance instead of requiring --host, toggles between two
accounts, asks on a terminal with more, and accepts --user. auth logout
takes the account as an argument, names it in its errors, and asks git's
other credential helpers to forget what they stored for the account.
auth refresh points token accounts to tea auth login --with-token, and
the new auth token prints the token of an account for scripts.

Every auth command reports what it did on a terminal and has a long
help text and examples.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-11 23:50:44 +02:00

182 lines
6.3 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"errors"
"fmt"
"path/filepath"
"strings"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"github.com/spf13/cobra"
)
func (a *application) gitCredentialCommand() *cobra.Command {
return &cobra.Command{Use: "git-credential <get|store|erase>", Hidden: true, Annotations: textOutput("Git credentials use Git's protocol"), Short: "Serve Git's credential helper protocol", Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error {
if args[0] != "get" && args[0] != "store" && args[0] != "erase" {
return clierr.Wrap(clierr.Usage, errors.New("Git credential operation must be get, store, or erase"))
}
request, err := auth.ReadGitRequest(cmd.InOrStdin())
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
if err := cmd.Context().Err(); err != nil {
return err
}
if args[0] != "get" {
return nil
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
if snapshot.Hosts.Hosts, err = a.environmentHosts(snapshot.Hosts.Hosts); err != nil {
return err
}
environmentHost := a.tokenInstance()
instance, err := auth.MatchGitInstance(request, snapshot.Hosts.Hosts, a.options.host, environmentHost)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
if instance == "" {
return nil
}
host := snapshot.Hosts.Hosts[instance]
user, _ := a.selectedUser()
if user == "" {
user = request.Username
}
if user == "" {
user = host.User
}
if request.Username != "" && user != "" && !strings.EqualFold(request.Username, user) {
return nil
}
// Account keys retain the canonical login returned at authentication time.
if configured, ok := matchAccount(host.Users, user); ok {
user = configured
}
credential := auth.Credential{}
token := a.deps.Getenv("GITEA_TOKEN")
environmentMatches := false
if environmentHost != "" {
normalized, err := settings.NormalizeHost(environmentHost)
environmentMatches = err == nil && normalized == instance
}
if token != "" && environmentMatches {
actual, err := services.CurrentAccount(cmd.Context(), a.deps.Client(instance, token, host.Insecure))
if err != nil {
return err
}
if !services.MatchAccount(user, actual) {
return nil
}
user, credential.AccessToken = actual, token
} else {
if user == "" {
return nil
}
if _, exists := host.Users[user]; !exists {
return nil
}
manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials)
if err != nil {
return err
}
credential, err = manager.GitCredential(cmd.Context(), instance, user, a.deps.Client(instance, "", host.Insecure).HTTP, a.deps.Now())
if err != nil {
return clierr.Wrap(clierr.Authentication, err)
}
}
if strings.ContainsAny(user+credential.AccessToken, "\x00\r\n") {
return clierr.Wrap(clierr.Authentication, errors.New("stored Git credential contains invalid protocol characters; run tea auth login"))
}
if err := cmd.Context().Err(); err != nil {
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "username=%s\npassword=%s\n\n", user, credential.AccessToken)
return err
}}
}
func (a *application) setupGitCommand() *cobra.Command {
var local, keepHelpers bool
command := &cobra.Command{
Use: "setup-git", Short: "Configure git to use tea for credentials", Args: argsExact(0),
Annotations: textOutput("setup-git configures Git"),
Long: `Configure git to ask tea for the credentials of an instance, so that git push and fetch
over HTTPS use the active account, or the account of --user. tea's own clone and checkout
commands do not need this.
Other credential helpers, such as a system keychain, are turned off for the instance:
otherwise they answer first and keep a copy of tea's token. --keep-helpers keeps them.`,
Example: `$ tea auth setup-git
$ tea auth setup-git --host gitea.com
# Only for the current repository, always as alice
$ tea auth setup-git --local --user alice`,
RunE: func(cmd *cobra.Command, _ []string) error {
t, err := a.instanceTarget(cmd.Context())
if err != nil {
return err
}
return a.setupGit(cmd, t.Host, local, keepHelpers)
},
}
command.Flags().BoolVar(&local, "local", false, "Configure only the current Git repository instead of global Git configuration")
command.Flags().BoolVar(&keepHelpers, "keep-helpers", false, "Keep other credential helpers for the instance; they are asked first and store tea's token")
return command
}
// setupGit configures Git to ask tea for the credentials of instance, globally or in the repository.
func (a *application) setupGit(cmd *cobra.Command, instance string, local, keepHelpers bool) error {
executable, configDir, err := a.credentialHelper()
if err != nil {
return err
}
pinned, _ := a.selectedUser()
if err := git.SetupCredentials(cmd.Context(), a.deps.Git, git.CredentialSetup{Directory: a.options.cwd, Local: local, KeepHelpers: keepHelpers, Instance: instance, Executable: executable, User: pinned, ConfigDirectory: configDir}); err != nil {
return err
}
scope := "global git configuration"
if local {
scope = "this repository"
}
a.done(cmd, "Configured git to use tea for %s (%s)", instance, scope)
return nil
}
// credentialHelper locates tea's executable and absolute configuration directory for Git's credential helper.
func (a *application) credentialHelper() (executable, configDir string, err error) {
executable, err = a.deps.Executable()
if err != nil {
return "", "", errors.New("cannot locate tea's executable for Git authentication")
}
configDir, err = filepath.Abs(a.deps.Settings.Dir)
if err != nil {
return "", "", errors.New("cannot resolve configuration directory for Git authentication")
}
return executable, configDir, nil
}
func (a *application) gitCredentialArguments(instance, user string) ([]string, error) {
executable, configDir, err := a.credentialHelper()
if err != nil {
return nil, err
}
return git.CredentialArguments(instance, executable, user, configDir)
}
func (a *application) gitTransportArguments(target target, ssh bool) ([]string, error) {
if !ssh {
return a.gitCredentialArguments(target.Host, target.User)
}
account := target.host.Users[target.User]
return git.SSHArguments(account.SSHKey, account.SSHAgent)
}