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