f814859ab2
Managed GitHub credentials compared a repository's scope identity verbatim across sides that spell it differently. A repository cloned over SSH stores an SSH remote_url, which kandev itself backfills for local-path repositories, and the strict scheme assertion added in #2117 then rejected the launch with "must use an HTTPS clone URL for managed credentials". Credential identity now comes from the persisted provider identity, whose host carries the real HTTPS origin including a non-default port, falling back to rewriting a non-HTTPS remote in place. Issuance and authorization derive it the same way, so they cannot disagree. Cloning is untouched. Scope paths are compared through one canonical form (no trailing slash, no ".git") at the shim, the broker redemption, and the backend authorizer, so the suffix never decides whether a lease matches. Lease scopes still carry the exact path their provider resolver was issued for, because plugin providers compare it verbatim. Closes #2673 Closes #2675
281 lines
9.8 KiB
Go
281 lines
9.8 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"`
|
|
ProviderID string `json:"provider_id,omitempty"`
|
|
ParentProviderID string `json:"parent_provider_id,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 issued 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 != "" {
|
|
// The scope path comes from a clone URL and keeps its ".git" suffix,
|
|
// while the gh CLI shim asks for a bare "/<owner>/<repo>". Compare the
|
|
// canonical spelling so both reach the same lease. The broker compares
|
|
// the same way, so the exact issued path is what travels onward.
|
|
if githubauth.CanonicalCredentialPath(path) != githubauth.CanonicalCredentialPath(scope.Path) {
|
|
return githubBrokerResolveRequest{}, fmt.Errorf("git repository does not match credential lease scope")
|
|
}
|
|
path = scope.Path
|
|
} 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
|
|
}
|