From a2bea8da23771e0354f4d7569e2b347b1bdad5a7 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 23:29:10 +0200 Subject: [PATCH] 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 --- internal/app/app_test.go | 7 - internal/app/auth.go | 1069 ++++++++++++++++++++-------- internal/app/auth_test.go | 268 ++++++- internal/app/git_credentials.go | 51 +- internal/auth/manager.go | 35 +- internal/auth/manager_test.go | 54 ++ internal/auth/oauth.go | 12 +- internal/auth/oauth_test.go | 4 +- internal/git/credentials.go | 57 ++ internal/git/credentials_test.go | 24 + internal/services/identity.go | 24 + internal/services/identity_test.go | 22 + internal/terminal/login_form.go | 121 ++++ 13 files changed, 1421 insertions(+), 327 deletions(-) create mode 100644 internal/terminal/login_form.go diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 279a08c3..baba80f0 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -420,12 +420,6 @@ const ( pr review cancel-request ` examplesToWrite = ` - auth login - auth logout - auth refresh - auth setup-git - auth status - auth switch branch create branch delete branch list @@ -547,7 +541,6 @@ const ( wiki view ` longsToWrite = ` - auth branch issue issue attachment diff --git a/internal/app/auth.go b/internal/app/auth.go index c734b0d7..cd077b49 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -4,30 +4,35 @@ package app import ( + "cmp" "context" "errors" "fmt" "io" + "maps" "os" - "sort" + "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. -func (a *application) verifyAccount(ctx context.Context, policy terminal.Policy, instance, user string, host settings.Host, account settings.Account, token string) error { +// 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 err + return nil, err } client.Signer = signer } @@ -35,12 +40,16 @@ func (a *application) verifyAccount(ctx context.Context, policy terminal.Policy, defer cancel() actual, err := services.CurrentAccount(ctx, client) if err != nil { - return err + return nil, err } if !strings.EqualFold(actual, user) { - return errors.New("credential authenticates a different account") + return nil, errors.New("credential authenticates a different account") } - return nil + 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. @@ -54,321 +63,805 @@ func (a *application) sshPassphrase(ctx context.Context, policy terminal.Policy, } } -// authStatusRow is one configured account reported by auth status. -type authStatusRow struct { - Host string `json:"host"` - User string `json:"user"` - Active bool `json:"active"` - Store string `json:"credential_store"` - Available bool `json:"credential_available"` - ExpiresAt *time.Time `json:"expires_at"` - Verified *bool `json:"verified"` - Error string `json:"error"` +// 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: "Manage instance accounts and credentials"} - group.AddCommand(a.gitCredentialCommand(), a.setupGitCommand()) - var withToken bool - var backend string - var oauth bool - var clientID string - var sshKey string - var sshAgentPrincipal string - var sshAgentKey string - var insecure bool - login := &cobra.Command{Use: "login", Short: "Authenticate with a token, OAuth, or SSH HTTP signature", Annotations: textOutput("login does not return a resource"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - methods := 0 - for _, selected := range []bool{withToken, oauth, sshKey != "", sshAgentPrincipal != "", sshAgentKey != ""} { - if selected { - methods++ + 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, --ssh-agent-principal sign requests with a key in ssh-agent + +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++ + } } - } - if methods != 1 { - return clierr.Wrap(clierr.Usage, errors.New("select exactly one of --with-token, --oauth, --ssh-key, --ssh-agent-principal, or --ssh-agent-key")) - } - if clientID != "" && !oauth { - return clierr.Wrap(clierr.Usage, errors.New("--client-id requires --oauth")) - } - snapshot, err := a.deps.Settings.Load() - if err != nil { - return err - } - instance := a.options.host - if instance == "" { - instance = a.deps.Getenv("TEA_HOST") - } - if instance == "" { - instance = a.deps.Getenv("GITEA_INSTANCE_URL") - } - prompted, err := a.require(cmd, &instance, "host") - if err != nil { - return err - } - instance, err = settings.NormalizeHost(instance) - if err != nil { - return clierr.Wrap(clierr.Usage, err) - } - host := snapshot.Hosts.Hosts[instance] - insecure = insecure || host.Insecure - manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials) - if err != nil { - return err - } - if sshKey != "" || sshAgentPrincipal != "" || sshAgentKey != "" { - account := settings.Account{} switch { - case sshKey != "": - account.SSHKey = sshKey - if _, err := os.Stat(sshKey + "-cert.pub"); err == nil { - account.AuthMethod = settings.AuthSSH - account.SSHCertPrincipal = "yes" - } else if errors.Is(err, os.ErrNotExist) { - account.AuthMethod = settings.AuthHTTPSign - } else { + 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.deps.Settings.Load() + 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 } - case sshAgentPrincipal != "": - account.AuthMethod = settings.AuthSSH - account.SSHAgent = true - account.SSHCertPrincipal = sshAgentPrincipal - case sshAgentKey != "": - account.AuthMethod = settings.AuthHTTPSign - account.SSHAgent = true - account.SSHKeyFingerprint = sshAgentKey + } 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) + } } - signer, err := auth.SignerForAccount(account, a.sshPassphrase(cmd.Context(), a.policy(snapshot), account.SSHKey)) + 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 clierr.Wrap(clierr.Authentication, err) + return err } - client := a.deps.Client(instance, "", insecure) - client.Signer = signer - user, err := services.CurrentAccount(cmd.Context(), client) + 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("authenticated account does not match --user or TEA_USER")) + return clierr.Wrap(clierr.Usage, errors.New("token account does not match --user or TEA_USER")) } - if prompted { - if err := a.confirm(cmd, "Save credentials for "+user+" on "+instance); err != nil { + if !replacing { + if _, err := a.confirmReplace(cmd, instance, user); err != nil { return err } } - return manager.RegisterHTTPSign(instance, user, account, auth.HostOptions{Insecure: insecure}) - } - if err := oneOf("credential-store", backend, settings.StoreKeyring, settings.StoreFile); err != nil { - return err - } - credential := auth.Credential{} - method := settings.AuthToken - if oauth { - if a.options.noInput { - return clierr.Wrap(clierr.Usage, errors.New("OAuth authorization requires interaction; use --with-token with --no-input")) + 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 } - if clientID == "" { - clientID = host.OAuthClientID - } - if clientID == "" { - clientID = auth.DefaultClientID - } - credential, err = auth.OAuthLogin(cmd.Context(), auth.OAuthOptions{Instance: instance, ClientID: clientID, 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 + 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) } - 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 - } else if 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 - } - credential.AccessToken = strings.TrimSpace(token) - if credential.AccessToken == "" { - return clierr.Wrap(clierr.Usage, errors.New("access token cannot be empty")) - } - } else { - 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")) - } - } - token := credential.AccessToken - client := a.deps.Client(instance, token, insecure) - user, err := services.CurrentAccount(cmd.Context(), client) - 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 prompted { - if err := a.confirm(cmd, "Save credentials for "+user+" on "+instance); err != nil { - return err - } - } - return manager.Register(instance, user, backend, method, auth.HostOptions{OAuthClientID: clientID, Insecure: insecure}, credential) - }} - login.Flags().BoolVar(&oauth, "oauth", false, "Authorize through a browser using OAuth PKCE") - login.Flags().StringVar(&clientID, "client-id", "", "OAuth client ID registered on the instance (default: the instance's stored ID, then tea's)") + 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 a personal access token from stdin, or prompt for it on a terminal") - login.Flags().StringVar(&sshKey, "ssh-key", "", "Authenticate with an SSH private key via HTTP signatures") - login.Flags().StringVar(&sshAgentPrincipal, "ssh-agent-principal", "", "Authenticate with an ssh-agent certificate principal via HTTP signatures") - login.Flags().StringVar(&sshAgentKey, "ssh-agent-key", "", "Authenticate with an ssh-agent public key fingerprint via HTTP signatures") - login.Flags().StringVar(&backend, "credential-store", settings.StoreKeyring, "Credential backend: keyring or explicitly opted-in file") - group.AddCommand(login) + 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.deps.Settings.Load() + 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 configured accounts without revealing credentials", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - selectedHost := "" - if a.options.host != "" { - var err error - selectedHost, err = settings.NormalizeHost(a.options.host) + 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.deps.Settings.Load() if err != nil { - return clierr.Wrap(clierr.Usage, err) + return err } - } - s, err := a.deps.Settings.Load() - if err != nil { - return err - } - rows := []authStatusRow{} - for instance, host := range s.Hosts.Hosts { - if selectedHost != "" && instance != selectedHost { - continue + selectedHost := "" + if a.options.host != "" { + if selectedHost, err = settings.ResolveHost(a.options.host, s.Hosts.Hosts); err != nil { + return clierr.Wrap(clierr.Usage, err) + } } - for user, account := range host.Users { - if !services.MatchAccount(a.options.user, user) { + rows := []authStatusRow{} + for instance, host := range s.Hosts.Hosts { + if selectedHost != "" && instance != selectedHost { continue } - item := authStatusRow{Host: instance, User: user, Active: host.User == user, Store: account.CredentialStore, Available: account.Tokenless()} - var credential auth.Credential - if !item.Available { - var err error - credential, err = a.deps.Credentials.Load(instance, user, account.CredentialStore) - item.Available = err == nil - if item.Available && account.AuthMethod == settings.AuthOAuth && !credential.ExpiresAt.IsZero() { - item.ExpiresAt = &credential.ExpiresAt + 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 verify { - err := errors.New("credential unavailable") - if item.Available { - err = a.verifyAccount(cmd.Context(), a.policy(s), instance, user, host, account, credential.AccessToken) - } - verified := err == nil - item.Verified = &verified - if err != nil { - item.Error = err.Error() - } + } + 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), -compareBool(x.environment(), y.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)) } - rows = append(rows, item) + return clierr.Wrap(clierr.Authentication, errors.New("not logged in to any Gitea instance; run tea auth login")) } - } - sort.Slice(rows, func(i, j int) bool { - if rows[i].Host == rows[j].Host { - return rows[i].User < rows[j].User - } - return rows[i].Host < rows[j].Host - }) - if a.options.json != "" { - return a.writeJSON(cmd, rows) - } - for _, row := range rows { - line := fmt.Sprintf("%s\t%s\tactive=%t\tcredentials=%t", row.Host, row.User, row.Active, row.Available) - if row.ExpiresAt != nil { - line += "\texpires=" + row.ExpiresAt.Format(time.RFC3339) - } - if row.Verified != nil { - line += fmt.Sprintf("\tverified=%t", *row.Verified) - } - if row.Error != "" { - line += "\terror=" + row.Error - } - if _, err := fmt.Fprintln(cmd.OutOrStdout(), line); err != nil { + if err := a.writeAuthStatus(cmd, rows); err != nil { return err } - } - return nil - }} - status.Flags().BoolVar(&verify, "verify", false, "Check each listed credential against its instance") - group.AddCommand(status) - group.AddCommand(&cobra.Command{Use: "switch ", Short: "Select the active account for an instance", Annotations: textOutput("switching accounts does not return a resource"), Args: argsExact(1), RunE: func(_ *cobra.Command, args []string) error { - if a.options.host == "" { - return clierr.Wrap(clierr.Usage, errors.New("--host is required")) - } - instance, err := settings.NormalizeHost(a.options.host) - if err != nil { - return clierr.Wrap(clierr.Usage, err) - } - return a.deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error { - host, exists := s.Hosts.Hosts[instance] - if !exists { - return clierr.Wrap(clierr.Usage, errors.New("instance is not configured; run tea auth login first")) + 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))) } - user, exists := matchAccount(host.Users, args[0]) - if !exists { - return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance; use tea auth status --host URL to list accounts")) - } - host.User = user - s.Hosts.Hosts[instance] = host return nil - }) - }}) - group.AddCommand(&cobra.Command{Use: "logout", Short: "Remove one account and its credentials", Annotations: textOutput("logout does not return a resource"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - selected, err := a.resolveTarget(cmd.Context(), false, false) - if err != nil { - return err - } - instance, user := selected.Host, selected.User - s, err := a.deps.Settings.Load() - if err != nil { - return err - } - if _, exists := s.Hosts.Hosts[instance].Users[user]; !exists { - return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance; use tea auth status --host URL to list accounts")) - } - if err := a.confirm(cmd, "Remove account "+user+" on "+instance); err != nil { - return err - } - manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials) - if err != nil { - return err - } - return manager.Logout(instance, user) - }}) - group.AddCommand(&cobra.Command{Use: "refresh", Short: "Refresh the selected OAuth account", Annotations: textOutput("refresh does not return a resource"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - t, err := a.resolveTarget(cmd.Context(), false, false) - if err != nil { - return err - } - account, exists := t.host.Users[t.User] - if !exists { - return clierr.Wrap(clierr.Authentication, errors.New("no selected account on this instance; run tea auth login or tea auth switch")) - } - if account.AuthMethod != settings.AuthOAuth { - return clierr.Wrap(clierr.Usage, errors.New("only OAuth accounts can be refreshed; use tea auth login to replace a token")) - } - manager, err := auth.NewManager(a.deps.Settings, a.deps.Credentials) - if err != nil { - return err - } - _, err = manager.Refresh(cmd.Context(), t.Host, t.User, a.deps.Client(t.Host, "", t.host.Insecure).HTTP, true, a.deps.Now()) - return clierr.Wrap(clierr.Authentication, err) - }}) - return group + }, + } + status.Flags().BoolVar(&verify, "verify", false, "Check each credential against its instance") + return status +} + +func compareBool(x, y bool) int { + switch { + case x == y: + return 0 + case x: + return 1 + } + return -1 +} + +// 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 { + cells := make([][]terminal.Cell, len(rows)) + for i, row := range rows { + verified, expires := "", "" + if row.Verified != nil { + verified = fmt.Sprint(*row.Verified) + } + if row.ExpiresAt != nil { + expires = terminal.Timestamp(*row.ExpiresAt) + } + cells[i] = terminal.Row(row.Host, row.User, fmt.Sprint(row.Active), row.Method, row.Store, fmt.Sprint(row.Available), verified, expires, row.Error) + } + return a.writeHuman(cmd, terminal.Table(nil, cells, d)) + } + 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 []", 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) { + s, err := a.deps.Settings.Load() + if err != nil { + return "", err + } + chooser, ok := a.deps.Prompter.(terminal.Chooser) + if !ok || !a.policy(s).CanPrompt(a.deps.Streams) { + return "", clierr.Wrap(clierr.Usage, fmt.Errorf("name the account: tea auth switch (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 []", 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 (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), NonTTY: "log out " + t.User + " from " + 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.deps.Settings.Load(); 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.deps.Settings.Load() + 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 + }, + } } diff --git a/internal/app/auth_test.go b/internal/app/auth_test.go index 9716cca6..c41201f4 100644 --- a/internal/app/auth_test.go +++ b/internal/app/auth_test.go @@ -4,6 +4,8 @@ package app import ( + "bytes" + "cmp" "context" "crypto/ed25519" "crypto/rand" @@ -24,6 +26,7 @@ import ( "gitea.dev/tea/internal/auth" "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/settings" + "gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/transport" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" @@ -161,7 +164,7 @@ func TestLogoutRejectsUnknownAccountBeforeConfirmation(t *testing.T) { configureHost(t, deps, "https://example.org", settings.Host{User: "alice", Users: tokenAccounts(settings.StoreKeyring, "alice")}) err := Execute(context.Background(), deps, []string{"auth", "logout", "--host", "https://example.org", "--user", "bob", "--no-input"}) require.Equal(t, clierr.Usage, clierr.ExitCode(err)) - require.ErrorContains(t, err, "account is not configured") + require.EqualError(t, err, `account "bob" is not logged in on https://example.org (accounts: alice)`) require.Empty(t, out.String()) require.Empty(t, diagnostics.String()) require.Empty(t, keyring.reads) @@ -179,14 +182,17 @@ func TestAuthStatusFiltersBeforeReadingCredentials(t *testing.T) { } require.NoError(t, Execute(context.Background(), deps, []string{"auth", "status", "--host", "https://EXAMPLE.org/base/", "--user", "BOB", "--json"})) require.Equal(t, []string{auth.Identity("https://example.org/base", "bob")}, keyring.reads) - require.JSONEq(t, `[{"host":"https://example.org/base","user":"bob","active":false,"credential_store":"keyring","credential_available":true,"expires_at":null,"verified":null,"error":""}]`, out.String()) + require.JSONEq(t, `[{"host":"https://example.org/base","user":"bob","active":false,"method":"token","credential_store":"keyring","credential_available":true,"expires_at":null,"verified":null,"error":""}]`, out.String()) out.Reset() keyring.reads = nil require.NoError(t, Execute(context.Background(), deps, []string{"auth", "status", "--host", "https://unconfigured.example.org", "--json"})) require.JSONEq(t, `[]`, out.String()) require.Empty(t, keyring.reads) out.Reset() - require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"auth", "status", "--host", "invalid-host"}))) + require.Equal(t, clierr.Usage, clierr.ExitCode(Execute(context.Background(), deps, []string{"auth", "status", "--host", "ftp://invalid-host"}))) + err := Execute(context.Background(), deps, []string{"auth", "status", "--host", "unconfigured.example.org"}) + require.Equal(t, clierr.Authentication, clierr.ExitCode(err)) + require.EqualError(t, err, "not logged in to https://unconfigured.example.org; run tea auth login --host https://unconfigured.example.org") require.Empty(t, out.String()) require.Empty(t, keyring.reads) } @@ -352,7 +358,11 @@ func TestAuthLoginMethodFlagsAreMutuallyExclusive(t *testing.T) { t.Run(strings.Join(args[3:], " "), func(t *testing.T) { err := Execute(context.Background(), deps, args) require.Equal(t, clierr.Usage, clierr.ExitCode(err)) - require.ErrorContains(t, err, "select exactly one") + if strings.Count(strings.Join(args, " "), "--") > 2 { + require.ErrorContains(t, err, "select one of --with-token, --oauth, --ssh-key") + } else { + require.ErrorContains(t, err, "select how to log in: --with-token, --oauth or --ssh-key") + } require.Empty(t, out.String()) }) } @@ -360,8 +370,10 @@ func TestAuthLoginMethodFlagsAreMutuallyExclusive(t *testing.T) { func TestAuthLoginValidatesMethodBeforePromptingForHost(t *testing.T) { for args, want := range map[string]string{ - "auth login": "select exactly one", + "auth login --with-token --oauth": "select one of", "auth login --with-token --client-id x": "--client-id requires --oauth", + "auth login --with-token --scopes all": "--scopes requires --oauth", + "auth login --oauth --scopes nope": "--scopes must be all, read:activitypub", } { t.Run(args, func(t *testing.T) { deps, out, _ := testDeps(t) @@ -433,13 +445,18 @@ func TestAuthRefreshRejectsTokenAccounts(t *testing.T) { func TestAuthStatusVerifyChecksStoredCredentials(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - require.Equal(t, "/api/v1/user", r.URL.Path) - if r.Header.Get("Authorization") != "token valid-status-token" { - w.WriteHeader(http.StatusUnauthorized) - return + if r.URL.Path == "/api/v1/user" { + requests++ + } + switch { + case r.Header.Get("Authorization") != "token valid-status-token": + w.WriteHeader(http.StatusUnauthorized) + case r.URL.Path == "/api/v1/token": + _, _ = w.Write([]byte(`{"scopes":["write:repository","read:user"]}`)) + default: + require.Equal(t, "/api/v1/user", r.URL.Path) + _, _ = w.Write([]byte(`{"login":"alice"}`)) } - _, _ = w.Write([]byte(`{"login":"alice"}`)) })) defer server.Close() deps, out, _ := testDeps(t) @@ -451,7 +468,7 @@ func TestAuthStatusVerifyChecksStoredCredentials(t *testing.T) { configureHost(t, deps, server.URL, settings.Host{User: "alice", Users: accounts}) require.NoError(t, Execute(context.Background(), deps, []string{"auth", "status", "--json=user,verified,error"})) require.Zero(t, requests) - require.JSONEq(t, `[{"user":"alice","verified":null,"error":""},{"user":"bob","verified":null,"error":""},{"user":"carol","verified":null,"error":""}]`, out.String()) + require.JSONEq(t, `[{"user":"alice","verified":null,"error":""},{"user":"bob","verified":null,"error":""},{"user":"carol","verified":null,"error":"credential unavailable; run tea auth login for this instance and account"}]`, out.String()) out.Reset() require.NoError(t, Execute(context.Background(), deps, []string{"auth", "status", "--verify", "--json"})) require.Equal(t, 2, requests) @@ -476,7 +493,7 @@ func TestAuthStatusVerifyChecksStoredCredentials(t *testing.T) { require.NotContains(t, out.String(), "status-token") out.Reset() require.NoError(t, Execute(context.Background(), deps, []string{"auth", "status", "--verify", "--user", "alice"})) - require.Equal(t, server.URL+"\talice\tactive=true\tcredentials=true\texpires=2030-01-02T03:04:05Z\tverified=true\n", out.String()) + require.Equal(t, server.URL+"\talice\ttrue\toauth\tfile\ttrue\ttrue\t2030-01-02T03:04:05Z\t\n", out.String()) } func TestOAuthLoginOpensAuthorizationURLInBrowser(t *testing.T) { @@ -540,3 +557,228 @@ func TestOAuthLoginOpensAuthorizationURLInBrowser(t *testing.T) { require.NoError(t, err) require.Contains(t, snapshot.Hosts.Hosts[normalized].Users, "alice") } + +// loginPrompter answers the interactive login and account choices, recording what it was asked. +type loginPrompter struct { + scriptedPrompter + answers terminal.LoginAnswers + questions terminal.LoginQuestions + prefilled terminal.LoginAnswers + choice int + options []string +} + +func (p *loginPrompter) Login(_ context.Context, questions terminal.LoginQuestions, answers *terminal.LoginAnswers) error { + p.questions, p.prefilled = questions, *answers + host := answers.Host + *answers = p.answers + answers.Host = cmp.Or(answers.Host, host) + return nil +} + +func (p *loginPrompter) Choose(_ context.Context, _ string, choices []terminal.Choice, _ string) (string, error) { + p.options = make([]string, len(choices)) + for i, choice := range choices { + p.options[i] = choice.Label + } + return choices[p.choice].Value, nil +} + +// terminalDeps returns testDeps on a terminal, where tea prompts and reports mutations. +func terminalDeps(t *testing.T) (Dependencies, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + deps, out, diagnostics := testDeps(t) + deps.Streams.InTTY, deps.Streams.OutTTY, deps.Streams.ErrTTY = true, true, true + return deps, out, diagnostics +} + +func TestAuthLoginAsksHowToLogIn(t *testing.T) { + server := newAuthenticatedServer(http.NotFoundHandler()) + defer server.Close() + deps, _, diagnostics := terminalDeps(t) + prompt := &loginPrompter{answers: terminal.LoginAnswers{Method: terminal.LoginToken, Token: "pasted-token", Store: settings.StoreFile}} + deps.Prompter = prompt + require.NoError(t, Execute(context.Background(), deps, []string{"auth", "login", "--host", server.URL})) + require.Equal(t, terminal.LoginAnswers{Host: server.URL, Method: terminal.LoginToken, Store: settings.StoreKeyring, SSHKey: prompt.prefilled.SSHKey, SetupGit: true}, prompt.prefilled) + require.False(t, prompt.questions.Host) + require.True(t, prompt.questions.Method && prompt.questions.Store && prompt.questions.SetupGit) + require.Equal(t, "Create one at https://gitea.com/user/settings/applications", prompt.questions.TokenHint("https://gitea.com")) + credential, err := deps.Credentials.Load(server.URL, "alice", settings.StoreFile) + require.NoError(t, err) + require.Equal(t, "pasted-token", credential.AccessToken) + require.Equal(t, "✓ Logged in to "+server.URL+" as alice (token, file store)\n", withoutANSI(diagnostics.String())) + // Without --host, the sole instance is the suggestion; logging in again replaces the token. + diagnostics.Reset() + prompt.answers.Token, prompt.accept = "rotated-token", true + require.NoError(t, Execute(context.Background(), deps, []string{"auth", "login"})) + require.True(t, prompt.questions.Host) + require.Equal(t, server.URL, prompt.prefilled.Host) + require.Equal(t, []string{"alice is already logged in to " + server.URL + ". Replace the stored credential?"}, prompt.confirmations) + credential, err = deps.Credentials.Load(server.URL, "alice", settings.StoreFile) + require.NoError(t, err) + require.Equal(t, "rotated-token", credential.AccessToken) +} + +func TestAuthLoginReplacesACredentialOnlyWhenConfirmed(t *testing.T) { + server := newAuthenticatedServer(http.NotFoundHandler()) + defer server.Close() + deps, _, _ := testDeps(t) + configureAccounts(t, deps, server.URL, "alice", "alice") + login := func(extra ...string) error { + deps.Streams.In = strings.NewReader("new-token\n") + return Execute(context.Background(), deps, append([]string{"auth", "login", "--host", server.URL, "--with-token", "--credential-store", "file", "--no-input"}, extra...)) + } + err := login() + require.Equal(t, clierr.Usage, clierr.ExitCode(err)) + require.EqualError(t, err, "confirmation required to replace the stored credential of alice on "+server.URL+"; rerun with --yes") + credential, err := deps.Credentials.Load(server.URL, "alice", settings.StoreFile) + require.NoError(t, err) + require.Equal(t, "alice-token", credential.AccessToken) + require.NoError(t, login("--yes")) + credential, err = deps.Credentials.Load(server.URL, "alice", settings.StoreFile) + require.NoError(t, err) + require.Equal(t, "new-token", credential.AccessToken) + // A named account is confirmed before tea asks for its token. + deps, _, _ = terminalDeps(t) + configureAccounts(t, deps, server.URL, "alice", "alice") + prompt := &scriptedPrompter{secret: "unused"} + deps.Prompter = prompt + err = Execute(context.Background(), deps, []string{"auth", "login", "--host", server.URL, "--with-token", "--user", "alice"}) + require.Equal(t, clierr.Canceled, clierr.ExitCode(err)) + require.Len(t, prompt.confirmations, 1) + require.Empty(t, prompt.secrets) +} + +func TestAuthStatusShowsAccountsByInstance(t *testing.T) { + const instance = "https://example.org" + helper := "config --global --get-all credential." + instance + ".helper" + for _, tc := range []struct { + name string + users []string + env map[string]string + helper bool + want string + failure string + exitCode int + }{ + { + name: "accounts", users: []string{"alice", "bob"}, helper: true, + want: instance + "\n ✓ Logged in as alice (token, file store) · active\n ✓ Logged in as bob (token, file store)\n - Git credential helper: configured (global)\n", + }, + { + name: "environment token", users: []string{"alice"}, env: map[string]string{"GITEA_TOKEN": "env", "TEA_HOST": "example.org"}, + want: instance + "\n ✓ Using GITEA_TOKEN from the environment\n ✓ Logged in as alice (token, file store) · active\n - Git credential helper: not configured; run tea auth setup-git\n", + }, + { + name: "environment token only", env: map[string]string{"GITEA_TOKEN": "env", "GITEA_INSTANCE_URL": instance}, + want: instance + "\n ✓ Using GITEA_TOKEN from the environment\n", + }, + {name: "logged out", failure: "not logged in to any Gitea instance; run tea auth login", exitCode: clierr.Authentication}, + } { + t.Run(tc.name, func(t *testing.T) { + deps, out, _ := terminalDeps(t) + deps.Getenv = envFunc(tc.env) + if tc.helper { + deps.Git = &fakeGit{results: map[string]string{helper: "!'/usr/bin/tea' auth git-credential --host " + instance}} + } + if len(tc.users) > 0 { + configureAccounts(t, deps, instance, tc.users[0], tc.users...) + } + err := Execute(context.Background(), deps, []string{"auth", "status"}) + if tc.failure != "" { + require.Equal(t, tc.exitCode, clierr.ExitCode(err)) + require.EqualError(t, err, tc.failure) + } else { + require.NoError(t, err) + } + require.Equal(t, tc.want, withoutANSI(out.String())) + }) + } + deps, out, _ := testDeps(t) + configureHost(t, deps, instance, settings.Host{User: "alice", Users: tokenAccounts(settings.StoreFile, "alice")}) + err := Execute(context.Background(), deps, []string{"auth", "status"}) + require.Equal(t, clierr.Authentication, clierr.ExitCode(err)) + require.EqualError(t, err, "1 of 1 credentials cannot authenticate; log in again with tea auth login") + require.Equal(t, instance+"\talice\ttrue\ttoken\tfile\tfalse\t\t\tcredential unavailable; run tea auth login for this instance and account\n", out.String()) +} + +func TestAuthSwitchSelectsInstanceAndAccount(t *testing.T) { + const instance = "https://example.org" + deps, _, diagnostics := terminalDeps(t) + configureAccounts(t, deps, instance, "alice", "alice") + err := Execute(context.Background(), deps, []string{"auth", "switch"}) + require.EqualError(t, err, "alice is the only account on "+instance+"; log in to another with tea auth login --host "+instance) + configureAccounts(t, deps, instance, "alice", "alice", "bob") + active := func() string { + s, err := deps.Settings.Load() + require.NoError(t, err) + return s.Hosts.Hosts[instance].User + } + for _, tc := range []struct { + args []string + want, diag string + }{ + {nil, "bob", "✓ Switched active account for " + instance + " to bob\n"}, + {nil, "alice", "✓ Switched active account for " + instance + " to alice\n"}, + {[]string{"ALICE"}, "alice", "! alice is already the active account on " + instance + "\n"}, + {[]string{"--user", "bob"}, "bob", "✓ Switched active account for " + instance + " to bob\n"}, + } { + diagnostics.Reset() + require.NoError(t, Execute(context.Background(), deps, append([]string{"auth", "switch"}, tc.args...))) + require.Equal(t, tc.want, active()) + require.Equal(t, tc.diag, withoutANSI(diagnostics.String())) + } + configureAccounts(t, deps, instance, "bob", "alice", "bob", "carol") + prompt := &loginPrompter{choice: 2} + deps.Prompter = prompt + require.NoError(t, Execute(context.Background(), deps, []string{"auth", "switch"})) + require.Equal(t, []string{"alice", "bob (active)", "carol"}, prompt.options) + require.Equal(t, "carol", active()) + err = Execute(context.Background(), deps, []string{"auth", "switch", "--no-input"}) + require.Equal(t, clierr.Usage, clierr.ExitCode(err)) + require.EqualError(t, err, "name the account: tea auth switch (accounts on "+instance+": alice, bob, carol)") + err = Execute(context.Background(), deps, []string{"auth", "switch", "dave"}) + require.EqualError(t, err, `account "dave" is not logged in on `+instance+" (accounts: alice, bob, carol)") +} + +func TestAuthTokenPrintsTheCredential(t *testing.T) { + const instance = "https://example.org" + deps, out, _ := testDeps(t) + configureAccounts(t, deps, instance, "alice", "alice") + require.NoError(t, Execute(context.Background(), deps, []string{"auth", "token"})) + require.Equal(t, "alice-token\n", out.String()) + err := Execute(context.Background(), deps, []string{"auth", "token", "--user", "bob"}) + require.Equal(t, clierr.Authentication, clierr.ExitCode(err)) + out.Reset() + deps.Getenv = envFunc(map[string]string{"GITEA_TOKEN": "env-token", "TEA_HOST": instance}) + require.NoError(t, Execute(context.Background(), deps, []string{"auth", "token"})) + require.Equal(t, "env-token\n", out.String()) + deps.Getenv = envFunc(nil) + configureHost(t, deps, instance, settings.Host{User: "alice", Users: map[string]settings.Account{"alice": {AuthMethod: settings.AuthHTTPSign, SSHKey: "/keys/id"}}}) + require.EqualError(t, Execute(context.Background(), deps, []string{"auth", "token"}), "alice on "+instance+" signs requests with an SSH key and has no token") + require.EqualError(t, Execute(context.Background(), deps, []string{"auth", "token", "--json"}), "auth token prints the token; --json is not supported") +} + +// rejectingGit records the input of Git commands that read standard input. +type rejectingGit struct { + fakeGit + inputs []string +} + +func (g *rejectingGit) Input(_ context.Context, _, input string, args ...string) (string, error) { + g.inputs = append(g.inputs, strings.Join(args, " ")+"\n"+input) + return "", nil +} + +func TestLogoutAsksGitToForgetTheAccount(t *testing.T) { + const instance = "https://example.org:3000" + deps, _, diagnostics := terminalDeps(t) + runner := &rejectingGit{} + deps.Git = runner + configureAccounts(t, deps, instance, "alice", "alice", "bob") + require.NoError(t, Execute(context.Background(), deps, []string{"auth", "logout", "--yes"})) + require.Equal(t, []string{"credential reject\nprotocol=https\nhost=example.org:3000\nusername=alice\n\n"}, runner.inputs) + require.Equal(t, "✓ Logged out of "+instance+" (alice)\n✓ Switched active account for "+instance+" to bob\n", withoutANSI(diagnostics.String())) + err := Execute(context.Background(), deps, []string{"auth", "logout", "carol", "--yes"}) + require.EqualError(t, err, `account "carol" is not logged in on `+instance+" (accounts: bob)") +} diff --git a/internal/app/git_credentials.go b/internal/app/git_credentials.go index 2d502a94..d39958b8 100644 --- a/internal/app/git_credentials.go +++ b/internal/app/git_credentials.go @@ -107,23 +107,50 @@ func (a *application) gitCredentialCommand() *cobra.Command { func (a *application) setupGitCommand() *cobra.Command { var local, keepHelpers bool - command := &cobra.Command{Use: "setup-git", Short: "Explicitly register tea as a scoped Git credential helper", Annotations: textOutput("setup-git configures Git"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - target, err := a.resolveTarget(cmd.Context(), false, false) - if err != nil { - return err - } - executable, configDir, err := a.credentialHelper() - if err != nil { - return err - } - pinned, _ := a.selectedUser() - return git.SetupCredentials(cmd.Context(), a.deps.Git, git.CredentialSetup{Directory: a.options.cwd, Local: local, KeepHelpers: keepHelpers, Instance: target.Host, Executable: executable, User: pinned, ConfigDirectory: configDir}) - }} + 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() diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 037851b2..67e574c2 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -30,8 +30,14 @@ func NewManager(settingsStore *settings.Store, credentials *Store) (Manager, err type HostOptions struct { OAuthClientID string Insecure bool + // Replace lets a registration replace the metadata and stored credential of an account that is + // already configured, as when logging in again with a new token. + Replace bool } +// ErrAccountExists rejects a registration of a configured account without HostOptions.Replace. +var ErrAccountExists = errors.New("account already configured") + // Register publishes account metadata and compensates credential writes on failure. func (m Manager) Register(instance, user, backend, method string, options HostOptions, credential Credential) error { return m.register(instance, user, settings.Account{AuthMethod: method, CredentialStore: backend}, options, &credential) @@ -52,15 +58,34 @@ func (m Manager) register(instance, user string, account settings.Account, optio if host.Users == nil { host.Users = map[string]settings.Account{} } - if _, exists := host.Users[user]; exists { - return nil, errors.New("account already configured; logout before authenticating again") + previous, exists := host.Users[user] + if exists && !options.Replace { + return nil, ErrAccountExists + } + var undo []func() error + rollback := func() error { + var err error + for i := len(undo) - 1; i >= 0; i-- { + err = errors.Join(err, undo[i]()) + } + return err + } + // A replaced token is restored if publishing fails, and removed from a store the account leaves. + if exists && !previous.Tokenless() { + if old, err := m.credentials.Load(instance, user, previous.CredentialStore); err == nil { + undo = append(undo, func() error { return m.credentials.Save(instance, user, previous.CredentialStore, old) }) + } + if credential == nil || previous.CredentialStore != account.CredentialStore { + if err := m.credentials.Delete(instance, user, previous.CredentialStore); err != nil { + return nil, err + } + } } - var rollback func() error if credential != nil { if err := m.credentials.Save(instance, user, account.CredentialStore, *credential); err != nil { - return nil, err + return nil, errors.Join(err, rollback()) } - rollback = func() error { return m.credentials.Delete(instance, user, account.CredentialStore) } + undo = append(undo, func() error { return m.credentials.Delete(instance, user, account.CredentialStore) }) } host.Users[user] = account host.User = user diff --git a/internal/auth/manager_test.go b/internal/auth/manager_test.go index 41712adb..8301330e 100644 --- a/internal/auth/manager_test.go +++ b/internal/auth/manager_test.go @@ -177,3 +177,57 @@ func TestRegisterHTTPSignRejectsTokenAccounts(t *testing.T) { require.Equal(t, settings.Account{AuthMethod: settings.AuthSSH, SSHKey: "/keys/id"}, s.Hosts.Hosts["https://example.org"].Users["alice"]) require.NoError(t, manager.Logout("https://example.org", "alice")) } + +func TestRegisterReplacesAnAccountOnlyWhenAsked(t *testing.T) { + for _, tc := range []struct { + name string + previous settings.Account + next string + }{ + {"same store", settings.Account{AuthMethod: settings.AuthToken, CredentialStore: settings.StoreFile}, settings.StoreFile}, + {"other store", settings.Account{AuthMethod: settings.AuthOAuth, CredentialStore: settings.StoreKeyring}, settings.StoreFile}, + {"SSH key account", settings.Account{AuthMethod: settings.AuthHTTPSign, SSHKey: "/keys/id"}, settings.StoreKeyring}, + } { + t.Run(tc.name, func(t *testing.T) { + config := settings.New(t.TempDir()) + keyring := &memoryKeyring{values: map[string]string{}} + credentials := &Store{Dir: config.Dir, Keyring: keyring} + manager := newTestManager(t, config, credentials) + if tc.previous.Tokenless() { + require.NoError(t, manager.RegisterHTTPSign("https://example.org", "alice", tc.previous, HostOptions{})) + } else { + require.NoError(t, manager.Register("https://example.org", "alice", tc.previous.CredentialStore, tc.previous.AuthMethod, HostOptions{}, Credential{AccessToken: "old"})) + } + require.NoError(t, manager.Register("https://example.org", "bob", settings.StoreFile, settings.AuthToken, HostOptions{}, Credential{AccessToken: "bob"})) + err := manager.Register("https://example.org", "alice", tc.next, settings.AuthToken, HostOptions{}, Credential{AccessToken: "new"}) + require.ErrorIs(t, err, ErrAccountExists) + require.NoError(t, manager.Register("https://example.org", "alice", tc.next, settings.AuthToken, HostOptions{Replace: true}, Credential{AccessToken: "new"})) + s, err := config.Load() + require.NoError(t, err) + host := s.Hosts.Hosts["https://example.org"] + require.Equal(t, "alice", host.User) + require.Equal(t, settings.Account{AuthMethod: settings.AuthToken, CredentialStore: tc.next}, host.Users["alice"]) + credential, err := credentials.Load("https://example.org", "alice", tc.next) + require.NoError(t, err) + require.Equal(t, "new", credential.AccessToken) + if !tc.previous.Tokenless() && tc.previous.CredentialStore != tc.next { + _, err := credentials.Load("https://example.org", "alice", tc.previous.CredentialStore) + require.Error(t, err, "the replaced credential leaves its old store") + } + }) + } +} + +func TestFailedReplacementRestoresTheCredential(t *testing.T) { + config := settings.New(t.TempDir()) + keyring := &memoryKeyring{values: map[string]string{}} + credentials := &Store{Dir: config.Dir, Keyring: keyring} + manager := newTestManager(t, config, credentials) + require.NoError(t, manager.Register("https://example.org", "alice", settings.StoreKeyring, settings.AuthToken, HostOptions{}, Credential{AccessToken: "old"})) + require.Error(t, manager.Register("https://example.org", "alice", settings.StoreFile, "invalid", HostOptions{Replace: true}, Credential{AccessToken: "new"})) + credential, err := credentials.Load("https://example.org", "alice", settings.StoreKeyring) + require.NoError(t, err) + require.Equal(t, "old", credential.AccessToken) + _, err = credentials.Load("https://example.org", "alice", settings.StoreFile) + require.Error(t, err) +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index f3ff6315..334f5b43 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -20,6 +20,10 @@ import ( // DefaultClientID identifies the public tea OAuth application. const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4" +// DefaultScopes are the token scopes tea requests through OAuth: read and write access to everything +// but instance administration. +var DefaultScopes = []string{"write:issue", "write:misc", "write:notification", "write:organization", "write:package", "write:repository", "write:user"} + // refreshTimeout bounds a token refresh, which holds the settings lock, below settings.LockTimeout. const refreshTimeout = 20 * time.Second @@ -31,6 +35,8 @@ type OAuthOptions struct { HTTP *http.Client Authorize func(context.Context, string) error Timeout time.Duration + // Scopes are the token scopes to request; none requests DefaultScopes. + Scopes []string } // OAuthLogin exchanges a PKCE authorization code through a loopback callback. @@ -57,6 +63,10 @@ func OAuthLogin(ctx context.Context, opts OAuthOptions) (Credential, error) { // Gitea ignores the port of a loopback redirect but compares its path; the built-in tea application registers none. redirect := "http://" + listener.Addr().String() config := oauthConfig(opts.Instance, opts.ClientID) + config.Scopes = opts.Scopes + if len(config.Scopes) == 0 { + config.Scopes = DefaultScopes + } config.RedirectURL = redirect codes := make(chan string, 1) failures := make(chan error, 1) @@ -130,7 +140,7 @@ func withHTTPClient(ctx context.Context, client *http.Client) context.Context { } func oauthConfig(instance, clientID string) *oauth2.Config { - return &oauth2.Config{ClientID: clientID, Endpoint: oauth2.Endpoint{AuthURL: instance + "/login/oauth/authorize", TokenURL: instance + "/login/oauth/access_token", AuthStyle: oauth2.AuthStyleInParams}, Scopes: []string{"admin", "user", "issue", "misc", "notification", "organization", "package", "repository"}} + return &oauth2.Config{ClientID: clientID, Endpoint: oauth2.Endpoint{AuthURL: instance + "/login/oauth/authorize", TokenURL: instance + "/login/oauth/access_token", AuthStyle: oauth2.AuthStyleInParams}} } // Refresh locks by instance and account, then reads again before rotating secrets. diff --git a/internal/auth/oauth_test.go b/internal/auth/oauth_test.go index 91a51ac8..941b6d56 100644 --- a/internal/auth/oauth_test.go +++ b/internal/auth/oauth_test.go @@ -38,6 +38,7 @@ func TestOAuthLoginPKCEAndState(t *testing.T) { } require.Equal(t, "configured-client", u.Query().Get("client_id")) require.Equal(t, "S256", u.Query().Get("code_challenge_method")) + require.Equal(t, "write:issue write:misc write:notification write:organization write:package write:repository write:user", u.Query().Get("scope")) redirect = u.Query().Get("redirect_uri") callback, err := url.Parse(redirect) require.NoError(t, err) @@ -111,11 +112,12 @@ func TestOAuthWithoutHTTPClientUsesDefaultClient(t *testing.T) { _, _ = fmt.Fprintln(w, `{"access_token":"access","refresh_token":"refresh","expires_in":3600,"token_type":"Bearer"}`) })) defer server.Close() - credential, err := OAuthLogin(context.Background(), OAuthOptions{Instance: server.URL, Timeout: time.Second, Authorize: func(_ context.Context, raw string) error { + credential, err := OAuthLogin(context.Background(), OAuthOptions{Instance: server.URL, Timeout: time.Second, Scopes: []string{"read:repository", "read:user"}, Authorize: func(_ context.Context, raw string) error { u, err := url.Parse(raw) if err != nil { return err } + require.Equal(t, "read:repository read:user", u.Query().Get("scope")) response, err := http.Get(u.Query().Get("redirect_uri") + "?code=code&state=" + url.QueryEscape(u.Query().Get("state"))) if err != nil { return err diff --git a/internal/git/credentials.go b/internal/git/credentials.go index 5009cf51..ef4594a4 100644 --- a/internal/git/credentials.go +++ b/internal/git/credentials.go @@ -6,6 +6,9 @@ package git import ( "context" "errors" + "fmt" + "net/url" + "os" "path/filepath" "slices" "strings" @@ -118,3 +121,57 @@ func SetupCredentials(ctx context.Context, runner Runner, options CredentialSetu _, err = runner.Output(ctx, cwd, false, "config", scope, "--add", prefix+"helper", helper) return err } + +// HelperScope returns where Git is configured to ask tea for credentials of instance: "local" for +// the repository in dir, "global", or "" when it is not. +func HelperScope(ctx context.Context, runner Runner, dir, instance string) string { + normalized, err := settings.NormalizeHost(instance) + if err != nil { + return "" + } + for _, scope := range []string{"local", "global"} { + helpers, err := runner.Output(ctx, dir, false, "config", "--"+scope, "--get-all", "credential."+normalized+".helper") + if err == nil && strings.Contains(helpers, " auth git-credential ") { + return scope + } + } + return "" +} + +// InputRunner runs Git with text on its standard input. +type InputRunner interface { + Input(ctx context.Context, dir, input string, args ...string) (string, error) +} + +// Input runs Git like Output with input on its standard input, without terminal prompts. +func (s System) Input(ctx context.Context, dir, input string, args ...string) (string, error) { + env := s.Env + if env == nil { + env = os.Environ() + } + s.Env, s.In = setEnv(env, "GIT_TERMINAL_PROMPT", "0"), strings.NewReader(input) + return s.Output(ctx, dir, true, args...) +} + +// RejectCredentials asks Git's credential helpers to erase the credentials they stored for user on +// instance, such as a copy of tea's token that another helper kept after a push. Runners that cannot +// send input reject nothing. +func RejectCredentials(ctx context.Context, runner Runner, dir, instance, user string) error { + input, ok := runner.(InputRunner) + if !ok { + return nil + } + u, err := url.Parse(instance) + if err != nil || u.Host == "" { + return fmt.Errorf("invalid instance URL %q", instance) + } + if strings.ContainsAny(user, "\x00\r\n") { + return errors.New("invalid account name") + } + request := "protocol=" + u.Scheme + "\nhost=" + u.Host + "\n" + if user != "" { + request += "username=" + user + "\n" + } + _, err = input.Input(ctx, dir, request+"\n", "credential", "reject") + return err +} diff --git a/internal/git/credentials_test.go b/internal/git/credentials_test.go index 548da2ff..fdcc9afc 100644 --- a/internal/git/credentials_test.go +++ b/internal/git/credentials_test.go @@ -90,6 +90,30 @@ func TestSetupGitCredentialsWorksWithoutAnExistingGlobalFile(t *testing.T) { require.Contains(t, string(data), "auth git-credential") } +func TestRejectCredentialsAsksHelpersToEraseTheAccount(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("Git is not installed") + } + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + home := t.TempDir() + log := filepath.Join(home, "helper.log") + t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(home, "gitconfig")) + ctx := context.Background() + runner := System{} + _, err := runner.Output(ctx, home, false, "config", "--global", "credential.helper", "!f() { echo \"op=$1\" >> '"+log+"'; cat >> '"+log+"'; }; f") + require.NoError(t, err) + require.Empty(t, HelperScope(ctx, runner, home, "http://localhost:3001")) + require.NoError(t, RejectCredentials(ctx, runner, home, "http://localhost:3001/gitea", "alice")) + data, err := os.ReadFile(log) + require.NoError(t, err) + require.Equal(t, "op=erase\nprotocol=http\nhost=localhost:3001\nusername=alice\n", string(data)) + executable, err := os.Executable() + require.NoError(t, err) + require.NoError(t, SetupCredentials(ctx, runner, CredentialSetup{Directory: home, Instance: "http://localhost:3001", Executable: executable})) + require.Equal(t, "global", HelperScope(ctx, runner, home, "http://localhost:3001")) + require.Empty(t, HelperScope(ctx, runner, home, "http://localhost:3002")) +} + func TestGitMissingConfigValueCarriesExitCode(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("Git is not installed") diff --git a/internal/services/identity.go b/internal/services/identity.go index 74019e9a..168e1453 100644 --- a/internal/services/identity.go +++ b/internal/services/identity.go @@ -42,6 +42,30 @@ func CurrentAccount(ctx context.Context, client *transport.Client) (string, erro return account, nil } +// TokenScopes returns the scopes of the access token that client authenticates with, such as +// "all" or "write:repository". Gitea answers only for access tokens. +func TokenScopes(ctx context.Context, client *transport.Client) ([]string, error) { + token, _, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (*gitea.CurrentAccessToken, *gitea.Response, error) { + return c.Users.GetCurrentAccessToken(ctx) + }) + if err != nil { + return nil, err + } + scopes := make([]string, len(token.Scopes)) + for i, scope := range token.Scopes { + scopes[i] = string(scope) + } + return scopes, nil +} + +// ServerVersion returns the Gitea version of the instance. +func ServerVersion(ctx context.Context, client *transport.Client) (string, error) { + version, _, err := call(ctx, client, func(ctx context.Context, c *gitea.Client) (string, *gitea.Response, error) { + return c.Meta.ServerVersion(ctx) + }) + return version, err +} + // MatchAccount reports whether selected matches the authenticated login. // An empty selection accepts any account. func MatchAccount(selected, authenticated string) bool { diff --git a/internal/services/identity_test.go b/internal/services/identity_test.go index d8231b88..367d55bc 100644 --- a/internal/services/identity_test.go +++ b/internal/services/identity_test.go @@ -79,3 +79,25 @@ func TestMatchAccount(t *testing.T) { require.True(t, MatchAccount("alice", "Alice")) require.False(t, MatchAccount("bob", "Alice")) } + +func TestTokenScopesAndServerVersion(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/token": + require.Equal(t, "token secret", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"id":1,"name":"tea","scopes":["write:repository","read:user"]}`)) + case "/api/v1/version": + _, _ = w.Write([]byte(`{"version":"28.1.0"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + client := transport.New(server.URL, "secret", false) + scopes, err := TokenScopes(context.Background(), client) + require.NoError(t, err) + require.Equal(t, []string{"write:repository", "read:user"}, scopes) + version, err := ServerVersion(context.Background(), client) + require.NoError(t, err) + require.Equal(t, "28.1.0", version) +} diff --git a/internal/terminal/login_form.go b/internal/terminal/login_form.go new file mode 100644 index 00000000..c958115f --- /dev/null +++ b/internal/terminal/login_form.go @@ -0,0 +1,121 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + + "charm.land/huh/v2" +) + +// Login methods that an interactive login offers. +const ( + LoginToken = "token" + LoginOAuth = "oauth" + LoginSSHKey = "ssh-key" +) + +// LoginAnswers holds the choices of an interactive login. Callers prefill the defaults. +type LoginAnswers struct { + // Host is the instance URL; Method one of LoginToken, LoginOAuth and LoginSSHKey. + Host, Method string + // Token is the pasted access token, and SSHKey the private key path, for their methods. + Token, SSHKey string + // Store is the credential store of a token: keyring or file. + Store string + // SetupGit configures Git to use tea for the instance. + SetupGit bool +} + +// LoginQuestions selects what an interactive login asks. The token of the token method and the key +// of the SSH key method are always asked; Store and SetupGit only for tokens. +type LoginQuestions struct { + Host, Method, Store, SetupGit bool + // ResolveHost normalizes an entered instance, or explains why it is not one. + ResolveHost func(string) (string, error) + // TokenHint tells where to create an access token on an instance. + TokenHint func(host string) string +} + +// LoginForm collects how to log in to a Gitea instance. +type LoginForm interface { + Login(context.Context, LoginQuestions, *LoginAnswers) error +} + +// Login asks the selected questions on stderr, one at a time, so that later questions can depend +// on earlier answers. +func (p *FormPrompter) Login(ctx context.Context, q LoginQuestions, a *LoginAnswers) error { + if q.Host { + input := huh.NewInput().Title("Gitea instance").Description("A URL or host name, such as gitea.com").Value(&a.Host).Validate(func(s string) error { + _, err := q.ResolveHost(strings.TrimSpace(s)) + return err + }) + if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(input))); err != nil { + return err + } + host, err := q.ResolveHost(strings.TrimSpace(a.Host)) + if err != nil { + return err + } + a.Host = host + } + if q.Method { + methods := huh.NewSelect[string]().Title("How would you like to log in?").Value(&a.Method).Options( + huh.NewOption("Paste an access token", LoginToken), + huh.NewOption("Log in with a web browser (OAuth)", LoginOAuth), + huh.NewOption("Sign requests with an SSH key", LoginSSHKey), + ) + if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(methods))); err != nil { + return err + } + } + var fields []huh.Field + switch a.Method { + case LoginToken: + fields = append(fields, huh.NewInput().Title("Paste an access token").Description(q.TokenHint(a.Host)).EchoMode(huh.EchoModePassword).Value(&a.Token).Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return errors.New("paste the token") + } + return nil + })) + case LoginSSHKey: + fields = append(fields, huh.NewInput().Title("SSH private key").Value(&a.SSHKey).Validate(func(s string) error { + _, err := os.Stat(ExpandHome(strings.TrimSpace(s))) + return err + })) + } + if a.Method != LoginSSHKey && q.Store { + fields = append(fields, huh.NewSelect[string]().Title("Where should tea keep the token?").Value(&a.Store).Options( + huh.NewOption("System keyring", "keyring"), + huh.NewOption("Private file in tea's configuration directory", "file"), + )) + } + if a.Method != LoginSSHKey && q.SetupGit { + fields = append(fields, huh.NewConfirm().Title("Configure git to use tea for "+a.Host+"?").Description("Git then authenticates HTTPS pushes and fetches with this account").Value(&a.SetupGit)) + } + if len(fields) > 0 { + if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(fields...))); err != nil { + return err + } + } + a.Token, a.SSHKey = strings.TrimSpace(a.Token), ExpandHome(strings.TrimSpace(a.SSHKey)) + return nil +} + +// ExpandHome replaces a leading ~/ with the home directory, as a shell would. +func ExpandHome(path string) string { + rest, ok := strings.CutPrefix(path, "~/") + if !ok { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + return filepath.Join(home, rest) +}