feat: Phase 2 Workstream 3 — Go SDK (github.com/sentryagent/idp-sdk-go)

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>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 15:23:02 +00:00
parent c93562e685
commit 91c759f455
19 changed files with 2048 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
# Phase 2: Production-Ready — Tasks
**Status**: In progress — Workstream 1 complete.
**Status**: In progress — Workstreams 1, 2, 3 complete.
## CEO Approval Gates (required before implementation)
@@ -40,17 +40,17 @@
## Workstream 3: Go SDK
- [ ] 3.1 Create `sdk-go/` with `go.mod` — module: github.com/sentryagent/idp-sdk-go, go 1.21
- [ ] 3.2 Write `sdk-go/types.go` — all request/response structs
- [ ] 3.3 Write `sdk-go/errors.go` — AgentIdPError type implementing error interface
- [ ] 3.4 Write `sdk-go/token_manager.go` — mutex-guarded TokenManager
- [ ] 3.5 Write `sdk-go/services/agents.go` — AgentRegistryClient
- [ ] 3.6 Write `sdk-go/services/credentials.go` — CredentialClient
- [ ] 3.7 Write `sdk-go/services/token.go` — TokenClient
- [ ] 3.8 Write `sdk-go/services/audit.go` — AuditClient
- [ ] 3.9 Write `sdk-go/client.go` — AgentIdPClient
- [ ] 3.10 Write `sdk-go/README.md`
- [ ] 3.11 QA: `go vet` clean, `staticcheck` clean, all 14 endpoints, goroutine-safe, `go test ./...` >80%
- [x] 3.1 Create `sdk-go/` with `go.mod` — module: github.com/sentryagent/idp-sdk-go, go 1.21
- [x] 3.2 Write `sdk-go/types.go` — all request/response structs
- [x] 3.3 Write `sdk-go/errors.go` — AgentIdPError type implementing error interface
- [x] 3.4 Write `sdk-go/token_manager.go` — mutex-guarded TokenManager
- [x] 3.5 Write `sdk-go/agents.go` — AgentRegistryClient (flat package; see ADR below)
- [x] 3.6 Write `sdk-go/credentials.go` — CredentialClient
- [x] 3.7 Write `sdk-go/token_service.go` — TokenServiceClient
- [x] 3.8 Write `sdk-go/audit.go` — AuditClient
- [x] 3.9 Write `sdk-go/client.go` — AgentIdPClient
- [x] 3.10 Write `sdk-go/README.md`
- [x] 3.11 QA: `go vet` clean, `staticcheck` clean, all 14 endpoints, goroutine-safe, `go test ./...` >80%
## Workstream 4: Java SDK

200
sdk-go/README.md Normal file
View File

