xapi-cli/cmd/oauth.go
Soldier 94635e7ace feat: MVP release - OAuth 1.0a CLI for X API
Complete CLI tool with 4 core commands:
- xapi login: Configure OAuth credentials via editor
- xapi status: Test authentication
- xapi search: Search tweets with preview/execute modes
- xapi create: Post tweets with preview/execute modes

Features:
- OAuth 1.0a authentication with HMAC-SHA1 signing
- OAuth 2.0 Client ID/Secret support (for future features)
- TOML-based configuration
- Editor integration for config management
- Helpful error messages for permission issues
- Quota-aware design (no caching to avoid complexity)

Built for developers on Free/Basic X API tiers.
2025-11-13 21:46:18 +00:00

116 lines
3.1 KiB
Go

/*
Copyright © 2025 maxtheweb
*/
package cmd
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"fmt"
"math/rand"
"net/url"
"sort"
"strings"
"time"
)
// OAuthClient handles OAuth 1.0a authentication
type OAuthClient struct {
ConsumerKey string
ConsumerSecret string
AccessToken string
AccessTokenSecret string
}
// NewOAuthClient creates a new OAuth client from config
func NewOAuthClient(config *Config) *OAuthClient {
return &OAuthClient{
ConsumerKey: config.OAuth.ConsumerKey,
ConsumerSecret: config.OAuth.ConsumerSecret,
AccessToken: config.OAuth.AccessToken,
AccessTokenSecret: config.OAuth.AccessTokenSecret,
}
}
// GetAuthorizationHeader generates the OAuth 1.0a Authorization header
func (o *OAuthClient) GetAuthorizationHeader(method, requestURL string, params map[string]string) string {
// OAuth parameters
oauthParams := map[string]string{
"oauth_consumer_key": o.ConsumerKey,
"oauth_token": o.AccessToken,
"oauth_signature_method": "HMAC-SHA1",
"oauth_timestamp": fmt.Sprintf("%d", time.Now().Unix()),
"oauth_nonce": generateNonce(),
"oauth_version": "1.0",
}
// Combine OAuth params and request params for signature
allParams := make(map[string]string)
for k, v := range oauthParams {
allParams[k] = v
}
for k, v := range params {
allParams[k] = v
}
// Generate signature
signature := o.generateSignature(method, requestURL, allParams)
oauthParams["oauth_signature"] = signature
// Build Authorization header
var headerParts []string
for k, v := range oauthParams {
headerParts = append(headerParts, fmt.Sprintf(`%s="%s"`, percentEncode(k), percentEncode(v)))
}
sort.Strings(headerParts)
return "OAuth " + strings.Join(headerParts, ", ")
}
// generateSignature creates the OAuth signature
func (o *OAuthClient) generateSignature(method, requestURL string, params map[string]string) string {
// 1. Create parameter string
var paramPairs []string
for k, v := range params {
paramPairs = append(paramPairs, percentEncode(k)+"="+percentEncode(v))
}
sort.Strings(paramPairs)
paramString := strings.Join(paramPairs, "&")
// 2. Create signature base string
signatureBase := strings.Join([]string{
method,
percentEncode(requestURL),
percentEncode(paramString),
}, "&")
// 3. Create signing key
signingKey := percentEncode(o.ConsumerSecret) + "&" + percentEncode(o.AccessTokenSecret)
// 4. Calculate HMAC-SHA1 signature
mac := hmac.New(sha1.New, []byte(signingKey))
mac.Write([]byte(signatureBase))
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return signature
}
// percentEncode encodes a string according to RFC 3986
func percentEncode(s string) string {
encoded := url.QueryEscape(s)
// url.QueryEscape uses + for spaces, but OAuth needs %20
encoded = strings.ReplaceAll(encoded, "+", "%20")
return encoded
}
// generateNonce generates a random nonce for OAuth
func generateNonce() string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result := make([]byte, 32)
for i := range result {
result[i] = chars[rand.Intn(len(chars))]
}
return string(result)
}