thread-master/backend/internal/library/threadsapi/oauth.go

258 lines
7.1 KiB
Go

package threadsapi
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
threadsAuthorizeURL = "https://threads.net/oauth/authorize"
threadsShortTokenURL = "https://graph.threads.net/oauth/access_token"
threadsLongLivedTokenURL = "https://graph.threads.net/access_token"
)
// flexString unmarshals JSON string or number (Meta often returns user_id as a number).
type flexString string
func (s *flexString) UnmarshalJSON(data []byte) error {
if len(data) == 0 || string(data) == "null" {
*s = ""
return nil
}
if data[0] == '"' {
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
*s = flexString(str)
return nil
}
var num json.Number
if err := json.Unmarshal(data, &num); err != nil {
return err
}
*s = flexString(num.String())
return nil
}
func (s flexString) String() string {
return strings.TrimSpace(string(s))
}
type OAuthConfig struct {
AppID string
AppSecret string
RedirectURI string
SuccessRedirect string
}
func (c OAuthConfig) Enabled() bool {
return strings.TrimSpace(c.AppID) != "" &&
strings.TrimSpace(c.AppSecret) != "" &&
strings.TrimSpace(c.RedirectURI) != ""
}
type TokenExchangeResult struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
UserID flexString `json:"user_id"`
}
type UserProfile struct {
ID flexString `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
}
func BuildAuthorizeURL(cfg OAuthConfig, state string) (string, error) {
if !cfg.Enabled() {
return "", fmt.Errorf("threads oauth is not configured")
}
state = strings.TrimSpace(state)
if state == "" {
return "", fmt.Errorf("oauth state is required")
}
params := url.Values{}
params.Set("client_id", strings.TrimSpace(cfg.AppID))
params.Set("redirect_uri", strings.TrimSpace(cfg.RedirectURI))
params.Set("scope", OAuthScopeString())
params.Set("response_type", "code")
params.Set("state", state)
return threadsAuthorizeURL + "?" + params.Encode(), nil
}
func ExchangeCodeForToken(ctx context.Context, cfg OAuthConfig, code string) (*TokenExchangeResult, error) {
if !cfg.Enabled() {
return nil, fmt.Errorf("threads oauth is not configured")
}
code = strings.TrimSpace(code)
if code == "" {
return nil, fmt.Errorf("authorization code is required")
}
// Meta may append #_ to the redirect code; strip suffix if present.
if idx := strings.Index(code, "#_"); idx >= 0 {
code = code[:idx]
}
params := url.Values{}
params.Set("client_id", strings.TrimSpace(cfg.AppID))
params.Set("client_secret", strings.TrimSpace(cfg.AppSecret))
params.Set("grant_type", "authorization_code")
params.Set("redirect_uri", strings.TrimSpace(cfg.RedirectURI))
params.Set("code", code)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, threadsShortTokenURL+"?"+params.Encode(), nil)
if err != nil {
return nil, err
}
body, status, err := oauthDoRequest(req)
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, parseAPIError(body, status)
}
var result TokenExchangeResult
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse threads token response: %w", err)
}
if strings.TrimSpace(result.AccessToken) == "" {
return nil, fmt.Errorf("threads token exchange returned empty access_token")
}
return &result, nil
}
func ExchangeLongLivedToken(ctx context.Context, cfg OAuthConfig, shortLivedToken string) (*TokenExchangeResult, error) {
if !cfg.Enabled() {
return nil, fmt.Errorf("threads oauth is not configured")
}
shortLivedToken = strings.TrimSpace(shortLivedToken)
if shortLivedToken == "" {
return nil, fmt.Errorf("short-lived access token is required")
}
params := url.Values{}
params.Set("grant_type", "th_exchange_token")
params.Set("client_secret", strings.TrimSpace(cfg.AppSecret))
params.Set("access_token", shortLivedToken)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, threadsLongLivedTokenURL+"?"+params.Encode(), nil)
if err != nil {
return nil, err
}
body, status, err := oauthDoRequest(req)
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, parseAPIError(body, status)
}
var result TokenExchangeResult
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse long-lived token response: %w", err)
}
if strings.TrimSpace(result.AccessToken) == "" {
return nil, fmt.Errorf("long-lived token exchange returned empty access_token")
}
return &result, nil
}
func GetUserProfile(ctx context.Context, accessToken, userID string) (*UserProfile, error) {
accessToken = strings.TrimSpace(accessToken)
userID = strings.TrimSpace(userID)
if accessToken == "" {
return nil, fmt.Errorf("access token is required")
}
if userID == "" {
return GetMeProfile(ctx, accessToken)
}
params := url.Values{}
params.Set("access_token", accessToken)
params.Set("fields", "id,username,name")
endpoint := graphBaseURL + "/" + url.PathEscape(userID) + "?" + params.Encode()
return fetchUserProfile(ctx, endpoint)
}
// GetMeProfile loads the authenticated Threads user via /me (preferred after OAuth).
func GetMeProfile(ctx context.Context, accessToken string) (*UserProfile, error) {
accessToken = strings.TrimSpace(accessToken)
if accessToken == "" {
return nil, fmt.Errorf("access token is required")
}
params := url.Values{}
params.Set("access_token", accessToken)
params.Set("fields", "id,username,name")
endpoint := graphBaseURL + "/me?" + params.Encode()
return fetchUserProfile(ctx, endpoint)
}
// ThreadsUserIDFromToken picks user_id returned by the OAuth token exchange.
func ThreadsUserIDFromToken(short, long *TokenExchangeResult) string {
if short != nil {
if id := short.UserID.String(); id != "" {
return id
}
}
if long != nil {
return long.UserID.String()
}
return ""
}
// IsPermissionError reports Meta permission / app-review / tester-list errors.
func IsPermissionError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "threads_basic") ||
strings.Contains(msg, "app review") ||
strings.Contains(msg, "testers")
}
func fetchUserProfile(ctx context.Context, endpoint string) (*UserProfile, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
body, status, err := oauthDoRequest(req)
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, parseAPIError(body, status)
}
var profile UserProfile
if err := json.Unmarshal(body, &profile); err != nil {
return nil, fmt.Errorf("parse threads user profile: %w", err)
}
if profile.ID.String() == "" {
return nil, fmt.Errorf("threads user profile missing id")
}
return &profile, nil
}
func oauthDoRequest(req *http.Request) ([]byte, int, error) {
client := &http.Client{Timeout: 25 * time.Second}
res, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer res.Body.Close()
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, res.StatusCode, err
}
return body, res.StatusCode, nil
}