diff --git a/openspec/changes/phase-2-production-ready/tasks.md b/openspec/changes/phase-2-production-ready/tasks.md index 9f9513e..98ddc13 100644 --- a/openspec/changes/phase-2-production-ready/tasks.md +++ b/openspec/changes/phase-2-production-ready/tasks.md @@ -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 diff --git a/sdk-go/README.md b/sdk-go/README.md new file mode 100644 index 0000000..fd1917c --- /dev/null +++ b/sdk-go/README.md @@ -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). diff --git a/sdk-go/agents.go b/sdk-go/agents.go new file mode 100644 index 0000000..3b4eeeb --- /dev/null +++ b/sdk-go/agents.go @@ -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) +} diff --git a/sdk-go/agents_test.go b/sdk-go/agents_test.go new file mode 100644 index 0000000..851f2b7 --- /dev/null +++ b/sdk-go/agents_test.go @@ -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) + } +} diff --git a/sdk-go/audit.go b/sdk-go/audit.go new file mode 100644 index 0000000..eb01414 --- /dev/null +++ b/sdk-go/audit.go @@ -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 +} diff --git a/sdk-go/audit_test.go b/sdk-go/audit_test.go new file mode 100644 index 0000000..3f4796f --- /dev/null +++ b/sdk-go/audit_test.go @@ -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) + } +} diff --git a/sdk-go/client.go b/sdk-go/client.go new file mode 100644 index 0000000..c7930db --- /dev/null +++ b/sdk-go/client.go @@ -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() +} diff --git a/sdk-go/client_test.go b/sdk-go/client_test.go new file mode 100644 index 0000000..0ff36b6 --- /dev/null +++ b/sdk-go/client_test.go @@ -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) + } +} diff --git a/sdk-go/credentials.go b/sdk-go/credentials.go new file mode 100644 index 0000000..f0cc5f5 --- /dev/null +++ b/sdk-go/credentials.go @@ -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 +} diff --git a/sdk-go/credentials_test.go b/sdk-go/credentials_test.go new file mode 100644 index 0000000..dcc2e2a --- /dev/null +++ b/sdk-go/credentials_test.go @@ -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) + } +} diff --git a/sdk-go/errors.go b/sdk-go/errors.go new file mode 100644 index 0000000..b7e1899 --- /dev/null +++ b/sdk-go/errors.go @@ -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, + } +} diff --git a/sdk-go/errors_test.go b/sdk-go/errors_test.go new file mode 100644 index 0000000..999ca07 --- /dev/null +++ b/sdk-go/errors_test.go @@ -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 } diff --git a/sdk-go/go.mod b/sdk-go/go.mod new file mode 100644 index 0000000..ff7a10d --- /dev/null +++ b/sdk-go/go.mod @@ -0,0 +1,3 @@ +module github.com/sentryagent/idp-sdk-go + +go 1.21 diff --git a/sdk-go/request.go b/sdk-go/request.go new file mode 100644 index 0000000..26d7e44 --- /dev/null +++ b/sdk-go/request.go @@ -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 +} diff --git a/sdk-go/token_manager.go b/sdk-go/token_manager.go new file mode 100644 index 0000000..3d75b64 --- /dev/null +++ b/sdk-go/token_manager.go @@ -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 +} diff --git a/sdk-go/token_manager_test.go b/sdk-go/token_manager_test.go new file mode 100644 index 0000000..f899404 --- /dev/null +++ b/sdk-go/token_manager_test.go @@ -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() +} diff --git a/sdk-go/token_service.go b/sdk-go/token_service.go new file mode 100644 index 0000000..f9ec5c3 --- /dev/null +++ b/sdk-go/token_service.go @@ -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 +} diff --git a/sdk-go/token_service_test.go b/sdk-go/token_service_test.go new file mode 100644 index 0000000..c98cc1b --- /dev/null +++ b/sdk-go/token_service_test.go @@ -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) + } +} diff --git a/sdk-go/types.go b/sdk-go/types.go new file mode 100644 index 0000000..d65f50e --- /dev/null +++ b/sdk-go/types.go @@ -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 +}