@@ -0,0 +1,200 @@
# SentryAgent.ai AgentIdP — Go SDK
Official Go client for the [SentryAgent.ai AgentIdP](https://sentryagent.ai) — an open-source Identity Provider for AI agents built on OAuth 2.0 (RFC 6749) and aligned with the [AGNTCY](https://agntcy.org) open standard.
## Requirements
- Go 1.21+
- A running AgentIdP server
## Installation
```bash
go get github.com/sentryagent/idp-sdk-go
```
## Quick Start
```go
package main
import (
"context"
"fmt"
"log"
agentidp "github.com/sentryagent/idp-sdk-go"
)
func main() {
ctx := context.Background()
client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
BaseURL: "https://idp.example.com",
ClientID: "your-agent-client-id",
ClientSecret: "sk_live_...",
})
// Register a new AI agent
agent, err := client.Agents.RegisterAgent(ctx, agentidp.RegisterAgentRequest{
Email: "screener@example.com",
AgentType: "screener",
Version: "1.0.0",
Capabilities: []string{"read", "classify"},
Owner: "platform-team",
DeploymentEnv: "production",
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Registered agent: %s\n", agent.AgentID)
}
```
## Authentication
The SDK handles OAuth 2.0 Client Credentials automatically. Tokens are cached and refreshed 60 seconds before expiry. All operations are goroutine-safe.
```go
client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
BaseURL: "https://idp.example.com",
ClientID: "my-client-id",
ClientSecret: "my-client-secret",
Scope: "agents:read agents:write", // optional, defaults to all four scopes
})
```
## Agent Registry
```go
ctx := context.Background()
// Register
agent, err := client.Agents.RegisterAgent(ctx, agentidp.RegisterAgentRequest{...})
// List (with optional filters)
agents, err := client.Agents.ListAgents(ctx, &agentidp.ListAgentsParams{
Status: "active",
AgentType: "screener",
Page: 1,
Limit: 20,
})
// Get by ID
agent, err := client.Agents.GetAgent(ctx, "agent-uuid")
// Partial update
version := "2.0.0"
agent, err := client.Agents.UpdateAgent(ctx, "agent-uuid", agentidp.UpdateAgentRequest{
Version: &version,
})
// Decommission (permanent)
err = client.Agents.DecommissionAgent(ctx, "agent-uuid")
```
## Credential Management
```go
// Generate (returns one-time ClientSecret)
cred, err := client.Credentials.GenerateCredential(ctx, "agent-uuid")
fmt.Println(cred.ClientSecret) // store this — it is never shown again
// List
creds, err := client.Credentials.ListCredentials(ctx, "agent-uuid", 1, 20)
// Rotate (old secret is immediately invalidated)
newCred, err := client.Credentials.RotateCredential(ctx, "agent-uuid", "cred-uuid")
// Revoke
revoked, err := client.Credentials.RevokeCredential(ctx, "agent-uuid", "cred-uuid")
```
## Token Operations
```go
// Introspect (RFC 7662)
result, err := client.Tokens.IntrospectToken(ctx, "access-token-to-check")
if result.Active {
fmt.Printf("Token belongs to: %s\n", *result.Sub)
}
// Revoke
err = client.Tokens.RevokeToken(ctx, "access-token-to-revoke")
```
## Audit Log
```go
// Query with filters
events, err := client.Audit.QueryAuditLog(ctx, &agentidp.QueryAuditParams{
AgentID: "agent-uuid",
Action: "token.issued",
Outcome: "success",
FromDate: "2026-01-01",
ToDate: "2026-01-31",
Page: 1,
Limit: 50,
})
// Get single event
event, err := client.Audit.GetAuditEvent(ctx, "event-uuid")
```
## Error Handling
All errors are returned as `*AgentIdPError`:
```go
agent, err := client.Agents.GetAgent(ctx, "unknown-id")
if err != nil {
if apiErr, ok := err.(*agentidp.AgentIdPError); ok {
fmt.Printf("code=%s status=%d\n", apiErr.Code, apiErr.HTTPStatus)
// e.g. code=AgentNotFoundError status=404
}
return err
}
```
| Field | Type | Description |
|--------------|--------------------------|-------------------------------------------------|
| `Code` | `string` | Machine-readable error code |
| `Message` | `string` | Human-readable description |
| `HTTPStatus` | `int` | HTTP status code (0 for network/build errors) |
| `Details` | `map[string]interface{}` | Optional structured context from the API |
## Custom HTTP Client
Inject a custom `*http.Client` for proxy support, custom timeouts, or test mocking:
```go
client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
BaseURL: "https://idp.example.com",
ClientID: "cid",
ClientSecret: "secret",
HTTPClient: &http.Client{Timeout: 5 * time.Second},
})
```
## API Coverage
| Endpoint | Method | SDK Method |
|--------------------------------------------------|--------|-----------------------------------------|
| POST /api/v1/agents | POST | `Agents.RegisterAgent` |
| GET /api/v1/agents | GET | `Agents.ListAgents` |
| GET /api/v1/agents/:id | GET | `Agents.GetAgent` |
| PATCH /api/v1/agents/:id | PATCH | `Agents.UpdateAgent` |
| DELETE /api/v1/agents/:id | DELETE | `Agents.DecommissionAgent` |
| POST /api/v1/agents/:id/credentials | POST | `Credentials.GenerateCredential` |
| GET /api/v1/agents/:id/credentials | GET | `Credentials.ListCredentials` |
| POST /api/v1/agents/:id/credentials/:cid/rotate | POST | `Credentials.RotateCredential` |
| DELETE /api/v1/agents/:id/credentials/:cid | DELETE | `Credentials.RevokeCredential` |
| POST /api/v1/token | POST | (TokenManager — automatic) |
| POST /api/v1/token/introspect | POST | `Tokens.IntrospectToken` |
| POST /api/v1/token/revoke | POST | `Tokens.RevokeToken` |
| GET /api/v1/audit | GET | `Audit.QueryAuditLog` |
| GET /api/v1/audit/:id | GET | `Audit.GetAuditEvent` |
## License
Apache 2.0 — see [LICENSE](../LICENSE).

113
sdk-go/agents.go Normal file
View File

@@ -0,0 +1,113 @@
package agentidp
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)
// AgentRegistryClient provides methods for the Agent Registry API endpoints.
// All methods take a context.Context as first argument.
type AgentRegistryClient struct {
baseURL string
getToken func(ctx context.Context) (string, error)
httpClient *http.Client
}
func newAgentRegistryClient(baseURL string, getToken func(ctx context.Context) (string, error), httpClient *http.Client) *AgentRegistryClient {
return &AgentRegistryClient{
baseURL: strings.TrimRight(baseURL, "/"),
getToken: getToken,
httpClient: httpClient,
}
}
// RegisterAgent registers a new AI agent identity.
// POST /api/v1/agents → 201 Agent
func (c *AgentRegistryClient) RegisterAgent(ctx context.Context, req RegisterAgentRequest) (*Agent, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
var agent Agent
if err := doRequest(ctx, c.httpClient, http.MethodPost, c.baseURL+"/api/v1/agents", req, token, &agent); err != nil {
return nil, err
}
return &agent, nil
}
// ListAgents returns a paginated list of registered agents.
// GET /api/v1/agents → 200 PaginatedAgents
func (c *AgentRegistryClient) ListAgents(ctx context.Context, params *ListAgentsParams) (*PaginatedAgents, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
rawURL := c.baseURL + "/api/v1/agents"
if params != nil {
q := url.Values{}
if params.Status != "" {
q.Set("status", params.Status)
}
if params.AgentType != "" {
q.Set("agentType", params.AgentType)
}
if params.DeploymentEnv != "" {
q.Set("deploymentEnv", params.DeploymentEnv)
}
if params.Page > 0 {
q.Set("page", fmt.Sprintf("%d", params.Page))
}
if params.Limit > 0 {
q.Set("limit", fmt.Sprintf("%d", params.Limit))
}
if len(q) > 0 {
rawURL += "?" + q.Encode()
}
}
var result PaginatedAgents
if err := doRequest(ctx, c.httpClient, http.MethodGet, rawURL, nil, token, &result); err != nil {
return nil, err
}
return &result, nil
}
// GetAgent retrieves a single agent by ID.
// GET /api/v1/agents/:id → 200 Agent
func (c *AgentRegistryClient) GetAgent(ctx context.Context, agentID string) (*Agent, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
var agent Agent
if err := doRequest(ctx, c.httpClient, http.MethodGet, c.baseURL+"/api/v1/agents/"+agentID, nil, token, &agent); err != nil {
return nil, err
}
return &agent, nil
}
// UpdateAgent partially updates an agent.
// PATCH /api/v1/agents/:id → 200 Agent
func (c *AgentRegistryClient) UpdateAgent(ctx context.Context, agentID string, req UpdateAgentRequest) (*Agent, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
var agent Agent
if err := doRequest(ctx, c.httpClient, http.MethodPatch, c.baseURL+"/api/v1/agents/"+agentID, req, token, &agent); err != nil {
return nil, err
}
return &agent, nil
}
// DecommissionAgent permanently removes an agent.
// DELETE /api/v1/agents/:id → 204 No Content
func (c *AgentRegistryClient) DecommissionAgent(ctx context.Context, agentID string) error {
token, err := c.getToken(ctx)
if err != nil {
return err
}
return doRequest(ctx, c.httpClient, http.MethodDelete, c.baseURL+"/api/v1/agents/"+agentID, nil, token, nil)
}

181
sdk-go/agents_test.go Normal file
View File

