mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
services.IsNotFound replaces the hand-rolled 404 checks, convertPage the copied page conversions and lastPathNumber the comment's own number parser. The List wrappers around ListPage of releases, branches, wiki pages, secrets and variables go, pull request filters resolve milestones through Milestones.FindOne like issues, and localFlagsChanged, boolOrder, Cell.Render, stateBadge and dueText replace their copies. Signed-off-by: bircni <bircni@icloud.com>
853 lines
34 KiB
Go
853 lines
34 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: "token, keyring", "OAuth, file store" or
|
|
// "SSH key".
|
|
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.`}
|
|
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. Otherwise choose one
|
|
method:
|
|
|
|
--with-token read an access token from stdin, or ask for it on a terminal
|
|
--oauth, --web authorize tea in a web browser
|
|
--ssh-key FILE sign requests with an SSH private key
|
|
--ssh-agent-key FP sign requests with the key of fingerprint FP in ssh-agent
|
|
--ssh-agent-principal P sign requests with the ssh-agent certificate of principal P
|
|
|
|
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: `# Log in interactively
|
|
$ tea auth login
|
|
# Log in with a token from a file or a secret store
|
|
$ tea auth login --host gitea.com --with-token < token.txt
|
|
# Log in in the browser, with read-only access to repositories
|
|
$ tea auth login --host gitea.com --oauth --scopes read:repository,read:user
|
|
# Sign requests with an SSH key instead of a token
|
|
$ tea auth login --host gitea.com --ssh-key ~/.ssh/id_ed25519`,
|
|
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"} {
|
|
if path := filepath.Join(home, ".ssh", name); fileExists(path) {
|
|
return path
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// 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. --verify asks each instance whether the credentials still work.
|
|
|
|
A GITEA_TOKEN in the environment shows under its instance, where it replaces the stored
|
|
accounts. tea auth status 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. The instance is the one of the current repository, else
|
|
--host, TEA_HOST, default_host or the only instance.`,
|
|
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.
|
|
|
|
Without an account argument or --user, tea logs out of the active account of the
|
|
current repository's instance, else of --host, TEA_HOST, default_host or the only
|
|
instance.`,
|
|
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
|
|
},
|
|
}
|
|
}
|