Files
gitea-tea/internal/auth/gitcredential.go
bircni 88b91faa54 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>
2026-10-11 17:09:37 +00:00

173 lines
5.7 KiB
Go

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