feat(auth): guide login, readable status and switch without --host

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

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

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

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

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:50:44 +02:00
1 parent 60de407eca
commit a2bea8da23
13 files changed
+1421 -327

No files matched your search

-7
View File
@@ -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
+781 -288
View File
File diff suppressed because it is too large. Load diff
+255 -13
View File
@@ -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 <user> (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)")
}
+39 -12
View File
@@ -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()
+30 -5
View File
@@ -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
+54
View File
@@ -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)
}
+11 -1
View File
@@ -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.
+3 -1
View File
@@ -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
+57
View File
@@ -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
}
+24
View File
@@ -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")
+24
View File
@@ -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 {
+22
View File
@@ -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)
}
+121
View File
@@ -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)
}