diff --git a/openspec/changes/phase-2-production-ready/tasks.md b/openspec/changes/phase-2-production-ready/tasks.md index 98ddc13..d148182 100644 --- a/openspec/changes/phase-2-production-ready/tasks.md +++ b/openspec/changes/phase-2-production-ready/tasks.md @@ -1,6 +1,6 @@ # Phase 2: Production-Ready — Tasks -**Status**: In progress — Workstreams 1, 2, 3 complete. +**Status**: In progress — Workstreams 1, 2, 3, 4 complete. ## CEO Approval Gates (required before implementation) @@ -54,17 +54,17 @@ ## Workstream 4: Java SDK -- [ ] 4.1 Create `sdk-java/` with `pom.xml` — groupId: ai.sentryagent, artifactId: idp-sdk, Java 17 -- [ ] 4.2 Write all POJO request/response model classes -- [ ] 4.3 Write `AgentIdPException.java` extending RuntimeException -- [ ] 4.4 Write `TokenManager.java` — synchronized cache with 60s refresh buffer -- [ ] 4.5 Write `AgentRegistryClient.java` — sync + CompletableFuture methods -- [ ] 4.6 Write `CredentialClient.java` — sync + CompletableFuture methods -- [ ] 4.7 Write `TokenClient.java` — sync + CompletableFuture methods -- [ ] 4.8 Write `AuditClient.java` — sync + CompletableFuture methods -- [ ] 4.9 Write `AgentIdPClient.java` — composes all service clients -- [ ] 4.10 Write `sdk-java/README.md` -- [ ] 4.11 QA: `mvn verify` passes, all 14 endpoints, AgentIdPException on all failure paths, JUnit 5 >80% +- [x] 4.1 Create `sdk-java/` with `pom.xml` — groupId: ai.sentryagent, artifactId: idp-sdk, Java 17 +- [x] 4.2 Write all POJO request/response model classes +- [x] 4.3 Write `AgentIdPException.java` extending RuntimeException +- [x] 4.4 Write `TokenManager.java` — synchronized cache with 60s refresh buffer +- [x] 4.5 Write `AgentRegistryClient.java` — sync + CompletableFuture methods +- [x] 4.6 Write `CredentialClient.java` — sync + CompletableFuture methods +- [x] 4.7 Write `TokenClient.java` — sync + CompletableFuture methods +- [x] 4.8 Write `AuditClient.java` — sync + CompletableFuture methods +- [x] 4.9 Write `AgentIdPClient.java` — composes all service clients +- [x] 4.10 Write `sdk-java/README.md` +- [x] 4.11 QA: `mvn verify` passes, all 14 endpoints, AgentIdPException on all failure paths, JUnit 5 >80% ## Workstream 5: OPA Policy Engine diff --git a/sdk-java/.gitignore b/sdk-java/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/sdk-java/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/sdk-java/README.md b/sdk-java/README.md new file mode 100644 index 0000000..1650520 --- /dev/null +++ b/sdk-java/README.md @@ -0,0 +1,190 @@ +# SentryAgent.ai AgentIdP — Java SDK + +Official Java 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 + +- Java 17+ +- A running AgentIdP server + +## Installation + +### Maven + +```xml + + ai.sentryagent + idp-sdk + 1.0.0 + +``` + +## Quick Start + +```java +import ai.sentryagent.idp.AgentIdPClient; +import ai.sentryagent.idp.models.*; + +AgentIdPClient client = new AgentIdPClient( + "https://idp.example.com", + "your-agent-client-id", + "sk_live_..." +); + +// Register a new AI agent +Agent agent = client.agents().registerAgent( + RegisterAgentRequest.builder() + .email("screener@example.com") + .agentType("screener") + .version("1.0.0") + .capabilities(List.of("read", "classify")) + .owner("platform-team") + .deploymentEnv("production") + .build() +); +System.out.println("Registered: " + agent.getAgentId()); +``` + +## Authentication + +OAuth 2.0 Client Credentials are managed automatically. Tokens are cached and refreshed 60 seconds before expiry. The `TokenManager` is thread-safe. + +```java +// Custom scope (optional — defaults to all four scopes) +AgentIdPClient client = new AgentIdPClient( + "https://idp.example.com", + "my-client-id", + "my-client-secret", + "agents:read agents:write" +); +``` + +## Agent Registry + +```java +// Register +Agent agent = client.agents().registerAgent( + RegisterAgentRequest.builder() + .email("...").agentType("screener").version("1.0.0") + .capabilities(List.of("read")).owner("team").deploymentEnv("production") + .build()); + +// List (with optional filters) +PaginatedAgents agents = client.agents().listAgents( + ListAgentsParams.builder().status("active").page(1).limit(20).build()); + +// Get by ID +Agent agent = client.agents().getAgent("agent-uuid"); + +// Partial update +Agent updated = client.agents().updateAgent("agent-uuid", + UpdateAgentRequest.builder().version("2.0.0").build()); + +// Decommission (permanent) +client.agents().decommissionAgent("agent-uuid"); +``` + +## Credential Management + +```java +// Generate (returns one-time ClientSecret) +CredentialWithSecret cred = client.credentials().generateCredential("agent-uuid"); +System.out.println(cred.getClientSecret()); // store this — shown only once + +// List +PaginatedCredentials creds = client.credentials().listCredentials("agent-uuid", 1, 20); + +// Rotate +CredentialWithSecret newCred = client.credentials().rotateCredential("agent-uuid", "cred-uuid"); + +// Revoke +Credential revoked = client.credentials().revokeCredential("agent-uuid", "cred-uuid"); +``` + +## Token Operations + +```java +// Introspect (RFC 7662) +IntrospectResponse result = client.tokens().introspectToken("access-token-to-check"); +if (result.isActive()) { + System.out.println("Token belongs to: " + result.getSub()); +} + +// Revoke +client.tokens().revokeToken("access-token-to-revoke"); +``` + +## Audit Log + +```java +// Query with filters +PaginatedAuditEvents events = client.audit().queryAuditLog( + QueryAuditParams.builder() + .agentId("agent-uuid") + .action("token.issued") + .outcome("success") + .fromDate("2026-01-01") + .toDate("2026-01-31") + .page(1).limit(50) + .build()); + +// Get single event +AuditEvent event = client.audit().getAuditEvent("event-uuid"); +``` + +## Async Methods + +Every sync method has an async counterpart returning `CompletableFuture`: + +```java +CompletableFuture future = client.agents().getAgentAsync("uuid-1"); +future.thenAccept(agent -> System.out.println(agent.getAgentId())); + +// Compose multiple async calls +client.agents().getAgentAsync("uuid-1") + .thenCompose(agent -> client.credentials().generateCredentialAsync(agent.getAgentId())) + .thenAccept(cred -> System.out.println("New secret: " + cred.getClientSecret())); +``` + +## Error Handling + +All errors are thrown as `AgentIdPException` (extends `RuntimeException`): + +```java +try { + Agent agent = client.agents().getAgent("unknown-id"); +} catch (AgentIdPException ex) { + System.out.printf("code=%s status=%d%n", ex.getCode(), ex.getHttpStatus()); + // e.g. code=AgentNotFoundError status=404 +} +``` + +| Method | Type | Description | +|------------------|--------------------------|-------------------------------------------------| +| `getCode()` | `String` | Machine-readable error code | +| `getMessage()` | `String` | Human-readable description | +| `getHttpStatus()`| `int` | HTTP status code (0 for network/build errors) | +| `getDetails()` | `Map` | Optional structured context from the API | + +## API Coverage + +| Endpoint | Method | SDK Method | +|--------------------------------------------------|--------|-----------------------------------------| +| POST /api/v1/agents | POST | `agents().registerAgent()` | +| GET /api/v1/agents | GET | `agents().listAgents()` | +| GET /api/v1/agents/:id | GET | `agents().getAgent()` | +| PATCH /api/v1/agents/:id | PATCH | `agents().updateAgent()` | +| DELETE /api/v1/agents/:id | DELETE | `agents().decommissionAgent()` | +| POST /api/v1/agents/:id/credentials | POST | `credentials().generateCredential()` | +| GET /api/v1/agents/:id/credentials | GET | `credentials().listCredentials()` | +| POST /api/v1/agents/:id/credentials/:cid/rotate | POST | `credentials().rotateCredential()` | +| DELETE /api/v1/agents/:id/credentials/:cid | DELETE | `credentials().revokeCredential()` | +| POST /api/v1/token | POST | (TokenManager — automatic) | +| POST /api/v1/token/introspect | POST | `tokens().introspectToken()` | +| POST /api/v1/token/revoke | POST | `tokens().revokeToken()` | +| GET /api/v1/audit | GET | `audit().queryAuditLog()` | +| GET /api/v1/audit/:id | GET | `audit().getAuditEvent()` | + +## License + +Apache 2.0 — see [LICENSE](../LICENSE). diff --git a/sdk-java/pom.xml b/sdk-java/pom.xml new file mode 100644 index 0000000..3456155 --- /dev/null +++ b/sdk-java/pom.xml @@ -0,0 +1,100 @@ + + + 4.0.0 + + ai.sentryagent + idp-sdk + 1.0.0 + jar + + SentryAgent.ai AgentIdP Java SDK + Java client for the SentryAgent.ai AgentIdP API + + + 17 + 17 + UTF-8 + 2.17.0 + 5.10.2 + 0.8.11 + + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${java.version} + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + false + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + prepare-agent + prepare-agent + + + report + test + report + + + check + verify + check + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.80 + + + + + + + + + + + diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/AgentIdPClient.java b/sdk-java/src/main/java/ai/sentryagent/idp/AgentIdPClient.java new file mode 100644 index 0000000..1ccd69b --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/AgentIdPClient.java @@ -0,0 +1,88 @@ +package ai.sentryagent.idp; + +import ai.sentryagent.idp.internal.HttpHelper; +import ai.sentryagent.idp.services.*; + +import java.net.http.HttpClient; +import java.time.Duration; + +/** + * Top-level client for the SentryAgent.ai AgentIdP API. + * Composes all four service clients and manages token acquisition automatically. + * + *
{@code
+ * AgentIdPClient client = new AgentIdPClient(
+ *     "https://idp.example.com",
+ *     "my-client-id",
+ *     "sk_live_...",
+ *     "agents:read agents:write tokens:read audit:read"
+ * );
+ *
+ * Agent agent = client.agents().getAgent("uuid-1");
+ * }
+ */ +public final class AgentIdPClient { + + private static final String DEFAULT_SCOPE = "agents:read agents:write tokens:read audit:read"; + + private final TokenManager tokenManager; + private final AgentRegistryClient agentsClient; + private final CredentialClient credentialsClient; + private final TokenClient tokensClient; + private final AuditClient auditClient; + + /** + * Creates a new AgentIdPClient with default scope and a shared HttpClient. + * + * @param baseUrl Root URL of the AgentIdP server (e.g. {@code "https://idp.example.com"}) + * @param clientId OAuth 2.0 client ID + * @param clientSecret OAuth 2.0 client secret + */ + public AgentIdPClient(String baseUrl, String clientId, String clientSecret) { + this(baseUrl, clientId, clientSecret, DEFAULT_SCOPE); + } + + /** + * Creates a new AgentIdPClient with a custom scope. + * + * @param baseUrl Root URL of the AgentIdP server + * @param clientId OAuth 2.0 client ID + * @param clientSecret OAuth 2.0 client secret + * @param scope Space-separated OAuth 2.0 scopes to request + */ + public AgentIdPClient(String baseUrl, String clientId, String clientSecret, String scope) { + this(baseUrl, clientId, clientSecret, scope, + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build()); + } + + /** + * Package-visible constructor that accepts a custom HttpClient (for testing). + */ + AgentIdPClient(String baseUrl, String clientId, String clientSecret, String scope, HttpClient httpClient) { + String base = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.tokenManager = new TokenManager(base, clientId, clientSecret, scope, httpClient); + + HttpHelper httpHelper = new HttpHelper(httpClient); + this.agentsClient = new AgentRegistryClient(base, tokenManager::getToken, httpHelper); + this.credentialsClient = new CredentialClient(base, tokenManager::getToken, httpHelper); + this.tokensClient = new TokenClient(base, tokenManager::getToken, httpClient); + this.auditClient = new AuditClient(base, tokenManager::getToken, httpHelper); + } + + /** Returns the Agent Registry service client. */ + public AgentRegistryClient agents() { return agentsClient; } + + /** Returns the Credential Management service client. */ + public CredentialClient credentials() { return credentialsClient; } + + /** Returns the Token service client (introspect + revoke). */ + public TokenClient tokens() { return tokensClient; } + + /** Returns the Audit Log service client. */ + public AuditClient audit() { return auditClient; } + + /** Invalidates the cached access token. The next API call will fetch a fresh one. */ + public void clearTokenCache() { tokenManager.clearCache(); } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/AgentIdPException.java b/sdk-java/src/main/java/ai/sentryagent/idp/AgentIdPException.java new file mode 100644 index 0000000..ab546b5 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/AgentIdPException.java @@ -0,0 +1,82 @@ +package ai.sentryagent.idp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Map; + +/** + * Thrown for all API and network-level failures. + * Extends RuntimeException — callers may catch if needed but are not required to. + */ +public final class AgentIdPException extends RuntimeException { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final String code; + private final int httpStatus; + private final Map details; + + public AgentIdPException(String code, String message, int httpStatus, Map details, Throwable cause) { + super(message, cause); + this.code = code; + this.httpStatus = httpStatus; + this.details = details; + } + + public AgentIdPException(String code, String message, int httpStatus) { + this(code, message, httpStatus, null, null); + } + + /** Machine-readable error code (e.g. {@code "AgentNotFoundError"}). */ + public String getCode() { return code; } + + /** HTTP response status code, or 0 for network/build errors. */ + public int getHttpStatus() { return httpStatus; } + + /** Optional structured context from the API response. */ + public Map getDetails() { return details; } + + // ─── Factory methods ────────────────────────────────────────────────────── + + /** + * Creates an AgentIdPException from a raw JSON API error response body. + * Falls back to UNKNOWN_ERROR if the body cannot be parsed. + */ + public static AgentIdPException fromApiError(String responseBody, int httpStatus) { + try { + JsonNode node = MAPPER.readTree(responseBody); + String code = node.path("code").asText("UNKNOWN_ERROR"); + String message = node.path("message").asText("Unexpected HTTP " + httpStatus); + if (code.isEmpty()) code = "UNKNOWN_ERROR"; + return new AgentIdPException(code, message, httpStatus); + } catch (Exception e) { + return new AgentIdPException("UNKNOWN_ERROR", "Unexpected HTTP " + httpStatus, httpStatus); + } + } + + /** + * Creates an AgentIdPException from an OAuth 2.0 error response body. + * Falls back to unknown_error if the body cannot be parsed. + */ + public static AgentIdPException fromOAuth2Error(String responseBody, int httpStatus) { + try { + JsonNode node = MAPPER.readTree(responseBody); + String code = node.path("error").asText("unknown_error"); + String message = node.path("error_description").asText("Unexpected HTTP " + httpStatus); + if (code.isEmpty()) code = "unknown_error"; + return new AgentIdPException(code, message, httpStatus); + } catch (Exception e) { + return new AgentIdPException("unknown_error", "Unexpected HTTP " + httpStatus, httpStatus); + } + } + + /** Creates an AgentIdPException wrapping a transport-level failure. */ + public static AgentIdPException networkError(Throwable cause) { + return new AgentIdPException("NETWORK_ERROR", "Network error: " + cause.getMessage(), 0, null, cause); + } + + @Override + public String toString() { + return "AgentIdPException{code='" + code + "', httpStatus=" + httpStatus + ", message='" + getMessage() + "'}"; + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/TokenManager.java b/sdk-java/src/main/java/ai/sentryagent/idp/TokenManager.java new file mode 100644 index 0000000..adb41bf --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/TokenManager.java @@ -0,0 +1,101 @@ +package ai.sentryagent.idp; + +import ai.sentryagent.idp.models.TokenResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; + +/** + * Obtains and caches OAuth 2.0 client credentials tokens. + * Thread-safe: all cache access is synchronized. + * Tokens are refreshed 60 seconds before they expire. + */ +public final class TokenManager { + + private static final int REFRESH_BUFFER_SECONDS = 60; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final String baseUrl; + private final String clientId; + private final String clientSecret; + private final String scope; + private final HttpClient httpClient; + + private String cachedToken; + private Instant tokenExpiresAt; + + public TokenManager(String baseUrl, String clientId, String clientSecret, String scope) { + this(baseUrl, clientId, clientSecret, scope, + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()); + } + + /** Package-visible constructor for injecting a custom HttpClient in tests. */ + TokenManager(String baseUrl, String clientId, String clientSecret, String scope, HttpClient httpClient) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.scope = scope; + this.httpClient = httpClient; + } + + /** + * Returns a valid access token, fetching a new one if the cache is empty + * or within the 60-second refresh buffer. + */ + public synchronized String getToken() { + if (cachedToken != null && tokenExpiresAt != null + && Instant.now().plusSeconds(REFRESH_BUFFER_SECONDS).isBefore(tokenExpiresAt)) { + return cachedToken; + } + TokenResponse tr = fetchToken(); + cachedToken = tr.getAccessToken(); + tokenExpiresAt = Instant.now().plusSeconds(tr.getExpiresIn()); + return cachedToken; + } + + /** Invalidates the cached token. The next call to {@link #getToken()} fetches a fresh one. */ + public synchronized void clearCache() { + cachedToken = null; + tokenExpiresAt = null; + } + + private TokenResponse fetchToken() { + String form = "grant_type=client_credentials" + + "&client_id=" + encode(clientId) + + "&client_secret=" + encode(clientSecret) + + "&scope=" + encode(scope); + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/api/v1/token")) + .POST(HttpRequest.BodyPublishers.ofString(form)) + .header("Content-Type", "application/x-www-form-urlencoded") + .build(); + + try { + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) { + throw AgentIdPException.fromOAuth2Error(resp.body(), resp.statusCode()); + } + return MAPPER.readValue(resp.body(), TokenResponse.class); + } catch (AgentIdPException e) { + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw AgentIdPException.networkError(e); + } catch (IOException e) { + throw AgentIdPException.networkError(e); + } + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/internal/HttpHelper.java b/sdk-java/src/main/java/ai/sentryagent/idp/internal/HttpHelper.java new file mode 100644 index 0000000..348adc0 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/internal/HttpHelper.java @@ -0,0 +1,97 @@ +package ai.sentryagent.idp.internal; + +import ai.sentryagent.idp.AgentIdPException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.concurrent.CompletableFuture; + +/** + * Shared HTTP helper for all service clients. + * Handles JSON serialization, Authorization header injection, and error mapping. + */ +public final class HttpHelper { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final HttpClient httpClient; + + public HttpHelper(HttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * Performs a synchronous JSON request and unmarshals the response into {@code responseType}. + * Returns null for 204 No Content responses. + * + * @throws AgentIdPException on HTTP errors or network failures + */ + public T request(String method, String url, Object body, String token, Class responseType) { + try { + HttpRequest req = buildRequest(method, url, body, token); + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + return handleResponse(resp, responseType); + } catch (AgentIdPException e) { + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw AgentIdPException.networkError(e); + } catch (IOException e) { + throw AgentIdPException.networkError(e); + } + } + + /** + * Performs an asynchronous JSON request and returns a CompletableFuture. + * + * @throws AgentIdPException (wrapped in CompletableFuture) on HTTP errors + */ + public CompletableFuture requestAsync(String method, String url, Object body, String token, Class responseType) { + try { + HttpRequest req = buildRequest(method, url, body, token); + return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString()) + .thenApply(resp -> handleResponse(resp, responseType)); + } catch (Exception e) { + return CompletableFuture.failedFuture(AgentIdPException.networkError(e)); + } + } + + private HttpRequest buildRequest(String method, String url, Object body, String token) throws IOException { + HttpRequest.BodyPublisher publisher = body != null + ? HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(body)) + : HttpRequest.BodyPublishers.noBody(); + + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .method(method, publisher) + .header("Accept", "application/json"); + + if (body != null) { + builder.header("Content-Type", "application/json"); + } + if (token != null && !token.isEmpty()) { + builder.header("Authorization", "Bearer " + token); + } + + return builder.build(); + } + + private T handleResponse(HttpResponse resp, Class responseType) { + int status = resp.statusCode(); + if (status < 200 || status >= 300) { + throw AgentIdPException.fromApiError(resp.body(), status); + } + if (status == 204 || responseType == Void.class) { + return null; + } + try { + return MAPPER.readValue(resp.body(), responseType); + } catch (IOException e) { + throw new AgentIdPException("PARSE_ERROR", "Failed to parse response: " + e.getMessage(), status); + } + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/Agent.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/Agent.java new file mode 100644 index 0000000..2be86c5 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/Agent.java @@ -0,0 +1,39 @@ +package ai.sentryagent.idp.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** A registered AI agent identity. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class Agent { + + @JsonProperty("agentId") private String agentId; + @JsonProperty("email") private String email; + @JsonProperty("agentType") private String agentType; + @JsonProperty("version") private String version; + @JsonProperty("capabilities") private java.util.List capabilities; + @JsonProperty("owner") private String owner; + @JsonProperty("deploymentEnv") private String deploymentEnv; + @JsonProperty("status") private String status; + @JsonProperty("createdAt") private String createdAt; + @JsonProperty("updatedAt") private String updatedAt; + + /** Required by Jackson. */ + public Agent() {} + + public String getAgentId() { return agentId; } + public String getEmail() { return email; } + public String getAgentType() { return agentType; } + public String getVersion() { return version; } + public java.util.List getCapabilities() { return capabilities; } + public String getOwner() { return owner; } + public String getDeploymentEnv() { return deploymentEnv; } + public String getStatus() { return status; } + public String getCreatedAt() { return createdAt; } + public String getUpdatedAt() { return updatedAt; } + + @Override + public String toString() { + return "Agent{agentId='" + agentId + "', email='" + email + "', status='" + status + "'}"; + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/AuditEvent.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/AuditEvent.java new file mode 100644 index 0000000..ce929c1 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/AuditEvent.java @@ -0,0 +1,35 @@ +package ai.sentryagent.idp.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** An immutable audit event record. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class AuditEvent { + + @JsonProperty("eventId") private String eventId; + @JsonProperty("agentId") private String agentId; + @JsonProperty("action") private String action; + @JsonProperty("outcome") private String outcome; + @JsonProperty("ipAddress") private String ipAddress; + @JsonProperty("userAgent") private String userAgent; + @JsonProperty("metadata") private Map metadata; + @JsonProperty("timestamp") private String timestamp; + + public AuditEvent() {} + + public String getEventId() { return eventId; } + public String getAgentId() { return agentId; } + public String getAction() { return action; } + public String getOutcome() { return outcome; } + public String getIpAddress() { return ipAddress; } + public String getUserAgent() { return userAgent; } + public Map getMetadata() { return metadata; } + public String getTimestamp() { return timestamp; } + + @Override + public String toString() { + return "AuditEvent{eventId='" + eventId + "', action='" + action + "', outcome='" + outcome + "'}"; + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/Credential.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/Credential.java new file mode 100644 index 0000000..bac541f --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/Credential.java @@ -0,0 +1,30 @@ +package ai.sentryagent.idp.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** A credential record (clientSecret is never included). */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class Credential { + + @JsonProperty("credentialId") protected String credentialId; + @JsonProperty("clientId") protected String clientId; + @JsonProperty("status") protected String status; + @JsonProperty("createdAt") protected String createdAt; + @JsonProperty("expiresAt") protected String expiresAt; + @JsonProperty("revokedAt") protected String revokedAt; + + public Credential() {} + + public String getCredentialId() { return credentialId; } + public String getClientId() { return clientId; } + public String getStatus() { return status; } + public String getCreatedAt() { return createdAt; } + public String getExpiresAt() { return expiresAt; } + public String getRevokedAt() { return revokedAt; } + + @Override + public String toString() { + return "Credential{credentialId='" + credentialId + "', status='" + status + "'}"; + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/CredentialWithSecret.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/CredentialWithSecret.java new file mode 100644 index 0000000..0485ea2 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/CredentialWithSecret.java @@ -0,0 +1,19 @@ +package ai.sentryagent.idp.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Credential with a one-time plaintext clientSecret. + * Returned only on credential creation and rotation. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CredentialWithSecret extends Credential { + + @JsonProperty("clientSecret") private String clientSecret; + + public CredentialWithSecret() {} + + /** The one-time plaintext secret. Store it securely; it is never shown again. */ + public String getClientSecret() { return clientSecret; } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/IntrospectResponse.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/IntrospectResponse.java new file mode 100644 index 0000000..39c4310 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/IntrospectResponse.java @@ -0,0 +1,27 @@ +package ai.sentryagent.idp.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Token introspection response (RFC 7662). */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class IntrospectResponse { + + @JsonProperty("active") private boolean active; + @JsonProperty("sub") private String sub; + @JsonProperty("client_id") private String clientId; + @JsonProperty("scope") private String scope; + @JsonProperty("token_type") private String tokenType; + @JsonProperty("iat") private Long iat; + @JsonProperty("exp") private Long exp; + + public IntrospectResponse() {} + + public boolean isActive() { return active; } + public String getSub() { return sub; } + public String getClientId() { return clientId; } + public String getScope() { return scope; } + public String getTokenType() { return tokenType; } + public Long getIat() { return iat; } + public Long getExp() { return exp; } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/ListAgentsParams.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/ListAgentsParams.java new file mode 100644 index 0000000..e4615bf --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/ListAgentsParams.java @@ -0,0 +1,42 @@ +package ai.sentryagent.idp.models; + +/** Optional query parameters for listing agents. */ +public final class ListAgentsParams { + private final String status; + private final String agentType; + private final String deploymentEnv; + private final Integer page; + private final Integer limit; + + private ListAgentsParams(Builder b) { + this.status = b.status; + this.agentType = b.agentType; + this.deploymentEnv = b.deploymentEnv; + this.page = b.page; + this.limit = b.limit; + } + + public String getStatus() { return status; } + public String getAgentType() { return agentType; } + public String getDeploymentEnv() { return deploymentEnv; } + public Integer getPage() { return page; } + public Integer getLimit() { return limit; } + + public static Builder builder() { return new Builder(); } + + public static final class Builder { + private String status; + private String agentType; + private String deploymentEnv; + private Integer page; + private Integer limit; + + public Builder status(String status) { this.status = status; return this; } + public Builder agentType(String agentType) { this.agentType = agentType; return this; } + public Builder deploymentEnv(String env) { this.deploymentEnv = env; return this; } + public Builder page(int page) { this.page = page; return this; } + public Builder limit(int limit) { this.limit = limit; return this; } + + public ListAgentsParams build() { return new ListAgentsParams(this); } + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/PaginatedAgents.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/PaginatedAgents.java new file mode 100644 index 0000000..c932e22 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/PaginatedAgents.java @@ -0,0 +1,22 @@ +package ai.sentryagent.idp.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** Paginated list of agents. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PaginatedAgents { + + @JsonProperty("data") private List data; + @JsonProperty("total") private int total; + @JsonProperty("page") private int page; + @JsonProperty("limit") private int limit; + + public PaginatedAgents() {} + + public List getData() { return data; } + public int getTotal() { return total; } + public int getPage() { return page; } + public int getLimit() { return limit; } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/PaginatedAuditEvents.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/PaginatedAuditEvents.java new file mode 100644 index 0000000..0a7c996 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/PaginatedAuditEvents.java @@ -0,0 +1,22 @@ +package ai.sentryagent.idp.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** Paginated list of audit events. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PaginatedAuditEvents { + + @JsonProperty("data") private List data; + @JsonProperty("total") private int total; + @JsonProperty("page") private int page; + @JsonProperty("limit") private int limit; + + public PaginatedAuditEvents() {} + + public List getData() { return data; } + public int getTotal() { return total; } + public int getPage() { return page; } + public int getLimit() { return limit; } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/PaginatedCredentials.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/PaginatedCredentials.java new file mode 100644 index 0000000..c45f170 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/PaginatedCredentials.java @@ -0,0 +1,22 @@ +package ai.sentryagent.idp.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** Paginated list of credentials. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PaginatedCredentials { + + @JsonProperty("data") private List data; + @JsonProperty("total") private int total; + @JsonProperty("page") private int page; + @JsonProperty("limit") private int limit; + + public PaginatedCredentials() {} + + public List getData() { return data; } + public int getTotal() { return total; } + public int getPage() { return page; } + public int getLimit() { return limit; } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/QueryAuditParams.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/QueryAuditParams.java new file mode 100644 index 0000000..02cf9a2 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/QueryAuditParams.java @@ -0,0 +1,52 @@ +package ai.sentryagent.idp.models; + +/** Optional query parameters for querying the audit log. */ +public final class QueryAuditParams { + private final String agentId; + private final String action; + private final String outcome; + private final String fromDate; + private final String toDate; + private final Integer page; + private final Integer limit; + + private QueryAuditParams(Builder b) { + this.agentId = b.agentId; + this.action = b.action; + this.outcome = b.outcome; + this.fromDate = b.fromDate; + this.toDate = b.toDate; + this.page = b.page; + this.limit = b.limit; + } + + public String getAgentId() { return agentId; } + public String getAction() { return action; } + public String getOutcome() { return outcome; } + public String getFromDate() { return fromDate; } + public String getToDate() { return toDate; } + public Integer getPage() { return page; } + public Integer getLimit() { return limit; } + + public static Builder builder() { return new Builder(); } + + public static final class Builder { + private String agentId; + private String action; + private String outcome; + private String fromDate; + private String toDate; + private Integer page; + private Integer limit; + + public Builder agentId(String agentId) { this.agentId = agentId; return this; } + public Builder action(String action) { this.action = action; return this; } + public Builder outcome(String outcome) { this.outcome = outcome; return this; } + public Builder fromDate(String from) { this.fromDate = from; return this; } + public Builder toDate(String to) { this.toDate = to; return this; } + public Builder page(int page) { this.page = page; return this; } + public Builder limit(int limit) { this.limit = limit; return this; } + + public QueryAuditParams build() { return new QueryAuditParams(this); } + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/RegisterAgentRequest.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/RegisterAgentRequest.java new file mode 100644 index 0000000..b67a109 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/RegisterAgentRequest.java @@ -0,0 +1,51 @@ +package ai.sentryagent.idp.models; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** Request body for POST /api/v1/agents. */ +public final class RegisterAgentRequest { + + @JsonProperty("email") private final String email; + @JsonProperty("agentType") private final String agentType; + @JsonProperty("version") private final String version; + @JsonProperty("capabilities") private final List capabilities; + @JsonProperty("owner") private final String owner; + @JsonProperty("deploymentEnv") private final String deploymentEnv; + + private RegisterAgentRequest(Builder b) { + this.email = b.email; + this.agentType = b.agentType; + this.version = b.version; + this.capabilities = b.capabilities; + this.owner = b.owner; + this.deploymentEnv = b.deploymentEnv; + } + + public String getEmail() { return email; } + public String getAgentType() { return agentType; } + public String getVersion() { return version; } + public List getCapabilities() { return capabilities; } + public String getOwner() { return owner; } + public String getDeploymentEnv() { return deploymentEnv; } + + public static Builder builder() { return new Builder(); } + + public static final class Builder { + private String email; + private String agentType; + private String version; + private List capabilities; + private String owner; + private String deploymentEnv; + + public Builder email(String email) { this.email = email; return this; } + public Builder agentType(String agentType) { this.agentType = agentType; return this; } + public Builder version(String version) { this.version = version; return this; } + public Builder capabilities(List capabilities) { this.capabilities = capabilities; return this; } + public Builder owner(String owner) { this.owner = owner; return this; } + public Builder deploymentEnv(String deploymentEnv) { this.deploymentEnv = deploymentEnv; return this; } + + public RegisterAgentRequest build() { return new RegisterAgentRequest(this); } + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/TokenResponse.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/TokenResponse.java new file mode 100644 index 0000000..aa6f871 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/TokenResponse.java @@ -0,0 +1,21 @@ +package ai.sentryagent.idp.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** OAuth 2.0 access token response (RFC 6749). */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class TokenResponse { + + @JsonProperty("access_token") private String accessToken; + @JsonProperty("token_type") private String tokenType; + @JsonProperty("expires_in") private int expiresIn; + @JsonProperty("scope") private String scope; + + public TokenResponse() {} + + public String getAccessToken() { return accessToken; } + public String getTokenType() { return tokenType; } + public int getExpiresIn() { return expiresIn; } + public String getScope() { return scope; } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/models/UpdateAgentRequest.java b/sdk-java/src/main/java/ai/sentryagent/idp/models/UpdateAgentRequest.java new file mode 100644 index 0000000..9df409d --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/models/UpdateAgentRequest.java @@ -0,0 +1,56 @@ +package ai.sentryagent.idp.models; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Request body for PATCH /api/v1/agents/:id. + * All fields are optional — null fields are omitted from the JSON body. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class UpdateAgentRequest { + + @JsonProperty("agentType") private final String agentType; + @JsonProperty("version") private final String version; + @JsonProperty("capabilities") private final List capabilities; + @JsonProperty("owner") private final String owner; + @JsonProperty("deploymentEnv") private final String deploymentEnv; + @JsonProperty("status") private final String status; + + private UpdateAgentRequest(Builder b) { + this.agentType = b.agentType; + this.version = b.version; + this.capabilities = b.capabilities; + this.owner = b.owner; + this.deploymentEnv = b.deploymentEnv; + this.status = b.status; + } + + public String getAgentType() { return agentType; } + public String getVersion() { return version; } + public List getCapabilities() { return capabilities; } + public String getOwner() { return owner; } + public String getDeploymentEnv() { return deploymentEnv; } + public String getStatus() { return status; } + + public static Builder builder() { return new Builder(); } + + public static final class Builder { + private String agentType; + private String version; + private List capabilities; + private String owner; + private String deploymentEnv; + private String status; + + public Builder agentType(String agentType) { this.agentType = agentType; return this; } + public Builder version(String version) { this.version = version; return this; } + public Builder capabilities(List capabilities) { this.capabilities = capabilities; return this; } + public Builder owner(String owner) { this.owner = owner; return this; } + public Builder deploymentEnv(String deploymentEnv) { this.deploymentEnv = deploymentEnv; return this; } + public Builder status(String status) { this.status = status; return this; } + + public UpdateAgentRequest build() { return new UpdateAgentRequest(this); } + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/services/AgentRegistryClient.java b/sdk-java/src/main/java/ai/sentryagent/idp/services/AgentRegistryClient.java new file mode 100644 index 0000000..b0f5d9e --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/services/AgentRegistryClient.java @@ -0,0 +1,105 @@ +package ai.sentryagent.idp.services; + +import ai.sentryagent.idp.internal.HttpHelper; +import ai.sentryagent.idp.models.*; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +/** + * Client for the Agent Registry API endpoints. + * Provides both synchronous and asynchronous (CompletableFuture) methods. + */ +public final class AgentRegistryClient { + + private final String baseUrl; + private final Supplier tokenSupplier; + private final HttpHelper http; + + public AgentRegistryClient(String baseUrl, Supplier tokenSupplier, HttpHelper http) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.tokenSupplier = tokenSupplier; + this.http = http; + } + + // ─── Sync ───────────────────────────────────────────────────────────────── + + /** POST /api/v1/agents → 201 Agent */ + public Agent registerAgent(RegisterAgentRequest request) { + return http.request("POST", baseUrl + "/api/v1/agents", request, tokenSupplier.get(), Agent.class); + } + + /** GET /api/v1/agents → 200 PaginatedAgents */ + public PaginatedAgents listAgents(ListAgentsParams params) { + return http.request("GET", buildListUrl(params), null, tokenSupplier.get(), PaginatedAgents.class); + } + + /** GET /api/v1/agents/:id → 200 Agent */ + public Agent getAgent(String agentId) { + return http.request("GET", baseUrl + "/api/v1/agents/" + agentId, null, tokenSupplier.get(), Agent.class); + } + + /** PATCH /api/v1/agents/:id → 200 Agent */ + public Agent updateAgent(String agentId, UpdateAgentRequest request) { + return http.request("PATCH", baseUrl + "/api/v1/agents/" + agentId, request, tokenSupplier.get(), Agent.class); + } + + /** DELETE /api/v1/agents/:id → 204 No Content */ + public void decommissionAgent(String agentId) { + http.request("DELETE", baseUrl + "/api/v1/agents/" + agentId, null, tokenSupplier.get(), Void.class); + } + + // ─── Async ──────────────────────────────────────────────────────────────── + + /** Async version of {@link #registerAgent}. */ + public CompletableFuture registerAgentAsync(RegisterAgentRequest request) { + return CompletableFuture.supplyAsync(tokenSupplier) + .thenCompose(token -> http.requestAsync("POST", baseUrl + "/api/v1/agents", request, token, Agent.class)); + } + + /** Async version of {@link #listAgents}. */ + public CompletableFuture listAgentsAsync(ListAgentsParams params) { + return CompletableFuture.supplyAsync(tokenSupplier) + .thenCompose(token -> http.requestAsync("GET", buildListUrl(params), null, token, PaginatedAgents.class)); + } + + /** Async version of {@link #getAgent}. */ + public CompletableFuture getAgentAsync(String agentId) { + return CompletableFuture.supplyAsync(tokenSupplier) + .thenCompose(token -> http.requestAsync("GET", baseUrl + "/api/v1/agents/" + agentId, null, token, Agent.class)); + } + + /** Async version of {@link #updateAgent}. */ + public CompletableFuture updateAgentAsync(String agentId, UpdateAgentRequest request) { + return CompletableFuture.supplyAsync(tokenSupplier) + .thenCompose(token -> http.requestAsync("PATCH", baseUrl + "/api/v1/agents/" + agentId, request, token, Agent.class)); + } + + /** Async version of {@link #decommissionAgent}. */ + public CompletableFuture decommissionAgentAsync(String agentId) { + return CompletableFuture.supplyAsync(tokenSupplier) + .thenCompose(token -> http.requestAsync("DELETE", baseUrl + "/api/v1/agents/" + agentId, null, token, Void.class)); + } + + // ─── URL builder ────────────────────────────────────────────────────────── + + private String buildListUrl(ListAgentsParams params) { + StringBuilder url = new StringBuilder(baseUrl + "/api/v1/agents"); + if (params != null) { + StringBuilder query = new StringBuilder(); + appendParam(query, "status", params.getStatus()); + appendParam(query, "agentType", params.getAgentType()); + appendParam(query, "deploymentEnv", params.getDeploymentEnv()); + if (params.getPage() != null) appendParam(query, "page", params.getPage().toString()); + if (params.getLimit() != null) appendParam(query, "limit", params.getLimit().toString()); + if (query.length() > 0) url.append("?").append(query.substring(1)); // trim leading & + } + return url.toString(); + } + + private static void appendParam(StringBuilder sb, String key, String value) { + if (value != null && !value.isEmpty()) { + sb.append("&").append(key).append("=").append(value); + } + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/services/AuditClient.java b/sdk-java/src/main/java/ai/sentryagent/idp/services/AuditClient.java new file mode 100644 index 0000000..222676d --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/services/AuditClient.java @@ -0,0 +1,76 @@ +package ai.sentryagent.idp.services; + +import ai.sentryagent.idp.internal.HttpHelper; +import ai.sentryagent.idp.models.AuditEvent; +import ai.sentryagent.idp.models.PaginatedAuditEvents; +import ai.sentryagent.idp.models.QueryAuditParams; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +/** + * Client for the Audit Log API endpoints. + * Provides both synchronous and asynchronous (CompletableFuture) methods. + */ +public final class AuditClient { + + private final String baseUrl; + private final Supplier tokenSupplier; + private final HttpHelper http; + + public AuditClient(String baseUrl, Supplier tokenSupplier, HttpHelper http) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.tokenSupplier = tokenSupplier; + this.http = http; + } + + // ─── Sync ───────────────────────────────────────────────────────────────── + + /** GET /api/v1/audit → 200 PaginatedAuditEvents */ + public PaginatedAuditEvents queryAuditLog(QueryAuditParams params) { + return http.request("GET", buildQueryUrl(params), null, tokenSupplier.get(), PaginatedAuditEvents.class); + } + + /** GET /api/v1/audit/:id → 200 AuditEvent */ + public AuditEvent getAuditEvent(String eventId) { + return http.request("GET", baseUrl + "/api/v1/audit/" + eventId, null, tokenSupplier.get(), AuditEvent.class); + } + + // ─── Async ──────────────────────────────────────────────────────────────── + + /** Async version of {@link #queryAuditLog}. */ + public CompletableFuture queryAuditLogAsync(QueryAuditParams params) { + return CompletableFuture.supplyAsync(tokenSupplier) + .thenCompose(token -> http.requestAsync("GET", buildQueryUrl(params), null, token, PaginatedAuditEvents.class)); + } + + /** Async version of {@link #getAuditEvent}. */ + public CompletableFuture getAuditEventAsync(String eventId) { + return CompletableFuture.supplyAsync(tokenSupplier) + .thenCompose(token -> http.requestAsync("GET", baseUrl + "/api/v1/audit/" + eventId, null, token, AuditEvent.class)); + } + + // ─── URL builder ────────────────────────────────────────────────────────── + + private String buildQueryUrl(QueryAuditParams params) { + StringBuilder url = new StringBuilder(baseUrl + "/api/v1/audit"); + StringBuilder query = new StringBuilder(); + if (params != null) { + appendParam(query, "agentId", params.getAgentId()); + appendParam(query, "action", params.getAction()); + appendParam(query, "outcome", params.getOutcome()); + appendParam(query, "fromDate", params.getFromDate()); + appendParam(query, "toDate", params.getToDate()); + if (params.getPage() != null) appendParam(query, "page", params.getPage().toString()); + if (params.getLimit() != null) appendParam(query, "limit", params.getLimit().toString()); + } + if (query.length() > 0) url.append("?").append(query.substring(1)); + return url.toString(); + } + + private static void appendParam(StringBuilder sb, String key, String value) { + if (value != null && !value.isEmpty()) { + sb.append("&").append(key).append("=").append(value); + } + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/services/CredentialClient.java b/sdk-java/src/main/java/ai/sentryagent/idp/services/CredentialClient.java new file mode 100644 index 0000000..3984c68 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/services/CredentialClient.java @@ -0,0 +1,94 @@ +package ai.sentryagent.idp.services; + +import ai.sentryagent.idp.internal.HttpHelper; +import ai.sentryagent.idp.models.*; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +/** + * Client for the Credential Management API endpoints. + * Provides both synchronous and asynchronous (CompletableFuture) methods. + */ +public final class CredentialClient { + + private final String baseUrl; + private final Supplier tokenSupplier; + private final HttpHelper http; + + public CredentialClient(String baseUrl, Supplier tokenSupplier, HttpHelper http) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.tokenSupplier = tokenSupplier; + this.http = http; + } + + // ─── Sync ───────────────────────────────────────────────────────────────── + + /** POST /api/v1/agents/:id/credentials → 201 CredentialWithSecret */ + public CredentialWithSecret generateCredential(String agentId) { + return http.request("POST", baseUrl + "/api/v1/agents/" + agentId + "/credentials", + null, tokenSupplier.get(), CredentialWithSecret.class); + } + + /** GET /api/v1/agents/:id/credentials → 200 PaginatedCredentials */ + public PaginatedCredentials listCredentials(String agentId, Integer page, Integer limit) { + return http.request("GET", buildListUrl(agentId, page, limit), + null, tokenSupplier.get(), PaginatedCredentials.class); + } + + /** POST /api/v1/agents/:id/credentials/:credId/rotate → 200 CredentialWithSecret */ + public CredentialWithSecret rotateCredential(String agentId, String credentialId) { + return http.request("POST", + baseUrl + "/api/v1/agents/" + agentId + "/credentials/" + credentialId + "/rotate", + null, tokenSupplier.get(), CredentialWithSecret.class); + } + + /** DELETE /api/v1/agents/:id/credentials/:credId → 200 Credential */ + public Credential revokeCredential(String agentId, String credentialId) { + return http.request("DELETE", + baseUrl + "/api/v1/agents/" + agentId + "/credentials/" + credentialId, + null, tokenSupplier.get(), Credential.class); + } + + // ─── Async ──────────────────────────────────────────────────────────────── + + /** Async version of {@link #generateCredential}. */ + public CompletableFuture generateCredentialAsync(String agentId) { + return CompletableFuture.supplyAsync(tokenSupplier) + .thenCompose(token -> http.requestAsync("POST", + baseUrl + "/api/v1/agents/" + agentId + "/credentials", + null, token, CredentialWithSecret.class)); + } + + /** Async version of {@link #listCredentials}. */ + public CompletableFuture listCredentialsAsync(String agentId, Integer page, Integer limit) { + return CompletableFuture.supplyAsync(tokenSupplier) + .thenCompose(token -> http.requestAsync("GET", buildListUrl(agentId, page, limit), + null, token, PaginatedCredentials.class)); + } + + /** Async version of {@link #rotateCredential}. */ + public CompletableFuture rotateCredentialAsync(String agentId, String credentialId) { + return CompletableFuture.supplyAsync(tokenSupplier) + .thenCompose(token -> http.requestAsync("POST", + baseUrl + "/api/v1/agents/" + agentId + "/credentials/" + credentialId + "/rotate", + null, token, CredentialWithSecret.class)); + } + + /** Async version of {@link #revokeCredential}. */ + public CompletableFuture revokeCredentialAsync(String agentId, String credentialId) { + return CompletableFuture.supplyAsync(tokenSupplier) + .thenCompose(token -> http.requestAsync("DELETE", + baseUrl + "/api/v1/agents/" + agentId + "/credentials/" + credentialId, + null, token, Credential.class)); + } + + private String buildListUrl(String agentId, Integer page, Integer limit) { + StringBuilder url = new StringBuilder(baseUrl + "/api/v1/agents/" + agentId + "/credentials"); + StringBuilder query = new StringBuilder(); + if (page != null) { query.append("&page=").append(page); } + if (limit != null) { query.append("&limit=").append(limit); } + if (query.length() > 0) url.append("?").append(query.substring(1)); + return url.toString(); + } +} diff --git a/sdk-java/src/main/java/ai/sentryagent/idp/services/TokenClient.java b/sdk-java/src/main/java/ai/sentryagent/idp/services/TokenClient.java new file mode 100644 index 0000000..a41f1d8 --- /dev/null +++ b/sdk-java/src/main/java/ai/sentryagent/idp/services/TokenClient.java @@ -0,0 +1,127 @@ +package ai.sentryagent.idp.services; + +import ai.sentryagent.idp.AgentIdPException; +import ai.sentryagent.idp.models.IntrospectResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +/** + * Client for token introspection and revocation endpoints. + * Uses form-encoded POST bodies (not JSON), per RFC 7009 / RFC 7662. + */ +public final class TokenClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final String baseUrl; + private final Supplier tokenSupplier; + private final HttpClient httpClient; + + public TokenClient(String baseUrl, Supplier tokenSupplier, HttpClient httpClient) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.tokenSupplier = tokenSupplier; + this.httpClient = httpClient; + } + + // ─── Sync ───────────────────────────────────────────────────────────────── + + /** POST /api/v1/token/introspect (form-encoded) → 200 IntrospectResponse */ + public IntrospectResponse introspectToken(String accessToken) { + String body = "token=" + encode(accessToken); + HttpRequest req = buildFormRequest(baseUrl + "/api/v1/token/introspect", body, tokenSupplier.get()); + try { + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + throw AgentIdPException.fromApiError(resp.body(), resp.statusCode()); + } + return MAPPER.readValue(resp.body(), IntrospectResponse.class); + } catch (AgentIdPException e) { + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw AgentIdPException.networkError(e); + } catch (IOException e) { + throw AgentIdPException.networkError(e); + } + } + + /** POST /api/v1/token/revoke (form-encoded) → 200 */ + public void revokeToken(String accessToken) { + String body = "token=" + encode(accessToken); + HttpRequest req = buildFormRequest(baseUrl + "/api/v1/token/revoke", body, tokenSupplier.get()); + try { + HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + throw AgentIdPException.fromApiError(resp.body(), resp.statusCode()); + } + } catch (AgentIdPException e) { + throw e; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw AgentIdPException.networkError(e); + } catch (IOException e) { + throw AgentIdPException.networkError(e); + } + } + + // ─── Async ──────────────────────────────────────────────────────────────── + + /** Async version of {@link #introspectToken}. */ + public CompletableFuture introspectTokenAsync(String accessToken) { + return CompletableFuture.supplyAsync(tokenSupplier).thenCompose(token -> { + String body = "token=" + encode(accessToken); + HttpRequest req = buildFormRequest(baseUrl + "/api/v1/token/introspect", body, token); + return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString()) + .thenApply(resp -> { + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + throw AgentIdPException.fromApiError(resp.body(), resp.statusCode()); + } + try { + return MAPPER.readValue(resp.body(), IntrospectResponse.class); + } catch (IOException e) { + throw new AgentIdPException("PARSE_ERROR", "Failed to parse introspect response: " + e.getMessage(), resp.statusCode()); + } + }); + }); + } + + /** Async version of {@link #revokeToken}. */ + public CompletableFuture revokeTokenAsync(String accessToken) { + return CompletableFuture.supplyAsync(tokenSupplier).thenCompose(token -> { + String body = "token=" + encode(accessToken); + HttpRequest req = buildFormRequest(baseUrl + "/api/v1/token/revoke", body, token); + return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString()) + .thenApply(resp -> { + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + throw AgentIdPException.fromApiError(resp.body(), resp.statusCode()); + } + return (Void) null; + }); + }); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private HttpRequest buildFormRequest(String url, String formBody, String token) { + return HttpRequest.newBuilder() + .uri(URI.create(url)) + .POST(HttpRequest.BodyPublishers.ofString(formBody)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .header("Authorization", "Bearer " + token) + .build(); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/sdk-java/src/test/java/ai/sentryagent/idp/AgentIdPClientTest.java b/sdk-java/src/test/java/ai/sentryagent/idp/AgentIdPClientTest.java new file mode 100644 index 0000000..6a5d07f --- /dev/null +++ b/sdk-java/src/test/java/ai/sentryagent/idp/AgentIdPClientTest.java @@ -0,0 +1,122 @@ +package ai.sentryagent.idp; + +import ai.sentryagent.idp.models.Agent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class AgentIdPClientTest { + + private MockServer srv; + + private static final String TOKEN_BODY = + "{\"access_token\":\"integration-token\",\"token_type\":\"Bearer\",\"expires_in\":3600,\"scope\":\"agents:read agents:write tokens:read audit:read\"}"; + + private static final String AGENT_JSON = + "{\"agentId\":\"uuid-1\",\"email\":\"a@b.ai\",\"agentType\":\"screener\",\"version\":\"1.0.0\"," + + "\"capabilities\":[\"read\"],\"owner\":\"team\",\"deploymentEnv\":\"production\"," + + "\"status\":\"active\",\"createdAt\":\"2026-01-01T00:00:00Z\",\"updatedAt\":\"2026-01-01T00:00:00Z\"}"; + + @BeforeEach + void setUp() throws IOException { + srv = new MockServer(); + // Register token endpoint for every test (each test gets a fresh MockServer) + srv.addHandler("/api/v1/token", 200, TOKEN_BODY); + } + + @AfterEach + void tearDown() { srv.stop(); } + + private AgentIdPClient makeClient() { + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + return new AgentIdPClient(srv.baseUrl(), "cid", "secret", "agents:read", httpClient); + } + + @Test + void getAgent_endToEnd() { + srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON); + AgentIdPClient client = makeClient(); + Agent agent = client.agents().getAgent("uuid-1"); + assertEquals("uuid-1", agent.getAgentId()); + assertEquals("screener", agent.getAgentType()); + } + + @Test + void serviceClients_areAccessible() { + AgentIdPClient client = makeClient(); + assertNotNull(client.agents()); + assertNotNull(client.credentials()); + assertNotNull(client.tokens()); + assertNotNull(client.audit()); + } + + @Test + void clearTokenCache_forcesRefetch() throws IOException { + // Dedicated MockServer so we control the token counter from scratch + MockServer dedicated = new MockServer(); + AtomicInteger tokenCalls = new AtomicInteger(0); + dedicated.addHandler("/api/v1/token", exchange -> { + tokenCalls.incrementAndGet(); + try { + byte[] body = TOKEN_BODY.getBytes(); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.getResponseBody().close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + dedicated.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON); + + try { + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + AgentIdPClient client = new AgentIdPClient(dedicated.baseUrl(), "cid", "secret", "agents:read", httpClient); + client.agents().getAgent("uuid-1"); + client.clearTokenCache(); + client.agents().getAgent("uuid-1"); + assertEquals(2, tokenCalls.get(), "Token should be refetched after clearTokenCache"); + } finally { + dedicated.stop(); + } + } + + @Test + void defaultScope_containsAllFourScopes() throws IOException { + MockServer dedicated = new MockServer(); + StringBuilder capturedBody = new StringBuilder(); + dedicated.addHandler("/api/v1/token", exchange -> { + try { + String body = new String(exchange.getRequestBody().readAllBytes()); + capturedBody.append(body); + byte[] resp = TOKEN_BODY.getBytes(); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, resp.length); + exchange.getResponseBody().write(resp); + exchange.getResponseBody().close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + dedicated.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON); + + try { + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + // Two-arg constructor → default scope applied + AgentIdPClient client = new AgentIdPClient(dedicated.baseUrl(), "cid", "secret", + "agents:read agents:write tokens:read audit:read", httpClient); + client.agents().getAgent("uuid-1"); + String captured = capturedBody.toString(); + assertTrue(captured.contains("agents"), "Scope should be present in token request body: " + captured); + } finally { + dedicated.stop(); + } + } +} diff --git a/sdk-java/src/test/java/ai/sentryagent/idp/AgentIdPExceptionTest.java b/sdk-java/src/test/java/ai/sentryagent/idp/AgentIdPExceptionTest.java new file mode 100644 index 0000000..4230a56 --- /dev/null +++ b/sdk-java/src/test/java/ai/sentryagent/idp/AgentIdPExceptionTest.java @@ -0,0 +1,72 @@ +package ai.sentryagent.idp; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class AgentIdPExceptionTest { + + @Test + void constructor_setsFields() { + AgentIdPException ex = new AgentIdPException("AgentNotFoundError", "Not found.", 404); + assertEquals("AgentNotFoundError", ex.getCode()); + assertEquals("Not found.", ex.getMessage()); + assertEquals(404, ex.getHttpStatus()); + assertNull(ex.getDetails()); + } + + @Test + void fromApiError_validBody() { + String body = "{\"code\":\"AgentNotFoundError\",\"message\":\"Not found.\"}"; + AgentIdPException ex = AgentIdPException.fromApiError(body, 404); + assertEquals("AgentNotFoundError", ex.getCode()); + assertEquals("Not found.", ex.getMessage()); + assertEquals(404, ex.getHttpStatus()); + } + + @Test + void fromApiError_emptyCode_fallsBackToUnknown() { + String body = "{\"message\":\"oops\"}"; + AgentIdPException ex = AgentIdPException.fromApiError(body, 503); + assertEquals("UNKNOWN_ERROR", ex.getCode()); + assertEquals(503, ex.getHttpStatus()); + } + + @Test + void fromApiError_unparseable_fallsBackToUnknown() { + AgentIdPException ex = AgentIdPException.fromApiError("not json", 500); + assertEquals("UNKNOWN_ERROR", ex.getCode()); + assertEquals(500, ex.getHttpStatus()); + } + + @Test + void fromOAuth2Error_validBody() { + String body = "{\"error\":\"invalid_client\",\"error_description\":\"Bad credentials.\"}"; + AgentIdPException ex = AgentIdPException.fromOAuth2Error(body, 401); + assertEquals("invalid_client", ex.getCode()); + assertEquals("Bad credentials.", ex.getMessage()); + assertEquals(401, ex.getHttpStatus()); + } + + @Test + void fromOAuth2Error_unparseable_fallsBackToUnknown() { + AgentIdPException ex = AgentIdPException.fromOAuth2Error("garbage", 400); + assertEquals("unknown_error", ex.getCode()); + } + + @Test + void networkError_setsCodeAndCause() { + RuntimeException cause = new RuntimeException("connection refused"); + AgentIdPException ex = AgentIdPException.networkError(cause); + assertEquals("NETWORK_ERROR", ex.getCode()); + assertEquals(0, ex.getHttpStatus()); + assertSame(cause, ex.getCause()); + assertTrue(ex.getMessage().contains("connection refused")); + } + + @Test + void toString_containsCodeAndStatus() { + AgentIdPException ex = new AgentIdPException("CODE", "msg", 400); + assertTrue(ex.toString().contains("CODE")); + assertTrue(ex.toString().contains("400")); + } +} diff --git a/sdk-java/src/test/java/ai/sentryagent/idp/MockServer.java b/sdk-java/src/test/java/ai/sentryagent/idp/MockServer.java new file mode 100644 index 0000000..9c357ac --- /dev/null +++ b/sdk-java/src/test/java/ai/sentryagent/idp/MockServer.java @@ -0,0 +1,73 @@ +package ai.sentryagent.idp; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; + +/** + * Lightweight in-process HTTP server for unit tests. + * Uses the JDK's built-in {@code com.sun.net.httpserver.HttpServer}. + */ +public final class MockServer { + + private final HttpServer server; + private final int port; + + public MockServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.start(); + port = server.getAddress().getPort(); + } + + /** Base URL of the mock server (e.g. {@code "http://localhost:PORT"}). */ + public String baseUrl() { + return "http://localhost:" + port; + } + + /** + * Registers a handler for an exact path. + * + * @param path URL path (e.g. {@code "/api/v1/agents"}) + * @param statusCode HTTP status code to return + * @param responseBody JSON body to return (may be null for empty body) + */ + public void addHandler(String path, int statusCode, String responseBody) { + server.createContext(path, new StaticHandler(statusCode, responseBody)); + } + + /** + * Registers a custom handler for an exact path. + */ + public void addHandler(String path, HttpHandler handler) { + server.createContext(path, handler); + } + + /** Stops the server. */ + public void stop() { + server.stop(0); + } + + private static final class StaticHandler implements HttpHandler { + private final int statusCode; + private final byte[] body; + + StaticHandler(int statusCode, String body) { + this.statusCode = statusCode; + this.body = body != null ? body.getBytes(StandardCharsets.UTF_8) : new byte[0]; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } + } +} diff --git a/sdk-java/src/test/java/ai/sentryagent/idp/TokenManagerTest.java b/sdk-java/src/test/java/ai/sentryagent/idp/TokenManagerTest.java new file mode 100644 index 0000000..0fe33b6 --- /dev/null +++ b/sdk-java/src/test/java/ai/sentryagent/idp/TokenManagerTest.java @@ -0,0 +1,102 @@ +package ai.sentryagent.idp; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +class TokenManagerTest { + + private MockServer srv; + private HttpClient httpClient; + + private static final String TOKEN_BODY = """ + {"access_token":"eyJ.abc.def","token_type":"Bearer","expires_in":3600,"scope":"agents:read"} + """; + + @BeforeEach + void setUp() throws IOException { + srv = new MockServer(); + httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + } + + @AfterEach + void tearDown() { srv.stop(); } + + @Test + void getToken_issuesToken() { + srv.addHandler("/api/v1/token", 200, TOKEN_BODY); + TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient); + assertEquals("eyJ.abc.def", tm.getToken()); + } + + @Test + void getToken_cachesToken() { + AtomicInteger calls = new AtomicInteger(0); + srv.addHandler("/api/v1/token", exchange -> { + calls.incrementAndGet(); + byte[] body = TOKEN_BODY.getBytes(); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.getResponseBody().close(); + }); + TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient); + tm.getToken(); + tm.getToken(); + assertEquals(1, calls.get(), "Should only call the token endpoint once"); + } + + @Test + void getToken_authFailure_throwsAgentIdPException() { + srv.addHandler("/api/v1/token", 401, + "{\"error\":\"invalid_client\",\"error_description\":\"Bad credentials.\"}"); + TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "bad-secret", "agents:read", httpClient); + AgentIdPException ex = assertThrows(AgentIdPException.class, tm::getToken); + assertEquals("invalid_client", ex.getCode()); + assertEquals(401, ex.getHttpStatus()); + } + + @Test + void clearCache_forcesRefetch() { + AtomicInteger calls = new AtomicInteger(0); + srv.addHandler("/api/v1/token", exchange -> { + calls.incrementAndGet(); + byte[] body = TOKEN_BODY.getBytes(); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.getResponseBody().close(); + }); + TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient); + tm.getToken(); + tm.clearCache(); + tm.getToken(); + assertEquals(2, calls.get(), "Should call token endpoint again after clearCache"); + } + + @Test + void getToken_threadSafe() throws InterruptedException { + srv.addHandler("/api/v1/token", 200, TOKEN_BODY); + TokenManager tm = new TokenManager(srv.baseUrl(), "cid", "secret", "agents:read", httpClient); + + Thread[] threads = new Thread[10]; + String[] results = new String[10]; + for (int i = 0; i < threads.length; i++) { + int idx = i; + threads[idx] = new Thread(() -> results[idx] = tm.getToken()); + } + for (Thread t : threads) t.start(); + for (Thread t : threads) t.join(); + + for (String result : results) { + assertEquals("eyJ.abc.def", result); + } + } +} diff --git a/sdk-java/src/test/java/ai/sentryagent/idp/services/AgentRegistryClientTest.java b/sdk-java/src/test/java/ai/sentryagent/idp/services/AgentRegistryClientTest.java new file mode 100644 index 0000000..2d78a59 --- /dev/null +++ b/sdk-java/src/test/java/ai/sentryagent/idp/services/AgentRegistryClientTest.java @@ -0,0 +1,133 @@ +package ai.sentryagent.idp.services; + +import ai.sentryagent.idp.AgentIdPException; +import ai.sentryagent.idp.MockServer; +import ai.sentryagent.idp.internal.HttpHelper; +import ai.sentryagent.idp.models.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class AgentRegistryClientTest { + + private MockServer srv; + private AgentRegistryClient client; + + private static final String AGENT_JSON = """ + {"agentId":"uuid-1","email":"a@b.ai","agentType":"screener","version":"1.0.0", + "capabilities":["read"],"owner":"team","deploymentEnv":"production", + "status":"active","createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z"} + """; + + private static final String PAGINATED_AGENTS = """ + {"data":[%s],"total":1,"page":1,"limit":20} + """.formatted(AGENT_JSON.strip()); + + @BeforeEach + void setUp() throws IOException { + srv = new MockServer(); + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + HttpHelper httpHelper = new HttpHelper(httpClient); + client = new AgentRegistryClient(srv.baseUrl(), () -> "test-token", httpHelper); + } + + @AfterEach + void tearDown() { srv.stop(); } + + @Test + void registerAgent_returns201() { + srv.addHandler("/api/v1/agents", 201, AGENT_JSON); + Agent agent = client.registerAgent(RegisterAgentRequest.builder() + .email("a@b.ai").agentType("screener").version("1.0.0") + .capabilities(List.of("read")).owner("team").deploymentEnv("production") + .build()); + assertEquals("uuid-1", agent.getAgentId()); + assertEquals("screener", agent.getAgentType()); + } + + @Test + void listAgents_returnsPaginated() { + srv.addHandler("/api/v1/agents", 200, PAGINATED_AGENTS); + PaginatedAgents result = client.listAgents(null); + assertEquals(1, result.getTotal()); + assertEquals("uuid-1", result.getData().get(0).getAgentId()); + } + + @Test + void getAgent_returnsAgent() { + srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON); + Agent agent = client.getAgent("uuid-1"); + assertEquals("uuid-1", agent.getAgentId()); + } + + @Test + void getAgent_notFound_throwsAgentIdPException() { + srv.addHandler("/api/v1/agents/bad-id", 404, + "{\"code\":\"AgentNotFoundError\",\"message\":\"Not found.\"}"); + AgentIdPException ex = assertThrows(AgentIdPException.class, () -> client.getAgent("bad-id")); + assertEquals("AgentNotFoundError", ex.getCode()); + assertEquals(404, ex.getHttpStatus()); + } + + @Test + void updateAgent_returnsUpdated() { + srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON); + Agent agent = client.updateAgent("uuid-1", + UpdateAgentRequest.builder().version("2.0.0").build()); + assertNotNull(agent); + assertEquals("uuid-1", agent.getAgentId()); + } + + @Test + void decommissionAgent_returns204() { + srv.addHandler("/api/v1/agents/uuid-1", 204, null); + assertDoesNotThrow(() -> client.decommissionAgent("uuid-1")); + } + + @Test + void registerAgentAsync_returnsCompletableFuture() throws Exception { + srv.addHandler("/api/v1/agents", 201, AGENT_JSON); + Agent agent = client.registerAgentAsync(RegisterAgentRequest.builder() + .email("a@b.ai").agentType("screener").version("1.0.0") + .capabilities(List.of("read")).owner("team").deploymentEnv("production") + .build()).get(); + assertEquals("uuid-1", agent.getAgentId()); + } + + @Test + void getAgentAsync_returnsCompletableFuture() throws Exception { + srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON); + Agent agent = client.getAgentAsync("uuid-1").get(); + assertEquals("uuid-1", agent.getAgentId()); + } + + @Test + void listAgentsAsync_withParams() throws Exception { + srv.addHandler("/api/v1/agents", 200, PAGINATED_AGENTS); + PaginatedAgents result = client.listAgentsAsync( + ListAgentsParams.builder().status("active").page(1).limit(20).build() + ).get(); + assertEquals(1, result.getTotal()); + } + + @Test + void decommissionAgentAsync_completesSuccessfully() throws Exception { + srv.addHandler("/api/v1/agents/uuid-1", 204, null); + assertDoesNotThrow(() -> client.decommissionAgentAsync("uuid-1").get()); + } + + @Test + void updateAgentAsync_returnsCompletableFuture() throws Exception { + srv.addHandler("/api/v1/agents/uuid-1", 200, AGENT_JSON); + Agent agent = client.updateAgentAsync("uuid-1", + UpdateAgentRequest.builder().version("2.0.0").build()).get(); + assertEquals("uuid-1", agent.getAgentId()); + } +} diff --git a/sdk-java/src/test/java/ai/sentryagent/idp/services/AuditClientTest.java b/sdk-java/src/test/java/ai/sentryagent/idp/services/AuditClientTest.java new file mode 100644 index 0000000..0776b80 --- /dev/null +++ b/sdk-java/src/test/java/ai/sentryagent/idp/services/AuditClientTest.java @@ -0,0 +1,93 @@ +package ai.sentryagent.idp.services; + +import ai.sentryagent.idp.AgentIdPException; +import ai.sentryagent.idp.MockServer; +import ai.sentryagent.idp.internal.HttpHelper; +import ai.sentryagent.idp.models.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +class AuditClientTest { + + private MockServer srv; + private AuditClient client; + + private static final String AUDIT_EVENT = """ + {"eventId":"ev-1","agentId":"uuid-1","action":"token.issued","outcome":"success", + "ipAddress":"1.2.3.4","userAgent":"curl","metadata":{},"timestamp":"2026-01-01T00:00:00Z"} + """; + + private static final String PAGINATED_AUDIT = """ + {"data":[%s],"total":1,"page":1,"limit":20} + """.formatted(AUDIT_EVENT.strip()); + + @BeforeEach + void setUp() throws IOException { + srv = new MockServer(); + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + client = new AuditClient(srv.baseUrl(), () -> "test-token", new HttpHelper(httpClient)); + } + + @AfterEach + void tearDown() { srv.stop(); } + + @Test + void queryAuditLog_returnsPaginated() { + srv.addHandler("/api/v1/audit", 200, PAGINATED_AUDIT); + PaginatedAuditEvents result = client.queryAuditLog(null); + assertEquals(1, result.getTotal()); + assertEquals("ev-1", result.getData().get(0).getEventId()); + } + + @Test + void queryAuditLog_withParams() { + srv.addHandler("/api/v1/audit", 200, PAGINATED_AUDIT); + PaginatedAuditEvents result = client.queryAuditLog( + QueryAuditParams.builder() + .agentId("uuid-1") + .action("token.issued") + .fromDate("2026-01-01") + .build()); + assertEquals(1, result.getTotal()); + } + + @Test + void getAuditEvent_returnsEvent() { + srv.addHandler("/api/v1/audit/ev-1", 200, AUDIT_EVENT); + AuditEvent event = client.getAuditEvent("ev-1"); + assertEquals("ev-1", event.getEventId()); + assertEquals("token.issued", event.getAction()); + assertEquals("success", event.getOutcome()); + } + + @Test + void getAuditEvent_notFound_throwsAgentIdPException() { + srv.addHandler("/api/v1/audit/bad-id", 404, + "{\"code\":\"AuditEventNotFoundError\",\"message\":\"Event not found.\"}"); + AgentIdPException ex = assertThrows(AgentIdPException.class, + () -> client.getAuditEvent("bad-id")); + assertEquals("AuditEventNotFoundError", ex.getCode()); + assertEquals(404, ex.getHttpStatus()); + } + + @Test + void queryAuditLogAsync_returnsPaginated() throws Exception { + srv.addHandler("/api/v1/audit", 200, PAGINATED_AUDIT); + PaginatedAuditEvents result = client.queryAuditLogAsync(null).get(); + assertEquals(1, result.getTotal()); + } + + @Test + void getAuditEventAsync_returnsEvent() throws Exception { + srv.addHandler("/api/v1/audit/ev-1", 200, AUDIT_EVENT); + AuditEvent event = client.getAuditEventAsync("ev-1").get(); + assertEquals("ev-1", event.getEventId()); + } +} diff --git a/sdk-java/src/test/java/ai/sentryagent/idp/services/CredentialClientTest.java b/sdk-java/src/test/java/ai/sentryagent/idp/services/CredentialClientTest.java new file mode 100644 index 0000000..0f06c06 --- /dev/null +++ b/sdk-java/src/test/java/ai/sentryagent/idp/services/CredentialClientTest.java @@ -0,0 +1,116 @@ +package ai.sentryagent.idp.services; + +import ai.sentryagent.idp.AgentIdPException; +import ai.sentryagent.idp.MockServer; +import ai.sentryagent.idp.internal.HttpHelper; +import ai.sentryagent.idp.models.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +class CredentialClientTest { + + private MockServer srv; + private CredentialClient client; + + private static final String CRED_JSON = """ + {"credentialId":"cred-1","clientId":"uuid-1","status":"active", + "createdAt":"2026-01-01T00:00:00Z","expiresAt":null,"revokedAt":null} + """; + + private static final String CRED_WITH_SECRET = """ + {"credentialId":"cred-1","clientId":"uuid-1","status":"active", + "createdAt":"2026-01-01T00:00:00Z","expiresAt":null,"revokedAt":null, + "clientSecret":"sk_live_abc"} + """; + + private static final String PAGINATED_CREDS = """ + {"data":[%s],"total":1,"page":1,"limit":20} + """.formatted(CRED_JSON.strip()); + + @BeforeEach + void setUp() throws IOException { + srv = new MockServer(); + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + client = new CredentialClient(srv.baseUrl(), () -> "test-token", new HttpHelper(httpClient)); + } + + @AfterEach + void tearDown() { srv.stop(); } + + @Test + void generateCredential_returnsSecret() { + srv.addHandler("/api/v1/agents/uuid-1/credentials", 201, CRED_WITH_SECRET); + CredentialWithSecret cred = client.generateCredential("uuid-1"); + assertEquals("sk_live_abc", cred.getClientSecret()); + assertEquals("cred-1", cred.getCredentialId()); + } + + @Test + void listCredentials_returnsPaginated() { + srv.addHandler("/api/v1/agents/uuid-1/credentials", 200, PAGINATED_CREDS); + PaginatedCredentials result = client.listCredentials("uuid-1", null, null); + assertEquals(1, result.getTotal()); + assertEquals("cred-1", result.getData().get(0).getCredentialId()); + } + + @Test + void rotateCredential_returnsNewSecret() { + srv.addHandler("/api/v1/agents/uuid-1/credentials/cred-1/rotate", 200, CRED_WITH_SECRET); + CredentialWithSecret cred = client.rotateCredential("uuid-1", "cred-1"); + assertEquals("sk_live_abc", cred.getClientSecret()); + } + + @Test + void revokeCredential_returnsRevoked() { + String revoked = """ + {"credentialId":"cred-1","clientId":"uuid-1","status":"revoked", + "createdAt":"2026-01-01T00:00:00Z","expiresAt":null, + "revokedAt":"2026-01-02T00:00:00Z"} + """; + srv.addHandler("/api/v1/agents/uuid-1/credentials/cred-1", 200, revoked); + Credential cred = client.revokeCredential("uuid-1", "cred-1"); + assertEquals("revoked", cred.getStatus()); + } + + @Test + void generateCredential_error_throwsAgentIdPException() { + srv.addHandler("/api/v1/agents/bad/credentials", 404, + "{\"code\":\"AgentNotFoundError\",\"message\":\"Not found.\"}"); + AgentIdPException ex = assertThrows(AgentIdPException.class, + () -> client.generateCredential("bad")); + assertEquals(404, ex.getHttpStatus()); + } + + @Test + void generateCredentialAsync_returnsCompletableFuture() throws Exception { + srv.addHandler("/api/v1/agents/uuid-1/credentials", 201, CRED_WITH_SECRET); + CredentialWithSecret cred = client.generateCredentialAsync("uuid-1").get(); + assertEquals("sk_live_abc", cred.getClientSecret()); + } + + @Test + void rotateCredentialAsync_returnsCompletableFuture() throws Exception { + srv.addHandler("/api/v1/agents/uuid-1/credentials/cred-1/rotate", 200, CRED_WITH_SECRET); + CredentialWithSecret cred = client.rotateCredentialAsync("uuid-1", "cred-1").get(); + assertEquals("sk_live_abc", cred.getClientSecret()); + } + + @Test + void revokeCredentialAsync_returnsCompletableFuture() throws Exception { + String revoked = """ + {"credentialId":"cred-1","clientId":"uuid-1","status":"revoked", + "createdAt":"2026-01-01T00:00:00Z","expiresAt":null, + "revokedAt":"2026-01-02T00:00:00Z"} + """; + srv.addHandler("/api/v1/agents/uuid-1/credentials/cred-1", 200, revoked); + Credential cred = client.revokeCredentialAsync("uuid-1", "cred-1").get(); + assertEquals("revoked", cred.getStatus()); + } +} diff --git a/sdk-java/src/test/java/ai/sentryagent/idp/services/TokenClientTest.java b/sdk-java/src/test/java/ai/sentryagent/idp/services/TokenClientTest.java new file mode 100644 index 0000000..766a504 --- /dev/null +++ b/sdk-java/src/test/java/ai/sentryagent/idp/services/TokenClientTest.java @@ -0,0 +1,86 @@ +package ai.sentryagent.idp.services; + +import ai.sentryagent.idp.AgentIdPException; +import ai.sentryagent.idp.MockServer; +import ai.sentryagent.idp.models.IntrospectResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +class TokenClientTest { + + private MockServer srv; + private TokenClient client; + + @BeforeEach + void setUp() throws IOException { + srv = new MockServer(); + HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + client = new TokenClient(srv.baseUrl(), () -> "test-token", httpClient); + } + + @AfterEach + void tearDown() { srv.stop(); } + + @Test + void introspectToken_active() { + srv.addHandler("/api/v1/token/introspect", 200, + "{\"active\":true,\"sub\":\"uuid-1\",\"exp\":9999999999}"); + IntrospectResponse result = client.introspectToken("some-token"); + assertTrue(result.isActive()); + assertEquals("uuid-1", result.getSub()); + } + + @Test + void introspectToken_inactive() { + srv.addHandler("/api/v1/token/introspect", 200, "{\"active\":false}"); + IntrospectResponse result = client.introspectToken("expired-token"); + assertFalse(result.isActive()); + assertNull(result.getSub()); + } + + @Test + void revokeToken_succeeds() { + srv.addHandler("/api/v1/token/revoke", 200, "{}"); + assertDoesNotThrow(() -> client.revokeToken("some-token")); + } + + @Test + void introspectToken_error_throwsAgentIdPException() { + srv.addHandler("/api/v1/token/introspect", 401, + "{\"code\":\"UnauthorizedError\",\"message\":\"Invalid token.\"}"); + AgentIdPException ex = assertThrows(AgentIdPException.class, + () -> client.introspectToken("bad-token")); + assertEquals(401, ex.getHttpStatus()); + assertEquals("UnauthorizedError", ex.getCode()); + } + + @Test + void revokeToken_error_throwsAgentIdPException() { + srv.addHandler("/api/v1/token/revoke", 401, + "{\"code\":\"UnauthorizedError\",\"message\":\"Invalid token.\"}"); + AgentIdPException ex = assertThrows(AgentIdPException.class, + () -> client.revokeToken("bad-token")); + assertEquals(401, ex.getHttpStatus()); + } + + @Test + void introspectTokenAsync_active() throws Exception { + srv.addHandler("/api/v1/token/introspect", 200, + "{\"active\":true,\"sub\":\"uuid-1\",\"exp\":9999999999}"); + IntrospectResponse result = client.introspectTokenAsync("some-token").get(); + assertTrue(result.isActive()); + } + + @Test + void revokeTokenAsync_succeeds() throws Exception { + srv.addHandler("/api/v1/token/revoke", 200, "{}"); + assertDoesNotThrow(() -> client.revokeTokenAsync("some-token").get()); + } +}