Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ AEP_NODE_DIR=../aep-node make interoperability
```

The flow runs the Java Agent against the Node.js Service and Platform, then the Node.js Agent
against the Java Service and Platform. Its report is written under `.interop/reports/`.
against the Java Service and Platform. It also tests Java Service hosted verification against the
Node.js Platform, including retries, forged assertions, resource binding, and replay rejection.
Its report is written under `.interop/reports/`.

## Module boundaries

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ The shared Agent, Service, Platform, and credential-profile conformance reports
Set `AEP_SPECS_DIR` when the specifications repository is not located at `../aep-specs`. Reports are written to
`.conformance/reports/`.

Agent reports include the hosted-identity provider. Protected-resource redirect checks cover
origin rejection and fresh resource-bound authentication headers; the application owns HTTP
forwarding and anonymous restarts, as described in the [Agent guide](aep-agent/README.md#authenticate-and-revoke).

Format Java sources with:

```sh
Expand Down
28 changes: 26 additions & 2 deletions aep-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ See the root [installation guide](../README.md#installation) for dependency coor
Supply an HTTP transport and an identity provider. The transport boundary is asynchronous and can
adapt the HTTP client already used by the application. The identity provider returns a
Service-scoped Agent identity whose signer receives the complete client-assertion claims.
The signer must use an algorithm advertised by the Service. Configure transports to return
redirect responses without following them automatically, so the SDK can enforce origin checks
before credentials or assertions are sent elsewhere.

```java
AepAgent agent = AepAgent.builder()
Expand All @@ -30,6 +33,15 @@ Production identity providers should scope identities by Service, protect signin
the same identity for repeated calls. Production credential stores should encrypt credentials at
rest and make replacement, expiry, and revocation atomic.

Implement `AgentCredentialStore.find(serviceDid, grantType, now)` to select a credential whose
`expiresAt` is later than the supplied Agent time. An expired credential must not hide another
usable credential of the same type. A custom credential with no expiry can remain selectable.
This local selection does not authorize access: the Service validates the credential using its own
clock. Changing the Agent's clock cannot extend a credential's lifetime at the Service.

Custom Inspect caches must preserve `AgentInspectCache.Entry.cacheHeaders` alongside the document
and validators, so a `304 Not Modified` response retains the cached freshness policy.

## Use a hosted identity Platform

`PlatformIdentityProvider` implements the Agent identity boundary against an AEP Platform. It
Expand Down Expand Up @@ -77,8 +89,9 @@ ClaimValues claims = ClaimValues.builder()
.build();

EnrollResponse enrolled = service.enroll(claims).join();
StatusResponse active = service.waitForActive(
Duration.ofSeconds(2), Duration.ofMinutes(2)).join();
if (enrolled.status() == AgentStatus.PENDING) {
service.waitForActive(Duration.ofSeconds(2), Duration.ofMinutes(2)).join();
}

AgentGrantResult grant = service.grant("api-key", List.of("catalog:read")).join();
```
Expand All @@ -87,6 +100,10 @@ AgentGrantResult grant = service.grant("api-key", List.of("catalog:read")).join(
for a Service that returns a pending enrollment; an immediately active enrollment can proceed
directly to Grant.

Polling timeout and cancellation stop the returned wait and prevent further polls. They do not
guarantee cancellation of an HTTP request already running in an application-supplied transport;
configure request timeouts on that transport as well.

Built-in credential handlers parse and store OAuth Bearer, API-key, and Basic responses. A custom
Grant Type remains available through `AgentCredentialHandler`.

Expand All @@ -109,5 +126,12 @@ Do not forward the returned headers to another origin. The Agent refuses cross-o
presentation, command redirects, cross-origin Inspect redirects, oversized responses, and invalid
Service DID bindings.

The application sends protected-resource requests and handles their redirects; `authenticate`
only returns headers. For a same-origin redirect, discard the previous authentication headers and
call `authenticate` for the target URL. When JWT authentication is selected, this creates a fresh
assertion bound to that URL. For a cross-origin
redirect, remove all Service authentication headers, including any API-key header, and restart
anonymously. These forwarding decisions belong to the application's HTTP client, not this SDK.

See the runnable [Agent and Service example](../examples/README.md#agent-and-service) for a complete
JDK `HttpClient` transport and lifecycle flow.
93 changes: 65 additions & 28 deletions aep-agent/src/main/java/foundation/aep/agent/AepServiceSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public final class AepServiceSession {
private final AepAgent agent;
private final URI serviceOrigin;
private Optional<CompletableFuture<AgentInspection>> inspectionInFlight = Optional.empty();
private volatile Optional<EnrollmentIdentity> activeEnrollment = Optional.empty();

AepServiceSession(AepAgent agent, URI origin) {
this.agent = agent;
Expand Down Expand Up @@ -66,20 +67,34 @@ public CompletableFuture<EnrollResponse> enroll(ClaimValues claims) {
String key = idempotencyKey(context, AepCommand.ENROLL, "");
EnrollRequest request = new EnrollRequest(context.identity.did(), claims, key);
requireValid("Enroll request", AepValidation.enrollRequest(request));
return execute(context, AepCommand.ENROLL, "POST", request, key, AepJson::parseEnrollResponse);
return execute(context, AepCommand.ENROLL, "POST", request, key, AepJson::parseEnrollResponse)
.thenApply(response -> {
recordEnrollment(context, response.status());
return response;
});
});
}

public CompletableFuture<StatusResponse> status() {
return commandContext(AepCommand.STATUS)
.thenCompose(context ->
execute(context, AepCommand.STATUS, "GET", null, null, AepJson::parseStatusResponse));
.thenCompose(context -> execute(
context, AepCommand.STATUS, "GET", null, null, AepJson::parseStatusResponse)
.thenApply(response -> {
recordEnrollment(context, response.status());
return response;
}));
}

public CompletableFuture<StatusResponse> waitForActive(Duration interval, Duration timeout) {
requirePositive(interval, "interval");
requirePositive(timeout, "timeout");
CompletableFuture<StatusResponse> result = new CompletableFuture<>();
CompletableFuture<Void> deadline = new CompletableFuture<>();
deadline.orTimeout(timeoutNanos(timeout), TimeUnit.NANOSECONDS).whenComplete((ignored, failure) -> {
if (failure != null)
result.completeExceptionally(new AepAgentException("poll_timeout", "AEP Status polling timed out"));
});
result.whenComplete((ignored, failure) -> deadline.complete(null));
pollStatus(result, interval, System.nanoTime(), timeoutNanos(timeout));
return result;
}
Expand All @@ -98,8 +113,9 @@ public CompletableFuture<AgentGrantResult> grant(GrantRequest request) {
"grant_type_not_advertised",
"AEP Service does not advertise Grant Type " + request.grantType());
}
if (!commands.supported().contains(AepCommand.STATUS.value())) {
return failed("command_not_advertised", "AEP Grant requires advertised Status support");
if (activeEnrollment.filter(enrollmentIdentity(context)::equals).isPresent()
|| !commands.supported().contains(AepCommand.STATUS.value())) {
return issueGrant(context, request);
}
return execute(context, AepCommand.STATUS, "GET", null, null, AepJson::parseStatusResponse)
.thenCompose(status -> grantActive(context, status, request));
Expand All @@ -110,6 +126,12 @@ public CompletableFuture<RevokeResponse> revoke(RevokeRequest request) {
Objects.requireNonNull(request, "request");
requireValid("Revoke request", AepValidation.revokeRequest(request));
return commandContext(AepCommand.REVOKE).thenCompose(context -> {
if (request.grantType() != null
&& !context.inspection.document().commands().grantTypes().contains(request.grantType())) {
return failed(
"grant_type_not_advertised",
"AEP Service does not advertise Grant Type " + request.grantType());
}
requirePerCredentialRevoke(context.inspection.document(), request);
String discriminator = request.credentialId() == null ? request.grantType() : request.credentialId();
String key = idempotencyKey(context, AepCommand.REVOKE, discriminator == null ? "all" : discriminator);
Expand Down Expand Up @@ -159,13 +181,15 @@ private CompletableFuture<AgentInspection> fetchInspect(
}
if (response.status() == 304 && cached.isPresent()) {
AgentInspectCache.Entry prior = cached.orElseThrow();
AepHttpTransport.Response metadata = AgentHttp.revalidated(response, prior.cacheHeaders());
AgentInspectCache.Entry refreshed = new AgentInspectCache.Entry(
uri,
prior.json(),
AgentHttp.header(response, "ETag").orElse(prior.etag()),
AgentHttp.header(response, "Last-Modified").orElse(prior.lastModified()),
AgentHttp.expiresAt(response, agent.clock.instant(), agent.defaultInspectFreshness));
CompletionStage<Void> update = AgentHttp.isNoStore(response)
AgentHttp.expiresAt(metadata, agent.clock.instant(), agent.defaultInspectFreshness),
AgentHttp.cacheHeaders(metadata));
CompletionStage<Void> update = AgentHttp.isNoStore(metadata)
? agent.inspectCache.delete(serviceOrigin)
: agent.inspectCache.put(serviceOrigin, refreshed);
return future(update).thenApply(ignored -> parseInspection(prior.json(), uri));
Expand All @@ -181,7 +205,8 @@ private CompletableFuture<AgentInspection> fetchInspect(
json,
AgentHttp.header(response, "ETag").orElse(null),
AgentHttp.header(response, "Last-Modified").orElse(null),
AgentHttp.expiresAt(response, agent.clock.instant(), agent.defaultInspectFreshness));
AgentHttp.expiresAt(response, agent.clock.instant(), agent.defaultInspectFreshness),
AgentHttp.cacheHeaders(response));
CompletionStage<Void> update = AgentHttp.isNoStore(response)
? agent.inspectCache.delete(serviceOrigin)
: agent.inspectCache.put(serviceOrigin, entry);
Expand Down Expand Up @@ -235,10 +260,8 @@ private AgentInspection parseInspection(String json, URI documentUri) {
}

private boolean bindsServiceDid(String serviceDid) {
if (!agent.allowInsecureLoopback) {
return DidWeb.bindsOrigin(serviceDid, serviceOrigin);
}
try {
if (!agent.allowInsecureLoopback) return DidWeb.bindsOrigin(serviceDid, serviceOrigin);
return AgentHttp.sameOrigin(DidWeb.documentUri(serviceDid, true), serviceOrigin);
} catch (IllegalArgumentException exception) {
return false;
Expand Down Expand Up @@ -310,28 +333,47 @@ private CompletableFuture<String> assertion(CommandContext context, AepCommand c

private CompletableFuture<AgentGrantResult> grantActive(
CommandContext context, StatusResponse status, GrantRequest request) {
recordEnrollment(context, status.status());
if (status.status() != AgentStatus.ACTIVE) {
return failed("enrollment_not_active", "AEP Grant requires active enrollment");
}
return issueGrant(context, request);
}

private void recordEnrollment(CommandContext context, AgentStatus status) {
activeEnrollment = status == AgentStatus.ACTIVE ? Optional.of(enrollmentIdentity(context)) : Optional.empty();
}

private static EnrollmentIdentity enrollmentIdentity(CommandContext context) {
return new EnrollmentIdentity(context.inspection.document().service().did(), context.identity.did());
}

private record EnrollmentIdentity(String serviceDid, String agentDid) {}

private CompletableFuture<AgentGrantResult> issueGrant(CommandContext context, GrantRequest request) {
String key = idempotencyKey(context, AepCommand.GRANT, request.grantType());
return execute(context, AepCommand.GRANT, "POST", request, key, Function.identity())
.thenCompose(json -> storeGrant(context, request.grantType(), json));
}

private CompletableFuture<AgentGrantResult> storeGrant(CommandContext context, String grantType, String json) {
Object identifier = AepJson.parseObject(json, "Grant response").get("credential_id");
if (!(identifier instanceof String credentialId) || credentialId.isEmpty()) {
return failed("invalid_credential", "Grant response must contain a non-empty credential_id");
}
Optional<AgentCredentialHandler> handler = agent.credentialHandlers.stream()
.filter(candidate -> candidate.grantType().equals(grantType))
.findFirst();
if (handler.isEmpty()) {
AepJson.requireObject(json, "Grant response");
return CompletableFuture.completedFuture(new AgentGrantResult(grantType, json, Optional.empty()));
}
AgentCredential credential = handler.orElseThrow()
.parse(context.inspection.document().service().did(), json);
if (!credential
.serviceDid()
.equals(context.inspection.document().service().did())
|| !credential.grantType().equals(grantType)) {
|| !credential.grantType().equals(grantType)
|| !credential.credentialId().equals(credentialId)) {
return failed("invalid_credential", "Credential handler returned mismatched credential metadata");
}
return future(agent.credentialStore.save(credential))
Expand Down Expand Up @@ -375,7 +417,8 @@ private CompletableFuture<AgentAuthentication> authenticate(
return authenticate(inspection, resource, methodIndex + 1);
}
AgentCredentialHandler selected = handler.orElseThrow();
return future(agent.credentialStore.find(inspection.document().service().did(), selected.grantType()))
return future(agent.credentialStore.find(
inspection.document().service().did(), selected.grantType(), agent.clock.instant()))
.thenCompose(credential -> {
if (credential.isEmpty()
|| (credential.orElseThrow().expiresAt() != null
Expand All @@ -400,6 +443,10 @@ private void pollStatus(
if (result.isDone()) {
return;
}
if (remainingNanos(startedNanos, timeoutNanos) == 0) {
result.completeExceptionally(new AepAgentException("poll_timeout", "AEP Status polling timed out"));
return;
}
status().whenComplete((status, failure) -> {
if (result.isDone()) {
return;
Expand All @@ -420,13 +467,11 @@ private void pollStatus(
"AEP enrollment is " + status.status().value()));
return;
}
if (remainingNanos(startedNanos, timeoutNanos) <= timeoutNanos(interval)) {
result.completeExceptionally(new AepAgentException("poll_timeout", "AEP Status polling timed out"));
return;
}
CompletableFuture.runAsync(
() -> pollStatus(result, interval, startedNanos, timeoutNanos),
CompletableFuture.delayedExecutor(delayMillis(interval), TimeUnit.MILLISECONDS));
CompletableFuture.delayedExecutor(
Math.min(remainingNanos(startedNanos, timeoutNanos), timeoutNanos(interval)),
TimeUnit.NANOSECONDS));
});
}

Expand Down Expand Up @@ -455,7 +500,7 @@ private static void requirePerCredentialRevoke(InspectDocument document, RevokeR
}

private String idempotencyKey(CommandContext context, AepCommand command, String discriminator) {
return requireNonBlank(
return AgentHttp.requireFieldValue(
agent.idempotencyKeyProvider.keyFor(
context.inspection.document().service().did(), command, discriminator),
"idempotency key");
Expand Down Expand Up @@ -512,14 +557,6 @@ private static long timeoutNanos(Duration timeout) {
}
}

private static long delayMillis(Duration duration) {
try {
return Math.max(1, duration.toMillis());
} catch (ArithmeticException exception) {
return Long.MAX_VALUE;
}
}

private static long remainingNanos(long startedNanos, long timeoutNanos) {
long elapsed = System.nanoTime() - startedNanos;
return elapsed >= timeoutNanos ? 0 : timeoutNanos - elapsed;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package foundation.aep.agent;

import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;

public interface AgentCredentialStore {
CompletionStage<Optional<AgentCredential>> find(String serviceDid, String grantType);
/** Selects a credential unexpired at the Agent's time; the Service independently validates it. */
CompletionStage<Optional<AgentCredential>> find(String serviceDid, String grantType, Instant now);

CompletionStage<Void> save(AgentCredential credential);

Expand All @@ -22,11 +24,14 @@ static AgentCredentialStore inMemory() {
private final Map<String, AgentCredential> credentials = new LinkedHashMap<>();

@Override
public synchronized CompletionStage<Optional<AgentCredential>> find(String serviceDid, String grantType) {
public synchronized CompletionStage<Optional<AgentCredential>> find(
String serviceDid, String grantType, Instant now) {
AgentCredential selected = null;
for (AgentCredential credential : credentials.values()) {
if (credential.serviceDid().equals(serviceDid)
&& credential.grantType().equals(grantType)) {
&& credential.grantType().equals(grantType)
&& (credential.expiresAt() == null
|| credential.expiresAt().isAfter(now))) {
selected = credential;
}
}
Expand Down
Loading
Loading