Files
gitea-tea/internal/auth/manager.go
T
bircni a2bea8da23 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>
2026-10-11 23:50:44 +02:00

136 lines
4.9 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"gitea.dev/tea/internal/settings"
)
// Manager coordinates account metadata and credential changes under one lock.
type Manager struct {
settings *settings.Store
credentials *Store
}
// NewManager couples the settings and credential stores that account changes keep consistent.
func NewManager(settingsStore *settings.Store, credentials *Store) (Manager, error) {
if settingsStore == nil {
return Manager{}, errors.New("account manager requires a settings store")
}
if credentials == nil {
return Manager{}, errors.New("account manager requires a credential store")
}
return Manager{settings: settingsStore, credentials: credentials}, nil
}
// HostOptions are instance settings recorded together with a new account.
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)
}
// RegisterHTTPSign publishes SSH/HTTP-signature account metadata without storing a token.
func (m Manager) RegisterHTTPSign(instance, user string, account settings.Account, options HostOptions) error {
if !account.Tokenless() {
return errors.New("HTTP signature registration requires auth_method ssh or httpsign")
}
account.CredentialStore = ""
return m.register(instance, user, account, options, nil)
}
func (m Manager) register(instance, user string, account settings.Account, options HostOptions, credential *Credential) error {
return m.settings.Transaction(settings.TargetHosts, func(s *settings.Snapshot) (func() error, error) {
host := s.Hosts.Hosts[instance]
if host.Users == nil {
host.Users = map[string]settings.Account{}
}
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
}
}
}
if credential != nil {
if err := m.credentials.Save(instance, user, account.CredentialStore, *credential); err != nil {
return nil, errors.Join(err, rollback())
}
undo = append(undo, func() error { return m.credentials.Delete(instance, user, account.CredentialStore) })
}
host.Users[user] = account
host.User = user
if options.OAuthClientID != "" {
host.OAuthClientID = options.OAuthClientID
}
host.Insecure = host.Insecure || options.Insecure
s.Hosts.Hosts[instance] = host
return rollback, nil
})
}
// Logout removes one account and restores its credential if publication fails.
// An unreadable credential does not prevent removing the account.
func (m Manager) Logout(instance, user string) error {
return m.settings.Transaction(settings.TargetHosts, func(s *settings.Snapshot) (func() error, error) {
host := s.Hosts.Hosts[instance]
account, exists := host.Users[user]
if !exists {
return nil, errors.New("account is not configured")
}
var restore func() error
if !account.Tokenless() {
credential, err := m.credentials.Load(instance, user, account.CredentialStore)
if err == nil {
// Preserve a compensating copy until the account file is successfully published.
restore = func() error { return m.credentials.Save(instance, user, account.CredentialStore, credential) }
}
// A missing credential deletes cleanly; any other failure keeps the account so
// its token is not orphaned in storage.
if err := m.credentials.Delete(instance, user, account.CredentialStore); err != nil {
return nil, err
}
}
delete(host.Users, user)
if host.User == user {
host.User = ""
if len(host.Users) == 1 {
for name := range host.Users {
host.User = name
}
}
}
s.Hosts.Hosts[instance] = host
return restore, nil
})
}