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: 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/`.

Service reports include Service Documents parsing, structural limits, repeated enrollment,
credential preservation, and unmet-requirements handling. Agent reports do not claim the Service
Documents presentation profile.

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).
Expand Down
36 changes: 35 additions & 1 deletion aep-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ if (enrolled.status() == AgentStatus.PENDING) {
AgentGrantResult grant = service.grant("api-key", List.of("catalog:read")).join();
```

`enroll` rejects locally when required advertised claims are missing. `waitForActive` is intended
`enroll` validates supplied claims; the Service decides whether required claims are satisfied by
the request and its stored enrollment. `waitForActive` is intended
for a Service that returns a pending enrollment; an immediately active enrollment can proceed
directly to Grant.

Expand All @@ -127,6 +128,39 @@ 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`.

### Service documents

`inspection.document().serviceDocuments()` exposes document IDs, titles, open types, enrollment
requirements, and representation links. Present required documents to the Owner before supplying
their explicit acknowledgement:

```java
ServiceDocument descriptor = inspection.document().serviceDocuments().get(0);
AepDocumentReader.Result reading = AepDocumentReader.read(descriptor).join();
// Present reading.document() when available; otherwise offer reading.webUrl().

EnrollResponse enrolled = service.enroll(claims, List.of("general-terms", "privacy")).join();
```

`AepDocumentReader` uses a separate anonymous JDK HTTP client without authentication or cookies.
It accepts HTTPS with normal certificate and hostname verification, or HTTP on literal
`localhost`, `127.0.0.1`, and `[::1]` hosts for local development. The caller owns destination
trust; the reader does not inspect DNS answers or connected addresses. It refuses redirects,
limits responses to one mebibyte, and validates UTF-8, media type, portable structure, and document ID.
The default ten-second timeout covers the complete download, including the body; use
`read(descriptor, Duration)` to set a positive timeout representable in nanoseconds. Cancel the returned future
to cancel the download. Missing or unusable portable content returns the HTML link with reason
`not_advertised` or `unavailable_or_unsupported`; cancellation remains cancellation, and invalid
descriptors or timeout arguments throw. A successful result has a document and no reason.

Only send IDs the Owner accepted. A null list omits the declaration; an empty list declares no
accepted documents. The Service must advertise the extension, and a successful response must echo
the exact declaration. Inspecting or reading a document does not acknowledge it.

Explicit repeated Enroll uses the same Agent identity and reaches the Service. Grant errors retain
the Service's `requirementsPending` and `requiredDocumentIds` in `AepAgentException.problem()`. Present those
requirements so the caller can choose to repeat Enroll and then Grant; the SDK does not chain them.

## Authenticate and revoke

Authentication selects the first advertised method that the Agent can satisfy. The returned
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package foundation.aep.agent;

import foundation.aep.core.ProblemDetails;

public final class AepAgentException extends RuntimeException {
private static final long serialVersionUID = 1L;

private final String errorCode;
private final int responseStatus;
private final transient ProblemDetails responseProblem;

public AepAgentException(String code, String message) {
this(code, message, 0, null);
Expand All @@ -19,9 +22,18 @@ public AepAgentException(String code, String message, int status) {
}

private AepAgentException(String code, String message, int status, Throwable cause) {
this(code, message, status, cause, null);
}

AepAgentException(ProblemDetails problem, int status) {
this(problem.code(), problem.detail() == null ? problem.title() : problem.detail(), status, null, problem);
}

private AepAgentException(String code, String message, int status, Throwable cause, ProblemDetails problem) {
super(message, cause);
errorCode = code;
responseStatus = status;
responseProblem = problem;
}

public String code() {
Expand All @@ -31,4 +43,8 @@ public String code() {
public int status() {
return responseStatus;
}

public ProblemDetails problem() {
return responseProblem;
}
}
154 changes: 154 additions & 0 deletions aep-agent/src/main/java/foundation/aep/agent/AepDocumentReader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package foundation.aep.agent;

import foundation.aep.core.AepJson;
import foundation.aep.core.AepValidationException;
import foundation.aep.core.PortableServiceDocument;
import foundation.aep.core.ServiceDocument;
import foundation.aep.core.ServiceDocuments;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Flow;
import java.util.concurrent.TimeUnit;

public final class AepDocumentReader {
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(10);
private static final HttpClient CLIENT =
HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build();

private AepDocumentReader() {}

public record Result(ServiceDocument descriptor, URI webUrl, PortableServiceDocument document, String reason) {}

public static CompletableFuture<Result> read(ServiceDocument descriptor) {
return read(descriptor, DEFAULT_TIMEOUT);
}

public static CompletableFuture<Result> read(ServiceDocument descriptor, Duration timeout) {
var issues = ServiceDocuments.descriptor(descriptor);
if (!issues.isEmpty()) throw new AepValidationException("Service document", issues);
Objects.requireNonNull(timeout, "timeout");
if (timeout.isNegative() || timeout.isZero()) {
throw new IllegalArgumentException("Document timeout must be positive");
}
long timeoutNanos = timeout.toNanos();
URI webUrl = URI.create(descriptor.representations().stream()
.filter(item -> "text/html".equals(item.mediaType()))
.findFirst()
.orElseThrow()
.url());
var portable = descriptor.representations().stream()
.filter(item -> "application/json".equals(item.mediaType()))
.findFirst();
if (portable.isEmpty()) {
return CompletableFuture.completedFuture(new Result(descriptor, webUrl, null, "not_advertised"));
}
Result fallback = new Result(descriptor, webUrl, null, "unavailable_or_unsupported");
HttpRequest request = HttpRequest.newBuilder(
URI.create(portable.orElseThrow().url()))
.timeout(timeout)
.header("Accept", "application/json")
.header("Accept-Encoding", "identity")
.GET()
.build();
var exchange = CLIENT.sendAsync(request, info -> {
requireResponse(info);
return new LimitedBodySubscriber();
});
// Keep caller cancellation separate from HttpClient's cancellation-propagating futures.
CompletableFuture<Result> result = new CompletableFuture<>();
result.whenComplete((value, failure) -> exchange.cancel(true));
exchange.whenComplete((response, failure) -> {
if (result.isDone()) return;
if (failure != null) {
result.complete(fallback);
return;
}
try {
String json = StandardCharsets.UTF_8
.newDecoder()
.decode(ByteBuffer.wrap(response.body()))
.toString();
var document = AepJson.parsePortableServiceDocument(json, descriptor.id());
result.complete(new Result(descriptor, webUrl, document, null));
} catch (CharacterCodingException | RuntimeException exception) {
result.complete(fallback);
}
})
.exceptionally(failure -> {
result.completeExceptionally(failure);
return null;
});
return result.completeOnTimeout(fallback, timeoutNanos, TimeUnit.NANOSECONDS);
}

private static void requireResponse(HttpResponse.ResponseInfo info) {
String contentType = info.headers().firstValue("Content-Type").orElse("");
String encoding = info.headers().firstValue("Content-Encoding").orElse("identity");
String[] parts = contentType.split(";", -1);
if (info.statusCode() < 200
|| info.statusCode() >= 300
|| !"application/json".equalsIgnoreCase(parts[0].trim())
|| !"identity".equalsIgnoreCase(encoding)) {
throw new IllegalArgumentException("Unsupported document response");
}
for (int index = 1; index < parts.length; index++) {
String[] parameter = parts[index].trim().split("=", 2);
if ("charset".equalsIgnoreCase(parameter[0].trim())
&& (parameter.length != 2
|| !("utf-8".equalsIgnoreCase(parameter[1].trim())
|| "\"utf-8\"".equalsIgnoreCase(parameter[1].trim())))) {
throw new IllegalArgumentException("Document must use UTF-8");
}
}
}

private static final class LimitedBodySubscriber implements HttpResponse.BodySubscriber<byte[]> {
private final HttpResponse.BodySubscriber<byte[]> delegate = HttpResponse.BodySubscribers.ofByteArray();
private Flow.Subscription subscription;
private int size;

@Override
public CompletionStage<byte[]> getBody() {
return delegate.getBody();
}

@Override
public void onSubscribe(Flow.Subscription value) {
subscription = value;
delegate.onSubscribe(value);
}

@Override
public void onNext(List<ByteBuffer> buffers) {
for (ByteBuffer buffer : buffers) {
if (buffer.remaining() > ServiceDocuments.MAX_BYTES - size) {
subscription.cancel();
delegate.onError(new IllegalArgumentException("Document exceeds one MiB"));
return;
}
size += buffer.remaining();
}
delegate.onNext(buffers);
}

@Override
public void onError(Throwable failure) {
delegate.onError(failure);
}

@Override
public void onComplete() {
delegate.onComplete();
}
}
}
33 changes: 19 additions & 14 deletions aep-agent/src/main/java/foundation/aep/agent/AepServiceSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import foundation.aep.core.InspectDocument;
import foundation.aep.core.RevokeRequest;
import foundation.aep.core.RevokeResponse;
import foundation.aep.core.ServiceDocuments;
import foundation.aep.core.StatusResponse;
import foundation.aep.core.ValidationIssue;
import java.net.URI;
Expand Down Expand Up @@ -62,13 +63,29 @@ public synchronized CompletableFuture<AgentInspection> inspect() {
}

public CompletableFuture<EnrollResponse> enroll(ClaimValues claims) {
return enroll(claims, null);
}

public CompletableFuture<EnrollResponse> enroll(ClaimValues claims, List<String> acknowledgedDocumentIds) {
List<String> declared = acknowledgedDocumentIds == null ? null : List.copyOf(acknowledgedDocumentIds);
return commandContext(AepCommand.ENROLL).thenCompose(context -> {
requireClaims(context.inspection.document(), claims);
InspectDocument document = context.inspection.document();
if (declared != null
&& (document.extensions() == null
|| !document.extensions().supported().contains(ServiceDocuments.EXTENSION))) {
return failed("service_documents_unavailable", "AEP Service does not advertise Service Documents");
}
String key = idempotencyKey(context, AepCommand.ENROLL, "");
EnrollRequest request = new EnrollRequest(context.identity.did(), claims, key);
EnrollRequest request = new EnrollRequest(context.identity.did(), claims, key, declared);
requireValid("Enroll request", AepValidation.enrollRequest(request));
return execute(context, AepCommand.ENROLL, "POST", request, key, AepJson::parseEnrollResponse)
.thenApply(response -> {
if (response.status() != AgentStatus.REJECTED
&& !Objects.equals(declared, response.acknowledgedDocumentIds())) {
throw new AepAgentException(
"document_acknowledgement_mismatch",
"AEP enrollment document acknowledgement does not match the request");
}
recordEnrollment(context, response.status());
return response;
});
Expand Down Expand Up @@ -475,18 +492,6 @@ private void pollStatus(
});
}

private static void requireClaims(InspectDocument document, ClaimValues claims) {
List<String> required =
document.claims() == null ? List.of() : document.claims().required();
List<String> missing = required.stream()
.filter(name -> claims == null || !claims.contains(name))
.toList();
if (!missing.isEmpty()) {
throw new AepAgentException(
"requirements_unmet", "Required Service claims are unavailable: " + String.join(", ", missing));
}
}

private static void requirePerCredentialRevoke(InspectDocument document, RevokeRequest request) {
if (request.credentialId() == null) {
return;
Expand Down
3 changes: 1 addition & 2 deletions aep-agent/src/main/java/foundation/aep/agent/AgentHttp.java
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,7 @@ static AepAgentException commandError(AepHttpTransport.Response response) {
try {
requireMediaType(response, Aep.PROBLEM_MEDIA_TYPE);
ProblemDetails problem = AepJson.parseProblemDetails(body(response));
return new AepAgentException(
problem.code(), problem.detail() == null ? problem.title() : problem.detail(), response.status());
return new AepAgentException(problem, response.status());
} catch (RuntimeException ignored) {
return new AepAgentException("command_failed", "AEP command failed", response.status());
}
Expand Down
Loading
Loading