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:
@@ -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
200
sdk-go/README.md
Normal 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
113
sdk-go/agents.go
Normal 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
181
sdk-go/agents_test.go
Normal 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
80
sdk-go/audit.go
Normal 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
126
sdk-go/audit_test.go
Normal 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
83
sdk-go/client.go
Normal 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
124
sdk-go/client_test.go
Normal 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
93
sdk-go/credentials.go
Normal 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
146
sdk-go/credentials_test.go
Normal 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
83
sdk-go/errors.go
Normal 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
85
sdk-go/errors_test.go
Normal 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
3
sdk-go/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module github.com/sentryagent/idp-sdk-go
|
||||
|
||||
go 1.21
|
||||
79
sdk-go/request.go
Normal file
79
sdk-go/request.go
Normal 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
129
sdk-go/token_manager.go
Normal 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
|
||||
}
|
||||
169
sdk-go/token_manager_test.go
Normal file
169
sdk-go/token_manager_test.go
Normal 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
103
sdk-go/token_service.go
Normal 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
|
||||
}
|
||||
108
sdk-go/token_service_test.go
Normal file
108
sdk-go/token_service_test.go
Normal 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
131
sdk-go/types.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user