auth-runtime/cli/auth.go

181 lines
4.9 KiB
Go
Raw Normal View History

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
}