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) +}