Files
kandev/apps/backend/cmd/agentctl/github_credential.go
T

274 lines
9.3 KiB
Go

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"unicode/utf8"
"github.com/kandev/kandev/internal/githubauth"
)
type githubBrokerResolveRequest struct {
Lease string `json:"lease"`
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
RepositoryID string `json:"repository_id"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Host string `json:"host"`
Path string `json:"path,omitempty"`
}
type githubBrokerCredential struct {
Username string `json:"username"`
Password string `json:"password"`
}
type githubCredentialBrokerClient struct {
endpoint string
request githubBrokerResolveRequest
http *http.Client
}
func newGitHubCredentialBrokerClient(
getenv func(string) string,
httpClient *http.Client,
) (*githubCredentialBrokerClient, error) {
request := githubBrokerResolveRequest{
Lease: strings.TrimSpace(getenv(githubauth.CredentialLeaseEnv)),
TaskID: strings.TrimSpace(getenv(githubauth.CredentialTaskIDEnv)),
SessionID: strings.TrimSpace(getenv(githubauth.CredentialSessionIDEnv)),
RepositoryID: strings.TrimSpace(getenv(githubauth.CredentialRepositoryEnv)),
Owner: strings.TrimSpace(getenv(githubauth.CredentialOwnerEnv)),
Repo: strings.TrimSuffix(strings.TrimSpace(getenv(githubauth.CredentialRepoEnv)), ".git"),
Host: strings.ToLower(strings.TrimSpace(getenv(githubauth.CredentialHostEnv))),
}
endpoint := strings.TrimSpace(getenv(githubauth.CredentialBrokerURLEnv))
for name, value := range map[string]string{
"broker URL": endpoint, "lease": request.Lease, "task": request.TaskID,
"session": request.SessionID, "repository": request.RepositoryID,
"owner": request.Owner, "repo": request.Repo, "host": request.Host,
} {
if value == "" {
return nil, fmt.Errorf("GitHub credential %s is not configured", name)
}
}
parsed, err := url.ParseRequestURI(endpoint)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, fmt.Errorf("GitHub credential broker URL is invalid")
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
return &githubCredentialBrokerClient{endpoint: endpoint, request: request, http: httpClient}, nil
}
// maxBrokerErrorBodyBytes caps the broker error body appended to a resolve
// failure. Broker error bodies are code/message JSON and carry no credential
// (success bodies, which do carry the token, are never passed through this
// path), but the cap and control-char stripping stay regardless.
const maxBrokerErrorBodyBytes = 512
// formatBrokerErrorBody reads a bounded, sanitized broker error body suitable
// for appending to an error message, e.g. ": {\"error\":\"...\"}" — or "" when
// the body is empty after sanitizing.
func formatBrokerErrorBody(body io.Reader) string {
raw, _ := io.ReadAll(io.LimitReader(body, maxBrokerErrorBodyBytes))
for !utf8.Valid(raw) && len(raw) > 0 {
raw = raw[:len(raw)-1]
}
var sanitized strings.Builder
for _, r := range string(raw) {
if r < 0x20 {
continue
}
sanitized.WriteRune(r)
}
trimmed := strings.TrimSpace(sanitized.String())
if trimmed == "" {
return ""
}
return ": " + trimmed
}
func (c *githubCredentialBrokerClient) resolve(ctx context.Context) (*githubBrokerCredential, error) {
body, err := json.Marshal(c.request)
if err != nil {
return nil, fmt.Errorf("encode GitHub credential request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create GitHub credential request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("resolve GitHub credential: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("resolve GitHub credential: broker returned HTTP %d%s",
resp.StatusCode, formatBrokerErrorBody(resp.Body))
}
var credential githubBrokerCredential
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&credential); err != nil {
return nil, fmt.Errorf("decode GitHub credential: %w", err)
}
if credential.Username == "" || credential.Password == "" {
return nil, fmt.Errorf("resolve GitHub credential: broker returned an empty credential")
}
return &credential, nil
}
func runGitHubCredentialHelper(
ctx context.Context,
args []string,
stdin io.Reader,
stdout io.Writer,
getenv func(string) string,
httpClient *http.Client,
) error {
if len(args) != 1 {
return fmt.Errorf("usage: agentctl git-credential <get|store|erase>")
}
switch args[0] {
case "store", "erase":
return nil
case "get":
default:
return fmt.Errorf("unsupported git credential operation %q", args[0])
}
input, err := readGitCredentialInput(stdin)
if err != nil {
return err
}
client, err := newGitHubCredentialBrokerClientForInput(getenv, httpClient, input)
if err != nil {
return err
}
request, err := gitCredentialRequestForInput(input, client.request)
if err != nil {
return err
}
client.request = request
credential, err := client.resolve(ctx)
if err != nil {
return err
}
_, err = fmt.Fprintf(stdout, "username=%s\npassword=%s\n\n", credential.Username, credential.Password)
return err
}
func newGitHubCredentialBrokerClientForInput(
getenv func(string) string,
httpClient *http.Client,
input map[string]string,
) (*githubCredentialBrokerClient, error) {
rawScopes := strings.TrimSpace(getenv(githubauth.CredentialScopesEnv))
if rawScopes == "" {
return newGitHubCredentialBrokerClient(getenv, httpClient)
}
var scopes []githubBrokerResolveRequest
if err := json.Unmarshal([]byte(rawScopes), &scopes); err != nil {
return nil, fmt.Errorf("GitHub credential scopes are invalid: %w", err)
}
for _, scope := range scopes {
request, err := gitCredentialRequestForInput(input, scope)
if err == nil {
return newGitHubCredentialBrokerClientForRequest(getenv, httpClient, request)
}
}
return nil, fmt.Errorf("git repository does not match any credential lease scope")
}
func newGitHubCredentialBrokerClientForRequest(
getenv func(string) string,
httpClient *http.Client,
request githubBrokerResolveRequest,
) (*githubCredentialBrokerClient, error) {
endpoint := strings.TrimSpace(getenv(githubauth.CredentialBrokerURLEnv))
for name, value := range map[string]string{
"broker URL": endpoint, "lease": request.Lease, "task": request.TaskID,
"session": request.SessionID, "repository": request.RepositoryID,
"owner": request.Owner, "repo": request.Repo, "host": request.Host,
} {
if strings.TrimSpace(value) == "" {
return nil, fmt.Errorf("GitHub credential %s is not configured", name)
}
}
parsed, err := url.ParseRequestURI(endpoint)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, fmt.Errorf("GitHub credential broker URL is invalid")
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
return &githubCredentialBrokerClient{endpoint: endpoint, request: request, http: httpClient}, nil
}
func readGitCredentialInput(input io.Reader) (map[string]string, error) {
values := make(map[string]string)
scanner := bufio.NewScanner(io.LimitReader(input, 64<<10))
for scanner.Scan() {
line := scanner.Text()
if line == "" {
break
}
key, value, ok := strings.Cut(line, "=")
if ok {
values[key] = value
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read git credential request: %w", err)
}
return values, nil
}
// gitCredentialRequestForInput validates the Git credential helper request
// against its issued lease scope, then carries the exact requested repository
// path through to the broker. Git uses owner/repo fields on this compatibility
// endpoint, so Repo retains any provider namespace after the first segment.
func gitCredentialRequestForInput(input map[string]string, scope githubBrokerResolveRequest) (githubBrokerResolveRequest, error) {
protocol := strings.TrimSpace(input["protocol"])
if !strings.EqualFold(protocol, "https") {
return githubBrokerResolveRequest{}, fmt.Errorf("git credential protocol %q is not supported", protocol)
}
host := strings.TrimSpace(input["host"])
if host == "" || !strings.EqualFold(host, scope.Host) {
return githubBrokerResolveRequest{}, fmt.Errorf("git credential host does not match credential lease scope")
}
path, err := url.PathUnescape(strings.TrimSpace(input["path"]))
if err != nil {
return githubBrokerResolveRequest{}, fmt.Errorf("decode git credential path: %w", err)
}
path = "/" + strings.TrimLeft(path, "/")
if path == "/" {
return githubBrokerResolveRequest{}, fmt.Errorf("git repository does not match credential lease scope")
}
if scope.Path != "" {
if path != scope.Path {
return githubBrokerResolveRequest{}, fmt.Errorf("git repository does not match credential lease scope")
}
} else {
legacyPath := strings.TrimSuffix(strings.Trim(path, "/"), ".git")
owner, repo, found := strings.Cut(legacyPath, "/")
if !found || owner == "" || repo == "" || legacyPath != scope.Owner+"/"+scope.Repo {
return githubBrokerResolveRequest{}, fmt.Errorf("git repository does not match credential lease scope")
}
scope.Owner = owner
scope.Repo = repo
}
scope.Path = path
return scope, nil
}