@@ -0,0 +1,181 @@
package agentidp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// mockAgent is the canonical test agent fixture.
var mockAgent = Agent{
AgentID: "uuid-1",
Email: "a@b.ai",
AgentType: "screener",
Version: "1.0.0",
Capabilities: []string{"read"},
Owner: "team",
DeploymentEnv: "production",
Status: "active",
CreatedAt: "2026-01-01T00:00:00Z",
UpdatedAt: "2026-01-01T00:00:00Z",
}
var mockPaginatedAgents = PaginatedAgents{
Data: []Agent{mockAgent},
Total: 1,
Page: 1,
Limit: 20,
}
// staticToken returns a fixed token for all test service clients.
func staticToken(_ context.Context) (string, error) {
return "test-bearer-token", nil
}
func newAgentServer(t *testing.T, method, path string, status int, body interface{}) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != method {
t.Errorf("expected method %s, got %s", method, r.Method)
}
if r.URL.Path != path {
t.Errorf("expected path %s, got %s", path, r.URL.Path)
}
if r.Header.Get("Authorization") == "" {
t.Error("missing Authorization header")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if body != nil {
_ = json.NewEncoder(w).Encode(body)
}
}))
}
func TestAgentRegistryClient_RegisterAgent(t *testing.T) {
srv := newAgentServer(t, http.MethodPost, "/api/v1/agents", 201, mockAgent)
defer srv.Close()
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
agent, err := client.RegisterAgent(context.Background(), RegisterAgentRequest{
Email: "a@b.ai", AgentType: "screener", Version: "1.0.0",
Capabilities: []string{"read"}, Owner: "team", DeploymentEnv: "production",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if agent.AgentID != "uuid-1" {
t.Errorf("expected uuid-1, got %q", agent.AgentID)
}
}
func TestAgentRegistryClient_ListAgents(t *testing.T) {
srv := newAgentServer(t, http.MethodGet, "/api/v1/agents", 200, mockPaginatedAgents)
defer srv.Close()
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
result, err := client.ListAgents(context.Background(), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Total != 1 {
t.Errorf("expected total 1, got %d", result.Total)
}
if len(result.Data) != 1 || result.Data[0].AgentID != "uuid-1" {
t.Error("unexpected data in paginated result")
}
}
func TestAgentRegistryClient_ListAgents_WithParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("status") != "active" {
t.Errorf("expected status=active, got %q", r.URL.Query().Get("status"))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockPaginatedAgents)
}))
defer srv.Close()
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
_, err := client.ListAgents(context.Background(), &ListAgentsParams{Status: "active"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAgentRegistryClient_GetAgent(t *testing.T) {
srv := newAgentServer(t, http.MethodGet, "/api/v1/agents/uuid-1", 200, mockAgent)
defer srv.Close()
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
agent, err := client.GetAgent(context.Background(), "uuid-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if agent.AgentID != "uuid-1" {
t.Errorf("expected uuid-1, got %q", agent.AgentID)
}
}
func TestAgentRegistryClient_GetAgent_NotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
_ = json.NewEncoder(w).Encode(map[string]string{
"code": "AgentNotFoundError",
"message": "Agent not found.",
})
}))
defer srv.Close()
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
_, err := client.GetAgent(context.Background(), "bad-id")
if err == nil {
t.Fatal("expected error, got nil")
}
apiErr, ok := err.(*AgentIdPError)
if !ok {
t.Fatalf("expected *AgentIdPError, got %T", err)
}
if apiErr.Code != "AgentNotFoundError" {
t.Errorf("expected AgentNotFoundError, got %q", apiErr.Code)
}
if apiErr.HTTPStatus != 404 {
t.Errorf("expected 404, got %d", apiErr.HTTPStatus)
}
}
func TestAgentRegistryClient_UpdateAgent(t *testing.T) {
updated := mockAgent
updated.Version = "2.0.0"
srv := newAgentServer(t, http.MethodPatch, "/api/v1/agents/uuid-1", 200, updated)
defer srv.Close()
v := "2.0.0"
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
agent, err := client.UpdateAgent(context.Background(), "uuid-1", UpdateAgentRequest{Version: &v})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if agent.Version != "2.0.0" {
t.Errorf("expected version 2.0.0, got %q", agent.Version)
}
}
func TestAgentRegistryClient_DecommissionAgent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("expected DELETE, got %s", r.Method)
}
w.WriteHeader(204)
}))
defer srv.Close()
client := newAgentRegistryClient(srv.URL, staticToken, &http.Client{})
err := client.DecommissionAgent(context.Background(), "uuid-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

80
sdk-go/audit.go Normal file
View File

@@ -0,0 +1,80 @@
package agentidp
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)
// AuditClient provides methods for querying the Audit Log API endpoints.
type AuditClient struct {
baseURL string
getToken func(ctx context.Context) (string, error)
httpClient *http.Client
}
func newAuditClient(baseURL string, getToken func(ctx context.Context) (string, error), httpClient *http.Client) *AuditClient {
return &AuditClient{
baseURL: strings.TrimRight(baseURL, "/"),
getToken: getToken,
httpClient: httpClient,
}
}
// QueryAuditLog returns a filtered, paginated list of audit events.
// GET /api/v1/audit → 200 PaginatedAuditEvents
func (c *AuditClient) QueryAuditLog(ctx context.Context, params *QueryAuditParams) (*PaginatedAuditEvents, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
rawURL := c.baseURL + "/api/v1/audit"
if params != nil {
q := url.Values{}
if params.AgentID != "" {
q.Set("agentId", params.AgentID)
}
if params.Action != "" {
q.Set("action", params.Action)
}
if params.Outcome != "" {
q.Set("outcome", params.Outcome)
}
if params.FromDate != "" {
q.Set("fromDate", params.FromDate)
}
if params.ToDate != "" {
q.Set("toDate", params.ToDate)
}
if params.Page > 0 {
q.Set("page", fmt.Sprintf("%d", params.Page))
}
if params.Limit > 0 {
q.Set("limit", fmt.Sprintf("%d", params.Limit))
}
if len(q) > 0 {
rawURL += "?" + q.Encode()
}
}
var result PaginatedAuditEvents
if err := doRequest(ctx, c.httpClient, http.MethodGet, rawURL, nil, token, &result); err != nil {
return nil, err
}
return &result, nil
}
// GetAuditEvent retrieves a single audit event by ID.
// GET /api/v1/audit/:id → 200 AuditEvent
func (c *AuditClient) GetAuditEvent(ctx context.Context, eventID string) (*AuditEvent, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
var event AuditEvent
if err := doRequest(ctx, c.httpClient, http.MethodGet, c.baseURL+"/api/v1/audit/"+eventID, nil, token, &event); err != nil {
return nil, err
}
return &event, nil
}

126
sdk-go/audit_test.go Normal file
View File

