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:
bircni committed 2026-10-11 17:09:37 +00:00
1 parent 2b218a5bae
commit 88b91faa54
28 files changed
+3044 -3

No files matched your search

+152
View File
@@ -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)
}
}
+172
View File
@@ -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)
}
+54
View File
@@ -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")
}
}
+205
View File
@@ -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
}
+30
View File
@@ -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
}
+53
View File
@@ -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")
}
}
+21
View File
@@ -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
}
+123
View File
@@ -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)
}
}
+110
View File
@@ -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
})
}
+179
View File
@@ -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"))
}
+210
View File
@@ -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
}
+138
View File
@@ -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)
}