mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
827 lines
33 KiB
Go
827 lines
33 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package app
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
"gitea.dev/tea/internal/terminal"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// verifyAccount checks that a stored credential still authenticates as its account, and returns the
|
|
// scopes of its token when the instance reports them.
|
|
func (a *application) verifyAccount(ctx context.Context, policy terminal.Policy, instance, user string, host settings.Host, account settings.Account, token string) ([]string, error) {
|
|
client := a.deps.Client(instance, token, host.Insecure)
|
|
if account.Tokenless() {
|
|
signer, err := auth.SignerForAccount(account, a.sshPassphrase(ctx, policy, account.SSHKey))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client.Signer = signer
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
actual, err := services.CurrentAccount(ctx, client)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !strings.EqualFold(actual, user) {
|
|
return nil, errors.New("credential authenticates a different account")
|
|
}
|
|
if account.Tokenless() {
|
|
return nil, nil
|
|
}
|
|
scopes, _ := services.TokenScopes(ctx, client)
|
|
return scopes, nil
|
|
}
|
|
|
|
// sshPassphrase prompts for the passphrase of an encrypted SSH key, or returns nil when prompting is not allowed.
|
|
func (a *application) sshPassphrase(ctx context.Context, policy terminal.Policy, keyPath string) func() ([]byte, error) {
|
|
if a.deps.Prompter == nil || !policy.CanPrompt(a.deps.Streams) {
|
|
return nil
|
|
}
|
|
return func() ([]byte, error) {
|
|
passphrase, err := a.deps.Prompter.SecretInput(ctx, "Passphrase for "+keyPath)
|
|
return []byte(passphrase), err
|
|
}
|
|
}
|
|
|
|
// instanceTarget selects the instance and account an auth command acts on, without authenticating:
|
|
// with no --host, the instance of the working directory's repository when tea has accounts there,
|
|
// else the one other commands select.
|
|
func (a *application) instanceTarget(ctx context.Context) (target, error) {
|
|
if a.options.host == "" {
|
|
if t, err := a.resolveTarget(ctx, true, false); err == nil && len(t.host.Users) > 0 {
|
|
return t, nil
|
|
}
|
|
}
|
|
return a.resolveTarget(ctx, false, false)
|
|
}
|
|
|
|
// credentialLabel describes how an account authenticates, such as "token, keyring".
|
|
func credentialLabel(account settings.Account) string {
|
|
switch account.AuthMethod {
|
|
case settings.AuthSSH:
|
|
return "SSH certificate"
|
|
case settings.AuthHTTPSign:
|
|
if account.SSHAgent {
|
|
return "SSH key in ssh-agent"
|
|
}
|
|
return "SSH key"
|
|
}
|
|
method, store := "token", "keyring"
|
|
if account.AuthMethod == settings.AuthOAuth {
|
|
method = "OAuth"
|
|
}
|
|
if account.CredentialStore == settings.StoreFile {
|
|
store = "file store"
|
|
}
|
|
return method + ", " + store
|
|
}
|
|
|
|
func (a *application) authCommand() *cobra.Command {
|
|
group := &cobra.Command{Use: "auth", Short: "Authenticate tea and git with Gitea", Long: `Authenticate tea and git with Gitea.
|
|
|
|
tea keeps the accounts of each instance in hosts.yml and their tokens in the system
|
|
keyring, or in a private file when you choose so. One account per instance is active;
|
|
--user or TEA_USER selects another for a single command. GITEA_TOKEN, together with
|
|
GITEA_INSTANCE_URL or TEA_HOST, replaces the stored credentials of its instance. See
|
|
tea help environment for how commands select the instance.`}
|
|
group.AddCommand(a.loginCommand(), a.logoutCommand(), a.authStatusCommand(), a.authSwitchCommand(), a.authRefreshCommand(), a.authTokenCommand(), a.setupGitCommand(), a.gitCredentialCommand())
|
|
return group
|
|
}
|
|
|
|
// oauthScopes are the token scopes that tea auth login --scopes accepts.
|
|
var oauthScopes = func() []string {
|
|
scopes := []string{"all"}
|
|
for _, area := range []string{"activitypub", "admin", "issue", "misc", "notification", "organization", "package", "repository", "user"} {
|
|
scopes = append(scopes, "read:"+area, "write:"+area)
|
|
}
|
|
return scopes
|
|
}()
|
|
|
|
func (a *application) loginCommand() *cobra.Command {
|
|
var withToken, oauth, insecure bool
|
|
var backend, clientID, sshKey, sshAgentPrincipal, sshAgentKey string
|
|
var scopes []string
|
|
login := &cobra.Command{
|
|
Use: "login", Short: "Log in to a Gitea instance", Args: argsExact(0),
|
|
Annotations: map[string]string{"tea.text": "login does not return a resource", "tea.confirm": "true"},
|
|
Long: `Log in to a Gitea instance. On a terminal without a method flag, tea asks for the instance,
|
|
how to log in, where to keep the token and whether git should use tea for the instance.
|
|
|
|
Create a token on the instance under Settings > Applications. Logging in to an account
|
|
that tea already has replaces its stored credential after a confirmation (or with --yes).`,
|
|
Example: `$ tea auth login
|
|
$ tea auth login --host gitea.com --with-token < token.txt
|
|
$ tea auth login --host gitea.com --oauth --scopes read:repository,read:user`,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
selected := 0
|
|
for _, chosen := range []bool{withToken, oauth, sshKey != "", sshAgentPrincipal != "", sshAgentKey != ""} {
|
|
if chosen {
|
|
selected++
|
|
}
|
|
}
|
|
switch {
|
|
case selected > 1:
|
|
return clierr.Wrap(clierr.Usage, errors.New("select one of --with-token, --oauth, --ssh-key, --ssh-agent-principal or --ssh-agent-key"))
|
|
case selected == 1 && !oauth && clientID != "":
|
|
return clierr.Wrap(clierr.Usage, errors.New("--client-id requires --oauth"))
|
|
case selected == 1 && !oauth && cmd.Flags().Changed("scopes"):
|
|
return clierr.Wrap(clierr.Usage, errors.New("--scopes requires --oauth"))
|
|
}
|
|
snapshot, err := a.settings()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
form, hasForm := a.deps.Prompter.(terminal.LoginForm)
|
|
interactive := selected == 0
|
|
if interactive && (!hasForm || !a.policy(snapshot).CanPrompt(a.deps.Streams)) {
|
|
return clierr.Wrap(clierr.Usage, errors.New("select how to log in: --with-token, --oauth or --ssh-key; on a terminal, tea auth login asks"))
|
|
}
|
|
resolveHost := func(raw string) (string, error) { return settings.ResolveHost(raw, snapshot.Hosts.Hosts) }
|
|
instance := cmp.Or(a.options.host, a.deps.Getenv("TEA_HOST"), a.deps.Getenv("GITEA_INSTANCE_URL"))
|
|
if instance != "" {
|
|
if instance, err = resolveHost(instance); err != nil {
|
|
return clierr.Wrap(clierr.Usage, err)
|
|
}
|
|
}
|
|
answers := terminal.LoginAnswers{Host: instance, Store: backend, SSHKey: sshKey}
|
|
switch {
|
|
case oauth:
|
|
answers.Method = terminal.LoginOAuth
|
|
case selected == 1 && !withToken:
|
|
answers.Method = terminal.LoginSSHKey
|
|
default:
|
|
answers.Method = terminal.LoginToken
|
|
}
|
|
if interactive {
|
|
if answers.Host == "" {
|
|
answers.Host = a.suggestedHost(cmd.Context())
|
|
}
|
|
answers.SSHKey, answers.SetupGit = defaultSSHKey(), true
|
|
questions := terminal.LoginQuestions{
|
|
Host: instance == "", Method: true, Store: !cmd.Flags().Changed("credential-store"), SetupGit: true,
|
|
ResolveHost: resolveHost,
|
|
TokenHint: func(host string) string { return "Create one at " + host + "/user/settings/applications" },
|
|
}
|
|
if err := form.Login(cmd.Context(), questions, &answers); err != nil {
|
|
return err
|
|
}
|
|
} else if answers.Host == "" {
|
|
if _, err := a.requireInput(cmd, &answers.Host, "host", "Gitea instance", func(raw string) error { _, err := resolveHost(raw); return err }); err != nil {
|
|
return err
|
|
}
|
|
if answers.Host, err = resolveHost(answers.Host); err != nil {
|
|
return clierr.Wrap(clierr.Usage, err)
|
|
}
|
|
}
|
|
instance = answers.Host
|
|
host := snapshot.Hosts.Hosts[instance]
|
|
insecure = insecure || host.Insecure
|
|
replacing := false
|
|
if selectedUser, _ := a.selectedUser(); selectedUser != "" {
|
|
if replacing, err = a.confirmReplace(cmd, instance, selectedUser); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if answers.Method == terminal.LoginSSHKey {
|
|
account, user, err := a.signedLogin(cmd, snapshot, instance, insecure, settings.Account{SSHKey: answers.SSHKey}, sshAgentPrincipal, sshAgentKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !replacing {
|
|
if _, err := a.confirmReplace(cmd, instance, user); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := manager.RegisterHTTPSign(instance, user, account, auth.HostOptions{Insecure: insecure, Replace: true}); err != nil {
|
|
return err
|
|
}
|
|
a.done(cmd, "Logged in to %s as %s (%s)", instance, user, credentialLabel(account))
|
|
return nil
|
|
}
|
|
credential := auth.Credential{AccessToken: answers.Token}
|
|
method := settings.AuthToken
|
|
switch {
|
|
case answers.Method == terminal.LoginOAuth:
|
|
if a.options.noInput {
|
|
return clierr.Wrap(clierr.Usage, errors.New("OAuth authorization requires interaction; use --with-token with --no-input"))
|
|
}
|
|
clientID = cmp.Or(clientID, host.OAuthClientID, auth.DefaultClientID)
|
|
credential, err = auth.OAuthLogin(cmd.Context(), auth.OAuthOptions{Instance: instance, ClientID: clientID, Scopes: scopes, HTTP: a.deps.Client(instance, "", insecure).HTTP, Authorize: func(_ context.Context, authorizationURL string) error {
|
|
if _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Open this URL to authorize tea:", authorizationURL); err != nil {
|
|
return err
|
|
}
|
|
if err := a.openBrowser(cmd, authorizationURL); err != nil {
|
|
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Could not open a browser automatically; open the URL above manually.")
|
|
}
|
|
return nil
|
|
}})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
method = settings.AuthOAuth
|
|
case interactive:
|
|
case a.deps.Streams.InTTY:
|
|
if !a.policy(snapshot).CanPrompt(a.deps.Streams) {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--with-token reads the token from stdin; pipe it in, for example: tea auth login --with-token < token.txt"))
|
|
}
|
|
token, err := a.deps.Prompter.SecretInput(cmd.Context(), "Paste an access token")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if credential.AccessToken = strings.TrimSpace(token); credential.AccessToken == "" {
|
|
return clierr.Wrap(clierr.Usage, errors.New("access token cannot be empty"))
|
|
}
|
|
default:
|
|
data, err := io.ReadAll(io.LimitReader(a.deps.Streams.In, 65537))
|
|
if err != nil {
|
|
return errors.New("cannot read token from stdin")
|
|
}
|
|
credential.AccessToken = strings.TrimSpace(string(data))
|
|
if credential.AccessToken == "" || len(data) > 65536 || strings.ContainsAny(credential.AccessToken, "\r\n") {
|
|
return clierr.Wrap(clierr.Usage, errors.New("stdin must contain one nonempty token"))
|
|
}
|
|
}
|
|
user, err := services.CurrentAccount(cmd.Context(), a.deps.Client(instance, credential.AccessToken, insecure))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if selectedUser, _ := a.selectedUser(); !services.MatchAccount(selectedUser, user) {
|
|
return clierr.Wrap(clierr.Usage, errors.New("token account does not match --user or TEA_USER"))
|
|
}
|
|
if !replacing {
|
|
if _, err := a.confirmReplace(cmd, instance, user); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
account := settings.Account{AuthMethod: method, CredentialStore: answers.Store}
|
|
if err := manager.Register(instance, user, answers.Store, method, auth.HostOptions{OAuthClientID: clientID, Insecure: insecure, Replace: true}, credential); err != nil {
|
|
return err
|
|
}
|
|
a.done(cmd, "Logged in to %s as %s (%s)", instance, user, credentialLabel(account))
|
|
if interactive && answers.SetupGit {
|
|
if err := a.setupGit(cmd, instance, false, false); err != nil {
|
|
a.warn(cmd, "Could not configure git: %v; run tea auth setup-git", err)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
login.Flags().BoolVar(&oauth, "oauth", false, "Authorize in a web browser with OAuth")
|
|
login.Flags().StringVar(&clientID, "client-id", "", "OAuth application `ID` registered on the instance (default: the instance's stored ID, then tea's)")
|
|
login.Flags().StringSliceVar(&scopes, "scopes", nil, "OAuth token `scopes`, such as read:repository (default: write access to everything but administration)")
|
|
login.Flags().BoolVar(&insecure, "insecure", false, "Skip TLS certificate verification for this instance")
|
|
login.Flags().BoolVar(&withToken, "with-token", false, "Read an access token from stdin, or ask for it on a terminal")
|
|
login.Flags().StringVar(&sshKey, "ssh-key", "", "Sign requests with the SSH private key in `file`")
|
|
login.Flags().StringVar(&sshAgentPrincipal, "ssh-agent-principal", "", "Sign requests with the ssh-agent certificate of `principal`")
|
|
login.Flags().StringVar(&sshAgentKey, "ssh-agent-key", "", "Sign requests with the ssh-agent key of `fingerprint`")
|
|
enumFlag(login, &backend, "credential-store", "", settings.StoreKeyring, "Where to keep the token", settings.StoreKeyring, settings.StoreFile)
|
|
enumValues(login, "scopes", oauthScopes...)
|
|
flagAliases(login, map[string]string{"web": "oauth"})
|
|
return login
|
|
}
|
|
|
|
// confirmReplace asks before a login replaces the stored credential of an account that tea already
|
|
// has, and reports whether it does.
|
|
func (a *application) confirmReplace(cmd *cobra.Command, instance, user string) (bool, error) {
|
|
snapshot, err := a.settings()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
configured, exists := matchAccount(snapshot.Hosts.Hosts[instance].Users, user)
|
|
if !exists {
|
|
return false, nil
|
|
}
|
|
return true, a.confirmAction(cmd, terminal.Confirmation{
|
|
Prompt: fmt.Sprintf("%s is already logged in to %s. Replace the stored credential?", configured, instance),
|
|
DefaultYes: true,
|
|
NonTTY: "replace the stored credential of " + configured + " on " + instance,
|
|
})
|
|
}
|
|
|
|
// signedLogin authenticates with an SSH key or ssh-agent and returns the account to store and its login.
|
|
func (a *application) signedLogin(cmd *cobra.Command, snapshot settings.Snapshot, instance string, insecure bool, account settings.Account, agentPrincipal, agentKey string) (settings.Account, string, error) {
|
|
switch {
|
|
case agentPrincipal != "":
|
|
account = settings.Account{AuthMethod: settings.AuthSSH, SSHAgent: true, SSHCertPrincipal: agentPrincipal}
|
|
case agentKey != "":
|
|
account = settings.Account{AuthMethod: settings.AuthHTTPSign, SSHAgent: true, SSHKeyFingerprint: agentKey}
|
|
default:
|
|
if _, err := os.Stat(account.SSHKey + "-cert.pub"); err == nil {
|
|
account.AuthMethod, account.SSHCertPrincipal = settings.AuthSSH, "yes"
|
|
} else if errors.Is(err, os.ErrNotExist) {
|
|
account.AuthMethod = settings.AuthHTTPSign
|
|
} else {
|
|
return account, "", err
|
|
}
|
|
}
|
|
signer, err := auth.SignerForAccount(account, a.sshPassphrase(cmd.Context(), a.policy(snapshot), account.SSHKey))
|
|
if err != nil {
|
|
return account, "", clierr.Wrap(clierr.Authentication, err)
|
|
}
|
|
client := a.deps.Client(instance, "", insecure)
|
|
client.Signer = signer
|
|
user, err := services.CurrentAccount(cmd.Context(), client)
|
|
if err != nil {
|
|
return account, "", err
|
|
}
|
|
if selectedUser, _ := a.selectedUser(); !services.MatchAccount(selectedUser, user) {
|
|
return account, "", clierr.Wrap(clierr.Usage, errors.New("authenticated account does not match --user or TEA_USER"))
|
|
}
|
|
return account, user, nil
|
|
}
|
|
|
|
// suggestedHost returns the instance that other commands would select, to prefill a login.
|
|
func (a *application) suggestedHost(ctx context.Context) string {
|
|
for _, repository := range []bool{true, false} {
|
|
if t, err := a.resolveTarget(ctx, repository, false); err == nil && t.Host != "" {
|
|
return t.Host
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// defaultSSHKey returns the first of the usual SSH private keys that exists.
|
|
func defaultSSHKey() string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, name := range []string{"id_ed25519", "id_ecdsa", "id_rsa"} {
|
|
path := filepath.Join(home, ".ssh", name)
|
|
if _, err := os.Stat(path); err == nil {
|
|
return path
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// authStatusRow is one account, or the environment token, that auth status reports.
|
|
type authStatusRow struct {
|
|
Host string `json:"host"`
|
|
User string `json:"user"`
|
|
Active bool `json:"active"`
|
|
Method string `json:"method"`
|
|
Store string `json:"credential_store"`
|
|
Available bool `json:"credential_available"`
|
|
ExpiresAt *time.Time `json:"expires_at"`
|
|
Verified *bool `json:"verified"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
Error string `json:"error"`
|
|
account settings.Account
|
|
}
|
|
|
|
// usable reports whether the row's credential can authenticate, as far as tea knows.
|
|
func (r authStatusRow) usable() bool {
|
|
return r.Available && (r.Verified == nil || *r.Verified)
|
|
}
|
|
|
|
// environment reports whether the row is the GITEA_TOKEN of the environment.
|
|
func (r authStatusRow) environment() bool {
|
|
return r.Store == "environment"
|
|
}
|
|
|
|
func (a *application) authStatusCommand() *cobra.Command {
|
|
var verify bool
|
|
status := &cobra.Command{
|
|
Use: "status", Short: "Show the accounts tea can use", Args: argsExact(0),
|
|
Long: `Show the accounts tea can use on each instance, the active one, and how they
|
|
authenticate. Fails with exit code 4 when there is no account, or when a credential is
|
|
missing or rejected.`,
|
|
Example: `$ tea auth status
|
|
# Check the credentials against their instances
|
|
$ tea auth status --verify
|
|
$ tea auth status --host gitea.com --json`,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
s, err := a.settings()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
selectedHost := ""
|
|
if a.options.host != "" {
|
|
if selectedHost, err = settings.ResolveHost(a.options.host, s.Hosts.Hosts); err != nil {
|
|
return clierr.Wrap(clierr.Usage, err)
|
|
}
|
|
}
|
|
rows := []authStatusRow{}
|
|
for instance, host := range s.Hosts.Hosts {
|
|
if selectedHost != "" && instance != selectedHost {
|
|
continue
|
|
}
|
|
for user := range host.Users {
|
|
if !services.MatchAccount(a.options.user, user) {
|
|
continue
|
|
}
|
|
rows = append(rows, a.accountStatus(cmd.Context(), s, instance, user, verify))
|
|
}
|
|
}
|
|
if row, ok := a.environmentStatus(cmd.Context(), s, verify); ok && (selectedHost == "" || row.Host == selectedHost) && services.MatchAccount(a.options.user, cmp.Or(row.User, a.options.user)) {
|
|
rows = append(rows, row)
|
|
}
|
|
slices.SortFunc(rows, func(x, y authStatusRow) int {
|
|
return cmp.Or(strings.Compare(x.Host, y.Host), cmp.Compare(boolOrder(y.environment()), boolOrder(x.environment())), strings.Compare(x.User, y.User))
|
|
})
|
|
if a.options.json != "" {
|
|
return a.writeJSON(cmd, rows)
|
|
}
|
|
if len(rows) == 0 {
|
|
if selectedHost != "" {
|
|
return clierr.Wrap(clierr.Authentication, fmt.Errorf("not logged in to %s; run tea auth login --host %s", selectedHost, selectedHost))
|
|
}
|
|
return clierr.Wrap(clierr.Authentication, errors.New("not logged in to any Gitea instance; run tea auth login"))
|
|
}
|
|
if err := a.writeAuthStatus(cmd, rows); err != nil {
|
|
return err
|
|
}
|
|
if failed := len(slices.DeleteFunc(slices.Clone(rows), authStatusRow.usable)); failed > 0 {
|
|
return clierr.Wrap(clierr.Authentication, fmt.Errorf("%d of %d credentials cannot authenticate; log in again with tea auth login", failed, len(rows)))
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
status.Flags().BoolVar(&verify, "verify", false, "Check each credential against its instance")
|
|
return status
|
|
}
|
|
|
|
// accountStatus reports a stored account without revealing its credential.
|
|
func (a *application) accountStatus(ctx context.Context, s settings.Snapshot, instance, user string, verify bool) authStatusRow {
|
|
host := s.Hosts.Hosts[instance]
|
|
account := host.Users[user]
|
|
row := authStatusRow{Host: instance, User: user, Active: host.User == user, Method: account.AuthMethod, Store: account.CredentialStore, Available: account.Tokenless(), account: account}
|
|
var credential auth.Credential
|
|
if !row.Available {
|
|
var err error
|
|
if credential, err = a.deps.Credentials.Load(instance, user, account.CredentialStore); err != nil {
|
|
row.Error = err.Error()
|
|
}
|
|
row.Available = err == nil
|
|
if row.Available && account.AuthMethod == settings.AuthOAuth && !credential.ExpiresAt.IsZero() {
|
|
row.ExpiresAt = &credential.ExpiresAt
|
|
}
|
|
}
|
|
if verify {
|
|
err := errors.New("credential unavailable")
|
|
if row.Available {
|
|
row.Scopes, err = a.verifyAccount(ctx, a.policy(s), instance, user, host, account, credential.AccessToken)
|
|
}
|
|
verified := err == nil
|
|
row.Verified = &verified
|
|
if err != nil {
|
|
row.Error = err.Error()
|
|
}
|
|
}
|
|
return row
|
|
}
|
|
|
|
// environmentStatus reports the GITEA_TOKEN of the environment, when one is set.
|
|
func (a *application) environmentStatus(ctx context.Context, s settings.Snapshot, verify bool) (authStatusRow, bool) {
|
|
token := a.deps.Getenv("GITEA_TOKEN")
|
|
if token == "" {
|
|
return authStatusRow{}, false
|
|
}
|
|
row := authStatusRow{Method: settings.AuthToken, Store: "environment", Active: true, Available: true}
|
|
instance, err := settings.ResolveHost(a.tokenInstance(), s.Hosts.Hosts)
|
|
if a.tokenInstance() == "" || err != nil {
|
|
row.Available, row.Error = false, "GITEA_TOKEN needs GITEA_INSTANCE_URL or TEA_HOST naming its instance"
|
|
return row, true
|
|
}
|
|
row.Host = instance
|
|
if verify {
|
|
client := a.deps.Client(instance, token, s.Hosts.Hosts[instance].Insecure)
|
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
user, err := services.CurrentAccount(ctx, client)
|
|
verified := err == nil
|
|
row.User, row.Verified = user, &verified
|
|
if err != nil {
|
|
row.Error = err.Error()
|
|
} else {
|
|
row.Scopes, _ = services.TokenScopes(ctx, client)
|
|
}
|
|
}
|
|
return row, true
|
|
}
|
|
|
|
// writeAuthStatus shows the accounts by instance on a terminal, and as tab-separated rows of host,
|
|
// user, active, method, store, credential availability, verification, expiry and error otherwise.
|
|
func (a *application) writeAuthStatus(cmd *cobra.Command, rows []authStatusRow) error {
|
|
d := a.display()
|
|
if !d.TTY {
|
|
return writeTable(a, cmd, rows, listSpec[authStatusRow]{Row: func(row authStatusRow) []terminal.Cell {
|
|
verified, expires := "", terminal.Cell{}
|
|
if row.Verified != nil {
|
|
verified = fmt.Sprint(*row.Verified)
|
|
}
|
|
if row.ExpiresAt != nil {
|
|
expires.Time = *row.ExpiresAt
|
|
}
|
|
return []terminal.Cell{{Text: row.Host}, {Text: row.User}, {Text: fmt.Sprint(row.Active)}, {Text: row.Method}, {Text: row.Store}, {Text: fmt.Sprint(row.Available)}, {Text: verified}, expires, {Text: row.Error}}
|
|
}}, listPage{Number: 1})
|
|
}
|
|
byHost := map[string][]authStatusRow{}
|
|
for _, row := range rows {
|
|
byHost[row.Host] = append(byHost[row.Host], row)
|
|
}
|
|
var b strings.Builder
|
|
for _, instance := range slices.Sorted(maps.Keys(byHost)) {
|
|
if b.Len() > 0 {
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(terminal.Emphasis(cmp.Or(instance, "unknown instance"), d.Color) + "\n")
|
|
stored := false
|
|
for _, row := range byHost[instance] {
|
|
icon := terminal.Colorize("green", "✓", d.Color)
|
|
if !row.usable() {
|
|
icon = terminal.Colorize("red", "X", d.Color)
|
|
}
|
|
b.WriteString(" " + icon + " " + statusLine(row, d) + "\n")
|
|
stored = stored || (!row.environment() && !row.account.Tokenless())
|
|
}
|
|
if stored {
|
|
line := "Git credential helper: not configured; run tea auth setup-git"
|
|
if scope := git.HelperScope(cmd.Context(), a.gitProbe(), a.options.cwd, instance); scope != "" {
|
|
line = "Git credential helper: configured (" + scope + ")"
|
|
}
|
|
b.WriteString(" " + terminal.Colorize("muted", "- "+line, d.Color) + "\n")
|
|
}
|
|
}
|
|
return a.writeHuman(cmd, b.String())
|
|
}
|
|
|
|
// statusLine describes one account of auth status: "Logged in as alice (token, keyring) · active".
|
|
func statusLine(row authStatusRow, d terminal.Display) string {
|
|
muted := func(text string) string { return terminal.Colorize("muted", text, d.Color) }
|
|
var line string
|
|
if row.environment() {
|
|
line = "Using GITEA_TOKEN from the environment"
|
|
if row.User != "" {
|
|
line += " as " + terminal.Emphasis(row.User, d.Color)
|
|
}
|
|
} else {
|
|
line = "Logged in as " + terminal.Emphasis(terminal.SafeLine(row.User), d.Color) + " " + muted("("+credentialLabel(row.account)+")")
|
|
}
|
|
var notes []string
|
|
if row.Active && !row.environment() {
|
|
notes = append(notes, "active")
|
|
}
|
|
if row.ExpiresAt != nil {
|
|
if d.Now.Before(*row.ExpiresAt) {
|
|
notes = append(notes, "token expires "+terminal.RelativeTime(d.Now, *row.ExpiresAt))
|
|
} else {
|
|
notes = append(notes, "token expired; tea refreshes it when used")
|
|
}
|
|
}
|
|
if row.Verified != nil && *row.Verified {
|
|
note := "verified"
|
|
if len(row.Scopes) > 0 {
|
|
note += ", scopes: " + strings.Join(row.Scopes, ", ")
|
|
}
|
|
notes = append(notes, note)
|
|
}
|
|
if row.Error != "" {
|
|
notes = append(notes, terminal.Colorize("red", terminal.SafeLine(row.Error), d.Color))
|
|
}
|
|
if len(notes) > 0 {
|
|
line += " · " + strings.Join(notes, " · ")
|
|
}
|
|
return line
|
|
}
|
|
|
|
func (a *application) authSwitchCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "switch [<user>]", Short: "Switch the active account of an instance", Args: usageArgs(cobra.MaximumNArgs(1)),
|
|
Annotations: textOutput("switching accounts does not return a resource"), ValidArgsFunction: a.completeAccounts,
|
|
Long: `Switch the active account of an instance, which commands use unless --user or TEA_USER
|
|
selects another. With two accounts, tea auth switch toggles between them; with more, it
|
|
asks which one on a terminal.`,
|
|
Example: "$ tea auth switch\n$ tea auth switch bob\n$ tea auth switch bob --host gitea.com",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
t, err := a.instanceTarget(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
users := t.host.Users
|
|
if len(users) == 0 {
|
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("not logged in to %s; run tea auth login --host %s", t.Host, t.Host))
|
|
}
|
|
names := slices.Sorted(maps.Keys(users))
|
|
requested := a.options.user
|
|
if len(args) == 1 {
|
|
requested = args[0]
|
|
}
|
|
var user string
|
|
switch {
|
|
case requested != "":
|
|
matched, ok := matchAccount(users, requested)
|
|
if !ok {
|
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("account %q is not logged in on %s (accounts: %s)", requested, t.Host, accountNames(users)))
|
|
}
|
|
user = matched
|
|
case len(names) == 1:
|
|
return fmt.Errorf("%s is the only account on %s; log in to another with tea auth login --host %s", names[0], t.Host, t.Host)
|
|
case len(names) == 2 && t.host.User != "":
|
|
user = names[0]
|
|
if user == t.host.User {
|
|
user = names[1]
|
|
}
|
|
default:
|
|
if user, err = a.chooseAccount(cmd, t.Host, names, t.host.User); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if user == t.host.User {
|
|
a.warn(cmd, "%s is already the active account on %s", user, t.Host)
|
|
return nil
|
|
}
|
|
err = a.deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error {
|
|
host := s.Hosts.Hosts[t.Host]
|
|
if _, exists := host.Users[user]; !exists {
|
|
return fmt.Errorf("account %q was logged out meanwhile", user)
|
|
}
|
|
host.User = user
|
|
s.Hosts.Hosts[t.Host] = host
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.done(cmd, "Switched active account for %s to %s", t.Host, user)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// chooseAccount asks which of the accounts of instance to use, or explains how to name one.
|
|
func (a *application) chooseAccount(cmd *cobra.Command, instance string, names []string, active string) (string, error) {
|
|
chooser, ok := a.chooser()
|
|
if !ok {
|
|
return "", clierr.Wrap(clierr.Usage, fmt.Errorf("name the account: tea auth switch <user> (accounts on %s: %s)", instance, strings.Join(names, ", ")))
|
|
}
|
|
choices := make([]terminal.Choice, len(names))
|
|
for i, name := range names {
|
|
choices[i] = terminal.Choice{Label: name, Value: name}
|
|
if name == active {
|
|
choices[i].Label += " (active)"
|
|
}
|
|
}
|
|
return chooser.Choose(cmd.Context(), "Which account of "+instance+" should be active?", choices, active)
|
|
}
|
|
|
|
func (a *application) logoutCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "logout [<user>]", Short: "Log out of an account", Args: usageArgs(cobra.MaximumNArgs(1)), ValidArgsFunction: a.completeAccounts,
|
|
Annotations: map[string]string{"tea.text": "logout does not return a resource", "tea.confirm": "true"},
|
|
Long: `Log out of an account: tea forgets the account and deletes its stored credential. Git's
|
|
other credential helpers are asked to forget what they stored for the account on the
|
|
instance, such as a copy of its token that a keychain kept after a push.`,
|
|
Example: "$ tea auth logout\n$ tea auth logout bob --host gitea.com\n# Without confirmation\n$ tea auth logout alice --yes",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
if len(args) == 1 {
|
|
if a.options.user != "" && !strings.EqualFold(a.options.user, args[0]) {
|
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("--user %s and the account %s differ", a.options.user, args[0]))
|
|
}
|
|
a.options.user = args[0]
|
|
}
|
|
t, err := a.instanceTarget(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
account, exists := t.host.Users[t.User]
|
|
switch {
|
|
case len(t.host.Users) == 0:
|
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("not logged in to %s", t.Host))
|
|
case t.User == "":
|
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("no active account on %s; name the account: tea auth logout <user> (accounts: %s)", t.Host, accountNames(t.host.Users)))
|
|
case !exists:
|
|
return clierr.Wrap(clierr.Usage, t.accountError(t.User))
|
|
}
|
|
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: fmt.Sprintf("Log out %s from %s?", t.User, t.Host)}); err != nil {
|
|
return err
|
|
}
|
|
manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := manager.Logout(t.Host, t.User); err != nil {
|
|
return err
|
|
}
|
|
a.done(cmd, "Logged out of %s (%s)", t.Host, t.User)
|
|
if !account.Tokenless() {
|
|
if err := git.RejectCredentials(cmd.Context(), a.gitProbe(), a.options.cwd, t.Host, t.User); err != nil {
|
|
a.warn(cmd, "Could not ask git's credential helpers to forget %s on %s", t.User, t.Host)
|
|
}
|
|
}
|
|
if s, err := a.settings(); err == nil {
|
|
if active := s.Hosts.Hosts[t.Host].User; active != "" && active != t.host.User {
|
|
a.done(cmd, "Switched active account for %s to %s", t.Host, active)
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func (a *application) authRefreshCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "refresh", Short: "Refresh an OAuth token", Args: argsExact(0),
|
|
Annotations: textOutput("refresh does not return a resource"),
|
|
Long: `Refresh the OAuth token of the active account, or of --user, now. tea refreshes expiring
|
|
OAuth tokens on its own; tokens pasted with --with-token cannot be refreshed and are
|
|
replaced by logging in again.`,
|
|
Example: "$ tea auth refresh\n$ tea auth refresh --host gitea.com --user alice",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
t, err := a.instanceTarget(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
account, exists := t.host.Users[t.User]
|
|
if !exists {
|
|
return clierr.Wrap(clierr.Authentication, t.accountError(t.User))
|
|
}
|
|
if account.AuthMethod != settings.AuthOAuth {
|
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("only OAuth accounts can be refreshed; replace the token of %s with: tea auth login --host %s --with-token", t.User, t.Host))
|
|
}
|
|
manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := manager.Refresh(cmd.Context(), t.Host, t.User, a.deps.Client(t.Host, "", t.host.Insecure).HTTP, true, a.deps.Now()); err != nil {
|
|
return clierr.Wrap(clierr.Authentication, err)
|
|
}
|
|
a.done(cmd, "Refreshed the OAuth token of %s on %s", t.User, t.Host)
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func (a *application) authTokenCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "token", Short: "Print the access token of an account", Args: argsExact(0),
|
|
Annotations: textOutput("auth token prints the token"),
|
|
Long: `Print the access token that tea uses for the active account, or for --user, so that
|
|
scripts can call the API themselves. The GITEA_TOKEN of the instance takes precedence, as
|
|
it does for other commands, and an expiring OAuth token is refreshed first. Accounts that
|
|
sign requests with an SSH key have no token.`,
|
|
Example: `$ tea auth token
|
|
$ curl -H "Authorization: token $(tea auth token)" https://gitea.com/api/v1/user
|
|
$ tea auth token --host gitea.com --user alice`,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
t, err := a.instanceTarget(cmd.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s, err := a.settings()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
token := a.deps.Getenv("GITEA_TOKEN")
|
|
if instance, err := settings.ResolveHost(a.tokenInstance(), s.Hosts.Hosts); err != nil || instance != t.Host {
|
|
token = ""
|
|
}
|
|
if token == "" {
|
|
account, exists := t.host.Users[t.User]
|
|
switch {
|
|
case !exists:
|
|
return clierr.Wrap(clierr.Authentication, t.accountError(t.User))
|
|
case account.Tokenless():
|
|
return fmt.Errorf("%s on %s signs requests with an SSH key and has no token", t.User, t.Host)
|
|
}
|
|
manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
credential, err := manager.GitCredential(cmd.Context(), t.Host, t.User, a.deps.Client(t.Host, "", t.host.Insecure).HTTP, a.deps.Now())
|
|
if err != nil {
|
|
return clierr.Wrap(clierr.Authentication, err)
|
|
}
|
|
token = credential.AccessToken
|
|
}
|
|
_, err = fmt.Fprintln(cmd.OutOrStdout(), token)
|
|
return err
|
|
},
|
|
}
|
|
}
|