@@ -0,0 +1,126 @@
package agentidp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
var mockAuditEvent = AuditEvent{
EventID: "ev-1",
AgentID: "uuid-1",
Action: "token.issued",
Outcome: "success",
IPAddress: "1.2.3.4",
UserAgent: "curl",
Metadata: map[string]interface{}{},
Timestamp: "2026-01-01T00:00:00Z",
}
var mockPaginatedAudit = PaginatedAuditEvents{
Data: []AuditEvent{mockAuditEvent},
Total: 1,
Page: 1,
Limit: 20,
}
func TestAuditClient_QueryAuditLog(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/audit" {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockPaginatedAudit)
}))
defer srv.Close()
client := newAuditClient(srv.URL, staticToken, &http.Client{})
result, err := client.QueryAuditLog(context.Background(), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Total != 1 {
t.Errorf("expected total 1, got %d", result.Total)
}
if len(result.Data) == 0 || result.Data[0].EventID != "ev-1" {
t.Error("unexpected data in paginated result")
}
}
func TestAuditClient_QueryAuditLog_WithParams(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("agentId") != "uuid-1" {
t.Errorf("expected agentId=uuid-1, got %q", q.Get("agentId"))
}
if q.Get("action") != "token.issued" {
t.Errorf("expected action=token.issued, got %q", q.Get("action"))
}
if q.Get("fromDate") != "2026-01-01" {
t.Errorf("expected fromDate=2026-01-01, got %q", q.Get("fromDate"))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockPaginatedAudit)
}))
defer srv.Close()
client := newAuditClient(srv.URL, staticToken, &http.Client{})
_, err := client.QueryAuditLog(context.Background(), &QueryAuditParams{
AgentID: "uuid-1",
Action: "token.issued",
FromDate: "2026-01-01",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAuditClient_GetAuditEvent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/audit/ev-1" {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockAuditEvent)
}))
defer srv.Close()
client := newAuditClient(srv.URL, staticToken, &http.Client{})
event, err := client.GetAuditEvent(context.Background(), "ev-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event.EventID != "ev-1" {
t.Errorf("expected ev-1, got %q", event.EventID)
}
if event.Action != "token.issued" {
t.Errorf("expected token.issued, got %q", event.Action)
}
}
func TestAuditClient_Error_Propagated(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
_ = json.NewEncoder(w).Encode(map[string]string{
"code": "AuditEventNotFoundError",
"message": "Event not found.",
})
}))
defer srv.Close()
client := newAuditClient(srv.URL, staticToken, &http.Client{})
_, err := client.GetAuditEvent(context.Background(), "bad-id")
if err == nil {
t.Fatal("expected error, got nil")
}
apiErr, ok := err.(*AgentIdPError)
if !ok {
t.Fatalf("expected *AgentIdPError, got %T", err)
}
if apiErr.Code != "AuditEventNotFoundError" {
t.Errorf("expected AuditEventNotFoundError, got %q", apiErr.Code)
}
}

83
sdk-go/client.go Normal file
View File

@@ -0,0 +1,83 @@
package agentidp
import (
"context"
"net/http"
"strings"
"time"
)
// AgentIdPClientConfig holds all configuration for AgentIdPClient.
type AgentIdPClientConfig struct {
// BaseURL is the root URL of the AgentIdP server (e.g. "https://idp.example.com").
BaseURL string
// ClientID is the agent's OAuth 2.0 client ID.
ClientID string
// ClientSecret is the agent's OAuth 2.0 client secret.
ClientSecret string
// Scope is the space-separated list of OAuth 2.0 scopes to request.
// Defaults to all four scopes when empty.
Scope string
// HTTPClient allows injecting a custom *http.Client (e.g. for testing).
// When nil, a default client with a 30-second timeout is used.
HTTPClient *http.Client
}
// AgentIdPClient is the top-level client for the SentryAgent.ai AgentIdP API.
// It composes all four service clients and manages token acquisition automatically.
//
// Usage:
//
// client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
// BaseURL: "https://idp.example.com",
// ClientID: "my-agent-id",
// ClientSecret: "sk_live_...",
// })
// agent, err := client.Agents.GetAgent(ctx, "uuid-1")
type AgentIdPClient struct {
// Agents provides access to the Agent Registry endpoints.
Agents *AgentRegistryClient
// Credentials provides access to the Credential Management endpoints.
Credentials *CredentialClient
// Tokens provides access to the Token introspection and revocation endpoints.
Tokens *TokenServiceClient
// Audit provides access to the Audit Log endpoints.
Audit *AuditClient
tokenManager *TokenManager
}
// NewAgentIdPClient creates a new AgentIdPClient with the given configuration.
func NewAgentIdPClient(cfg AgentIdPClientConfig) *AgentIdPClient {
baseURL := strings.TrimRight(cfg.BaseURL, "/")
scope := cfg.Scope
if scope == "" {
scope = "agents:read agents:write tokens:read audit:read"
}
httpClient := cfg.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
tm := NewTokenManager(baseURL, cfg.ClientID, cfg.ClientSecret, scope)
getToken := func(ctx context.Context) (string, error) {
return tm.GetToken(ctx)
}
return &AgentIdPClient{
Agents: newAgentRegistryClient(baseURL, getToken, httpClient),
Credentials: newCredentialClient(baseURL, getToken, httpClient),
Tokens: newTokenServiceClient(baseURL, getToken, httpClient),
Audit: newAuditClient(baseURL, getToken, httpClient),
tokenManager: tm,
}
}
// ClearTokenCache invalidates the cached access token.
// The next API call will fetch a fresh token from the server.
func (c *AgentIdPClient) ClearTokenCache() {
c.tokenManager.ClearCache()
}

124
sdk-go/client_test.go Normal file
View File

@@ -0,0 +1,124 @@
package agentidp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// integrationServer returns a minimal mock server that handles the token endpoint
// plus a provided handler for all other routes.
func integrationServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "integration-token",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "agents:read agents:write tokens:read audit:read",
})
})
mux.HandleFunc("/", handler)
return httptest.NewServer(mux)
}
func TestNewAgentIdPClient_GetAgent(t *testing.T) {
srv := integrationServer(t, func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/v1/agents/") {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer integration-token" {
t.Errorf("unexpected Authorization: %q", r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockAgent)
})
defer srv.Close()
client := NewAgentIdPClient(AgentIdPClientConfig{
BaseURL: srv.URL,
ClientID: "cid",
ClientSecret: "secret",
})
agent, err := client.Agents.GetAgent(context.Background(), "uuid-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if agent.AgentID != "uuid-1" {
t.Errorf("expected uuid-1, got %q", agent.AgentID)
}
}
func TestNewAgentIdPClient_ClearTokenCache(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/token" {
callCount++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "tok",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "agents:read",
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockAgent)
}))
defer srv.Close()
client := NewAgentIdPClient(AgentIdPClientConfig{
BaseURL: srv.URL,
ClientID: "cid",
ClientSecret: "secret",
})
_, _ = client.Agents.GetAgent(context.Background(), "uuid-1")
client.ClearTokenCache()
_, _ = client.Agents.GetAgent(context.Background(), "uuid-1")
if callCount != 2 {
t.Errorf("expected 2 token fetches after ClearTokenCache, got %d", callCount)
}
}
func TestNewAgentIdPClient_DefaultScope(t *testing.T) {
var capturedScope string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/token" {
_ = r.ParseForm()
capturedScope = r.FormValue("scope")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": "tok",
"token_type": "Bearer",
"expires_in": 3600,
"scope": capturedScope,
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockAgent)
}))
defer srv.Close()
client := NewAgentIdPClient(AgentIdPClientConfig{
BaseURL: srv.URL,
ClientID: "cid",
ClientSecret: "secret",
// Scope intentionally omitted → defaults applied
})
_, _ = client.Agents.GetAgent(context.Background(), "uuid-1")
expected := "agents:read agents:write tokens:read audit:read"
if capturedScope != expected {
t.Errorf("expected scope %q, got %q", expected, capturedScope)
}
}

