mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
refactor: add credential and Git packages (#1123)
- `internal/auth`: credentials in the OS keyring or a private file, OAuth login with token refresh, SSH HTTP-signature signers (key files with passphrase prompt, ssh-agent keys and certificates), and the Git credential helper protocol. - `internal/git`: cancellable Git subprocesses, repository and remote discovery (tracked remote via `branch.<name>.remote`), PR checkout by full ref, AGit push, branch cleanup. Nothing imports these packages yet. Reviewed-on: https://gitea.com/gitea/tea/pulls/1123 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
1 parent
2b218a5bae
commit
88b91faa54
28 files changed
+3044
-3
No files matched your search
@@ -0,0 +1,152 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/settings"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// keyringService namespaces credentials in the platform credential manager.
|
||||
const keyringService = "tea-cli-v2"
|
||||
|
||||
// Credential holds the secret token material kept outside instance metadata.
|
||||
type Credential struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitzero"`
|
||||
}
|
||||
|
||||
// Keyring is the operating-system credential storage boundary.
|
||||
type Keyring interface {
|
||||
Get(string, string) (string, error)
|
||||
Set(string, string, string) error
|
||||
Delete(string, string) error
|
||||
}
|
||||
|
||||
// SystemKeyring delegates to the platform credential manager.
|
||||
type SystemKeyring struct{}
|
||||
|
||||
// Get retrieves a secret from the platform credential manager.
|
||||
func (SystemKeyring) Get(service, user string) (string, error) { return keyring.Get(service, user) }
|
||||
|
||||
// Set persists a secret in the platform credential manager.
|
||||
func (SystemKeyring) Set(service, user, value string) error { return keyring.Set(service, user, value) }
|
||||
|
||||
// Delete removes only the selected instance/account credential.
|
||||
func (SystemKeyring) Delete(service, user string) error { return keyring.Delete(service, user) }
|
||||
|
||||
// errNoKeyring rejects the keyring backend on a Store without a Keyring.
|
||||
var errNoKeyring = errors.New("keyring storage unavailable")
|
||||
|
||||
// Store selects secure storage or explicitly opted-in private files.
|
||||
type Store struct {
|
||||
Dir string
|
||||
Keyring Keyring
|
||||
}
|
||||
|
||||
// Identity builds a stable, opaque key from an instance URL and account.
|
||||
func Identity(instance, user string) string {
|
||||
sum := sha256.Sum256([]byte(instance + "\x00" + user))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Store) filename(instance, user string) string {
|
||||
return filepath.Join(s.Dir, "credentials", Identity(instance, user)+".json")
|
||||
}
|
||||
|
||||
// Load returns a credential without exposing storage contents in errors.
|
||||
func (s *Store) Load(instance, user, backend string) (Credential, error) {
|
||||
var data []byte
|
||||
var err error
|
||||
switch backend {
|
||||
case settings.StoreKeyring:
|
||||
if s.Keyring == nil {
|
||||
return Credential{}, errNoKeyring
|
||||
}
|
||||
value, loadErr := s.Keyring.Get(keyringService, Identity(instance, user))
|
||||
data, err = []byte(value), loadErr
|
||||
case settings.StoreFile:
|
||||
data, err = os.ReadFile(s.filename(instance, user))
|
||||
default:
|
||||
err = errors.New("unsupported credential store")
|
||||
}
|
||||
if errors.Is(err, keyring.ErrNotFound) || errors.Is(err, os.ErrNotExist) {
|
||||
return Credential{}, errors.New("credential unavailable; run tea auth login for this instance and account")
|
||||
}
|
||||
if err != nil {
|
||||
return Credential{}, fmt.Errorf("credential storage unavailable: %w", err)
|
||||
}
|
||||
var credential Credential
|
||||
if err := json.Unmarshal(data, &credential); err != nil {
|
||||
return Credential{}, errors.New("invalid stored credential; run tea auth login")
|
||||
}
|
||||
if credential.AccessToken == "" {
|
||||
return Credential{}, errors.New("stored credential has no access token")
|
||||
}
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
// Save persists credentials without falling back to a different backend.
|
||||
func (s *Store) Save(instance, user, backend string, credential Credential) error {
|
||||
if credential.AccessToken == "" {
|
||||
return errors.New("access token cannot be empty")
|
||||
}
|
||||
data, err := json.Marshal(credential)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch backend {
|
||||
case settings.StoreKeyring:
|
||||
if s.Keyring == nil {
|
||||
return errNoKeyring
|
||||
}
|
||||
if err := s.Keyring.Set(keyringService, Identity(instance, user), string(data)); err != nil {
|
||||
return errors.New("secure credential storage unavailable; use --credential-store file only if you accept private file storage")
|
||||
}
|
||||
return nil
|
||||
case settings.StoreFile:
|
||||
if err := os.MkdirAll(filepath.Dir(s.filename(instance, user)), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return settings.AtomicWrite(s.filename(instance, user), data)
|
||||
default:
|
||||
return errors.New("unsupported credential store")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes only the selected instance/account credential.
|
||||
func (s *Store) Delete(instance, user, backend string) error {
|
||||
switch backend {
|
||||
case settings.StoreKeyring:
|
||||
if s.Keyring == nil {
|
||||
return errNoKeyring
|
||||
}
|
||||
err := s.Keyring.Delete(keyringService, Identity(instance, user))
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("failed to remove credential from secure storage")
|
||||
}
|
||||
return nil
|
||||
case settings.StoreFile:
|
||||
err := os.Remove(s.filename(instance, user))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unsupported credential store %q", backend)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/settings"
|
||||
)
|
||||
|
||||
// GitRequest contains only the credential-routing parts of Git's input protocol.
|
||||
type GitRequest struct{ Protocol, Host, Path, Username string }
|
||||
|
||||
// ReadGitRequest limits input and rejects ambiguous routing without echoing values.
|
||||
func ReadGitRequest(input io.Reader) (GitRequest, error) {
|
||||
fields := map[string]string{}
|
||||
scanner := bufio.NewScanner(io.LimitReader(input, 65537))
|
||||
scanner.Buffer(make([]byte, 4096), 65537)
|
||||
size := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
size += len(line) + 1
|
||||
if size > 65536 {
|
||||
return GitRequest{}, errors.New("Git credential request exceeds 64 KiB")
|
||||
}
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok || strings.ContainsAny(value, "\x00\r") {
|
||||
return GitRequest{}, errors.New("invalid Git credential request")
|
||||
}
|
||||
switch key {
|
||||
case "protocol", "host", "path", "username", "url":
|
||||
if _, exists := fields[key]; exists {
|
||||
return GitRequest{}, errors.New("duplicate Git credential routing field")
|
||||
}
|
||||
fields[key] = value
|
||||
}
|
||||
}
|
||||
if scanner.Err() != nil {
|
||||
return GitRequest{}, errors.New("cannot read Git credential request")
|
||||
}
|
||||
if raw, exists := fields["url"]; exists {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" {
|
||||
return GitRequest{}, errors.New("invalid Git credential URL")
|
||||
}
|
||||
if u.User != nil {
|
||||
if _, present := u.User.Password(); present {
|
||||
return GitRequest{}, errors.New("Git credential URL must not contain a password")
|
||||
}
|
||||
username := u.User.Username()
|
||||
if strings.ContainsAny(username, "\x00\r\n") {
|
||||
return GitRequest{}, errors.New("invalid Git credential username")
|
||||
}
|
||||
if previous, exists := fields["username"]; exists && previous != username {
|
||||
return GitRequest{}, errors.New("conflicting Git credential usernames")
|
||||
}
|
||||
fields["username"] = username
|
||||
}
|
||||
values := map[string]string{"protocol": u.Scheme, "host": u.Host, "path": strings.TrimPrefix(u.EscapedPath(), "/")}
|
||||
for key, value := range values {
|
||||
if previous, exists := fields[key]; exists && previous != value {
|
||||
return GitRequest{}, errors.New("conflicting Git credential URL fields")
|
||||
}
|
||||
fields[key] = value
|
||||
}
|
||||
}
|
||||
return GitRequest{fields["protocol"], fields["host"], fields["path"], fields["username"]}, nil
|
||||
}
|
||||
|
||||
// MatchGitInstance selects the most specific configured installation. A pinned
|
||||
// helper declines a request belonging to a different installation on that origin.
|
||||
func MatchGitInstance(request GitRequest, hosts map[string]settings.Host, explicit, environment string) (string, error) {
|
||||
if request.Protocol != "http" && request.Protocol != "https" {
|
||||
return "", nil
|
||||
}
|
||||
origin, err := settings.NormalizeHost(request.Protocol + "://" + request.Host)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid Git credential origin")
|
||||
}
|
||||
parsedOrigin, _ := url.Parse(origin)
|
||||
if parsedOrigin.Path != "" {
|
||||
return "", errors.New("invalid Git credential host")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(request.Path), "%2f") || strings.Contains(strings.ToLower(request.Path), "%5c") {
|
||||
return "", errors.New("ambiguous Git credential path")
|
||||
}
|
||||
decoded, err := url.PathUnescape(request.Path)
|
||||
if err != nil || strings.ContainsAny(decoded, "\\\x00\r\n") {
|
||||
return "", errors.New("invalid Git credential path")
|
||||
}
|
||||
for segment := range strings.SplitSeq(decoded, "/") {
|
||||
if segment == "." || segment == ".." || strings.Contains(segment, "%") {
|
||||
return "", errors.New("ambiguous Git credential path")
|
||||
}
|
||||
}
|
||||
requestPath := "/" + strings.TrimPrefix(decoded, "/")
|
||||
candidates := make(map[string]bool, len(hosts)+2)
|
||||
for instance := range hosts {
|
||||
candidates[instance] = true
|
||||
}
|
||||
for _, extra := range []string{explicit, environment} {
|
||||
if extra != "" {
|
||||
normalized, err := settings.NormalizeHost(extra)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid configured Git credential instance")
|
||||
}
|
||||
candidates[normalized] = true
|
||||
}
|
||||
}
|
||||
best, specificity := "", -1
|
||||
sameOrigin := 0
|
||||
for instance := range candidates {
|
||||
u, err := url.Parse(instance)
|
||||
if err != nil || u.Scheme+"://"+u.Host != origin {
|
||||
continue
|
||||
}
|
||||
sameOrigin++
|
||||
base := strings.TrimSuffix(u.Path, "/")
|
||||
if requestPath != base && !strings.HasPrefix(requestPath, base+"/") {
|
||||
continue
|
||||
}
|
||||
if len(base) > specificity {
|
||||
best, specificity = instance, len(base)
|
||||
}
|
||||
}
|
||||
// Without useHttpPath, Git omits the path. Multiple installations then cannot
|
||||
// be distinguished safely, even if one is installed at the origin root.
|
||||
if request.Path == "" && sameOrigin > 1 {
|
||||
return "", nil
|
||||
}
|
||||
if explicit != "" {
|
||||
pinned, _ := settings.NormalizeHost(explicit)
|
||||
if pinned != best {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// GitCredential loads or refreshes one explicitly selected stored account.
|
||||
// It never accepts Git's store/erase protocol as an authentication mutation.
|
||||
func (m Manager) GitCredential(ctx context.Context, instance, user string, client *http.Client, now time.Time) (Credential, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
snapshot, err := m.settings.Load()
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
account, exists := snapshot.Hosts.Hosts[instance].Users[user]
|
||||
if !exists {
|
||||
return Credential{}, errors.New("selected Git account is not configured; run tea auth login")
|
||||
}
|
||||
if account.AuthMethod == settings.AuthOAuth {
|
||||
return m.Refresh(ctx, instance, user, client, false, now)
|
||||
}
|
||||
if account.Tokenless() {
|
||||
return Credential{}, errors.New("HTTP signature accounts cannot supply Git HTTPS credentials; use SSH remotes or a token account")
|
||||
}
|
||||
return m.credentials.Load(instance, user, account.CredentialStore)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/tea/internal/settings"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGitCredentialRoutingSeparatesInstallations(t *testing.T) {
|
||||
hosts := map[string]settings.Host{"https://example.test": {}, "https://example.test/gitea": {}, "https://example.test/gitea/nested": {}, "https://example.test:8443/gitea": {}}
|
||||
for _, tc := range []struct{ host, path, pinned, want string }{
|
||||
{"example.test", "gitea/owner/repo.git", "", "https://example.test/gitea"},
|
||||
{"example.test:443", "gitea/owner/repo.git", "", "https://example.test/gitea"},
|
||||
{"example.test", "gitea/nested/owner/repo.git", "", "https://example.test/gitea/nested"},
|
||||
{"example.test", "gitea/nested/owner/repo.git", "https://example.test/gitea", ""},
|
||||
{"example.test", "gitea-other/owner/repo.git", "", "https://example.test"},
|
||||
{"example.test", "", "", ""},
|
||||
{"example.test:8443", "gitea/owner/repo.git", "", "https://example.test:8443/gitea"},
|
||||
{"other.test", "gitea/owner/repo.git", "https://example.test/gitea", ""},
|
||||
} {
|
||||
got, err := MatchGitInstance(GitRequest{Protocol: "https", Host: tc.host, Path: tc.path}, hosts, tc.pinned, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.want, got)
|
||||
}
|
||||
for _, path := range []string{"gitea/../other", "gitea/%2e%2e/other", "gitea%2fother", "gitea/%252e%252e/other", `gitea\other`} { //nolint:misspell // The backslash is an intentionally invalid path separator.
|
||||
_, err := MatchGitInstance(GitRequest{Protocol: "https", Host: "example.test", Path: path}, hosts, "", "")
|
||||
require.Error(t, err, path)
|
||||
}
|
||||
got, err := MatchGitInstance(GitRequest{Protocol: "http", Host: "example.test", Path: "gitea/owner/repo.git"}, hosts, "", "")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestReadGitCredentialRequestRejectsAmbiguityWithoutEchoingSecrets(t *testing.T) {
|
||||
request, err := ReadGitRequest(strings.NewReader("protocol=https\nhost=example.test:8443\npath=gitea/owner/repo.git\nusername=alice\npassword=discarded-secret\n\n"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, GitRequest{"https", "example.test:8443", "gitea/owner/repo.git", "alice"}, request)
|
||||
request, err = ReadGitRequest(strings.NewReader("url=https://example.test/gitea/owner/repo.git\n\n"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "gitea/owner/repo.git", request.Path)
|
||||
request, err = ReadGitRequest(strings.NewReader("url=https://alice@example.test/gitea/owner/repo.git\n\n"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "alice", request.Username)
|
||||
for _, input := range []string{"host=secret-one\nhost=secret-two\n", "url=https://alice:secret@example.test/repo\n", "url=https://example.test/repo\nhost=secret.test\n", "host=secret\x00value\n", "password=" + strings.Repeat("secret", 12000) + "\n"} {
|
||||
_, err := ReadGitRequest(strings.NewReader(input))
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, err.Error(), "secret")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/settings"
|
||||
"github.com/42wim/httpsig"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
)
|
||||
|
||||
// HTTPSign signs Gitea API requests with an SSH private key or certificate.
|
||||
type HTTPSign struct {
|
||||
ssh.Signer
|
||||
Cert bool
|
||||
}
|
||||
|
||||
// SignWithAlgorithm implements ssh.AlgorithmSigner for RSA keys under httpsig.
|
||||
func (h *HTTPSign) SignWithAlgorithm(rand io.Reader, data []byte, algorithm string) (*ssh.Signature, error) {
|
||||
if as, ok := h.Signer.(ssh.AlgorithmSigner); ok {
|
||||
return as.SignWithAlgorithm(rand, data, algorithm)
|
||||
}
|
||||
return h.Sign(rand, data)
|
||||
}
|
||||
|
||||
// SignRequest attaches Signature and Digest headers required by Gitea HTTPSign.
|
||||
func (h *HTTPSign) SignRequest(r *http.Request, contents []byte) error {
|
||||
headersToSign := []string{httpsig.RequestTarget, "(created)", "(expires)"}
|
||||
if h.Cert {
|
||||
pubkey, err := ssh.ParsePublicKey(h.PublicKey().Marshal())
|
||||
if err != nil {
|
||||
return errors.New("cannot parse SSH certificate for request signing")
|
||||
}
|
||||
cert, ok := pubkey.(*ssh.Certificate)
|
||||
if !ok {
|
||||
return errors.New("no SSH certificate found for request signing")
|
||||
}
|
||||
r.Header.Set("x-ssh-certificate", base64.RawStdEncoding.EncodeToString(cert.Marshal()))
|
||||
headersToSign = append(headersToSign, "x-ssh-certificate")
|
||||
}
|
||||
if r.Body != nil && r.Body != http.NoBody {
|
||||
headersToSign = append(headersToSign, "Digest")
|
||||
}
|
||||
signer, _, err := httpsig.NewSSHSigner(h, httpsig.DigestSha512, headersToSign, httpsig.Signature, 10)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create HTTP signature: %w", err)
|
||||
}
|
||||
keyID := "gitea"
|
||||
if !h.Cert {
|
||||
keyID = ssh.FingerprintSHA256(h.PublicKey())
|
||||
}
|
||||
return signer.SignRequest(keyID, r, contents)
|
||||
}
|
||||
|
||||
// SignerForAccount builds an HTTP signer from persisted account SSH metadata. passphrase is
|
||||
// called only for an encrypted key file; when it is nil, such a key fails with advice to use ssh-agent.
|
||||
func SignerForAccount(account settings.Account, passphrase func() ([]byte, error)) (*HTTPSign, error) {
|
||||
keyPath := account.SSHKey
|
||||
if account.SSHAgent {
|
||||
keyPath = ""
|
||||
}
|
||||
if account.SSHCertPrincipal != "" || account.AuthMethod == settings.AuthSSH {
|
||||
return newHTTPSign(keyPath, true, account.SSHCertPrincipal, passphrase)
|
||||
}
|
||||
return newHTTPSign(keyPath, false, account.SSHKeyFingerprint, passphrase)
|
||||
}
|
||||
|
||||
// newHTTPSign loads a key file, or selects an ssh-agent key matching the fingerprint or, for
|
||||
// certificates, the principal.
|
||||
func newHTTPSign(keyPath string, cert bool, match string, passphrase func() ([]byte, error)) (*HTTPSign, error) {
|
||||
if keyPath == "" {
|
||||
agentClient, release, err := getAgent()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signer, err := agentSigner(agentClient, cert, match)
|
||||
if err != nil {
|
||||
release()
|
||||
return nil, err
|
||||
}
|
||||
// The agent connection stays open for the signer's lifetime: every request is signed by the agent.
|
||||
return signer, nil
|
||||
}
|
||||
signer, err := parsePrivateKey(keyPath, passphrase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cert {
|
||||
certbytes, err := os.ReadFile(keyPath + "-cert.pub")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read SSH certificate: %w", err)
|
||||
}
|
||||
pub, _, _, _, err := ssh.ParseAuthorizedKey(certbytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse SSH certificate: %w", err)
|
||||
}
|
||||
certificate, ok := pub.(*ssh.Certificate)
|
||||
if !ok {
|
||||
return nil, errors.New("SSH certificate file does not contain a certificate")
|
||||
}
|
||||
signer, err = ssh.NewCertSigner(certificate, signer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot use SSH certificate: %w", err)
|
||||
}
|
||||
}
|
||||
return &HTTPSign{Signer: signer, Cert: cert}, nil
|
||||
}
|
||||
|
||||
// agentSigner selects the agent key matching the fingerprint or, for certificates, the principal.
|
||||
func agentSigner(agentClient agent.Agent, cert bool, match string) (*HTTPSign, error) {
|
||||
signers, err := agentClient.Signers()
|
||||
if err != nil {
|
||||
return nil, errors.New("cannot list keys from ssh-agent")
|
||||
}
|
||||
if len(signers) == 0 {
|
||||
return nil, errors.New("no signers found in ssh-agent")
|
||||
}
|
||||
if cert {
|
||||
if signer := findCertSigner(signers, match); signer != nil {
|
||||
return &HTTPSign{Signer: signer, Cert: true}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no certificate found in ssh-agent for principal %q", match)
|
||||
}
|
||||
if signer := findPubkeySigner(signers, match); signer != nil {
|
||||
return &HTTPSign{Signer: signer}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no public key found in ssh-agent for %q", match)
|
||||
}
|
||||
|
||||
func parsePrivateKey(keyPath string, passphrase func() ([]byte, error)) (ssh.Signer, error) {
|
||||
priv, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read SSH key: %w", err)
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(priv)
|
||||
if _, ok := errors.AsType[*ssh.PassphraseMissingError](err); ok {
|
||||
if passphrase == nil {
|
||||
return nil, fmt.Errorf("SSH key %s is passphrase protected; add it to ssh-agent with ssh-add and log in with --ssh-agent-key or --ssh-agent-principal", keyPath)
|
||||
}
|
||||
secret, promptErr := passphrase()
|
||||
if promptErr != nil {
|
||||
return nil, promptErr
|
||||
}
|
||||
signer, err = ssh.ParsePrivateKeyWithPassphrase(priv, secret)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse SSH key: %w", err)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
|
||||
func findCertSigner(sshsigners []ssh.Signer, principal string) ssh.Signer {
|
||||
for _, s := range sshsigners {
|
||||
if !strings.Contains(s.PublicKey().Type(), "cert-v01@openssh.com") {
|
||||
continue
|
||||
}
|
||||
mpubkey, err := ssh.ParsePublicKey(s.PublicKey().Marshal())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cert, ok := mpubkey.(*ssh.Certificate)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cert.ValidBefore != ssh.CertTimeInfinity && time.Until(time.Unix(int64(cert.ValidBefore), 0)) <= 10*time.Second {
|
||||
continue
|
||||
}
|
||||
if principal == "" {
|
||||
return s
|
||||
}
|
||||
if slices.Contains(cert.ValidPrincipals, principal) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findPubkeySigner(sshsigners []ssh.Signer, fingerprint string) ssh.Signer {
|
||||
for _, s := range sshsigners {
|
||||
if strings.Contains(s.PublicKey().Type(), "cert-v01@openssh.com") {
|
||||
continue
|
||||
}
|
||||
if fingerprint == "" {
|
||||
return s
|
||||
}
|
||||
if strings.TrimSpace(string(ssh.MarshalAuthorizedKey(s.PublicKey()))) == fingerprint {
|
||||
return s
|
||||
}
|
||||
if ssh.FingerprintSHA256(s.PublicKey()) == fingerprint {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
)
|
||||
|
||||
// getAgent connects to the ssh-agent at SSH_AUTH_SOCK; release closes the connection.
|
||||
func getAgent() (client agent.Agent, release func(), err error) {
|
||||
socket := os.Getenv("SSH_AUTH_SOCK")
|
||||
if socket == "" {
|
||||
return nil, nil, errors.New("no ssh-agent available")
|
||||
}
|
||||
if _, err := os.Stat(socket); err != nil {
|
||||
return nil, nil, errors.New("no ssh-agent available")
|
||||
}
|
||||
conn, err := net.Dial("unix", socket)
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("cannot connect to ssh-agent")
|
||||
}
|
||||
return agent.NewClient(conn), func() { _ = conn.Close() }, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !windows
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
)
|
||||
|
||||
func TestAgentConnectionClosesWhenNoKeyMatches(t *testing.T) {
|
||||
// t.TempDir can exceed the length limit of Unix socket paths.
|
||||
dir, err := os.MkdirTemp("", "tea-agent")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
socket := filepath.Join(dir, "agent.sock")
|
||||
listener, err := net.Listen("unix", socket)
|
||||
require.NoError(t, err)
|
||||
defer listener.Close()
|
||||
_, key, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
keyring := agent.NewKeyring()
|
||||
require.NoError(t, keyring.Add(agent.AddedKey{PrivateKey: key}))
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
defer close(closed)
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
// ServeAgent returns once the client closes its connection.
|
||||
_ = agent.ServeAgent(keyring, conn)
|
||||
}()
|
||||
t.Setenv("SSH_AUTH_SOCK", socket)
|
||||
_, err = newHTTPSign("", false, "SHA256:missing", nil)
|
||||
require.ErrorContains(t, err, "no public key found in ssh-agent")
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ssh-agent connection stayed open after the lookup failed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/davidmz/go-pageant"
|
||||
"golang.org/x/crypto/ssh/agent"
|
||||
)
|
||||
|
||||
// getAgent returns the Pageant client, which holds no connection for release to close.
|
||||
func getAgent() (client agent.Agent, release func(), err error) {
|
||||
if !pageant.Available() {
|
||||
return nil, nil, errors.New("no pageant available")
|
||||
}
|
||||
return pageant.New(), func() {}, nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/settings"
|
||||
"gitea.dev/tea/internal/transport"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestHTTPSignAttachesSignatureHeaders(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
signer, err := ssh.NewSignerFromKey(key)
|
||||
require.NoError(t, err)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "id_rsa")
|
||||
block, err := ssh.MarshalPrivateKey(key, "")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(path, pem.EncodeToMemory(block), 0o600))
|
||||
|
||||
var sawSignature, sawDigest bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawSignature = r.Header.Get("Signature") != ""
|
||||
sawDigest = r.Header.Get("Digest") != ""
|
||||
require.Empty(t, r.Header.Get("Authorization"))
|
||||
_, _ = w.Write([]byte(`{"login":"alice"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
httpSign, err := newHTTPSign(path, false, "", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ssh.FingerprintSHA256(signer.PublicKey()), ssh.FingerprintSHA256(httpSign.PublicKey()))
|
||||
|
||||
client := transport.New(server.URL, "", false)
|
||||
client.Signer = httpSign
|
||||
response, err := client.Do(t.Context(), http.MethodGet, "user", nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, response.Body.Close())
|
||||
require.True(t, sawSignature)
|
||||
require.False(t, sawDigest)
|
||||
|
||||
sawSignature, sawDigest = false, false
|
||||
response, err = client.Do(t.Context(), http.MethodPost, "user", bytes.NewReader([]byte(`{"ok":true}`)), nil)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, response.Body.Close())
|
||||
require.True(t, sawSignature)
|
||||
require.True(t, sawDigest)
|
||||
|
||||
account := settings.Account{AuthMethod: "httpsign", SSHKey: path}
|
||||
fromAccount, err := SignerForAccount(account, func() ([]byte, error) {
|
||||
t.Error("unencrypted key asked for a passphrase")
|
||||
return nil, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fromAccount)
|
||||
}
|
||||
|
||||
func TestEncryptedSSHKeyUsesInjectedPassphrase(t *testing.T) {
|
||||
_, key, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
block, err := ssh.MarshalPrivateKeyWithPassphrase(key, "", []byte("correct horse"))
|
||||
require.NoError(t, err)
|
||||
path := filepath.Join(t.TempDir(), "id_ed25519")
|
||||
require.NoError(t, os.WriteFile(path, pem.EncodeToMemory(block), 0o600))
|
||||
account := settings.Account{AuthMethod: settings.AuthHTTPSign, SSHKey: path}
|
||||
|
||||
_, err = SignerForAccount(account, nil)
|
||||
require.ErrorContains(t, err, "passphrase protected; add it to ssh-agent")
|
||||
|
||||
prompts := 0
|
||||
signer, err := SignerForAccount(account, func() ([]byte, error) { prompts++; return []byte("correct horse"), nil })
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, prompts)
|
||||
require.Equal(t, ssh.KeyAlgoED25519, signer.PublicKey().Type())
|
||||
|
||||
_, err = SignerForAccount(account, func() ([]byte, error) { return []byte("wrong"), nil })
|
||||
require.ErrorContains(t, err, "cannot parse SSH key")
|
||||
}
|
||||
|
||||
func TestFindCertSignerHonorsValidity(t *testing.T) {
|
||||
_, caKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
ca, err := ssh.NewSignerFromKey(caKey)
|
||||
require.NoError(t, err)
|
||||
_, userKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
user, err := ssh.NewSignerFromKey(userKey)
|
||||
require.NoError(t, err)
|
||||
certSigner := func(validBefore uint64) ssh.Signer {
|
||||
cert := &ssh.Certificate{Key: user.PublicKey(), CertType: ssh.UserCert, ValidPrincipals: []string{"alice"}, ValidBefore: validBefore}
|
||||
require.NoError(t, cert.SignCert(rand.Reader, ca))
|
||||
signer, err := ssh.NewCertSigner(cert, user)
|
||||
require.NoError(t, err)
|
||||
return signer
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
validBefore uint64
|
||||
found bool
|
||||
}{
|
||||
"unlimited": {ssh.CertTimeInfinity, true},
|
||||
"valid": {uint64(time.Now().Add(time.Hour).Unix()), true},
|
||||
"expiring": {uint64(time.Now().Add(5 * time.Second).Unix()), false},
|
||||
"expired": {uint64(time.Now().Add(-time.Hour).Unix()), false},
|
||||
} {
|
||||
signer := certSigner(tc.validBefore)
|
||||
require.Equal(t, tc.found, findCertSigner([]ssh.Signer{signer}, "alice") != nil, name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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{}
|
||||
}
|
||||
if _, exists := host.Users[user]; exists {
|
||||
return nil, errors.New("account already configured; logout before authenticating again")
|
||||
}
|
||||
var rollback func() error
|
||||
if credential != nil {
|
||||
if err := m.credentials.Save(instance, user, account.CredentialStore, *credential); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rollback = 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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/settings"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
type memoryKeyring struct {
|
||||
values map[string]string
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (m *memoryKeyring) Get(_ string, k string) (string, error) {
|
||||
if m.fail {
|
||||
return "", errors.New("unavailable")
|
||||
}
|
||||
value, ok := m.values[k]
|
||||
if !ok {
|
||||
return "", keyring.ErrNotFound
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (m *memoryKeyring) Set(_, k, v string) error {
|
||||
if m.fail {
|
||||
return errors.New("unavailable")
|
||||
}
|
||||
m.values[k] = v
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryKeyring) Delete(_, k string) error {
|
||||
if m.fail {
|
||||
return errors.New("unavailable")
|
||||
}
|
||||
delete(m.values, k)
|
||||
return nil
|
||||
}
|
||||
|
||||
// newTestManager couples config and credentials, failing the test on a missing store.
|
||||
func newTestManager(t *testing.T, config *settings.Store, credentials *Store) Manager {
|
||||
t.Helper()
|
||||
manager, err := NewManager(config, credentials)
|
||||
require.NoError(t, err)
|
||||
return manager
|
||||
}
|
||||
|
||||
func TestNewManagerRequiresBothStores(t *testing.T) {
|
||||
config := settings.New(t.TempDir())
|
||||
_, err := NewManager(nil, &Store{Dir: config.Dir})
|
||||
require.ErrorContains(t, err, "settings store")
|
||||
_, err = NewManager(config, nil)
|
||||
require.ErrorContains(t, err, "credential store")
|
||||
}
|
||||
|
||||
func TestRegisterAndLogoutIsolateAccounts(t *testing.T) {
|
||||
config := settings.New(t.TempDir())
|
||||
credentials := &Store{Dir: config.Dir, Keyring: &memoryKeyring{values: map[string]string{}}}
|
||||
manager := newTestManager(t, config, credentials)
|
||||
for _, user := range []string{"alice", "bob", "charlie"} {
|
||||
require.NoError(t, manager.Register("https://example.org", user, "keyring", "token", HostOptions{}, Credential{AccessToken: "secret-" + user}))
|
||||
}
|
||||
require.NoError(t, manager.Logout("https://example.org", "charlie"))
|
||||
s, err := config.Load()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, s.Hosts.Hosts["https://example.org"].User)
|
||||
credential, err := credentials.Load("https://example.org", "alice", "keyring")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "secret-alice", credential.AccessToken)
|
||||
_, err = credentials.Load("https://example.org", "charlie", "keyring")
|
||||
require.Error(t, err)
|
||||
data, err := os.ReadFile(filepath.Join(config.Dir, "hosts.yml"))
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(data), "secret")
|
||||
}
|
||||
|
||||
func TestFailedConfigPublishRollsBackCredentials(t *testing.T) {
|
||||
config := settings.New(t.TempDir())
|
||||
// An invalid auth method fails validation after the credential was saved.
|
||||
keyring := &memoryKeyring{values: map[string]string{}}
|
||||
credentials := &Store{Dir: config.Dir, Keyring: keyring}
|
||||
manager := newTestManager(t, config, credentials)
|
||||
require.Error(t, manager.Register("https://example.org", "alice", "keyring", "invalid", HostOptions{}, Credential{AccessToken: "secret"}))
|
||||
require.Empty(t, keyring.values)
|
||||
_, err := os.Stat(filepath.Join(config.Dir, "hosts.yml"))
|
||||
require.True(t, errors.Is(err, os.ErrNotExist))
|
||||
}
|
||||
|
||||
func TestNoSilentCredentialFallback(t *testing.T) {
|
||||
config := settings.New(t.TempDir())
|
||||
credentials := &Store{Dir: config.Dir, Keyring: &memoryKeyring{values: map[string]string{}, fail: true}}
|
||||
manager := newTestManager(t, config, credentials)
|
||||
require.Error(t, manager.Register("https://example.org", "alice", "keyring", "token", HostOptions{}, Credential{AccessToken: "secret"}))
|
||||
require.NoError(t, manager.Register("https://example.org", "alice", "file", "token", HostOptions{}, Credential{AccessToken: "secret"}))
|
||||
credential, err := credentials.Load("https://example.org", "alice", "file")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "secret", credential.AccessToken)
|
||||
}
|
||||
|
||||
func TestCredentialLoadDistinguishesMissingFromUnavailable(t *testing.T) {
|
||||
keyring := &memoryKeyring{values: map[string]string{}}
|
||||
store := &Store{Dir: t.TempDir(), Keyring: keyring}
|
||||
for _, backend := range []string{settings.StoreKeyring, settings.StoreFile} {
|
||||
_, err := store.Load("https://example.org", "alice", backend)
|
||||
require.ErrorContains(t, err, "run tea auth login", backend)
|
||||
}
|
||||
// Logging in again cannot fix a locked or unreachable keyring, so do not suggest it.
|
||||
keyring.fail = true
|
||||
_, err := store.Load("https://example.org", "alice", settings.StoreKeyring)
|
||||
require.EqualError(t, err, "credential storage unavailable: unavailable")
|
||||
}
|
||||
|
||||
func TestCredentialStoreWithoutKeyringRejectsKeyringBackend(t *testing.T) {
|
||||
store := &Store{Dir: t.TempDir()}
|
||||
_, err := store.Load("https://example.org", "alice", settings.StoreKeyring)
|
||||
require.ErrorIs(t, err, errNoKeyring)
|
||||
require.ErrorIs(t, store.Save("https://example.org", "alice", settings.StoreKeyring, Credential{AccessToken: "secret"}), errNoKeyring)
|
||||
require.ErrorIs(t, store.Delete("https://example.org", "alice", settings.StoreKeyring), errNoKeyring)
|
||||
}
|
||||
|
||||
func TestCredentialFilesRoundTripExpiry(t *testing.T) {
|
||||
store := &Store{Dir: t.TempDir()}
|
||||
expiring := Credential{AccessToken: "access", RefreshToken: "refresh", ExpiresAt: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
|
||||
for _, credential := range []Credential{{AccessToken: "token"}, expiring} {
|
||||
require.NoError(t, store.Save("https://example.org", "alice", settings.StoreFile, credential))
|
||||
loaded, err := store.Load("https://example.org", "alice", settings.StoreFile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, credential, loaded)
|
||||
}
|
||||
require.NoError(t, store.Save("https://example.org", "alice", settings.StoreFile, Credential{AccessToken: "token"}))
|
||||
data, err := os.ReadFile(store.filename("https://example.org", "alice"))
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(data), "expires_at")
|
||||
// Files written while a missing expiry was stored as the zero time still load without one.
|
||||
require.NoError(t, os.WriteFile(store.filename("https://example.org", "alice"), []byte(`{"access_token":"token","expires_at":"0001-01-01T00:00:00Z"}`), 0o600))
|
||||
loaded, err := store.Load("https://example.org", "alice", settings.StoreFile)
|
||||
require.NoError(t, err)
|
||||
require.True(t, loaded.ExpiresAt.IsZero())
|
||||
}
|
||||
|
||||
func TestLogoutRemovesAccountWithMissingCredential(t *testing.T) {
|
||||
config := settings.New(t.TempDir())
|
||||
keyring := &memoryKeyring{values: map[string]string{}}
|
||||
manager := newTestManager(t, config, &Store{Dir: config.Dir, Keyring: keyring})
|
||||
require.NoError(t, manager.Register("https://example.org", "alice", settings.StoreKeyring, settings.AuthToken, HostOptions{}, Credential{AccessToken: "secret"}))
|
||||
clear(keyring.values)
|
||||
require.NoError(t, manager.Logout("https://example.org", "alice"))
|
||||
s, err := config.Load()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, s.Hosts.Hosts["https://example.org"].Users)
|
||||
require.NoError(t, manager.Register("https://example.org", "alice", settings.StoreKeyring, settings.AuthToken, HostOptions{}, Credential{AccessToken: "secret"}))
|
||||
// An unavailable keyring keeps the account rather than orphaning its token.
|
||||
keyring.fail = true
|
||||
require.Error(t, manager.Logout("https://example.org", "alice"))
|
||||
s, err = config.Load()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, s.Hosts.Hosts["https://example.org"].Users, "alice")
|
||||
}
|
||||
|
||||
func TestRegisterHTTPSignRejectsTokenAccounts(t *testing.T) {
|
||||
config := settings.New(t.TempDir())
|
||||
manager := newTestManager(t, config, &Store{Dir: config.Dir, Keyring: &memoryKeyring{values: map[string]string{}}})
|
||||
require.Error(t, manager.RegisterHTTPSign("https://example.org", "alice", settings.Account{AuthMethod: settings.AuthToken}, HostOptions{}))
|
||||
require.NoError(t, manager.RegisterHTTPSign("https://example.org", "alice", settings.Account{AuthMethod: settings.AuthSSH, CredentialStore: settings.StoreFile, SSHKey: "/keys/id"}, HostOptions{}))
|
||||
s, err := config.Load()
|
||||
require.NoError(t, err)
|
||||
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"))
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/settings"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// DefaultClientID identifies the public tea OAuth application.
|
||||
const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4"
|
||||
|
||||
// refreshTimeout bounds a token refresh, which holds the settings lock, below settings.LockTimeout.
|
||||
const refreshTimeout = 20 * time.Second
|
||||
|
||||
// OAuthOptions supplies the instance, client, and authorization interaction.
|
||||
type OAuthOptions struct {
|
||||
Instance string
|
||||
ClientID string
|
||||
// HTTP sends the token exchange; nil uses http.DefaultClient.
|
||||
HTTP *http.Client
|
||||
Authorize func(context.Context, string) error
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// OAuthLogin exchanges a PKCE authorization code through a loopback callback.
|
||||
func OAuthLogin(ctx context.Context, opts OAuthOptions) (Credential, error) {
|
||||
if opts.ClientID == "" {
|
||||
opts.ClientID = DefaultClientID
|
||||
}
|
||||
if opts.Timeout == 0 {
|
||||
opts.Timeout = 2 * time.Minute
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return Credential{}, errors.New("cannot listen for OAuth callback")
|
||||
}
|
||||
defer listener.Close()
|
||||
random := make([]byte, 32)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
state := base64.RawURLEncoding.EncodeToString(random)
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
// 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.RedirectURL = redirect
|
||||
codes := make(chan string, 1)
|
||||
failures := make(chan error, 1)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("state") != state {
|
||||
http.Error(w, "Invalid OAuth state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "Authorization declined", http.StatusBadRequest)
|
||||
select {
|
||||
case failures <- errors.New("OAuth authorization declined"):
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case codes <- code:
|
||||
default:
|
||||
}
|
||||
_, _ = fmt.Fprintln(w, "Authorization received. Return to tea.")
|
||||
})
|
||||
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
|
||||
select {
|
||||
case failures <- errors.New("OAuth callback server failed"):
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer server.Close()
|
||||
authorizeURL := config.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
||||
if opts.Authorize == nil {
|
||||
return Credential{}, errors.New("OAuth requires an authorization URL handler")
|
||||
}
|
||||
if err := opts.Authorize(ctx, authorizeURL); err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Credential{}, ctx.Err()
|
||||
case err := <-failures:
|
||||
return Credential{}, err
|
||||
case code := <-codes:
|
||||
ctx = withHTTPClient(ctx, opts.HTTP)
|
||||
token, err := config.Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return Credential{}, ctx.Err()
|
||||
}
|
||||
return Credential{}, errors.New("OAuth token exchange failed; verify client ID and registered loopback redirect URI")
|
||||
}
|
||||
return Credential{AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresAt: token.Expiry}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// withHTTPClient routes oauth2 requests through client, or http.DefaultClient when it is nil.
|
||||
// oauth2 would dereference a nil client stored in the context.
|
||||
func withHTTPClient(ctx context.Context, client *http.Client) context.Context {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return context.WithValue(ctx, oauth2.HTTPClient, client)
|
||||
}
|
||||
|
||||
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"}}
|
||||
}
|
||||
|
||||
// Refresh locks by instance and account, then reads again before rotating secrets.
|
||||
// A nil httpClient uses http.DefaultClient.
|
||||
func (m Manager) Refresh(ctx context.Context, instance, user string, httpClient *http.Client, force bool, now time.Time) (Credential, error) {
|
||||
snapshot, err := m.settings.Load()
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
host := snapshot.Hosts.Hosts[instance]
|
||||
account, exists := host.Users[user]
|
||||
if !exists {
|
||||
return Credential{}, errors.New("account is not configured")
|
||||
}
|
||||
if account.AuthMethod != settings.AuthOAuth {
|
||||
return m.credentials.Load(instance, user, account.CredentialStore)
|
||||
}
|
||||
credential, err := m.credentials.Load(instance, user, account.CredentialStore)
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
needsRefresh := func(c Credential) bool { return !c.ExpiresAt.IsZero() && !now.Add(5*time.Minute).Before(c.ExpiresAt) }
|
||||
if !force && !needsRefresh(credential) {
|
||||
return credential, nil
|
||||
}
|
||||
original := credential
|
||||
err = m.settings.Locked(func(current settings.Snapshot) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
currentAccount, exists := current.Hosts.Hosts[instance].Users[user]
|
||||
if !exists || currentAccount.AuthMethod != settings.AuthOAuth {
|
||||
return errors.New("OAuth account changed while waiting to refresh")
|
||||
}
|
||||
credential, err = m.credentials.Load(instance, user, currentAccount.CredentialStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if credential.AccessToken != original.AccessToken && !needsRefresh(credential) {
|
||||
return nil
|
||||
}
|
||||
if !force && !needsRefresh(credential) {
|
||||
return nil
|
||||
}
|
||||
if credential.RefreshToken == "" {
|
||||
return errors.New("OAuth token cannot be refreshed; run tea auth login")
|
||||
}
|
||||
clientID := current.Hosts.Hosts[instance].OAuthClientID
|
||||
if clientID == "" {
|
||||
clientID = DefaultClientID
|
||||
}
|
||||
config := oauthConfig(instance, clientID)
|
||||
requestContext, cancel := context.WithTimeout(ctx, refreshTimeout)
|
||||
defer cancel()
|
||||
requestContext = withHTTPClient(requestContext, httpClient)
|
||||
old := &oauth2.Token{AccessToken: credential.AccessToken, RefreshToken: credential.RefreshToken, Expiry: now.Add(-time.Hour)}
|
||||
token, err := config.TokenSource(requestContext, old).Token()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if requestContext.Err() != nil {
|
||||
return errors.New("OAuth refresh timed out; try again")
|
||||
}
|
||||
return errors.New("OAuth refresh failed; run tea auth login")
|
||||
}
|
||||
credential.AccessToken = token.AccessToken
|
||||
if token.RefreshToken != "" {
|
||||
credential.RefreshToken = token.RefreshToken
|
||||
}
|
||||
if !token.Expiry.IsZero() {
|
||||
credential.ExpiresAt = token.Expiry
|
||||
}
|
||||
return m.credentials.Save(instance, user, currentAccount.CredentialStore, credential)
|
||||
})
|
||||
return credential, err
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/tea/internal/settings"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOAuthLoginPKCEAndState(t *testing.T) {
|
||||
var redirect, challenge string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/base/login/oauth/access_token", r.URL.Path)
|
||||
require.NoError(t, r.ParseForm())
|
||||
require.Equal(t, "authorization_code", r.Form.Get("grant_type"))
|
||||
require.Equal(t, "valid-code", r.Form.Get("code"))
|
||||
require.NotEmpty(t, r.Form.Get("code_verifier"))
|
||||
require.Equal(t, redirect, r.Form.Get("redirect_uri"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "access", "refresh_token": "refresh", "expires_in": 3600, "token_type": "Bearer"})
|
||||
}))
|
||||
defer server.Close()
|
||||
credential, err := OAuthLogin(context.Background(), OAuthOptions{Instance: server.URL + "/base", ClientID: "configured-client", HTTP: server.Client(), Timeout: time.Second, Authorize: func(ctx context.Context, raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
require.Equal(t, "configured-client", u.Query().Get("client_id"))
|
||||
require.Equal(t, "S256", u.Query().Get("code_challenge_method"))
|
||||
redirect = u.Query().Get("redirect_uri")
|
||||
callback, err := url.Parse(redirect)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "127.0.0.1", callback.Hostname())
|
||||
// Gitea's built-in tea application registers only the bare loopback URL.
|
||||
require.Empty(t, callback.Path)
|
||||
challenge = u.Query().Get("code_challenge")
|
||||
require.NotEmpty(t, challenge)
|
||||
// An unrelated callback cannot consume or satisfy the authorization flow.
|
||||
response, err := http.Get(redirect + "?code=invalid&state=wrong")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response.Body.Close()
|
||||
require.Equal(t, 400, response.StatusCode)
|
||||
response, err = http.Get(redirect + "?code=valid-code&state=" + url.QueryEscape(u.Query().Get("state")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response.Body.Close()
|
||||
return nil
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "access", credential.AccessToken)
|
||||
require.Equal(t, "refresh", credential.RefreshToken)
|
||||
}
|
||||
|
||||
func TestRefreshSerializesAndRetainsRefreshToken(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)
|
||||
requests := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests++
|
||||
require.NoError(t, r.ParseForm())
|
||||
require.Equal(t, "old-refresh", r.Form.Get("refresh_token"))
|
||||
require.Equal(t, "client", r.Form.Get("client_id"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintln(w, `{"access_token":"new-access","expires_in":3600,"token_type":"Bearer"}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
now := time.Now()
|
||||
require.NoError(t, manager.Register(server.URL, "alice", "file", "oauth", HostOptions{OAuthClientID: "client"}, Credential{AccessToken: "old-access", RefreshToken: "old-refresh", ExpiresAt: now.Add(-time.Minute)}))
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan Credential, 8)
|
||||
failures := make(chan error, 8)
|
||||
for range 8 {
|
||||
wg.Go(func() {
|
||||
c, err := manager.Refresh(context.Background(), server.URL, "alice", server.Client(), false, now)
|
||||
results <- c
|
||||
failures <- err
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(failures)
|
||||
for err := range failures {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
for credential := range results {
|
||||
require.Equal(t, "new-access", credential.AccessToken)
|
||||
require.Equal(t, "old-refresh", credential.RefreshToken)
|
||||
}
|
||||
require.Equal(t, 1, requests)
|
||||
}
|
||||
|
||||
func TestOAuthWithoutHTTPClientUsesDefaultClient(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = 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 {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := http.Get(u.Query().Get("redirect_uri") + "?code=code&state=" + url.QueryEscape(u.Query().Get("state")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Body.Close()
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "access", credential.AccessToken)
|
||||
config := settings.New(t.TempDir())
|
||||
manager := newTestManager(t, config, &Store{Dir: config.Dir})
|
||||
require.NoError(t, manager.Register(server.URL, "alice", "file", "oauth", HostOptions{}, Credential{AccessToken: "old", RefreshToken: "refresh", ExpiresAt: time.Now().Add(-time.Minute)}))
|
||||
credential, err = manager.Refresh(context.Background(), server.URL, "alice", nil, false, time.Now())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "access", credential.AccessToken)
|
||||
}
|
||||
|
||||
func TestRefreshEndsBeforeSettingsLockWaitersGiveUp(t *testing.T) {
|
||||
// A refresh holds the settings lock, so concurrent tea processes must wait longer than it can take.
|
||||
require.Less(t, refreshTimeout, settings.LockTimeout)
|
||||
}
|
||||
Reference in new issue
Block a user