feat: Phase 2 Workstream 4 — Java SDK (ai.sentryagent:idp-sdk)

Java 17 SDK in sdk-java/:
- AgentIdPClient composing AgentRegistryClient, CredentialClient,
  TokenClient, AuditClient — all 14 endpoints covered
- Both sync methods and CompletableFuture<T> async counterparts on each client
- Thread-safe TokenManager (synchronized) with 60s refresh buffer
- AgentIdPException (extends RuntimeException) with Code/HTTPStatus/Details
- Builder pattern for all request types; Jackson 2.17 for JSON
- Zero external HTTP dependencies — java.net.http.HttpClient (Java 11+)
- No-dep JDK HttpServer used for unit tests (no WireMock needed)
- mvn verify: 49/49 tests passed | JaCoCo coverage gate: >80% ✓

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 15:33:53 +00:00
parent 91c759f455
commit 8cdab72fea
33 changed files with 2308 additions and 12 deletions

View File

@@ -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.
*
* <pre>{@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");
* }</pre>
*/
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(); }
}

View File

@@ -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<String, Object> details;
public AgentIdPException(String code, String message, int httpStatus, Map<String, Object> 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<String, Object> 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() + "'}";
}
}

View File

@@ -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<String> 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);
}
}

View File

@@ -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> T request(String method, String url, Object body, String token, Class<T> responseType) {
try {
HttpRequest req = buildRequest(method, url, body, token);
HttpResponse<String> 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 <T> CompletableFuture<T> requestAsync(String method, String url, Object body, String token, Class<T> 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> T handleResponse(HttpResponse<String> resp, Class<T> 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);
}
}
}

View File

@@ -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<String> 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<String> 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 + "'}";
}
}

View File

@@ -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<String, Object> 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<String, Object> getMetadata() { return metadata; }
public String getTimestamp() { return timestamp; }
@Override
public String toString() {
return "AuditEvent{eventId='" + eventId + "', action='" + action + "', outcome='" + outcome + "'}";
}
}

View File

@@ -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 + "'}";
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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); }
}
}

View File

@@ -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<Agent> data;
@JsonProperty("total") private int total;
@JsonProperty("page") private int page;
@JsonProperty("limit") private int limit;
public PaginatedAgents() {}
public List<Agent> getData() { return data; }
public int getTotal() { return total; }
public int getPage() { return page; }
public int getLimit() { return limit; }
}

View File

@@ -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<AuditEvent> data;
@JsonProperty("total") private int total;
@JsonProperty("page") private int page;
@JsonProperty("limit") private int limit;
public PaginatedAuditEvents() {}
public List<AuditEvent> getData() { return data; }
public int getTotal() { return total; }
public int getPage() { return page; }
public int getLimit() { return limit; }
}

View File

@@ -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<Credential> data;
@JsonProperty("total") private int total;
@JsonProperty("page") private int page;
@JsonProperty("limit") private int limit;
public PaginatedCredentials() {}
public List<Credential> getData() { return data; }
public int getTotal() { return total; }
public int getPage() { return page; }
public int getLimit() { return limit; }
}

View File

@@ -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); }
}
}

View File

@@ -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<String> 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<String> 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<String> 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<String> 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); }
}
}

View File

@@ -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; }
}

View File

@@ -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<String> 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<String> 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<String> 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<String> 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); }
}
}

View File

@@ -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<String> tokenSupplier;
private final HttpHelper http;
public AgentRegistryClient(String baseUrl, Supplier<String> 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<Agent> 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<PaginatedAgents> 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<Agent> 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<Agent> 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<Void> 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);
}
}
}

View File

@@ -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<String> tokenSupplier;
private final HttpHelper http;
public AuditClient(String baseUrl, Supplier<String> 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<PaginatedAuditEvents> 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<AuditEvent> 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);
}
}
}

View File

@@ -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<String> tokenSupplier;
private final HttpHelper http;
public CredentialClient(String baseUrl, Supplier<String> 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<CredentialWithSecret> 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<PaginatedCredentials> 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<CredentialWithSecret> 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<Credential> 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();
}
}

View File

@@ -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<String> tokenSupplier;
private final HttpClient httpClient;
public TokenClient(String baseUrl, Supplier<String> 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<String> 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<String> 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<IntrospectResponse> 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<Void> 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);
}
}