93
sdk-go/credentials.go Normal file
View File

@@ -0,0 +1,93 @@
package agentidp
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)
// CredentialClient provides methods for the Credential Management API endpoints.
type CredentialClient struct {
baseURL string
getToken func(ctx context.Context) (string, error)
httpClient *http.Client
}
func newCredentialClient(baseURL string, getToken func(ctx context.Context) (string, error), httpClient *http.Client) *CredentialClient {
return &CredentialClient{
baseURL: strings.TrimRight(baseURL, "/"),
getToken: getToken,
httpClient: httpClient,
}
}
// GenerateCredential creates a new credential for the given agent.
// POST /api/v1/agents/:id/credentials → 201 CredentialWithSecret
func (c *CredentialClient) GenerateCredential(ctx context.Context, agentID string) (*CredentialWithSecret, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
var cred CredentialWithSecret
if err := doRequest(ctx, c.httpClient, http.MethodPost, c.baseURL+"/api/v1/agents/"+agentID+"/credentials", struct{}{}, token, &cred); err != nil {
return nil, err
}
return &cred, nil
}
// ListCredentials returns a paginated list of credentials for the given agent.
// GET /api/v1/agents/:id/credentials → 200 PaginatedCredentials
func (c *CredentialClient) ListCredentials(ctx context.Context, agentID string, page, limit int) (*PaginatedCredentials, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
rawURL := c.baseURL + "/api/v1/agents/" + agentID + "/credentials"
q := url.Values{}
if page > 0 {
q.Set("page", fmt.Sprintf("%d", page))
}
if limit > 0 {
q.Set("limit", fmt.Sprintf("%d", limit))
}
if len(q) > 0 {
rawURL += "?" + q.Encode()
}
var result PaginatedCredentials
if err := doRequest(ctx, c.httpClient, http.MethodGet, rawURL, nil, token, &result); err != nil {
return nil, err
}
return &result, nil
}
// RotateCredential generates a new secret for the given credential.
// POST /api/v1/agents/:id/credentials/:credId/rotate → 200 CredentialWithSecret
func (c *CredentialClient) RotateCredential(ctx context.Context, agentID, credentialID string) (*CredentialWithSecret, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
rawURL := c.baseURL + "/api/v1/agents/" + agentID + "/credentials/" + credentialID + "/rotate"
var cred CredentialWithSecret
if err := doRequest(ctx, c.httpClient, http.MethodPost, rawURL, struct{}{}, token, &cred); err != nil {
return nil, err
}
return &cred, nil
}
// RevokeCredential permanently revokes a credential.
// DELETE /api/v1/agents/:id/credentials/:credId → 200 Credential
func (c *CredentialClient) RevokeCredential(ctx context.Context, agentID, credentialID string) (*Credential, error) {
token, err := c.getToken(ctx)
if err != nil {
return nil, err
}
rawURL := c.baseURL + "/api/v1/agents/" + agentID + "/credentials/" + credentialID
var cred Credential
if err := doRequest(ctx, c.httpClient, http.MethodDelete, rawURL, nil, token, &cred); err != nil {
return nil, err
}
return &cred, nil
}

146
sdk-go/credentials_test.go Normal file
View File

@@ -0,0 +1,146 @@
package agentidp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
var mockCred = Credential{
CredentialID: "cred-1",
ClientID: "uuid-1",
Status: "active",
CreatedAt: "2026-01-01T00:00:00Z",
}
var mockCredWithSecret = CredentialWithSecret{
Credential: mockCred,
ClientSecret: "sk_live_abc",
}
var mockPaginatedCreds = PaginatedCredentials{
Data: []Credential{mockCred},
Total: 1,
Page: 1,
Limit: 20,
}
func TestCredentialClient_GenerateCredential(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/agents/uuid-1/credentials" {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(201)
_ = json.NewEncoder(w).Encode(mockCredWithSecret)
}))
defer srv.Close()
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
cred, err := client.GenerateCredential(context.Background(), "uuid-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cred.ClientSecret != "sk_live_abc" {
t.Errorf("expected sk_live_abc, got %q", cred.ClientSecret)
}
if cred.CredentialID != "cred-1" {
t.Errorf("expected cred-1, got %q", cred.CredentialID)
}
}
func TestCredentialClient_ListCredentials(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/agents/uuid-1/credentials" {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockPaginatedCreds)
}))
defer srv.Close()
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
result, err := client.ListCredentials(context.Background(), "uuid-1", 0, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Total != 1 {
t.Errorf("expected total 1, got %d", result.Total)
}
}
func TestCredentialClient_RotateCredential(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expectedPath := "/api/v1/agents/uuid-1/credentials/cred-1/rotate"
if r.Method != http.MethodPost || r.URL.Path != expectedPath {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(mockCredWithSecret)
}))
defer srv.Close()
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
cred, err := client.RotateCredential(context.Background(), "uuid-1", "cred-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cred.ClientSecret != "sk_live_abc" {
t.Errorf("expected sk_live_abc, got %q", cred.ClientSecret)
}
}
func TestCredentialClient_RevokeCredential(t *testing.T) {
revokedAt := "2026-01-02T00:00:00Z"
revoked := Credential{
CredentialID: "cred-1",
ClientID: "uuid-1",
Status: "revoked",
CreatedAt: "2026-01-01T00:00:00Z",
RevokedAt: &revokedAt,
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("expected DELETE, got %s", r.Method)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(revoked)
}))
defer srv.Close()
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
cred, err := client.RevokeCredential(context.Background(), "uuid-1", "cred-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cred.Status != "revoked" {
t.Errorf("expected revoked, got %q", cred.Status)
}
}
func TestCredentialClient_Error_Propagated(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
_ = json.NewEncoder(w).Encode(map[string]string{
"code": "AgentNotFoundError",
"message": "Not found.",
})
}))
defer srv.Close()
client := newCredentialClient(srv.URL, staticToken, &http.Client{})
_, err := client.GenerateCredential(context.Background(), "bad-id")
if err == nil {
t.Fatal("expected error, got nil")
}
apiErr, ok := err.(*AgentIdPError)
if !ok {
t.Fatalf("expected *AgentIdPError, got %T", err)
}
if apiErr.HTTPStatus != 404 {
t.Errorf("expected 404, got %d", apiErr.HTTPStatus)
}
}

