294 lines
9.3 KiB
Go
294 lines
9.3 KiB
Go
//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)
|
|
}
|