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:
@@ -1,6 +1,6 @@
|
||||
# Phase 2: Production-Ready — Tasks
|
||||
|
||||
**Status**: In progress — Workstreams 1, 2, 3 complete.
|
||||
**Status**: In progress — Workstreams 1, 2, 3, 4 complete.
|
||||
|
||||
## CEO Approval Gates (required before implementation)
|
||||
|
||||
@@ -54,17 +54,17 @@
|
||||
|
||||
## Workstream 4: Java SDK
|
||||
|
||||
- [ ] 4.1 Create `sdk-java/` with `pom.xml` — groupId: ai.sentryagent, artifactId: idp-sdk, Java 17
|
||||
- [ ] 4.2 Write all POJO request/response model classes
|
||||
- [ ] 4.3 Write `AgentIdPException.java` extending RuntimeException
|
||||
- [ ] 4.4 Write `TokenManager.java` — synchronized cache with 60s refresh buffer
|
||||
- [ ] 4.5 Write `AgentRegistryClient.java` — sync + CompletableFuture methods
|
||||
- [ ] 4.6 Write `CredentialClient.java` — sync + CompletableFuture methods
|
||||
- [ ] 4.7 Write `TokenClient.java` — sync + CompletableFuture methods
|
||||
- [ ] 4.8 Write `AuditClient.java` — sync + CompletableFuture methods
|
||||
- [ ] 4.9 Write `AgentIdPClient.java` — composes all service clients
|
||||
- [ ] 4.10 Write `sdk-java/README.md`
|
||||
- [ ] 4.11 QA: `mvn verify` passes, all 14 endpoints, AgentIdPException on all failure paths, JUnit 5 >80%
|
||||
- [x] 4.1 Create `sdk-java/` with `pom.xml` — groupId: ai.sentryagent, artifactId: idp-sdk, Java 17
|
||||
- [x] 4.2 Write all POJO request/response model classes
|
||||
- [x] 4.3 Write `AgentIdPException.java` extending RuntimeException
|
||||
- [x] 4.4 Write `TokenManager.java` — synchronized cache with 60s refresh buffer
|
||||
- [x] 4.5 Write `AgentRegistryClient.java` — sync + CompletableFuture methods
|
||||
- [x] 4.6 Write `CredentialClient.java` — sync + CompletableFuture methods
|
||||
- [x] 4.7 Write `TokenClient.java` — sync + CompletableFuture methods
|
||||
- [x] 4.8 Write `AuditClient.java` — sync + CompletableFuture methods
|
||||
- [x] 4.9 Write `AgentIdPClient.java` — composes all service clients
|
||||
- [x] 4.10 Write `sdk-java/README.md`
|
||||
- [x] 4.11 QA: `mvn verify` passes, all 14 endpoints, AgentIdPException on all failure paths, JUnit 5 >80%
|
||||
|
||||
## Workstream 5: OPA Policy Engine
|
||||
|
||||
|
||||
1
sdk-java/.gitignore
vendored
Normal file
1
sdk-java/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
190
sdk-java/README.md
Normal file
190
sdk-java/README.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# SentryAgent.ai AgentIdP — Java SDK
|
||||
|
||||
Official Java client for the [SentryAgent.ai AgentIdP](https://sentryagent.ai) — an open-source Identity Provider for AI agents built on OAuth 2.0 (RFC 6749) and aligned with the [AGNTCY](https://agntcy.org) open standard.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java 17+
|
||||
- A running AgentIdP server
|
||||
|
||||
## Installation
|
||||
|
||||
### Maven
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>ai.sentryagent</groupId>
|
||||
<artifactId>idp-sdk</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```java
|
||||
import ai.sentryagent.idp.AgentIdPClient;
|
||||
import ai.sentryagent.idp.models.*;
|
||||
|
||||
AgentIdPClient client = new AgentIdPClient(
|
||||
"https://idp.example.com",
|
||||
"your-agent-client-id",
|
||||
"sk_live_..."
|
||||
);
|
||||
|
||||
// Register a new AI agent
|
||||
Agent agent = client.agents().registerAgent(
|
||||
RegisterAgentRequest.builder()
|
||||
.email("screener@example.com")
|
||||
.agentType("screener")
|
||||
.version("1.0.0")
|
||||
.capabilities(List.of("read", "classify"))
|
||||
.owner("platform-team")
|
||||
.deploymentEnv("production")
|
||||
.build()
|
||||
);
|
||||
System.out.println("Registered: " + agent.getAgentId());
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
OAuth 2.0 Client Credentials are managed automatically. Tokens are cached and refreshed 60 seconds before expiry. The `TokenManager` is thread-safe.
|
||||
|
||||
```java
|
||||
// Custom scope (optional — defaults to all four scopes)
|
||||
AgentIdPClient client = new AgentIdPClient(
|
||||
"https://idp.example.com",
|
||||
"my-client-id",
|
||||
"my-client-secret",
|
||||
"agents:read agents:write"
|
||||
);
|
||||
```
|
||||
|
||||
## Agent Registry
|
||||
|
||||
```java
|
||||
// Register
|
||||
Agent agent = client.agents().registerAgent(
|
||||
RegisterAgentRequest.builder()
|
||||
.email("...").agentType("screener").version("1.0.0")
|
||||
.capabilities(List.of("read")).owner("team").deploymentEnv("production")
|
||||
.build());
|
||||
|
||||
// List (with optional filters)
|
||||
PaginatedAgents agents = client.agents().listAgents(
|
||||
ListAgentsParams.builder().status("active").page(1).limit(20).build());
|
||||
|
||||
// Get by ID
|
||||
Agent agent = client.agents().getAgent("agent-uuid");
|
||||
|
||||
// Partial update
|
||||
Agent updated = client.agents().updateAgent("agent-uuid",
|
||||
UpdateAgentRequest.builder().version("2.0.0").build());
|
||||
|
||||
// Decommission (permanent)
|
||||
client.agents().decommissionAgent("agent-uuid");
|
||||
```
|
||||
|
||||
## Credential Management
|
||||
|
||||
```java
|
||||
// Generate (returns one-time ClientSecret)
|
||||
CredentialWithSecret cred = client.credentials().generateCredential("agent-uuid");
|
||||
System.out.println(cred.getClientSecret()); // store this — shown only once
|
||||
|
||||
// List
|
||||
PaginatedCredentials creds = client.credentials().listCredentials("agent-uuid", 1, 20);
|
||||
|
||||
// Rotate
|
||||
CredentialWithSecret newCred = client.credentials().rotateCredential("agent-uuid", "cred-uuid");
|
||||
|
||||
// Revoke
|
||||
Credential revoked = client.credentials().revokeCredential("agent-uuid", "cred-uuid");
|
||||
```
|
||||
|
||||
## Token Operations
|
||||
|
||||
```java
|
||||
// Introspect (RFC 7662)
|
||||
IntrospectResponse result = client.tokens().introspectToken("access-token-to-check");
|
||||
if (result.isActive()) {
|
||||
System.out.println("Token belongs to: " + result.getSub());
|
||||
}
|
||||
|
||||
// Revoke
|
||||
client.tokens().revokeToken("access-token-to-revoke");
|
||||
```
|
||||
|
||||
## Audit Log
|
||||
|
||||
```java
|
||||
// Query with filters
|
||||
PaginatedAuditEvents events = client.audit().queryAuditLog(
|
||||
QueryAuditParams.builder()
|
||||
.agentId("agent-uuid")
|
||||
.action("token.issued")
|
||||
.outcome("success")
|
||||
.fromDate("2026-01-01")
|
||||
.toDate("2026-01-31")
|
||||
.page(1).limit(50)
|
||||
.build());
|
||||
|
||||
// Get single event
|
||||
AuditEvent event = client.audit().getAuditEvent("event-uuid");
|
||||
```
|
||||
|
||||
## Async Methods
|
||||
|
||||
Every sync method has an async counterpart returning `CompletableFuture<T>`:
|
||||
|
||||
```java
|
||||
CompletableFuture<Agent> future = client.agents().getAgentAsync("uuid-1");
|
||||
future.thenAccept(agent -> System.out.println(agent.getAgentId()));
|
||||
|
||||
// Compose multiple async calls
|
||||
client.agents().getAgentAsync("uuid-1")
|
||||
.thenCompose(agent -> client.credentials().generateCredentialAsync(agent.getAgentId()))
|
||||
.thenAccept(cred -> System.out.println("New secret: " + cred.getClientSecret()));
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors are thrown as `AgentIdPException` (extends `RuntimeException`):
|
||||
|
||||
```java
|
||||
try {
|
||||
Agent agent = client.agents().getAgent("unknown-id");
|
||||
} catch (AgentIdPException ex) {
|
||||
System.out.printf("code=%s status=%d%n", ex.getCode(), ex.getHttpStatus());
|
||||
// e.g. code=AgentNotFoundError status=404
|
||||
}
|
||||
```
|
||||
|
||||
| Method | Type | Description |
|
||||
|------------------|--------------------------|-------------------------------------------------|
|
||||
| `getCode()` | `String` | Machine-readable error code |
|
||||
| `getMessage()` | `String` | Human-readable description |
|
||||
| `getHttpStatus()`| `int` | HTTP status code (0 for network/build errors) |
|
||||
| `getDetails()` | `Map<String, Object>` | 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).
|
||||
100
sdk-java/pom.xml
Normal file
100
sdk-java/pom.xml
Normal file
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>ai.sentryagent</groupId>
|
||||
<artifactId>idp-sdk</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>SentryAgent.ai AgentIdP Java SDK</name>
|
||||
<description>Java client for the SentryAgent.ai AgentIdP API</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<jackson.version>2.17.0</jackson.version>
|
||||
<junit.version>5.10.2</junit.version>
|
||||
<jacoco.version>0.8.11</jacoco.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- JSON serialization/deserialization -->
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JUnit 5 -->
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<release>${java.version}</release>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<configuration>
|
||||
<useModulePath>false</useModulePath>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- JaCoCo coverage gate: >80% instruction coverage required -->
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals><goal>prepare-agent</goal></goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals><goal>report</goal></goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<phase>verify</phase>
|
||||
<goals><goal>check</goal></goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>INSTRUCTION</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.80</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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() + "'}";
|
||||
}
|
||||
}
|
||||
101
sdk-java/src/main/java/ai/sentryagent/idp/TokenManager.java
Normal file
101
sdk-java/src/main/java/ai/sentryagent/idp/TokenManager.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
sdk-java/src/main/java/ai/sentryagent/idp/models/Agent.java
Normal file
39
sdk-java/src/main/java/ai/sentryagent/idp/models/Agent.java
Normal 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 + "'}";
|
||||
}
|
||||
}
|
||||
@@ -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 + "'}";
|
||||
}
|
||||
}
|
||||
@@ -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 + "'}";
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
73
sdk-java/src/test/java/ai/sentryagent/idp/MockServer.java
Normal file
73
sdk-java/src/test/java/ai/sentryagent/idp/MockServer.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
sdk-java/src/test/java/ai/sentryagent/idp/TokenManagerTest.java
Normal file
102
sdk-java/src/test/java/ai/sentryagent/idp/TokenManagerTest.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user