83
sdk-go/errors.go Normal file
View File

@@ -0,0 +1,83 @@
package agentidp
import (
"encoding/json"
"fmt"
)
// AgentIdPError is returned for all API and network failures.
// It implements the error interface.
type AgentIdPError struct {
// Code is a machine-readable error code (e.g. "AgentNotFoundError").
Code string
// Message is a human-readable description.
Message string
// HTTPStatus is the HTTP response status code, or 0 for network errors.
HTTPStatus int
// Details contains additional structured context, if provided by the API.
Details map[string]interface{}
}
// Error implements the error interface.
func (e *AgentIdPError) Error() string {
return e.Message
}
// apiErrorBody is the standard JSON error body from the AgentIdP REST API.
type apiErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]interface{} `json:"details,omitempty"`
}
// oauth2ErrorBody is the standard JSON error body from OAuth 2.0 token endpoints.
type oauth2ErrorBody struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// parseAPIError attempts to unmarshal a JSON response body into an AgentIdPError.
// Falls back to a generic UNKNOWN_ERROR if the body cannot be parsed.
func parseAPIError(body []byte, status int) *AgentIdPError {
var apiErr apiErrorBody
if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Code != "" {
return &AgentIdPError{
Code: apiErr.Code,
Message: apiErr.Message,
HTTPStatus: status,
Details: apiErr.Details,
}
}
return &AgentIdPError{
Code: "UNKNOWN_ERROR",
Message: fmt.Sprintf("unexpected HTTP %d", status),
HTTPStatus: status,
}
}
// parseOAuth2Error attempts to unmarshal a JSON response body into an AgentIdPError
// using the OAuth 2.0 error format. Falls back to UNKNOWN_ERROR on parse failure.
func parseOAuth2Error(body []byte, status int) *AgentIdPError {
var oauthErr oauth2ErrorBody
if err := json.Unmarshal(body, &oauthErr); err == nil && oauthErr.Error != "" {
return &AgentIdPError{
Code: oauthErr.Error,
Message: oauthErr.ErrorDescription,
HTTPStatus: status,
}
}
return &AgentIdPError{
Code: "UNKNOWN_ERROR",
Message: fmt.Sprintf("unexpected HTTP %d", status),
HTTPStatus: status,
}
}
// newNetworkError creates an AgentIdPError for transport-level failures.
func newNetworkError(cause error) *AgentIdPError {
return &AgentIdPError{
Code: "NETWORK_ERROR",
Message: fmt.Sprintf("network error: %s", cause.Error()),
HTTPStatus: 0,
}
}

85
sdk-go/errors_test.go Normal file
View File

@@ -0,0 +1,85 @@
package agentidp
import (
"strings"
"testing"
)
func TestAgentIdPError_Error(t *testing.T) {
err := &AgentIdPError{Code: "AgentNotFoundError", Message: "Agent not found.", HTTPStatus: 404}
if err.Error() != "Agent not found." {
t.Errorf("expected 'Agent not found.', got %q", err.Error())
}
}
func TestParseAPIError_ValidBody(t *testing.T) {
body := []byte(`{"code":"AgentNotFoundError","message":"Not found.","details":{"id":"x"}}`)
err := parseAPIError(body, 404)
if err.Code != "AgentNotFoundError" {
t.Errorf("expected code AgentNotFoundError, got %q", err.Code)
}
if err.HTTPStatus != 404 {
t.Errorf("expected status 404, got %d", err.HTTPStatus)
}
if err.Details == nil {
t.Error("expected non-nil Details")
}
}
func TestParseAPIError_UnparseableBody(t *testing.T) {
err := parseAPIError([]byte("not json"), 500)
if err.Code != "UNKNOWN_ERROR" {
t.Errorf("expected UNKNOWN_ERROR, got %q", err.Code)
}
if err.HTTPStatus != 500 {
t.Errorf("expected 500, got %d", err.HTTPStatus)
}
}
func TestParseAPIError_EmptyCode(t *testing.T) {
// Valid JSON but no "code" field → falls back to UNKNOWN_ERROR
err := parseAPIError([]byte(`{"message":"oops"}`), 503)
if err.Code != "UNKNOWN_ERROR" {
t.Errorf("expected UNKNOWN_ERROR, got %q", err.Code)
}
}
func TestParseOAuth2Error_ValidBody(t *testing.T) {
body := []byte(`{"error":"invalid_client","error_description":"Bad credentials."}`)
err := parseOAuth2Error(body, 401)
if err.Code != "invalid_client" {
t.Errorf("expected invalid_client, got %q", err.Code)
}
if err.Message != "Bad credentials." {
t.Errorf("expected 'Bad credentials.', got %q", err.Message)
}
if err.HTTPStatus != 401 {
t.Errorf("expected 401, got %d", err.HTTPStatus)
}
}
func TestParseOAuth2Error_UnparseableBody(t *testing.T) {
err := parseOAuth2Error([]byte("garbage"), 400)
if err.Code != "UNKNOWN_ERROR" {
t.Errorf("expected UNKNOWN_ERROR, got %q", err.Code)
}
}
func TestNewNetworkError(t *testing.T) {
cause := &testError{msg: "connection refused"}
err := newNetworkError(cause)
if err.Code != "NETWORK_ERROR" {
t.Errorf("expected NETWORK_ERROR, got %q", err.Code)
}
if err.HTTPStatus != 0 {
t.Errorf("expected HTTPStatus 0, got %d", err.HTTPStatus)
}
if !strings.Contains(err.Message, "connection refused") {
t.Errorf("expected message to contain 'connection refused', got %q", err.Message)
}
}
// testError is a simple error implementation for testing.
type testError struct{ msg string }
func (e *testError) Error() string { return e.msg }

