mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-08-01 01:58:09 -04:00
433 lines
10 KiB
Go
433 lines
10 KiB
Go
// Package httpclient provides authentication-aware HTTP transports for upstream requests.
|
|
package httpclient
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultTokenLifetime = 60 * time.Second
|
|
tokenExpirySkew = 5 * time.Second
|
|
maxTokenResponseSize = 1 << 20
|
|
shortTokenSkewDivisor = 10
|
|
)
|
|
|
|
// AuthFunc returns a configured authentication header for a URL.
|
|
type AuthFunc func(url string) (headerName, headerValue string)
|
|
|
|
// Transport adds configured authentication and follows OCI Bearer challenges.
|
|
type Transport struct {
|
|
base http.RoundTripper
|
|
authForURL AuthFunc
|
|
|
|
mu sync.Mutex
|
|
tokens map[string]cachedToken
|
|
challenges map[string]bearerChallenge
|
|
}
|
|
|
|
type cachedToken struct {
|
|
value string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
type bearerChallenge struct {
|
|
realm string
|
|
service string
|
|
scopes []string
|
|
}
|
|
|
|
type tokenResponse struct {
|
|
Token string `json:"token"`
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
IssuedAt string `json:"issued_at"`
|
|
}
|
|
|
|
// NewTransport creates an authentication-aware transport around base.
|
|
func NewTransport(base http.RoundTripper, authForURL AuthFunc) *Transport {
|
|
if base == nil {
|
|
base = http.DefaultTransport
|
|
}
|
|
return &Transport{
|
|
base: base,
|
|
authForURL: authForURL,
|
|
tokens: make(map[string]cachedToken),
|
|
challenges: make(map[string]bearerChallenge),
|
|
}
|
|
}
|
|
|
|
// RoundTrip implements http.RoundTripper.
|
|
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
hasExplicitAuthorization := req.Header.Get("Authorization") != ""
|
|
outbound := cloneRequest(req)
|
|
t.applyAuthentication(outbound, hasExplicitAuthorization)
|
|
|
|
resp, err := t.base.RoundTrip(outbound)
|
|
if err != nil || resp.StatusCode != http.StatusUnauthorized {
|
|
return resp, err
|
|
}
|
|
if hasExplicitAuthorization {
|
|
return resp, nil
|
|
}
|
|
if registryProtectionSpace(req.URL) == "" {
|
|
return resp, nil
|
|
}
|
|
|
|
challenge, ok := parseBearerChallenge(resp.Header.Values("WWW-Authenticate"))
|
|
if !ok || !canReplay(req) {
|
|
return resp, nil
|
|
}
|
|
|
|
drainAndClose(resp.Body)
|
|
token, err := t.token(req.Context(), challenge)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("registry authentication: %w", err)
|
|
}
|
|
t.rememberChallenge(req.URL, challenge)
|
|
|
|
retry, err := cloneRequestForRetry(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.applyConfiguredAuthentication(retry)
|
|
retry.Header.Set("Authorization", "Bearer "+token)
|
|
return t.base.RoundTrip(retry)
|
|
}
|
|
|
|
func (t *Transport) applyAuthentication(req *http.Request, hasExplicitAuthorization bool) {
|
|
t.applyConfiguredAuthentication(req)
|
|
if hasExplicitAuthorization {
|
|
return
|
|
}
|
|
if token := t.cachedTokenForRequest(req.URL); token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
}
|
|
|
|
func (t *Transport) applyConfiguredAuthentication(req *http.Request) {
|
|
if t.authForURL == nil {
|
|
return
|
|
}
|
|
name, value := t.authForURL(req.URL.String())
|
|
if name != "" && value != "" && req.Header.Get(name) == "" {
|
|
req.Header.Set(name, value)
|
|
}
|
|
}
|
|
|
|
func (t *Transport) token(ctx context.Context, challenge bearerChallenge) (string, error) {
|
|
key := challenge.key()
|
|
if token := t.cachedToken(key); token != "" {
|
|
return token, nil
|
|
}
|
|
|
|
token, expiresAt, err := t.fetchToken(ctx, challenge)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
t.cacheToken(key, cachedToken{value: token, expiresAt: expiresAt})
|
|
return token, nil
|
|
}
|
|
|
|
func (t *Transport) cacheToken(key string, token cachedToken) {
|
|
now := time.Now()
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
for cachedKey, cached := range t.tokens {
|
|
if !now.Before(cached.expiresAt) {
|
|
delete(t.tokens, cachedKey)
|
|
}
|
|
}
|
|
t.tokens[key] = token
|
|
}
|
|
|
|
func (t *Transport) fetchToken(ctx context.Context, challenge bearerChallenge) (string, time.Time, error) {
|
|
tokenURL, err := url.Parse(challenge.realm)
|
|
if err != nil || !tokenURL.IsAbs() || (tokenURL.Scheme != "https" && tokenURL.Scheme != "http") {
|
|
return "", time.Time{}, fmt.Errorf("invalid token realm %q", challenge.realm)
|
|
}
|
|
|
|
query := tokenURL.Query()
|
|
if challenge.service != "" {
|
|
query.Set("service", challenge.service)
|
|
}
|
|
for _, scope := range challenge.scopes {
|
|
query.Add("scope", scope)
|
|
}
|
|
query.Set("client_id", "git-pkgs-proxy")
|
|
tokenURL.RawQuery = query.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL.String(), nil)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
|
|
client := &http.Client{Transport: configuredTransport{parent: t}}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", time.Time{}, fmt.Errorf("requesting token: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxTokenResponseSize))
|
|
return "", time.Time{}, fmt.Errorf("token service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
var payload tokenResponse
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, maxTokenResponseSize)).Decode(&payload); err != nil {
|
|
return "", time.Time{}, fmt.Errorf("decoding token response: %w", err)
|
|
}
|
|
token := payload.Token
|
|
if token == "" {
|
|
token = payload.AccessToken
|
|
}
|
|
if token == "" {
|
|
return "", time.Time{}, fmt.Errorf("token response did not contain a token")
|
|
}
|
|
|
|
issuedAt := time.Now()
|
|
if payload.IssuedAt != "" {
|
|
if parsed, parseErr := time.Parse(time.RFC3339, payload.IssuedAt); parseErr == nil {
|
|
issuedAt = parsed
|
|
}
|
|
}
|
|
lifetime := time.Duration(payload.ExpiresIn) * time.Second
|
|
if lifetime <= 0 {
|
|
lifetime = defaultTokenLifetime
|
|
}
|
|
expiresAt := issuedAt.Add(lifetime).Add(-expirySkew(lifetime))
|
|
return token, expiresAt, nil
|
|
}
|
|
|
|
type configuredTransport struct {
|
|
parent *Transport
|
|
}
|
|
|
|
func (t configuredTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
outbound := cloneRequest(req)
|
|
t.parent.applyConfiguredAuthentication(outbound)
|
|
return t.parent.base.RoundTrip(outbound)
|
|
}
|
|
|
|
func (t *Transport) cachedTokenForRequest(requestURL *url.URL) string {
|
|
space := registryProtectionSpace(requestURL)
|
|
if space == "" {
|
|
return ""
|
|
}
|
|
|
|
t.mu.Lock()
|
|
challenge, ok := t.challenges[space]
|
|
t.mu.Unlock()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return t.cachedToken(challenge.key())
|
|
}
|
|
|
|
func (t *Transport) cachedToken(key string) string {
|
|
now := time.Now()
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
token, ok := t.tokens[key]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if !now.Before(token.expiresAt) {
|
|
delete(t.tokens, key)
|
|
return ""
|
|
}
|
|
return token.value
|
|
}
|
|
|
|
func (t *Transport) rememberChallenge(requestURL *url.URL, challenge bearerChallenge) {
|
|
space := registryProtectionSpace(requestURL)
|
|
if space == "" {
|
|
return
|
|
}
|
|
t.mu.Lock()
|
|
t.challenges[space] = challenge
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
func (c bearerChallenge) key() string {
|
|
return c.realm + "\x00" + c.service + "\x00" + strings.Join(c.scopes, "\x00")
|
|
}
|
|
|
|
func registryProtectionSpace(u *url.URL) string {
|
|
const registryPrefix = "/v2/"
|
|
if u == nil || !strings.HasPrefix(u.Path, registryPrefix) {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(u.Path, registryPrefix)
|
|
end := len(rest)
|
|
for _, marker := range []string{"/blobs/", "/manifests/", "/tags/", "/referrers/"} {
|
|
if index := strings.Index(rest, marker); index >= 0 && index < end {
|
|
end = index
|
|
}
|
|
}
|
|
if end == len(rest) || end == 0 {
|
|
return ""
|
|
}
|
|
return u.Scheme + "://" + u.Host + registryPrefix + rest[:end]
|
|
}
|
|
|
|
func parseBearerChallenge(values []string) (bearerChallenge, bool) {
|
|
for _, value := range values {
|
|
params, ok := bearerParameters(value)
|
|
if !ok || params["realm"] == "" {
|
|
continue
|
|
}
|
|
challenge := bearerChallenge{
|
|
realm: params["realm"],
|
|
service: params["service"],
|
|
}
|
|
if scope := params["scope"]; scope != "" {
|
|
challenge.scopes = append(challenge.scopes, scope)
|
|
}
|
|
return challenge, true
|
|
}
|
|
return bearerChallenge{}, false
|
|
}
|
|
|
|
func bearerParameters(value string) (map[string]string, bool) {
|
|
start := findAuthScheme(value, "Bearer")
|
|
if start < 0 {
|
|
return nil, false
|
|
}
|
|
rest := value[start+len("Bearer"):]
|
|
params := make(map[string]string)
|
|
for {
|
|
rest = strings.TrimLeft(rest, " \t,")
|
|
if rest == "" {
|
|
break
|
|
}
|
|
|
|
keyEnd := strings.IndexAny(rest, "= \t,")
|
|
if keyEnd <= 0 {
|
|
break
|
|
}
|
|
key := strings.ToLower(rest[:keyEnd])
|
|
rest = strings.TrimLeft(rest[keyEnd:], " \t")
|
|
if rest == "" || rest[0] != '=' {
|
|
break
|
|
}
|
|
rest = strings.TrimLeft(rest[1:], " \t")
|
|
|
|
parsed, remaining, ok := parseAuthValue(rest)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
params[key] = parsed
|
|
rest = remaining
|
|
}
|
|
return params, true
|
|
}
|
|
|
|
func findAuthScheme(value, scheme string) int {
|
|
inQuote := false
|
|
escaped := false
|
|
for index := 0; index+len(scheme) <= len(value); index++ {
|
|
char := value[index]
|
|
if escaped {
|
|
escaped = false
|
|
continue
|
|
}
|
|
if char == '\\' && inQuote {
|
|
escaped = true
|
|
continue
|
|
}
|
|
if char == '"' {
|
|
inQuote = !inQuote
|
|
continue
|
|
}
|
|
if inQuote || !strings.EqualFold(value[index:index+len(scheme)], scheme) {
|
|
continue
|
|
}
|
|
beforeOK := index == 0 || value[index-1] == ',' || value[index-1] == ' ' || value[index-1] == '\t'
|
|
after := index + len(scheme)
|
|
afterOK := after < len(value) && (value[after] == ' ' || value[after] == '\t')
|
|
if beforeOK && afterOK {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func parseAuthValue(value string) (parsed, remaining string, ok bool) {
|
|
if value == "" {
|
|
return "", "", false
|
|
}
|
|
if value[0] != '"' {
|
|
end := strings.IndexAny(value, " \t,")
|
|
if end < 0 {
|
|
return value, "", true
|
|
}
|
|
return value[:end], value[end:], end > 0
|
|
}
|
|
|
|
var builder strings.Builder
|
|
escaped := false
|
|
for index := 1; index < len(value); index++ {
|
|
char := value[index]
|
|
if escaped {
|
|
builder.WriteByte(char)
|
|
escaped = false
|
|
continue
|
|
}
|
|
if char == '\\' {
|
|
escaped = true
|
|
continue
|
|
}
|
|
if char == '"' {
|
|
return builder.String(), value[index+1:], true
|
|
}
|
|
builder.WriteByte(char)
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
func cloneRequest(req *http.Request) *http.Request {
|
|
clone := req.Clone(req.Context())
|
|
clone.Header = req.Header.Clone()
|
|
return clone
|
|
}
|
|
|
|
func canReplay(req *http.Request) bool {
|
|
return req.Body == nil || req.GetBody != nil
|
|
}
|
|
|
|
func cloneRequestForRetry(req *http.Request) (*http.Request, error) {
|
|
clone := cloneRequest(req)
|
|
if req.Body == nil {
|
|
return clone, nil
|
|
}
|
|
body, err := req.GetBody()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("replaying authenticated request: %w", err)
|
|
}
|
|
clone.Body = body
|
|
return clone, nil
|
|
}
|
|
|
|
func expirySkew(lifetime time.Duration) time.Duration {
|
|
if lifetime < tokenExpirySkew*2 {
|
|
return lifetime / shortTokenSkewDivisor
|
|
}
|
|
return tokenExpirySkew
|
|
}
|
|
|
|
func drainAndClose(body io.ReadCloser) {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(body, maxTokenResponseSize))
|
|
_ = body.Close()
|
|
}
|