` | 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());
+ }
+}