3
sdk-go/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/sentryagent/idp-sdk-go
go 1.21

79
sdk-go/request.go Normal file
View File

@@ -0,0 +1,79 @@
package agentidp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
// doRequest performs an authenticated JSON HTTP request.
//
// - method: HTTP method (GET, POST, PATCH, DELETE)
// - url: full URL (base + path + query)
// - body: request body (marshalled to JSON), or nil for bodyless requests
// - token: Bearer token for Authorization header
// - out: pointer to unmarshal the response body into, or nil to discard
//
// Returns nil on 2xx; returns *AgentIdPError on HTTP errors or network failures.
// 204 No Content responses are considered success; out is not populated.
func doRequest(ctx context.Context, client *http.Client, method, url string, body interface{}, token string, out interface{}) error {
var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return &AgentIdPError{
Code: "SERIALIZATION_ERROR",
Message: fmt.Sprintf("failed to marshal request body: %s", err.Error()),
HTTPStatus: 0,
}
}
bodyReader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return &AgentIdPError{
Code: "REQUEST_BUILD_ERROR",
Message: fmt.Sprintf("failed to build request: %s", err.Error()),
HTTPStatus: 0,
}
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
return newNetworkError(err)
}
defer resp.Body.Close() //nolint:errcheck
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return newNetworkError(err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return parseAPIError(respBody, resp.StatusCode)
}
if out != nil && resp.StatusCode != http.StatusNoContent {
if err := json.Unmarshal(respBody, out); err != nil {
return &AgentIdPError{
Code: "PARSE_ERROR",
Message: fmt.Sprintf("failed to parse response: %s", err.Error()),
HTTPStatus: resp.StatusCode,
}
}
}
return nil
}

129
sdk-go/token_manager.go Normal file
View File

@@ -0,0 +1,129 @@
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
}

View File

@@ -0,0 +1,169 @@
package agentidp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
func newTokenServer(t *testing.T, statusCode int, body interface{}) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/token" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(body)
}))
}
var tokenResp = map[string]interface{}{
"access_token": "eyJ.abc.def",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "agents:read",
}
func TestTokenManager_GetToken_Issues(t *testing.T) {
srv := newTokenServer(t, 200, tokenResp)
defer srv.Close()
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
tok, err := tm.GetToken(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tok != "eyJ.abc.def" {
t.Errorf("expected token eyJ.abc.def, got %q", tok)
}
}
func TestTokenManager_GetToken_Caches(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tokenResp)
}))
defer srv.Close()
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
_, _ = tm.GetToken(context.Background())
_, _ = tm.GetToken(context.Background())
if callCount != 1 {
t.Errorf("expected 1 HTTP call (cached), got %d", callCount)
}
}
func TestTokenManager_GetToken_RefreshesNearExpiry(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
resp := map[string]interface{}{
"access_token": "eyJ.abc.def",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "agents:read",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
_, _ = tm.GetToken(context.Background())
// Force the cached token to appear nearly expired
tm.mu.Lock()
tm.cached = &cachedToken{
accessToken: "old-token",
expiresAt: time.Now().Add(30 * time.Second), // < refreshBufferSeconds
}
tm.mu.Unlock()
tok, err := tm.GetToken(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tok != "eyJ.abc.def" {
t.Errorf("expected refreshed token, got %q", tok)
}
if callCount != 2 {
t.Errorf("expected 2 HTTP calls (initial + refresh), got %d", callCount)
}
}
func TestTokenManager_GetToken_AuthFailure(t *testing.T) {
srv := newTokenServer(t, 401, map[string]interface{}{
"error": "invalid_client",
"error_description": "Bad credentials.",
})
defer srv.Close()
tm := NewTokenManager(srv.URL, "client-id", "bad-secret", "agents:read")
_, err := tm.GetToken(context.Background())
if err == nil {
t.Fatal("expected error, got nil")
}
apiErr, ok := err.(*AgentIdPError)
if !ok {
t.Fatalf("expected *AgentIdPError, got %T", err)
}
if apiErr.Code != "invalid_client" {
t.Errorf("expected code invalid_client, got %q", apiErr.Code)
}
if apiErr.HTTPStatus != 401 {
t.Errorf("expected HTTPStatus 401, got %d", apiErr.HTTPStatus)
}
}
func TestTokenManager_ClearCache(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tokenResp)
}))
defer srv.Close()
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
_, _ = tm.GetToken(context.Background())
tm.ClearCache()
_, _ = tm.GetToken(context.Background())
if callCount != 2 {
t.Errorf("expected 2 HTTP calls (cache cleared), got %d", callCount)
}
}
func TestTokenManager_GoroutineSafe(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tokenResp)
}))
defer srv.Close()
tm := NewTokenManager(srv.URL, "client-id", "secret", "agents:read")
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
tok, err := tm.GetToken(context.Background())
if err != nil {
t.Errorf("goroutine error: %v", err)
}
if tok != "eyJ.abc.def" {
t.Errorf("unexpected token: %q", tok)
}
}()
}
wg.Wait()
}

103
sdk-go/token_service.go Normal file
View File

@@ -0,0 +1,103 @@
package agentidp
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
)
// TokenServiceClient provides token introspection and revocation.
// Token acquisition is handled separately by TokenManager.
type TokenServiceClient struct {
baseURL string
getToken func(ctx context.Context) (string, error)
httpClient *http.Client
}
func newTokenServiceClient(baseURL string, getToken func(ctx context.Context) (string, error), httpClient *http.Client) *TokenServiceClient {
return &TokenServiceClient{
baseURL: strings.TrimRight(baseURL, "/"),
getToken: getToken,
httpClient: httpClient,
}
}
// IntrospectToken introspects an access token per RFC 7662.
// POST /api/v1/token/introspect (form-encoded) → 200 IntrospectResponse
func (c *TokenServiceClient) IntrospectToken(ctx context.Context, accessToken string) (*IntrospectResponse, error) {
bearerToken, err := c.getToken(ctx)
if err != nil {
return nil, err
}
form := url.Values{}
form.Set("token", accessToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/token/introspect", bytes.NewBufferString(form.Encode()))
if err != nil {
return nil, &AgentIdPError{Code: "REQUEST_BUILD_ERROR", Message: "failed to build introspect request: " + err.Error()}
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+bearerToken)
resp, err := c.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 < 200 || resp.StatusCode >= 300 {
return nil, parseAPIError(respBody, resp.StatusCode)
}
var result IntrospectResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, &AgentIdPError{Code: "PARSE_ERROR", Message: "failed to parse introspect response: " + err.Error(), HTTPStatus: resp.StatusCode}
}
return &result, nil
}
// RevokeToken revokes an access token.
// POST /api/v1/token/revoke (form-encoded) → 200
func (c *TokenServiceClient) RevokeToken(ctx context.Context, accessToken string) error {
bearerToken, err := c.getToken(ctx)
if err != nil {
return err
}
form := url.Values{}
form.Set("token", accessToken)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/token/revoke", bytes.NewBufferString(form.Encode()))
if err != nil {
return &AgentIdPError{Code: "REQUEST_BUILD_ERROR", Message: "failed to build revoke request: " + err.Error()}
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Bearer "+bearerToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return newNetworkError(err)
}
defer resp.Body.Close() //nolint:errcheck
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return newNetworkError(err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return parseAPIError(respBody, resp.StatusCode)
}
return nil
}

