From 4441956129c453cfcd82ab34126e377666cd9c63 Mon Sep 17 00:00:00 2001 From: Paul Latzelsperger <43503240+paullatzelsperger@users.noreply.github.com> Date: Mon, 17 Jun 2024 12:01:49 +0200 Subject: [PATCH] feat: implement Delegated Authentication Service (#4270) * add jwks resolver and auth service * improved unit tests by splitting * added extension test * added fallback header, key caching [skip ci] * updated log line * license header * license header * DEPENDENCIES * DEPENDENCIES * only register tokenbased if no other is registered * DEPENDENCIES * pr remarks * use factory method for JwksPublicKeyResolverImpl --- .../edc/token/TokenValidationServiceImpl.java | 2 +- .../ExpirationIssuedAtValidationRule.java | 16 +- .../token/rules/NotBeforeValidationRule.java | 16 +- .../ExpirationIssuedAtValidationRuleTest.java | 15 +- .../rules/NotBeforeValidationRuleTest.java | 15 +- .../auth/ApiAuthenticationRegistryImpl.java | 5 + .../ApiAuthenticationRegistryImplTest.java | 7 + .../auth/auth-delegated/build.gradle.kts | 33 +++ .../DelegatedAuthenticationExtension.java | 85 +++++++ .../DelegatedAuthenticationService.java | 96 ++++++++ .../auth/delegated/JwksPublicKeyResolver.java | 139 +++++++++++ ...rg.eclipse.edc.spi.system.ServiceExtension | 15 ++ .../DelegatedAuthenticationExtensionTest.java | 68 ++++++ .../DelegatedAuthenticationServiceTest.java | 166 +++++++++++++ .../delegated/JwksPublicKeyResolverTest.java | 223 ++++++++++++++++++ .../edc/api/auth/delegated/TestFunctions.java | 59 +++++ .../TokenBasedAuthenticationExtension.java | 8 +- settings.gradle.kts | 1 + .../registry/ApiAuthenticationRegistry.java | 10 +- 19 files changed, 967 insertions(+), 12 deletions(-) create mode 100644 extensions/common/auth/auth-delegated/build.gradle.kts create mode 100644 extensions/common/auth/auth-delegated/src/main/java/org/eclipse/edc/api/auth/delegated/DelegatedAuthenticationExtension.java create mode 100644 extensions/common/auth/auth-delegated/src/main/java/org/eclipse/edc/api/auth/delegated/DelegatedAuthenticationService.java create mode 100644 extensions/common/auth/auth-delegated/src/main/java/org/eclipse/edc/api/auth/delegated/JwksPublicKeyResolver.java create mode 100644 extensions/common/auth/auth-delegated/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension create mode 100644 extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/DelegatedAuthenticationExtensionTest.java create mode 100644 extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/DelegatedAuthenticationServiceTest.java create mode 100644 extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/JwksPublicKeyResolverTest.java create mode 100644 extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/TestFunctions.java diff --git a/core/common/token-core/src/main/java/org/eclipse/edc/token/TokenValidationServiceImpl.java b/core/common/token-core/src/main/java/org/eclipse/edc/token/TokenValidationServiceImpl.java index ec26f823e81..703283410aa 100644 --- a/core/common/token-core/src/main/java/org/eclipse/edc/token/TokenValidationServiceImpl.java +++ b/core/common/token-core/src/main/java/org/eclipse/edc/token/TokenValidationServiceImpl.java @@ -44,7 +44,7 @@ public Result validate(TokenRepresentation tokenRepresentation, Publ var publicKeyResolutionResult = publicKeyResolver.resolveKey(publicKeyId); if (publicKeyResolutionResult.failed()) { - return publicKeyResolutionResult.mapTo(); + return publicKeyResolutionResult.mapFailure(); } var verifierCreationResult = CryptoConverter.createVerifierFor(publicKeyResolutionResult.getContent()); diff --git a/core/common/token-core/src/main/java/org/eclipse/edc/token/rules/ExpirationIssuedAtValidationRule.java b/core/common/token-core/src/main/java/org/eclipse/edc/token/rules/ExpirationIssuedAtValidationRule.java index b38e142b769..06128ba5694 100644 --- a/core/common/token-core/src/main/java/org/eclipse/edc/token/rules/ExpirationIssuedAtValidationRule.java +++ b/core/common/token-core/src/main/java/org/eclipse/edc/token/rules/ExpirationIssuedAtValidationRule.java @@ -34,10 +34,22 @@ public class ExpirationIssuedAtValidationRule implements TokenValidationRule { private final Clock clock; private final int issuedAtLeeway; + private final boolean allowNull; + /** + * Instantiates the rule + * + * @deprecated Please use {@link ExpirationIssuedAtValidationRule#ExpirationIssuedAtValidationRule(Clock, int, boolean)} instead + */ + @Deprecated(since = "0.7.0") public ExpirationIssuedAtValidationRule(Clock clock, int issuedAtLeeway) { + this(clock, issuedAtLeeway, false); + } + + public ExpirationIssuedAtValidationRule(Clock clock, int issuedAtLeeway, boolean allowNull) { this.clock = clock; this.issuedAtLeeway = issuedAtLeeway; + this.allowNull = allowNull; } @Override @@ -45,7 +57,9 @@ public Result checkRule(@NotNull ClaimToken toVerify, @Nullable Map checkRule(@NotNull ClaimToken toVerify, @Nullable Map> headers) { + + if (headers == null) { + var msg = "Headers were null"; + monitor.warning(msg); + throw new AuthenticationFailedException(msg); + } + + var authHeaders = headers.get(AUTHORIZATION); + if (authHeaders == null || authHeaders.isEmpty()) { + // fall back to X-API-Key - backwards compatibility + authHeaders = headers.get(X_API_KEY); + if (authHeaders != null && !authHeaders.isEmpty()) { + monitor.warning(OLD_API_KEY_WARNING); + } + } + + return Optional.ofNullable(authHeaders) + .map(this::performTokenValidation) + .orElseThrow(() -> { + var msg = "Header '%s' not present".formatted(AUTHORIZATION); + monitor.warning(msg); + return new AuthenticationFailedException(msg); + }); + + } + + private boolean performTokenValidation(List authHeaders) { + if (authHeaders.size() != 1) { + monitor.warning("Expected exactly 1 Authorization header, found %d".formatted(authHeaders.size())); + return false; + } + var token = authHeaders.get(0); + if (!token.toLowerCase().startsWith("bearer ")) { + monitor.warning("Authorization header must start with 'Bearer '"); + return false; + } + token = token.substring(6).trim(); // "bearer" has 7 characters, it could be upper case, lower case or capitalized + + var rules = rulesRegistry.getRules(MANAGEMENT_API_CONTEXT); + return tokenValidationService.validate(token, publicKeyResolver, rules).succeeded(); + } + +} diff --git a/extensions/common/auth/auth-delegated/src/main/java/org/eclipse/edc/api/auth/delegated/JwksPublicKeyResolver.java b/extensions/common/auth/auth-delegated/src/main/java/org/eclipse/edc/api/auth/delegated/JwksPublicKeyResolver.java new file mode 100644 index 00000000000..70890a13457 --- /dev/null +++ b/extensions/common/auth/auth-delegated/src/main/java/org/eclipse/edc/api/auth/delegated/JwksPublicKeyResolver.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.api.auth.delegated; + +import com.nimbusds.jose.KeySourceException; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKMatcher; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.proc.SimpleSecurityContext; +import org.eclipse.edc.keys.spi.KeyParserRegistry; +import org.eclipse.edc.keys.spi.PublicKeyResolver; +import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.Result; +import org.jetbrains.annotations.Nullable; + +import java.net.MalformedURLException; +import java.net.URI; +import java.security.PublicKey; +import java.util.List; +import java.util.Optional; + +import static com.nimbusds.jose.jwk.source.JWKSourceBuilder.DEFAULT_CACHE_REFRESH_TIMEOUT; +import static com.nimbusds.jose.jwk.source.JWKSourceBuilder.DEFAULT_RATE_LIMIT_MIN_INTERVAL; +import static com.nimbusds.jose.jwk.source.JWKSourceBuilder.DEFAULT_REFRESH_AHEAD_TIME; + +/** + * A {@link PublicKeyResolver} that resolves a JSON Web Key Set from a URL and parses the JWK with the given ID + */ +public class JwksPublicKeyResolver implements PublicKeyResolver { + private final Monitor monitor; + private final KeyParserRegistry keyParserRegistry; + private final JWKSource jwkSource; + + private JwksPublicKeyResolver(KeyParserRegistry keyParserRegistry, Monitor monitor, JWKSource jwkSource) { + this.keyParserRegistry = keyParserRegistry; + this.monitor = monitor; + this.jwkSource = jwkSource; + } + + /** + * Creates a new resolver that does use any cache. That means, that every request hits the server. + * + * @param keyParserRegistry Should contain all relevant key parsers. The minimum recommendation is adding a {@code JwkParser}. + * @param jwksUrl The URL of the public key server, where a JWK Set can be obtained. + * @param monitor A monitor + * @throws EdcException if the jwksUrl is malformed + */ + public static JwksPublicKeyResolver create(KeyParserRegistry keyParserRegistry, String jwksUrl, Monitor monitor) { + return create(keyParserRegistry, jwksUrl, monitor, 0); + } + + /** + * Creates a new resolver that does use any cache. That means, that every request hits the server. + * + * @param keyParserRegistry Should contain all relevant key parsers. The minimum recommendation is adding a {@code JwkParser}. + * @param jwksUrl The URL of the public key server, where a JWK Set can be obtained. + * @param monitor A monitor + * @param cacheValidityMs The time in milliseconds that public keys may be cached locally. + * @throws EdcException if the jwksUrl is malformed + */ + public static JwksPublicKeyResolver create(KeyParserRegistry keyParserRegistry, String jwksUrl, Monitor monitor, long cacheValidityMs) { + + try { + var builder = JWKSourceBuilder.create(URI.create(jwksUrl).toURL()).retrying(false); + if (cacheValidityMs > 0) { + builder.cache(cacheValidityMs, DEFAULT_CACHE_REFRESH_TIMEOUT); + + // rate-limit must be < cache TTL, this would cause the cache to be refreshed more often than allowed + if (cacheValidityMs < DEFAULT_RATE_LIMIT_MIN_INTERVAL) { + builder.rateLimited(cacheValidityMs - 1); + } + // cache TTL must be > refresh-ahead time plus refresh timeout + if (cacheValidityMs < DEFAULT_REFRESH_AHEAD_TIME + DEFAULT_CACHE_REFRESH_TIMEOUT) { + builder.refreshAheadCache(false); + } + + } else { + // disable all optimizations + builder.cache(false); + builder.rateLimited(false); + builder.refreshAheadCache(false); + } + var jwkSource = builder.build(); + return new JwksPublicKeyResolver(keyParserRegistry, monitor, jwkSource); + + } catch (MalformedURLException e) { + monitor.warning("Malformed JWK URL: " + jwksUrl, e); + throw new EdcException(e); + } + } + + @Override + public Result resolveKey(@Nullable String keyId) { + var matcher = Optional.ofNullable(keyId) // get matcher with optional keyID property + .map(kid -> new JWKMatcher.Builder().keyID(kid).build()) + .orElseGet(() -> new JWKMatcher.Builder().build()); + var selector = new JWKSelector(matcher); + List keys; + try { + keys = jwkSource.get(selector, new SimpleSecurityContext()); + } catch (KeySourceException e) { + monitor.warning("Error while retrieving JWKSet", e); + return Result.failure("Error while retrieving JWKSet: " + e.getMessage()); + } + + if (keys.isEmpty()) { + var msg = "JWKSet did not contain a matching key (desired keyId: '%s')".formatted(keyId); + monitor.warning(msg); + return Result.failure(msg); + } + if (keys.size() > 1) { + String msg = keyId == null ? + "JWKSet contained %d keys, but no keyId was specified. Please consider specifying a keyId.".formatted(keys.size()) : + "JWKSet contained %d matching keys (desired keyId: '%s'), where only 1 is expected. Will abort!".formatted(keys.size(), keyId); + monitor.warning(msg); + return Result.failure(msg); + } + + var jwk = keys.get(0); + + return keyParserRegistry.parse(jwk.toJSONString()).map(k -> (PublicKey) k); + } +} diff --git a/extensions/common/auth/auth-delegated/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/extensions/common/auth/auth-delegated/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 00000000000..20a14da0836 --- /dev/null +++ b/extensions/common/auth/auth-delegated/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,15 @@ +# +# Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# +# Contributors: +# Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation +# +# + +org.eclipse.edc.api.auth.delegated.DelegatedAuthenticationExtension diff --git a/extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/DelegatedAuthenticationExtensionTest.java b/extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/DelegatedAuthenticationExtensionTest.java new file mode 100644 index 00000000000..c6f1d7b0a89 --- /dev/null +++ b/extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/DelegatedAuthenticationExtensionTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.api.auth.delegated; + +import org.eclipse.edc.api.auth.spi.registry.ApiAuthenticationRegistry; +import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.spi.system.configuration.Config; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.eclipse.edc.api.auth.delegated.DelegatedAuthenticationExtension.AUTH_SETTING_KEY_URL; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(DependencyInjectionExtension.class) +class DelegatedAuthenticationExtensionTest { + + private final Monitor monitor = mock(Monitor.class); + private final ApiAuthenticationRegistry registry = mock(); + + @BeforeEach + void setUp(ServiceExtensionContext context) { + when(monitor.withPrefix(anyString())).thenReturn(monitor); + when(context.getMonitor()).thenReturn(monitor); + context.registerService(ApiAuthenticationRegistry.class, registry); + } + + @Test + void initialize(DelegatedAuthenticationExtension extension, ServiceExtensionContext context) { + + var configMock = mock(Config.class); + when(configMock.getString(eq(AUTH_SETTING_KEY_URL), eq(null))).thenReturn("http://foo.bar/.well-known/jwks.json"); + when(context.getConfig()).thenReturn(configMock); + + extension.initialize(context); + + verify(registry).register(eq("management-api"), isA(DelegatedAuthenticationService.class)); + } + + @Test + void initialize_noUrlGiven_shouldNotRegister(DelegatedAuthenticationExtension extension, ServiceExtensionContext context) { + + extension.initialize(context); + + verify(monitor).warning("The '%s' setting was not provided, so the DelegatedAuthenticationService will NOT be registered. In this case, the TokenBasedAuthenticationService usually acts as fallback.".formatted(AUTH_SETTING_KEY_URL)); + verify(registry, never()).register(eq("management-api"), isA(DelegatedAuthenticationService.class)); + } +} \ No newline at end of file diff --git a/extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/DelegatedAuthenticationServiceTest.java b/extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/DelegatedAuthenticationServiceTest.java new file mode 100644 index 00000000000..c9a0def58c6 --- /dev/null +++ b/extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/DelegatedAuthenticationServiceTest.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.api.auth.delegated; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.edc.keys.keyparsers.JwkParser; +import org.eclipse.edc.keys.spi.PublicKeyResolver; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.token.TokenValidationServiceImpl; +import org.eclipse.edc.token.spi.TokenValidationRulesRegistry; +import org.eclipse.edc.web.spi.exception.AuthenticationFailedException; +import org.junit.jupiter.api.Test; + +import java.security.PublicKey; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.eclipse.edc.api.auth.delegated.TestFunctions.createToken; +import static org.eclipse.edc.api.auth.delegated.TestFunctions.generateKey; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class DelegatedAuthenticationServiceTest { + + private static final long TEST_CACHE_VALIDITY = 50; + private final TokenValidationRulesRegistry rulesRegistry = mock(); + private final PublicKeyResolver publicKeyResolver = mock(); + private final ObjectMapper mapper = new ObjectMapper(); + private final Monitor monitor = mock(); + private final JwkParser jwkParser = new JwkParser(mapper, monitor); + private final DelegatedAuthenticationService service = new DelegatedAuthenticationService(publicKeyResolver, monitor, new TokenValidationServiceImpl(), rulesRegistry); + + @Test + void isAuthenticated_valid() { + var key = generateKey(); + var pk = jwkParser.parse(key.toPublicJWK().toJSONString()); + when(publicKeyResolver.resolveKey(anyString())).thenReturn(pk.map(k -> (PublicKey) k)); + + var token = createToken(key); + var headers = Map.of("Authorization", List.of("Bearer " + token)); + + assertThat(service.isAuthenticated(headers)).isTrue(); + verify(publicKeyResolver).resolveKey(eq(key.getKeyID())); + verify(rulesRegistry).getRules(eq(DelegatedAuthenticationService.MANAGEMENT_API_CONTEXT)); + verifyNoMoreInteractions(publicKeyResolver, rulesRegistry); + } + + @Test + void isAuthenticated_noHeaders() { + assertThatThrownBy(() -> service.isAuthenticated(null)) + .isInstanceOf(AuthenticationFailedException.class); + verify(monitor).warning("Headers were null"); + verifyNoInteractions(rulesRegistry, publicKeyResolver); + } + + @Test + void isAuthenticated_emptyHeaders() { + assertThatThrownBy(() -> service.isAuthenticated(Map.of())) + .isInstanceOf(AuthenticationFailedException.class); + verify(monitor).warning("Header 'Authorization' not present"); + verifyNoInteractions(rulesRegistry, publicKeyResolver); + } + + @Test + void isAuthenticated_noAuthHeader() { + assertThatThrownBy(() -> service.isAuthenticated(Map.of("foo", List.of("bar")))) + .isInstanceOf(AuthenticationFailedException.class); + verify(monitor).warning("Header 'Authorization' not present"); + verifyNoInteractions(rulesRegistry, publicKeyResolver); + } + + @Test + void isAuthenticated_multipleAuthHeaders_shouldReject() { + var key = generateKey(); + var token = createToken(key); + + var headers = Map.of("Authorization", List.of("Bearer " + token, "Bearer someOtherToken")); + + assertThat(service.isAuthenticated(headers)).isFalse(); + verify(monitor).warning(contains("Expected exactly 1 Authorization header, found 2")); + verifyNoInteractions(rulesRegistry, publicKeyResolver); + } + + @Test + void isAuthenticated_notBearer() { + var key = generateKey(); + var pk = jwkParser.parse(key.toPublicJWK().toJSONString()); + when(publicKeyResolver.resolveKey(anyString())).thenReturn(pk.map(k -> (PublicKey) k)); + + var token = createToken(key); + var headers = Map.of("Authorization", List.of(token)); + + assertThat(service.isAuthenticated(headers)).isFalse(); + verify(monitor).warning("Authorization header must start with 'Bearer '"); + verifyNoInteractions(rulesRegistry, publicKeyResolver); + } + + @Test + void isAuthenticated_withXapiKey() { + var key = generateKey(); + var pk = jwkParser.parse(key.toPublicJWK().toJSONString()); + when(publicKeyResolver.resolveKey(anyString())).thenReturn(pk.map(k -> (PublicKey) k)); + + var token = createToken(key); + var headers = Map.of("x-api-key", List.of("bearer " + token)); + + assertThat(service.isAuthenticated(headers)).isTrue(); + verify(publicKeyResolver).resolveKey(eq(key.getKeyID())); + verify(rulesRegistry).getRules(eq(DelegatedAuthenticationService.MANAGEMENT_API_CONTEXT)); + verifyNoMoreInteractions(publicKeyResolver, rulesRegistry); + } + + @Test + void isAuthenticated_withXapiKey_noBearerPrefix() { + var key = generateKey(); + var pk = jwkParser.parse(key.toPublicJWK().toJSONString()); + when(publicKeyResolver.resolveKey(anyString())).thenReturn(pk.map(k -> (PublicKey) k)); + + var token = createToken(key); + var headers = Map.of("x-api-key", List.of(token)); + + assertThat(service.isAuthenticated(headers)).isFalse(); + verifyNoInteractions(publicKeyResolver, rulesRegistry); + verify(monitor).warning(DelegatedAuthenticationService.OLD_API_KEY_WARNING); + } + + @Test + void isAuthenticated_withXapiKeyAndAuthHeader_authTakesPrecedence() { + var key = generateKey(); + var pk = jwkParser.parse(key.toPublicJWK().toJSONString()); + when(publicKeyResolver.resolveKey(anyString())).thenReturn(pk.map(k -> (PublicKey) k)); + + var token = createToken(key); + var headers = Map.of( + "x-api-key", List.of(token), + "Authorization", List.of("Bearer " + token)); + + assertThat(service.isAuthenticated(headers)).isTrue(); + verify(publicKeyResolver).resolveKey(eq(key.getKeyID())); + verify(rulesRegistry).getRules(eq(DelegatedAuthenticationService.MANAGEMENT_API_CONTEXT)); + verify(monitor, never()).warning(DelegatedAuthenticationService.OLD_API_KEY_WARNING); + verifyNoMoreInteractions(publicKeyResolver, rulesRegistry); + } + +} \ No newline at end of file diff --git a/extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/JwksPublicKeyResolverTest.java b/extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/JwksPublicKeyResolverTest.java new file mode 100644 index 00000000000..5f71fce099c --- /dev/null +++ b/extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/JwksPublicKeyResolverTest.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.api.auth.delegated; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.jwk.JWK; +import org.eclipse.edc.junit.annotations.ComponentTest; +import org.eclipse.edc.keys.KeyParserRegistryImpl; +import org.eclipse.edc.keys.keyparsers.JwkParser; +import org.eclipse.edc.keys.spi.KeyParserRegistry; +import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.monitor.Monitor; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; + +import java.net.MalformedURLException; +import java.time.Duration; +import java.util.Arrays; +import java.util.Map; + +import static com.nimbusds.jose.jwk.source.JWKSourceBuilder.DEFAULT_CACHE_TIME_TO_LIVE; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; +import static org.eclipse.edc.api.auth.delegated.TestFunctions.generateKey; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.edc.util.io.Ports.getFreePort; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.stop.Stop.stopQuietly; +import static org.mockserver.verify.VerificationTimes.exactly; +import static org.mockserver.verify.VerificationTimes.never; + +@ComponentTest +class JwksPublicKeyResolverTest { + + private static ClientAndServer jwksServer; + private final KeyParserRegistry keyParserRegistry = new KeyParserRegistryImpl(); + private final ObjectMapper mapper = new ObjectMapper(); + private final Monitor monitor = mock(); + private JwksPublicKeyResolver resolver; + + @BeforeAll + static void prepare() { + jwksServer = ClientAndServer.startClientAndServer(getFreePort()); + } + + @AfterAll + static void teardown() { + stopQuietly(jwksServer); + } + + @BeforeEach + void setup() { + jwksServer.reset(); + keyParserRegistry.register(new JwkParser(mapper, monitor)); + resolver = JwksPublicKeyResolver.create(keyParserRegistry, jwksServerUrl(), monitor, DEFAULT_CACHE_TIME_TO_LIVE); + } + + @Test + void resolve_singleKey() { + jwksServer.when(jwksRequest()) + .respond(response().withStatusCode(200).withBody(jwksObject(generateKey("foo-bar-key").toPublicJWK()))); + + assertThat(resolver.resolveKey("foo-bar-key")).isSucceeded(); + } + + @Test + void resolve_multipleUniqueKeys() { + + var key1 = generateKey("foo-bar-key1").toPublicJWK(); + var key2 = generateKey("foo-bar-key2").toPublicJWK(); + jwksServer.when(jwksRequest()) + .respond(response().withStatusCode(200).withBody(jwksObject(key1, key2))); + + assertThat(resolver.resolveKey("foo-bar-key2")).isSucceeded(); + jwksServer.verify(jwksRequest(), exactly(1)); + } + + @Test + void resolve_multipleKeysWithSameId() { + + var key1 = generateKey("foo-bar-keyX").toPublicJWK(); + var key2 = generateKey("foo-bar-keyX").toPublicJWK(); + jwksServer.when(jwksRequest()) + .respond(response().withStatusCode(200).withBody(jwksObject(key1, key2))); + + assertThat(resolver.resolveKey("foo-bar-keyX")).isFailed() + .detail().isEqualTo("JWKSet contained 2 matching keys (desired keyId: 'foo-bar-keyX'), where only 1 is expected. Will abort!"); + jwksServer.verify(jwksRequest(), exactly(1)); + + } + + @Test + void resolve_keyNotFound() { + jwksServer.when(jwksRequest()) + .respond(response().withStatusCode(200).withBody(jwksObject(generateKey("foo-bar-key").toPublicJWK()))); + + assertThat(resolver.resolveKey("not-exist")).isFailed() + .detail().isEqualTo("JWKSet did not contain a matching key (desired keyId: 'not-exist')"); + // the JWK source has this weird behaviour where it tries again when no key matches the selector. + // ref: JWKSetBasedJWKSource.java + jwksServer.verify(jwksRequest(), exactly(2)); + + } + + @Test + void resolve_multipleKeys_noKeyIdGiven() { + var key1 = generateKey("foo-bar-key1").toPublicJWK(); + var key2 = generateKey("foo-bar-key2").toPublicJWK(); + jwksServer.when(jwksRequest()) + .respond(response().withStatusCode(200).withBody(jwksObject(key1, key2))); + + assertThat(resolver.resolveKey(null)).isFailed() + .detail().isEqualTo("JWKSet contained 2 keys, but no keyId was specified. Please consider specifying a keyId."); + jwksServer.verify(jwksRequest(), exactly(1)); + } + + @Test + void resolve_singleKey_noKeyId() { + var key1 = generateKey("foo-bar-key1").toPublicJWK(); + jwksServer.when(jwksRequest()) + .respond(response().withStatusCode(200).withBody(jwksObject(key1))); + + assertThat(resolver.resolveKey(null)).isSucceeded(); + jwksServer.verify(jwksRequest(), exactly(1)); + } + + + @Test + void resolve_malformedKeyUrl() { + + assertThatThrownBy(() -> JwksPublicKeyResolver.create(keyParserRegistry, "foobar://invalid.url", monitor, DEFAULT_CACHE_TIME_TO_LIVE)) + .isInstanceOf(EdcException.class) + .hasRootCauseInstanceOf(MalformedURLException.class); + + verify(monitor).warning(contains("Malformed JWK URL: foobar://invalid.url"), isA(MalformedURLException.class)); + } + + @Test + void resolve_invalidKeyUrl() { + resolver = JwksPublicKeyResolver.create(keyParserRegistry, "http:_invalid.url", monitor, DEFAULT_CACHE_TIME_TO_LIVE); + assertThat(resolver.resolveKey("test-key")).isFailed() + .detail().contains("Error while retrieving JWKSet"); + jwksServer.verify(jwksRequest(), never()); + + } + + @Test + void resolve_verifyHitsCache() { + var cacheTtl = 1000; + resolver = JwksPublicKeyResolver.create(keyParserRegistry, jwksServerUrl(), monitor, cacheTtl); + + jwksServer.when(jwksRequest()) + .respond(response().withStatusCode(200).withBody(jwksObject(generateKey("foo-bar-key").toPublicJWK()))); + + assertThat(resolver.resolveKey("foo-bar-key")).isSucceeded(); + assertThat(resolver.resolveKey("foo-bar-key")).isSucceeded(); + jwksServer.verify(jwksRequest(), exactly(1)); + + // now wait for the cache to expire, try again and assert that the key server is hit + await().atMost(Duration.ofMillis(3 * cacheTtl)) + .untilAsserted(() -> { + assertThat(resolver.resolveKey("foo-bar-key")).isSucceeded(); + jwksServer.verify(jwksRequest(), exactly(1)); + }); + + } + + @Test + void resolve_verifyNoHitsCache() { + resolver = JwksPublicKeyResolver.create(keyParserRegistry, jwksServerUrl(), monitor); + + jwksServer.when(jwksRequest()) + .respond(response().withStatusCode(200).withBody(jwksObject(generateKey("foo-bar-key").toPublicJWK()))); + + assertThat(resolver.resolveKey("foo-bar-key")).isSucceeded(); + assertThat(resolver.resolveKey("foo-bar-key")).isSucceeded(); + assertThat(resolver.resolveKey("foo-bar-key")).isSucceeded(); + jwksServer.verify(jwksRequest(), exactly(3)); + } + + private @NotNull String jwksServerUrl() { + return "http://localhost:%d/.well-known/jwks.json".formatted(jwksServer.getPort()); + } + + private HttpRequest jwksRequest() { + return request() + .withPath("/.well-known/jwks.json"); + } + + private String jwksObject(JWK... keys) { + var keyList = Arrays.stream(keys).map(JWK::toJSONObject).toList(); + var m = Map.of("keys", keyList); + + try { + return mapper.writeValueAsString(m); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/TestFunctions.java b/extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/TestFunctions.java new file mode 100644 index 00000000000..eaf3da9e11b --- /dev/null +++ b/extensions/common/auth/auth-delegated/src/test/java/org/eclipse/edc/api/auth/delegated/TestFunctions.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.api.auth.delegated; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.gen.OctetKeyPairGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import org.eclipse.edc.security.token.jwt.CryptoConverter; + +public class TestFunctions { + public static JWK generateKey() { + return generateKey("test-key"); + } + + public static JWK generateKey(String keyId) { + try { + return new OctetKeyPairGenerator(Curve.Ed25519).keyID(keyId).keyUse(KeyUse.SIGNATURE).generate(); + } catch (JOSEException e) { + throw new RuntimeException(e); + } + } + + public static String createToken(JWK key) { + var signer = CryptoConverter.createSigner(key); + var algorithm = CryptoConverter.getRecommendedAlgorithm(signer); + + var header = new JWSHeader.Builder(algorithm).keyID(key.getKeyID()).build(); + var claims = new JWTClaimsSet.Builder() + .audience("test-audience") + .issuer("test-issuer") + .subject("test-subject") + .build(); + + var jwt = new SignedJWT(header, claims); + try { + jwt.sign(signer); + return jwt.serialize(); + } catch (JOSEException e) { + throw new RuntimeException(e); + } + } +} diff --git a/extensions/common/auth/auth-tokenbased/src/main/java/org/eclipse/edc/api/auth/token/TokenBasedAuthenticationExtension.java b/extensions/common/auth/auth-tokenbased/src/main/java/org/eclipse/edc/api/auth/token/TokenBasedAuthenticationExtension.java index 38473c84e14..a8f0e54d431 100644 --- a/extensions/common/auth/auth-tokenbased/src/main/java/org/eclipse/edc/api/auth/token/TokenBasedAuthenticationExtension.java +++ b/extensions/common/auth/auth-tokenbased/src/main/java/org/eclipse/edc/api/auth/token/TokenBasedAuthenticationExtension.java @@ -16,11 +16,9 @@ package org.eclipse.edc.api.auth.token; -import org.eclipse.edc.api.auth.spi.AuthenticationService; import org.eclipse.edc.api.auth.spi.registry.ApiAuthenticationRegistry; import org.eclipse.edc.runtime.metamodel.annotation.Extension; import org.eclipse.edc.runtime.metamodel.annotation.Inject; -import org.eclipse.edc.runtime.metamodel.annotation.Provides; import org.eclipse.edc.runtime.metamodel.annotation.Setting; import org.eclipse.edc.spi.security.Vault; import org.eclipse.edc.spi.system.ServiceExtension; @@ -32,7 +30,6 @@ /** * Extension that registers an AuthenticationService that uses API Keys */ -@Provides(AuthenticationService.class) @Extension(value = TokenBasedAuthenticationExtension.NAME) public class TokenBasedAuthenticationExtension implements ServiceExtension { @@ -58,6 +55,9 @@ public void initialize(ServiceExtensionContext context) { .map(alias -> vault.resolveSecret(alias)) .orElseGet(() -> context.getSetting(AUTH_SETTING_APIKEY, UUID.randomUUID().toString())); - authenticationRegistry.register("management-api", new TokenBasedAuthenticationService(apiKey)); + // only register as fallback, if no other has been registered + if (!authenticationRegistry.hasService("management-api")) { + authenticationRegistry.register("management-api", new TokenBasedAuthenticationService(apiKey)); + } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 550166d3c55..270831b2d0d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -104,6 +104,7 @@ include(":extensions:common:api:lib:management-api-lib") include(":extensions:common:api:version-api") include(":extensions:common:auth:auth-basic") include(":extensions:common:auth:auth-tokenbased") +include(":extensions:common:auth:auth-delegated") include(":extensions:common:crypto:ldp-verifiable-credentials") include(":extensions:common:crypto:jwt-verifiable-credentials") include(":extensions:common:crypto:lib:jws2020-lib") diff --git a/spi/common/auth-spi/src/main/java/org/eclipse/edc/api/auth/spi/registry/ApiAuthenticationRegistry.java b/spi/common/auth-spi/src/main/java/org/eclipse/edc/api/auth/spi/registry/ApiAuthenticationRegistry.java index b24f4b23297..f4997be2597 100644 --- a/spi/common/auth-spi/src/main/java/org/eclipse/edc/api/auth/spi/registry/ApiAuthenticationRegistry.java +++ b/spi/common/auth-spi/src/main/java/org/eclipse/edc/api/auth/spi/registry/ApiAuthenticationRegistry.java @@ -37,6 +37,14 @@ public interface ApiAuthenticationRegistry { * @param context the context. * @return the {@link AuthenticationService} */ - @NotNull AuthenticationService resolve(String context); + @NotNull + AuthenticationService resolve(String context); + /** + * Determines whether a specific authentication service registration exists for a given context. + * + * @param context The context name + * @return {@code true} if a non-default, non-null service is registered, {@code false} otherwise + */ + boolean hasService(String context); }