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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ The examples use process-local state and loopback HTTP so the complete flow is r
external infrastructure. Follow the role-module guides before replacing those development
boundaries in a production application.

## API-key credentials

For an `api-key` credential, `api_key` is the complete HTTP header value. If a Service returns
`header: "Authorization"` and `api_key: "Bearer secret"`, the Agent sends exactly
`Authorization: Bearer secret`. The SDK does not add or remove a prefix, split the value, or trim it.
Internal spaces and printable ASCII punctuation are allowed; empty values, leading or trailing
spaces, control characters, and non-ASCII characters are rejected. The selected API-key header
is also preserved when the caller selects the dedicated AEP carrier.

## Development

The Maven Wrapper provides the complete repository gate:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ final class AgentCredentialHandlersTest {
void rendersEveryBuiltInCredentialWithoutDisclosingSecrets() {
assertCredential(AgentCredentialHandlers.oauthBearer(), """
{"access_token":"bearer-secret","credential_id":"oauth-1",\
"expires_at":"2027-01-01T00:00:00Z","token_type":"Bearer"}
"expires_at":"2027-01-01T00:00:00Z","token_type":"bEaReR"}
""", Map.of("Authorization", "Bearer bearer-secret"));
assertCredential(AgentCredentialHandlers.apiKey(), """
{"api_key":"api-secret","credential_id":"api-1",\
Expand All @@ -44,6 +44,14 @@ void rejectsStoredPayloadAndMetadataMismatch() {
assertThrows(IllegalArgumentException.class, () -> handler.authorizationHeaders(record, RESOURCE));
}

@Test
void preservesServiceSuppliedAuthorizationValue() {
assertCredential(AgentCredentialHandlers.apiKey(), """
{"api_key":"Bearer api-secret","credential_id":"api-1",\
"expires_at":"2027-01-01T00:00:00Z","header":"Authorization"}
""", Map.of("Authorization", "Bearer api-secret"));
}

@Test
void inMemoryStoreRejectsCredentialIdentifierReassignment() {
AgentCredentialStore store = AgentCredentialStore.inMemory();
Expand Down
17 changes: 7 additions & 10 deletions aep-core/src/main/java/foundation/aep/core/AepValidation.java
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ public static List<ValidationIssue> grantResponse(GrantResponses.BuiltIn value)
&& !"jwt".equals(bearer.tokenFormat())) {
issues.add("$.token_format", "Expected opaque or jwt.");
}
if (!"Bearer".equals(bearer.tokenType())) {
if (!"Bearer".equalsIgnoreCase(bearer.tokenType())) {
issues.add("$.token_type", "Expected Bearer.");
}
} else if (value instanceof GrantResponses.ApiKey apiKey) {
Expand All @@ -515,9 +515,8 @@ public static List<ValidationIssue> grantResponse(GrantResponses.BuiltIn value)
issues.add("$.header", "Expected an HTTP field name.");
}
} else if (value instanceof GrantResponses.Basic basic) {
requiredNonEmpty(basic.password(), "$.password", issues);
requiredNonEmpty(basic.username(), "$.username", issues);
optionalNonEmpty(basic.realm(), "$.realm", issues);
if (basic.password() == null) issues.required("$.password", "string");
if (basic.username() == null) issues.required("$.username", "string");
if (nonEmpty(basic.username())
&& (basic.username().indexOf(':') >= 0 || containsControlCharacter(basic.username()))) {
issues.add("$.username", "Expected an RFC 7617 username without a colon or control character.");
Expand Down Expand Up @@ -986,14 +985,12 @@ static boolean isLoopbackHost(String host) {
}

private static boolean isApiKeyValue(String value) {
if (value.startsWith(" ") || value.endsWith(" ")) {
return false;
}
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (character < 0x21
|| character > 0x7e
|| character == 0x22
|| character == 0x2c
|| character == 0x3b
|| character == BACKSLASH) {
if (character < 0x20 || character > 0x7e) {
return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -413,13 +413,19 @@ void constrainsIssuedCredentialShapes() {
assertTrue(AepValidation.grantResponse(new GrantResponses.ApiKey(
"key-value", "credential-1", "2026-09-01T12:00:00Z", "x-api-key", List.of("read")))
.isEmpty());
for (String apiKey : List.of("key value", "key\"value", "key,value", "key;value", "key\\value", "key\u0001")) {
for (String apiKey : List.of(" key", "key ", "key\n", "key\r", "key\tvalue", "key\u0000", "key\u007f", "kéy")) {
assertTrue(
paths(AepValidation.grantResponse(new GrantResponses.ApiKey(
apiKey, "credential-1", "2026-09-01T12:00:00Z", "x-api-key", null)))
.contains("$.api_key"),
apiKey);
}
for (String apiKey :
List.of("k", "Bearer secret", "key value", "key\"value", "key,value", "key;value", "key\\value")) {
assertTrue(AepValidation.grantResponse(new GrantResponses.ApiKey(
apiKey, "credential-1", "2026-09-01T12:00:00Z", "Authorization", null))
.isEmpty());
}
assertTrue(paths(AepValidation.grantResponse(
new GrantResponses.ApiKey("key", "credential-1", "2026-09-01T12:00:00Z", "bad header", null)))
.contains("$.header"));
Expand All @@ -435,6 +441,16 @@ void constrainsIssuedCredentialShapes() {
assertTrue(paths(AepValidation.grantResponse(new GrantResponses.OAuthBearer(
"token", "credential-1", "2026-09-01T12:00:00Z", null, "saml", "Bearer")))
.contains("$.token_format"));
for (String tokenType : List.of("Bearer", "bearer", "BEARER", "bEaReR")) {
assertTrue(AepValidation.grantResponse(new GrantResponses.OAuthBearer(
"CaseSensitiveToken", "credential-1", "2026-09-01T12:00:00Z", null, tokenType))
.isEmpty());
}
for (String tokenType : List.of("Basic", " Bearer", "Bearer ", "Bearer\n", "")) {
assertTrue(paths(AepValidation.grantResponse(new GrantResponses.OAuthBearer(
"CaseSensitiveToken", "credential-1", "2026-09-01T12:00:00Z", null, tokenType)))
.contains("$.token_type"));
}
assertTrue(AepValidation.grantResponse(new GrantResponses.OAuthBearer(
"token", "credential-1", "2026-09-01T12:00:00Z", List.of(), "jwt", "Bearer"))
.isEmpty());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,15 @@ void validatesBuiltInGrantResponses() {
assertFalse(AepValidation.grantResponse(
new GrantResponses.ApiKey("secret value", "credential-1", expiration, "invalid header", null))
.isEmpty());
assertFalse(
assertTrue(
AepValidation.grantResponse(new GrantResponses.Basic("credential-1", expiration, "", "", List.of(), ""))
.isEmpty());
assertFalse(AepValidation.grantResponse(
new GrantResponses.Basic("credential-1", expiration, null, "", List.of(), ""))
.isEmpty());
assertFalse(AepValidation.grantResponse(
new GrantResponses.Basic("credential-1", expiration, "", "", List.of(), null))
.isEmpty());
assertFalse(AepValidation.grantResponse(
new GrantResponses.Basic("credential-1", expiration, "secret\n", null, null, "agent:name"))
.isEmpty());
Expand Down
58 changes: 34 additions & 24 deletions aep-service/src/main/java/foundation/aep/service/AepService.java
Original file line number Diff line number Diff line change
Expand Up @@ -191,23 +191,26 @@ public CompletionStage<ProtectedResourceResult> authenticate(ProtectedResourceRe
});
}
String presentedMethod = authenticationMethod(presentation.authorization());
if (presentedMethod != null && !authenticationMethods().contains(presentedMethod)) {
return completed(
ProtectedResourceResult.rejected(resourceProblem("unsupported_authentication_method", resource)));
}
return presentedCredentialMethods(authenticationMethods(), input, 0, List.of())
.thenCompose(methods -> {
if (methods.size() > 1
|| presentedMethod != null
&& (methods.size() != 1 || !presentedMethod.equals(methods.get(0)))) {
&& !headerValues(request.headers(), Aep.AUTHORIZATION_HEADER)
.isEmpty()) {
return completed(
ProtectedResourceResult.rejected(resourceProblem(ERROR_NOT_RECOGNIZED, resource)));
}
if (methods.isEmpty()) {
return completed(
ProtectedResourceResult.rejected(resourceProblem("authentication_required", resource)));
if (presentedMethod != null && !authenticationMethods().contains(presentedMethod)) {
return completed(ProtectedResourceResult.rejected(
resourceProblem("unsupported_authentication_method", resource)));
}
boolean presented = presentedMethod != null
|| headerValues(request.headers(), "Authorization").stream()
.anyMatch(AepService::namesAepScheme);
return completed(ProtectedResourceResult.rejected(resourceProblem(
presented ? ERROR_NOT_RECOGNIZED : "authentication_required", resource)));
}
return authenticateCredential(methods, 0, input, true, resource);
return authenticateCredential(methods, 0, input, true, resource, null);
});
}

Expand Down Expand Up @@ -486,23 +489,29 @@ private CompletionStage<ProtectedResourceResult> activePrincipal(
}

private CompletionStage<ProtectedResourceResult> authenticateCredential(
List<String> methods, int index, CredentialAuthenticationInput input, boolean presented, URI resource) {
List<String> methods,
int index,
CredentialAuthenticationInput input,
boolean presented,
URI resource,
AuthenticatedPrincipal matched) {
if (index == methods.size()) {
if (matched != null) return completed(ProtectedResourceResult.authenticated(matched));
String code = presented ? ERROR_NOT_RECOGNIZED : "authentication_required";
return completed(ProtectedResourceResult.rejected(resourceProblem(code, resource)));
}
String method = methods.get(index);
if (Aep.AUTHENTICATION_METHOD_JWT.equals(method)) {
return authenticateCredential(methods, index + 1, input, presented, resource);
return authenticateCredential(methods, index + 1, input, presented, resource, matched);
}
CredentialAuthenticator authenticator = authenticators.get(method);
return authenticator.hasPresentation(input).thenCompose(hasPresentation -> {
if (!hasPresentation) {
return authenticateCredential(methods, index + 1, input, presented, resource);
return authenticateCredential(methods, index + 1, input, presented, resource, matched);
}
return authenticator.authenticate(input).thenCompose(principal -> {
if (principal.isEmpty()) {
return authenticateCredential(methods, index + 1, input, true, resource);
return authenticateCredential(methods, index + 1, input, true, resource, matched);
}
AuthenticatedPrincipal value = principal.get();
if (value.kind() != AuthenticatedPrincipal.Kind.SESSION_CREDENTIAL
Expand All @@ -513,12 +522,16 @@ private CompletionStage<ProtectedResourceResult> authenticateCredential(
return CompletableFuture.failedFuture(
new IllegalArgumentException("Credential authenticator returned an invalid principal."));
}
return enrollmentStore
.find(value.agentDid())
.thenApply(enrollment -> enrollment.isPresent()
&& enrollment.get().status() == AgentStatus.ACTIVE
? ProtectedResourceResult.authenticated(value)
: ProtectedResourceResult.rejected(resourceProblem(ERROR_NOT_RECOGNIZED, resource)));
if (matched != null) {
return completed(ProtectedResourceResult.rejected(resourceProblem(ERROR_NOT_RECOGNIZED, resource)));
}
return enrollmentStore.find(value.agentDid()).thenCompose(enrollment -> {
if (enrollment.isEmpty() || enrollment.get().status() != AgentStatus.ACTIVE) {
return completed(
ProtectedResourceResult.rejected(resourceProblem(ERROR_NOT_RECOGNIZED, resource)));
}
return authenticateCredential(methods, index + 1, input, true, resource, value);
});
});
});
}
Expand Down Expand Up @@ -773,11 +786,8 @@ private static Presentation selectPresentation(Map<String, List<String>> headers
try {
recognized.add(AepHttp.parseAuthorization(value, AuthorizationCarrier.STANDARD));
} catch (IllegalArgumentException exception) {
// Unrelated Authorization schemes remain available to other protocols. A value that
// names an AEP scheme is not one of those: under CORE-PR-038 it is a malformed AEP
// presentation rather than an absent one, which answers not_recognized instead of
// authentication_required.
if (namesAepScheme(value)) return Presentation.invalid();
// API-key verifiers compare the complete field value without parsing its scheme.
continue;
}
}
if (recognized.size() > SINGLE_VALUE || (recognized.size() == SINGLE_VALUE && standard.size() > SINGLE_VALUE))
Expand Down
Loading
Loading