View File

@@ -0,0 +1,108 @@
package agentidp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestTokenServiceClient_IntrospectToken_Active(t *testing.T) {
introspectResp := map[string]interface{}{
"active": true,
"sub": "uuid-1",
"exp": 9999999999,
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/token/introspect" {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Errorf("expected form content-type, got %q", ct)
}
if err := r.ParseForm(); err != nil {
t.Fatalf("parse form: %v", err)
}
if r.FormValue("token") == "" {
t.Error("missing 'token' form field")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(introspectResp)
}))
defer srv.Close()
client := newTokenServiceClient(srv.URL, staticToken, &http.Client{})
result, err := client.IntrospectToken(context.Background(), "some-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Active {
t.Error("expected active=true")
}
if result.Sub == nil || *result.Sub != "uuid-1" {
t.Errorf("expected sub=uuid-1, got %v", result.Sub)
}
}
func TestTokenServiceClient_IntrospectToken_Inactive(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"active": false})
}))
defer srv.Close()
client := newTokenServiceClient(srv.URL, staticToken, &http.Client{})
result, err := client.IntrospectToken(context.Background(), "expired-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Active {
t.Error("expected active=false")
}
}
func TestTokenServiceClient_RevokeToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/token/revoke" {
t.Errorf("unexpected: %s %s", r.Method, r.URL.Path)
}
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Errorf("expected form content-type, got %q", ct)
}
w.WriteHeader(200)
_, _ = w.Write([]byte("{}"))
}))
defer srv.Close()
client := newTokenServiceClient(srv.URL, staticToken, &http.Client{})
err := client.RevokeToken(context.Background(), "some-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestTokenServiceClient_IntrospectToken_Error(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
_ = json.NewEncoder(w).Encode(map[string]string{
"code": "UnauthorizedError",
"message": "Invalid token.",
})
}))
defer srv.Close()
client := newTokenServiceClient(srv.URL, staticToken, &http.Client{})
_, err := client.IntrospectToken(context.Background(), "bad-token")
if err == nil {
t.Fatal("expected error, got nil")
}
apiErr, ok := err.(*AgentIdPError)
if !ok {
t.Fatalf("expected *AgentIdPError, got %T", err)
}
if apiErr.HTTPStatus != 401 {
t.Errorf("expected 401, got %d", apiErr.HTTPStatus)
}
}

131
sdk-go/types.go Normal file
View File

@@ -0,0 +1,131 @@
// Package agentidp provides a Go client for the SentryAgent.ai AgentIdP API.
// It covers all 14 endpoints across agent registry, credential management,
// OAuth 2.0 token operations, and audit log queries.
package agentidp
// Agent is a registered AI agent identity.
type Agent struct {
AgentID string `json:"agentId"`
Email string `json:"email"`
AgentType string `json:"agentType"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Owner string `json:"owner"`
DeploymentEnv string `json:"deploymentEnv"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// RegisterAgentRequest is the body for POST /api/v1/agents.
type RegisterAgentRequest struct {
Email string `json:"email"`
AgentType string `json:"agentType"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Owner string `json:"owner"`
DeploymentEnv string `json:"deploymentEnv"`
}
// UpdateAgentRequest is the body for PATCH /api/v1/agents/:id.
// All fields are optional — only non-nil pointer fields are sent.
type UpdateAgentRequest struct {
AgentType *string `json:"agentType,omitempty"`
Version *string `json:"version,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
Owner *string `json:"owner,omitempty"`
DeploymentEnv *string `json:"deploymentEnv,omitempty"`
Status *string `json:"status,omitempty"`
}
// PaginatedAgents is a paginated list of agents.
type PaginatedAgents struct {
Data []Agent `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// ListAgentsParams contains optional query parameters for ListAgents.
type ListAgentsParams struct {
Status string
AgentType string
DeploymentEnv string
Page int
Limit int
}
// Credential is a credential record (ClientSecret is never included).
type Credential struct {
CredentialID string `json:"credentialId"`
ClientID string `json:"clientId"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
ExpiresAt *string `json:"expiresAt"`
RevokedAt *string `json:"revokedAt"`
}
// CredentialWithSecret is a Credential with a one-time plaintext secret.
// Returned only on credential creation and rotation.
type CredentialWithSecret struct {
Credential
ClientSecret string `json:"clientSecret"`
}
// PaginatedCredentials is a paginated list of credentials.
type PaginatedCredentials struct {
Data []Credential `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// TokenResponse is the OAuth 2.0 access token response (RFC 6749).
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}
// IntrospectResponse is the token introspection response (RFC 7662).
type IntrospectResponse struct {
Active bool `json:"active"`
Sub *string `json:"sub,omitempty"`
ClientID *string `json:"client_id,omitempty"`
Scope *string `json:"scope,omitempty"`
TokenType *string `json:"token_type,omitempty"`
Iat *int64 `json:"iat,omitempty"`
Exp *int64 `json:"exp,omitempty"`
}
// AuditEvent is an immutable audit event record.
type AuditEvent struct {
EventID string `json:"eventId"`
AgentID string `json:"agentId"`
Action string `json:"action"`
Outcome string `json:"outcome"`
IPAddress string `json:"ipAddress"`
UserAgent string `json:"userAgent"`
Metadata map[string]interface{} `json:"metadata"`
Timestamp string `json:"timestamp"`
}
// PaginatedAuditEvents is a paginated list of audit events.
type PaginatedAuditEvents struct {
Data []AuditEvent `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// QueryAuditParams contains optional query parameters for QueryAuditLog.
type QueryAuditParams struct {
AgentID string
Action string
Outcome string
FromDate string
ToDate string
Page int
Limit int
}