diff --git a/README.md b/README.md index 7b6316a..a35d749 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/aep-agent/src/test/java/foundation/aep/agent/AgentCredentialHandlersTest.java b/aep-agent/src/test/java/foundation/aep/agent/AgentCredentialHandlersTest.java index 4f72ad7..903a707 100644 --- a/aep-agent/src/test/java/foundation/aep/agent/AgentCredentialHandlersTest.java +++ b/aep-agent/src/test/java/foundation/aep/agent/AgentCredentialHandlersTest.java @@ -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",\ @@ -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(); diff --git a/aep-core/src/main/java/foundation/aep/core/AepValidation.java b/aep-core/src/main/java/foundation/aep/core/AepValidation.java index 2a9e718..ac9ceac 100644 --- a/aep-core/src/main/java/foundation/aep/core/AepValidation.java +++ b/aep-core/src/main/java/foundation/aep/core/AepValidation.java @@ -501,7 +501,7 @@ public static List 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) { @@ -515,9 +515,8 @@ public static List 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."); @@ -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; } } diff --git a/aep-core/src/test/java/foundation/aep/core/AepCoreConformanceTest.java b/aep-core/src/test/java/foundation/aep/core/AepCoreConformanceTest.java index 3492f2b..c40cc75 100644 --- a/aep-core/src/test/java/foundation/aep/core/AepCoreConformanceTest.java +++ b/aep-core/src/test/java/foundation/aep/core/AepCoreConformanceTest.java @@ -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")); @@ -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()); diff --git a/aep-core/src/test/java/foundation/aep/core/AepProtocolValidationTest.java b/aep-core/src/test/java/foundation/aep/core/AepProtocolValidationTest.java index a93ed9b..2753ff0 100644 --- a/aep-core/src/test/java/foundation/aep/core/AepProtocolValidationTest.java +++ b/aep-core/src/test/java/foundation/aep/core/AepProtocolValidationTest.java @@ -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()); diff --git a/aep-service/src/main/java/foundation/aep/service/AepService.java b/aep-service/src/main/java/foundation/aep/service/AepService.java index 5b40900..3f19341 100644 --- a/aep-service/src/main/java/foundation/aep/service/AepService.java +++ b/aep-service/src/main/java/foundation/aep/service/AepService.java @@ -191,23 +191,26 @@ public CompletionStage 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); }); } @@ -486,23 +489,29 @@ private CompletionStage activePrincipal( } private CompletionStage authenticateCredential( - List methods, int index, CredentialAuthenticationInput input, boolean presented, URI resource) { + List 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 @@ -513,12 +522,16 @@ private CompletionStage 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); + }); }); }); } @@ -773,11 +786,8 @@ private static Presentation selectPresentation(Map> 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)) diff --git a/aep-service/src/test/java/foundation/aep/service/StoredCredentialGrantTypesTest.java b/aep-service/src/test/java/foundation/aep/service/StoredCredentialGrantTypesTest.java index 60009c4..bac9946 100644 --- a/aep-service/src/test/java/foundation/aep/service/StoredCredentialGrantTypesTest.java +++ b/aep-service/src/test/java/foundation/aep/service/StoredCredentialGrantTypesTest.java @@ -78,6 +78,64 @@ void issuesAuthenticatesAndRevokesEveryBuiltInCredential() { assertRejected(service, Map.of("X-Service-Key", List.of("api-secret")), "not_recognized"); } + @Test + void acceptsEmptyBasicUsernameAndPassword() { + for (List values : List.of(List.of("", "secret"), List.of("user", ""), List.of("", ""))) { + String username = values.get(0); + String password = values.get(1); + StoredCredentialGrantType basic = StoredCredentialGrantTypes.basic( + config(List.of()), + (request, context) -> completed(new GrantResponses.Basic( + "basic", NOW.plusSeconds(600).toString(), password, "", List.of(), username)), + ServiceCredentialStore.inMemory()); + AepService service = service(basic); + enroll(service); + grant(service, Aep.GRANT_TYPE_BASIC, "grant-basic"); + String value = "Basic " + + java.util.Base64.getEncoder() + .encodeToString( + (username + ":" + password).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertAuthenticated(service, Map.of("Authorization", List.of(value)), "basic"); + service.revoke( + RevokeRequest.credential(Aep.GRANT_TYPE_BASIC, "basic"), + CommandOptions.idempotent("revoke-basic", "revoke-basic")) + .toCompletableFuture() + .join(); + assertRejected(service, Map.of("Authorization", List.of(value)), "not_recognized"); + } + } + + @Test + void authenticatesOpaqueAuthorizationApiKeysWithoutSelectingAnOAuthOrBasicGrant() { + for (String value : List.of( + "Bearer secret", + "bearer secret", + "Basic secret", + "basic secret", + "Bearer two words", + "key,\";\\value")) { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + StoredCredentialGrantType apiKey = StoredCredentialGrantTypes.apiKey( + config(List.of("authorization")), + (request, context) -> completed(new GrantResponses.ApiKey( + value, "api-1", NOW.plusSeconds(600).toString(), "Authorization", List.of())), + store); + AepService service = service(apiKey); + enroll(service); + grant(service, Aep.GRANT_TYPE_API_KEY, "grant-api"); + assertAuthenticated(service, Map.of("Authorization", List.of(value)), "api-1"); + assertRejected(service, Map.of("Authorization", List.of("Bearer " + value)), "not_recognized"); + assertRejected(service, Map.of("Authorization", List.of(value, value)), "not_recognized"); + assertRejected(service, Map.of("Authorization", List.of(value + "\r\nInjected: true")), "not_recognized"); + service.revoke( + RevokeRequest.credential(Aep.GRANT_TYPE_API_KEY, "api-1"), + CommandOptions.idempotent("revoke-api", "revoke-api-key")) + .toCompletableFuture() + .join(); + assertRejected(service, Map.of("Authorization", List.of(value)), "not_recognized"); + } + } + @Test void distinguishesMissingWrongHeaderAndInvalidSelectedApiKey() { ServiceCredentialStore store = ServiceCredentialStore.inMemory(); @@ -95,6 +153,27 @@ void distinguishesMissingWrongHeaderAndInvalidSelectedApiKey() { assertRejected(service, Map.of("X-Service-Key", List.of("wrong")), "not_recognized"); } + @Test + void verifiesSharedAuthorizationFieldsAndRejectsMultipleCredentialMatches() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + StoredCredentialGrantType apiKey = StoredCredentialGrantTypes.apiKey( + config(List.of("authorization")), + (request, context) -> completed(new GrantResponses.ApiKey( + "Bearer secret", "api-1", NOW.plusSeconds(600).toString(), "Authorization", List.of())), + store); + StoredCredentialGrantType oauth = StoredCredentialGrantTypes.oauthBearer( + config(List.of()), + (request, context) -> completed(new GrantResponses.OAuthBearer( + "secret", "oauth-1", NOW.plusSeconds(600).toString(), List.of(), "Bearer")), + store); + AepService service = service(oauth, apiKey); + enroll(service); + grant(service, Aep.GRANT_TYPE_API_KEY, "grant-api"); + assertAuthenticated(service, Map.of("Authorization", List.of("Bearer secret")), "api-1"); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER, "grant-oauth"); + assertRejected(service, Map.of("Authorization", List.of("Bearer secret")), "not_recognized"); + } + @Test void doesNotFallThroughFromAnInvalidSelectedBearerToAValidApiKey() { ServiceCredentialStore store = ServiceCredentialStore.inMemory(); @@ -183,9 +262,7 @@ private static AepService service(StoredCredentialGrantType... grantTypes) { .toList(); Map configs = new LinkedHashMap<>(); for (StoredCredentialGrantType grantType : grantTypes) { - List headers = - Aep.GRANT_TYPE_API_KEY.equals(grantType.grantType()) ? List.of("x-service-key") : List.of(); - configs.put(grantType.grantType(), config(headers)); + configs.put(grantType.grantType(), grantType.config()); } AepService.Builder builder = baseBuilder(document(names, configs)); for (StoredCredentialGrantType grantType : grantTypes) builder.storedCredentialGrantType(grantType);