181 lines
4.9 KiB
Go
181 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// SessionResponse from /auth/skill-credit/session
|
|
type SessionResponse struct {
|
|
AccessToken string `json:"accessToken"`
|
|
OwnerSessionToken string `json:"ownerSessionToken,omitempty"`
|
|
HookURL string `json:"hookUrl,omitempty"`
|
|
HookToken string `json:"hookToken,omitempty"`
|
|
ExpiresIn int `json:"expiresIn"`
|
|
ClientID string `json:"clientId,omitempty"`
|
|
OwnerUserID string `json:"ownerUserId,omitempty"`
|
|
Balance int `json:"balance,omitempty"`
|
|
}
|
|
|
|
// ClientConfigResponse from /auth/skill-credit/client-config
|
|
type ClientConfigResponse struct {
|
|
ClientID string `json:"clientId"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Metadata map[string]interface{} `json:"metadata"`
|
|
}
|
|
|
|
var retryableStatuses = map[int]bool{401: true, 403: true}
|
|
var retryableMarkers = []string{
|
|
"session not found or expired",
|
|
"invalid or expired token",
|
|
"unauthorized",
|
|
"client key expired",
|
|
"client key revoked",
|
|
}
|
|
|
|
func isRetryable(resp APIResponse) bool {
|
|
if !retryableStatuses[resp.Status] {
|
|
return false
|
|
}
|
|
body := strings.ToLower(resp.Body)
|
|
if body == "" {
|
|
return true
|
|
}
|
|
for _, m := range retryableMarkers {
|
|
if strings.Contains(body, m) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// fetchSession exchanges CLIENT_KEY for a session token.
|
|
func fetchSession(cfg Config) (*SessionResponse, error) {
|
|
if cfg.ClientKey == "" {
|
|
return nil, fmt.Errorf("CLIENT_KEY is required")
|
|
}
|
|
|
|
payload := fmt.Sprintf(`{"clientKey":"%s"}`, cfg.ClientKey)
|
|
resp, err := doHTTP("POST", cfg.AuthBase+"/auth/skill-credit/session", "", payload, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("session request failed: %w", err)
|
|
}
|
|
|
|
if resp.Status < 200 || resp.Status >= 300 {
|
|
return nil, fmt.Errorf("auth session failed: HTTP %d — %s", resp.Status, resp.Body)
|
|
}
|
|
|
|
var session SessionResponse
|
|
if err := json.Unmarshal([]byte(resp.Body), &session); err != nil {
|
|
return nil, fmt.Errorf("invalid session JSON: %w", err)
|
|
}
|
|
if session.AccessToken == "" {
|
|
return nil, fmt.Errorf("missing accessToken in session response: %s", resp.Body)
|
|
}
|
|
|
|
return &session, nil
|
|
}
|
|
|
|
// fetchClientConfig retrieves client metadata.
|
|
func fetchClientConfig(cfg Config) (*ClientConfigResponse, error) {
|
|
if cfg.ClientKey == "" {
|
|
return nil, fmt.Errorf("CLIENT_KEY is required")
|
|
}
|
|
|
|
headers := map[string]string{"X-Client-Key": cfg.ClientKey}
|
|
resp, err := doHTTP("GET", cfg.AuthBase+"/auth/skill-credit/client-config", "", "", headers)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("client-config request failed: %w", err)
|
|
}
|
|
if resp.Status < 200 || resp.Status >= 300 {
|
|
return nil, fmt.Errorf("client-config failed: HTTP %d — %s", resp.Status, resp.Body)
|
|
}
|
|
|
|
var config ClientConfigResponse
|
|
if err := json.Unmarshal([]byte(resp.Body), &config); err != nil {
|
|
return nil, fmt.Errorf("invalid client-config JSON: %w", err)
|
|
}
|
|
return &config, nil
|
|
}
|
|
|
|
// getToken returns a cached or fresh token.
|
|
func getToken(cfg Config) (string, error) {
|
|
cf := cacheFile(cfg.AuthBase, cfg.ClientKey, cfg.CacheDir)
|
|
if cached := readCachedToken(cf, cfg.MinTTLSec); cached != "" {
|
|
return cached, nil
|
|
}
|
|
|
|
session, err := fetchSession(cfg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
writeCache(cf, session.AccessToken, session.ExpiresIn)
|
|
return session.AccessToken, nil
|
|
}
|
|
|
|
// refreshToken bypasses cache and fetches a new token.
|
|
func refreshToken(cfg Config) (string, error) {
|
|
cf := cacheFile(cfg.AuthBase, cfg.ClientKey, cfg.CacheDir)
|
|
deleteCache(cf)
|
|
|
|
session, err := fetchSession(cfg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
writeCache(cf, session.AccessToken, session.ExpiresIn)
|
|
return session.AccessToken, nil
|
|
}
|
|
|
|
// requestWithRetry implements the 3-tier retry logic:
|
|
// 1. cached/fresh token → try
|
|
// 2. refresh token with same key → retry
|
|
// 3. reload .env (maybe CLIENT_KEY changed) → refresh → retry
|
|
func requestWithRetry(cfg Config, method, path, body string) (*APIResponse, error) {
|
|
url := cfg.APIBase + path
|
|
|
|
// 1. Try with cached or fresh token
|
|
token, err := getToken(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
first, err := doHTTP(method, url, token, body, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !isRetryable(first) {
|
|
return &first, nil
|
|
}
|
|
|
|
// 2. Refresh token with same key
|
|
freshToken, err := refreshToken(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
second, err := doHTTP(method, url, freshToken, body, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !isRetryable(second) {
|
|
return &second, nil
|
|
}
|
|
|
|
// 3. Reload .env — CLIENT_KEY may have changed
|
|
oldKey := cfg.ClientKey
|
|
cfg = reloadConfig(cfg)
|
|
if cfg.ClientKey != oldKey {
|
|
oldCF := cacheFile(cfg.AuthBase, oldKey, cfg.CacheDir)
|
|
deleteCache(oldCF)
|
|
}
|
|
reloadedToken, err := refreshToken(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
third, err := doHTTP(method, url, reloadedToken, body, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &third, nil
|
|
}
|