feat: initial goget release — modern IPv6-first download utility
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BasicAuth adds Basic Authentication header to the request
|
||||
func BasicAuth(req *http.Request, username, password string) {
|
||||
if username == "" {
|
||||
return
|
||||
}
|
||||
auth := username + ":" + password
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(auth))
|
||||
req.Header.Set("Authorization", "Basic "+encoded)
|
||||
}
|
||||
|
||||
// ParseURLAuth extracts username/password from URL (https://user:pass@host)
|
||||
func ParseURLAuth(u *url.URL) (username, password string, hasAuth bool) {
|
||||
if u == nil || u.User == nil {
|
||||
return "", "", false
|
||||
}
|
||||
username = u.User.Username()
|
||||
if username == "" {
|
||||
return "", "", false
|
||||
}
|
||||
password, hasAuth = u.User.Password()
|
||||
return username, password, true
|
||||
}
|
||||
|
||||
// StripAuthFromURL removes credentials from URL for safe logging
|
||||
func StripAuthFromURL(u *url.URL) *url.URL {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
// Clone URL to avoid modifying original
|
||||
clean := *u
|
||||
clean.User = nil
|
||||
return &clean
|
||||
}
|
||||
|
||||
// MaskPassword returns a masked version of the password for logging
|
||||
func MaskPassword(password string) string {
|
||||
if password == "" {
|
||||
return ""
|
||||
}
|
||||
if len(password) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return password[:2] + strings.Repeat("*", len(password)-2)
|
||||
}
|
||||
|
||||
// GetAuthForURL gets credentials for a given URL (priority: CLI > URL > config)
|
||||
func GetAuthForURL(cliUsername, cliPassword, configUsername, configPassword string, u *url.URL) (username, password string) {
|
||||
// 1. CLI flags have highest priority
|
||||
if cliUsername != "" {
|
||||
return cliUsername, cliPassword
|
||||
}
|
||||
|
||||
// 2. URL-embedded credentials
|
||||
if urlUser, urlPass, ok := ParseURLAuth(u); ok {
|
||||
return urlUser, urlPass
|
||||
}
|
||||
|
||||
// 3. Config file credentials
|
||||
if configUsername != "" {
|
||||
return configUsername, configPassword
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Package auth handles HTTP authentication schemes (Basic, Digest, OAuth 2.0).
|
||||
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DigestAuthHandler handles HTTP Digest Authentication
|
||||
type DigestAuthHandler struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
// NewDigestAuthHandler creates new handler
|
||||
func NewDigestAuthHandler(username, password string) *DigestAuthHandler {
|
||||
return &DigestAuthHandler{username, password}
|
||||
}
|
||||
|
||||
// HandleResponse processes a 401 response and returns a modified request
|
||||
func (h *DigestAuthHandler) HandleResponse(req *http.Request, resp *http.Response) (*http.Request, error) {
|
||||
// Parse WWW-Authenticate header
|
||||
authHeader := resp.Header.Get("WWW-Authenticate")
|
||||
if !strings.HasPrefix(authHeader, "Digest ") {
|
||||
return nil, fmt.Errorf("not a digest auth challenge")
|
||||
}
|
||||
|
||||
// Parse digest parameters
|
||||
params := parseDigestParams(authHeader[7:])
|
||||
|
||||
// Validate required parameters
|
||||
requiredParams := []string{"realm", "nonce"}
|
||||
for _, param := range requiredParams {
|
||||
if _, exists := params[param]; !exists {
|
||||
return nil, fmt.Errorf("missing required digest parameter: %s", param)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate response hash according to RFC 2617 / RFC 7616
|
||||
qop := params.get("qop")
|
||||
nonceCount := params.get("nc")
|
||||
cnonce := params.get("cnonce")
|
||||
opaque := params.get("opaque")
|
||||
|
||||
// HA1 = MD5(username:realm:password) or MD5(username:realm:password):nonce:cnonce
|
||||
ha1 := h.calculateHA1(params["realm"], params["nonce"], cnonce, qop)
|
||||
|
||||
// HA2 = MD5(method:uri) or MD5(method:uri:response-body) for auth-int.
|
||||
// RFC 7616 §3.4 (and RFC 2617 §3.2.2.3) require uri to be the
|
||||
// request-uri — the path *and* the query string, exactly as it
|
||||
// appears in the request line. Using req.URL.Path strips the
|
||||
// query, which causes digest auth to fail for any URL that carries
|
||||
// a token or other parameter in the query string.
|
||||
ha2 := h.calculateHA2(req.Method, req.URL.RequestURI(), qop)
|
||||
|
||||
// Response calculation based on qop
|
||||
var response string
|
||||
if qop == "auth" || qop == "auth-int" {
|
||||
response = md5Sum(ha1 + ":" + params["nonce"] + ":" + nonceCount + ":" + cnonce + ":" + qop + ":" + ha2)
|
||||
} else {
|
||||
// No qop (old RFC 2617 style)
|
||||
response = md5Sum(ha1 + ":" + params["nonce"] + ":" + ha2)
|
||||
}
|
||||
|
||||
// Build Authorization header — see note above about request-uri
|
||||
// vs path.
|
||||
auth := h.buildAuthHeader(params, req.URL.RequestURI(), response, nonceCount, cnonce, qop, opaque)
|
||||
|
||||
// Clone request and add header
|
||||
newReq := req.Clone(req.Context())
|
||||
newReq.Header.Set("Authorization", auth)
|
||||
|
||||
return newReq, nil
|
||||
}
|
||||
|
||||
func (h *DigestAuthHandler) calculateHA1(realm, nonce, cnonce, qop string) string {
|
||||
a1 := h.username + ":" + realm + ":" + h.password
|
||||
ha1 := md5Sum(a1)
|
||||
|
||||
// If qop is specified, we need to include nonce and cnonce
|
||||
if qop == "auth" || qop == "auth-int" {
|
||||
ha1 = md5Sum(ha1 + ":" + nonce + ":" + cnonce)
|
||||
}
|
||||
|
||||
return ha1
|
||||
}
|
||||
|
||||
func (h *DigestAuthHandler) calculateHA2(method, uri, qop string) string {
|
||||
a2 := method + ":" + uri
|
||||
// For auth-int, we would need to include body hash, but that's complex
|
||||
// For now, we just support auth and no-qop modes
|
||||
return md5Sum(a2)
|
||||
}
|
||||
|
||||
func (h *DigestAuthHandler) buildAuthHeader(params map[string]string, uri, response, nc, cnonce, qop, opaque string) string {
|
||||
parts := []string{
|
||||
fmt.Sprintf(`username="%s"`, h.username),
|
||||
fmt.Sprintf(`realm="%s"`, params["realm"]),
|
||||
fmt.Sprintf(`nonce="%s"`, params["nonce"]),
|
||||
fmt.Sprintf(`uri="%s"`, uri),
|
||||
fmt.Sprintf(`response="%s"`, response),
|
||||
}
|
||||
|
||||
if qop != "" {
|
||||
parts = append(parts,
|
||||
fmt.Sprintf(`qop=%s`, qop),
|
||||
fmt.Sprintf(`nc=%s`, nc),
|
||||
fmt.Sprintf(`cnonce="%s"`, cnonce),
|
||||
)
|
||||
}
|
||||
|
||||
if opaque != "" {
|
||||
parts = append(parts, fmt.Sprintf(`opaque="%s"`, opaque))
|
||||
}
|
||||
|
||||
return "Digest " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// GenerateCnonce generates a cryptographically random client nonce
|
||||
func GenerateCnonce() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Fallback should never happen on a functioning system
|
||||
// but we still produce a value rather than crash
|
||||
for i := range b {
|
||||
b[i] = byte(i)
|
||||
}
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// parseDigestParams parses the WWW-Authenticate header parameters
|
||||
func parseDigestParams(header string) digestParams {
|
||||
params := make(digestParams)
|
||||
|
||||
// Handle quoted and unquoted values
|
||||
currentKey := ""
|
||||
currentVal := ""
|
||||
inQuotes := false
|
||||
hasSeenEquals := false
|
||||
|
||||
for i := 0; i < len(header); i++ {
|
||||
char := header[i]
|
||||
|
||||
switch {
|
||||
case char == '=' && !inQuotes:
|
||||
currentKey = strings.TrimSpace(currentKey)
|
||||
currentVal = ""
|
||||
hasSeenEquals = true
|
||||
case char == '"' && (i == 0 || header[i-1] != '\\'):
|
||||
inQuotes = !inQuotes
|
||||
case char == ',' && !inQuotes:
|
||||
if currentKey != "" {
|
||||
params[currentKey] = strings.Trim(strings.TrimSpace(currentVal), `"`)
|
||||
}
|
||||
currentKey = ""
|
||||
currentVal = ""
|
||||
hasSeenEquals = false
|
||||
case (char == ' ' || char == '\t') && !inQuotes && ((!hasSeenEquals && currentKey == "") || (hasSeenEquals && currentVal == "")):
|
||||
// Skip leading whitespace before a key or after '='
|
||||
default:
|
||||
if !hasSeenEquals {
|
||||
currentKey += string(char)
|
||||
} else {
|
||||
currentVal += string(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last parameter
|
||||
if currentKey != "" {
|
||||
params[currentKey] = strings.Trim(strings.TrimSpace(currentVal), `"`)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// digestParams is a map of digest authentication parameters
|
||||
type digestParams map[string]string
|
||||
|
||||
func (p digestParams) get(key string) string {
|
||||
if val, ok := p[key]; ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func md5Sum(s string) string {
|
||||
h := md5.Sum([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// SHA256 sum for future use (RFC 7616 supports SHA-256)
|
||||
func sha256Sum(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NetrcEntry holds credentials for a single machine entry in .netrc.
|
||||
type NetrcEntry struct {
|
||||
Machine string
|
||||
Login string
|
||||
Password string
|
||||
}
|
||||
|
||||
// LoadNetrc reads ~/.netrc and returns entries keyed by machine name.
|
||||
func LoadNetrc() (map[string]*NetrcEntry, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
path := filepath.Join(home, ".netrc")
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
entries := make(map[string]*NetrcEntry)
|
||||
var current *NetrcEntry
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
// Skip comments and empty lines
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
|
||||
// "default" is also valid but we treat it as a machine
|
||||
if len(fields) >= 2 && (fields[0] == "machine" || fields[0] == "default") {
|
||||
if current != nil && current.Machine != "" {
|
||||
entries[current.Machine] = current
|
||||
}
|
||||
name := fields[1]
|
||||
if fields[0] == "default" {
|
||||
name = "default"
|
||||
}
|
||||
current = &NetrcEntry{Machine: name}
|
||||
// Rest of line may have login/password
|
||||
parseNetrcLine(fields[2:], current)
|
||||
} else if current != nil {
|
||||
parseNetrcLine(fields, current)
|
||||
}
|
||||
}
|
||||
if current != nil && current.Machine != "" {
|
||||
entries[current.Machine] = current
|
||||
}
|
||||
|
||||
return entries, scanner.Err()
|
||||
}
|
||||
|
||||
func parseNetrcLine(fields []string, entry *NetrcEntry) {
|
||||
for i := 0; i < len(fields); i++ {
|
||||
switch fields[i] {
|
||||
case "login":
|
||||
if i+1 < len(fields) {
|
||||
entry.Login = fields[i+1]
|
||||
i++
|
||||
}
|
||||
case "password":
|
||||
if i+1 < len(fields) {
|
||||
entry.Password = fields[i+1]
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LookupNetrc returns credentials from .netrc for a given hostname.
|
||||
func LookupNetrc(host string) (login, password string, found bool) {
|
||||
entries, err := LoadNetrc()
|
||||
if err != nil || entries == nil {
|
||||
return "", "", false
|
||||
}
|
||||
// Exact match first
|
||||
if e, ok := entries[host]; ok {
|
||||
return e.Login, e.Password, true
|
||||
}
|
||||
// Default entry
|
||||
if e, ok := entries["default"]; ok {
|
||||
return e.Login, e.Password, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//go:build linux || freebsd
|
||||
// +build linux freebsd
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OAuthConfig configuration for OAuth 2.0
|
||||
type OAuthConfig struct {
|
||||
ClientID string `json:"client_id,omitempty" toml:"client_id,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty" toml:"client_secret,omitempty"`
|
||||
TokenURL string `json:"token_url,omitempty" toml:"token_url,omitempty"`
|
||||
AuthURL string `json:"auth_url,omitempty" toml:"auth_url,omitempty"`
|
||||
RedirectURI string `json:"redirect_uri,omitempty" toml:"redirect_uri,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty" toml:"scopes,omitempty"`
|
||||
AccessToken string `json:"access_token,omitempty" toml:"access_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty" toml:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty" toml:"token_type,omitempty"`
|
||||
Expiry time.Time `json:"expiry,omitempty" toml:"expiry,omitempty"`
|
||||
GrantType string `json:"grant_type,omitempty" toml:"grant_type,omitempty"` // authorization_code, client_credentials, password, refresh_token
|
||||
}
|
||||
|
||||
// TokenResponse response from a token endpoint
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorDesc string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// OAuthClient client for OAuth 2.0 authentication
|
||||
type OAuthClient struct {
|
||||
config *OAuthConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewOAuthClient creates a new OAuth client
|
||||
func NewOAuthClient(cfg *OAuthConfig) *OAuthClient {
|
||||
if cfg.TokenType == "" {
|
||||
cfg.TokenType = "Bearer"
|
||||
}
|
||||
return &OAuthClient{
|
||||
config: cfg,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// IsTokenValid checks whether the token is valid
|
||||
func (c *OAuthClient) IsTokenValid() bool {
|
||||
if c.config.AccessToken == "" {
|
||||
return false
|
||||
}
|
||||
if c.config.Expiry.IsZero() {
|
||||
return true // No expiry, assume valid
|
||||
}
|
||||
// Token is valid if it expires in more than 30 seconds
|
||||
return time.Now().Add(30 * time.Second).Before(c.config.Expiry)
|
||||
}
|
||||
|
||||
// RequestToken gets new access token
|
||||
func (c *OAuthClient) RequestToken(ctx context.Context) (*TokenResponse, error) {
|
||||
if c.config.TokenURL != "" {
|
||||
u, err := url.Parse(c.config.TokenURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid oauth token URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "https" && u.Hostname() != "localhost" && u.Hostname() != "127.0.0.1" && u.Hostname() != "::1" {
|
||||
return nil, fmt.Errorf("oauth token URL must use HTTPS for security")
|
||||
}
|
||||
}
|
||||
|
||||
formData := url.Values{}
|
||||
|
||||
grantType := c.config.GrantType
|
||||
if grantType == "" {
|
||||
grantType = "client_credentials"
|
||||
}
|
||||
|
||||
formData.Set("grant_type", grantType)
|
||||
|
||||
switch grantType {
|
||||
case "client_credentials":
|
||||
formData.Set("client_id", c.config.ClientID)
|
||||
formData.Set("client_secret", c.config.ClientSecret)
|
||||
if len(c.config.Scopes) > 0 {
|
||||
formData.Set("scope", strings.Join(c.config.Scopes, " "))
|
||||
}
|
||||
|
||||
case "password":
|
||||
formData.Set("client_id", c.config.ClientID)
|
||||
formData.Set("client_secret", c.config.ClientSecret)
|
||||
// Username and password would need to be passed separately
|
||||
// This is handled by caller
|
||||
|
||||
case "authorization_code":
|
||||
formData.Set("client_id", c.config.ClientID)
|
||||
formData.Set("client_secret", c.config.ClientSecret)
|
||||
// Code and redirect_uri would need to be passed separately
|
||||
|
||||
case "refresh_token":
|
||||
formData.Set("client_id", c.config.ClientID)
|
||||
formData.Set("client_secret", c.config.ClientSecret)
|
||||
formData.Set("refresh_token", c.config.RefreshToken)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.config.TokenURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read token response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("token endpoint returned %s", formatOAuthError(resp.StatusCode, body))
|
||||
}
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.Error != "" {
|
||||
return nil, fmt.Errorf("oauth error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc)
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
// ApplyToken applies the token to the HTTP request
|
||||
func (c *OAuthClient) ApplyToken(req *http.Request) {
|
||||
if c.config.AccessToken == "" {
|
||||
return
|
||||
}
|
||||
tokenType := c.config.TokenType
|
||||
if tokenType == "" {
|
||||
tokenType = "Bearer"
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenType, c.config.AccessToken))
|
||||
}
|
||||
|
||||
// RefreshToken refreshes access token using a refresh token
|
||||
func (c *OAuthClient) RefreshToken(ctx context.Context) error {
|
||||
if c.config.RefreshToken == "" {
|
||||
return fmt.Errorf("no refresh token available")
|
||||
}
|
||||
|
||||
oldGrantType := c.config.GrantType
|
||||
c.config.GrantType = "refresh_token"
|
||||
defer func() { c.config.GrantType = oldGrantType }()
|
||||
|
||||
tokenResp, err := c.RequestToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.UpdateToken(tokenResp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateToken updates internal token from response
|
||||
func (c *OAuthClient) UpdateToken(tokenResp *TokenResponse) {
|
||||
c.config.AccessToken = tokenResp.AccessToken
|
||||
c.config.TokenType = tokenResp.TokenType
|
||||
if tokenResp.RefreshToken != "" {
|
||||
c.config.RefreshToken = tokenResp.RefreshToken
|
||||
}
|
||||
if tokenResp.ExpiresIn > 0 {
|
||||
c.config.Expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthorizationURL returns the authorization URL (for authorization_code flow)
|
||||
func (c *OAuthClient) GetAuthorizationURL(state string) string {
|
||||
if c.config.AuthURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("client_id", c.config.ClientID)
|
||||
params.Set("redirect_uri", c.config.RedirectURI)
|
||||
params.Set("response_type", "code")
|
||||
params.Set("state", state)
|
||||
if len(c.config.Scopes) > 0 {
|
||||
params.Set("scope", strings.Join(c.config.Scopes, " "))
|
||||
}
|
||||
|
||||
baseURL := c.config.AuthURL
|
||||
if strings.Contains(baseURL, "?") {
|
||||
return baseURL + "&" + params.Encode()
|
||||
}
|
||||
return baseURL + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges an authorization code for an access token
|
||||
func (c *OAuthClient) ExchangeCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("code", code)
|
||||
formData.Set("redirect_uri", c.config.RedirectURI)
|
||||
formData.Set("client_id", c.config.ClientID)
|
||||
formData.Set("client_secret", c.config.ClientSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.config.TokenURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create code exchange request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("code exchange request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read code exchange response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("code exchange endpoint returned %s", formatOAuthError(resp.StatusCode, body))
|
||||
}
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse code exchange response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.Error != "" {
|
||||
return nil, fmt.Errorf("oauth error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc)
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
// formatOAuthError converts a non-2xx OAuth response into a human-readable
|
||||
// error string while avoiding leaking sensitive data. The OAuth provider's
|
||||
// raw response body is never returned verbatim: we first try to extract
|
||||
// the structured `error` and `error_description` fields defined in RFC
|
||||
// 6749 §5.2 (which is what well-behaved providers return), and only fall
|
||||
// back to a 512-byte truncated snippet of the raw body if the body is
|
||||
// not structured. This prevents upstream providers that echo back
|
||||
// secrets, stack traces, or other sensitive payloads in their error
|
||||
// bodies from leaking those payloads into our logs and error displays.
|
||||
func formatOAuthError(statusCode int, body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return fmt.Sprintf("status %d with empty body", statusCode)
|
||||
}
|
||||
|
||||
var structured struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &structured); err == nil && structured.Error != "" {
|
||||
if structured.ErrorDescription != "" {
|
||||
return fmt.Sprintf("status %d: %s - %s", statusCode, structured.Error, structured.ErrorDescription)
|
||||
}
|
||||
return fmt.Sprintf("status %d: %s", statusCode, structured.Error)
|
||||
}
|
||||
|
||||
const maxSnippet = 512
|
||||
snippet := string(body)
|
||||
if len(snippet) > maxSnippet {
|
||||
snippet = snippet[:maxSnippet] + "... (truncated)"
|
||||
}
|
||||
return fmt.Sprintf("status %d: %s", statusCode, snippet)
|
||||
}
|
||||
Reference in New Issue
Block a user