Single-package agentidp SDK in sdk-go/: - AgentIdPClient composing AgentRegistryClient, CredentialClient, TokenServiceClient, AuditClient — all 14 endpoints covered - Goroutine-safe TokenManager (sync.Mutex) with 60s refresh buffer - AgentIdPError implementing error interface with Code/HTTPStatus/Details - Context-aware: all service methods take context.Context as first arg - doRequest shared helper; token endpoints use form-encoded POST directly - go vet: 0 warnings | staticcheck: 0 warnings - go test ./...: 37/37 passed | coverage: 81.0% (>80% gate) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
130 lines
3.3 KiB
Go
130 lines
3.3 KiB
Go
package agentidp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const refreshBufferSeconds = 60
|
|
|
|
// cachedToken holds an access token and its expiry time.
|
|
type cachedToken struct {
|
|
accessToken string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// isValid returns true if the token will not expire within the refresh buffer.
|
|
func (c *cachedToken) isValid() bool {
|
|
return time.Now().Add(refreshBufferSeconds * time.Second).Before(c.expiresAt)
|
|
}
|
|
|
|
// TokenManager obtains and caches OAuth 2.0 client credentials tokens.
|
|
// It is safe for concurrent use by multiple goroutines.
|
|
type TokenManager struct {
|
|
baseURL string
|
|
clientID string
|
|
clientSecret string
|
|
scope string
|
|
httpClient *http.Client
|
|
mu sync.Mutex
|
|
cached *cachedToken
|
|
}
|
|
|
|
// NewTokenManager creates a TokenManager that fetches tokens from baseURL
|
|
// using the given client credentials and scope.
|
|
func NewTokenManager(baseURL, clientID, clientSecret, scope string) *TokenManager {
|
|
return &TokenManager{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
clientID: clientID,
|
|
clientSecret: clientSecret,
|
|
scope: scope,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
}
|
|
|
|
// GetToken returns a valid access token, fetching a new one if the cache is
|
|
// empty or the cached token is within the refresh buffer window.
|
|
// It is goroutine-safe.
|
|
func (tm *TokenManager) GetToken(ctx context.Context) (string, error) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
if tm.cached != nil && tm.cached.isValid() {
|
|
return tm.cached.accessToken, nil
|
|
}
|
|
|
|
token, err := tm.fetchToken(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
tm.cached = token
|
|
return token.accessToken, nil
|
|
}
|
|
|
|
// ClearCache invalidates the cached token. The next call to GetToken will
|
|
// fetch a fresh token from the server.
|
|
func (tm *TokenManager) ClearCache() {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
tm.cached = nil
|
|
}
|
|
|
|
// fetchToken performs the OAuth 2.0 client credentials grant.
|
|
// Must be called with mu held.
|
|
func (tm *TokenManager) fetchToken(ctx context.Context) (*cachedToken, error) {
|
|
form := url.Values{}
|
|
form.Set("grant_type", "client_credentials")
|
|
form.Set("client_id", tm.clientID)
|
|
form.Set("client_secret", tm.clientSecret)
|
|
form.Set("scope", tm.scope)
|
|
|
|
tokenURL := tm.baseURL + "/api/v1/token"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, bytes.NewBufferString(form.Encode()))
|
|
if err != nil {
|
|
return nil, &AgentIdPError{
|
|
Code: "REQUEST_BUILD_ERROR",
|
|
Message: fmt.Sprintf("failed to build token request: %s", err.Error()),
|
|
HTTPStatus: 0,
|
|
}
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := tm.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, newNetworkError(err)
|
|
}
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, newNetworkError(err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, parseOAuth2Error(respBody, resp.StatusCode)
|
|
}
|
|
|
|
var tr TokenResponse
|
|
if err := json.Unmarshal(respBody, &tr); err != nil {
|
|
return nil, &AgentIdPError{
|
|
Code: "PARSE_ERROR",
|
|
Message: fmt.Sprintf("failed to parse token response: %s", err.Error()),
|
|
HTTPStatus: resp.StatusCode,
|
|
}
|
|
}
|
|
|
|
return &cachedToken{
|
|
accessToken: tr.AccessToken,
|
|
expiresAt: time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second),
|
|
}, nil
|
|
}
|