//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) }