diff --git a/extensions/common/iam/oauth2/oauth2-client/src/main/java/org/eclipse/edc/iam/oauth2/client/Oauth2ClientImpl.java b/extensions/common/iam/oauth2/oauth2-client/src/main/java/org/eclipse/edc/iam/oauth2/client/Oauth2ClientImpl.java index 11c63469cac..c452d3ce963 100644 --- a/extensions/common/iam/oauth2/oauth2-client/src/main/java/org/eclipse/edc/iam/oauth2/client/Oauth2ClientImpl.java +++ b/extensions/common/iam/oauth2/oauth2-client/src/main/java/org/eclipse/edc/iam/oauth2/client/Oauth2ClientImpl.java @@ -70,7 +70,9 @@ private static Request toRequest(Oauth2CredentialsRequest request) { private static FormBody createRequestBody(Oauth2CredentialsRequest request) { var builder = new FormBody.Builder(); - request.getParams().forEach(builder::add); + request.getParams().entrySet().stream() + .filter(entry -> entry.getValue() != null) + .forEach(entry -> builder.add(entry.getKey(), entry.getValue())); return builder.build(); } diff --git a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/README.md b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/README.md index 1cec05b952a..e128ac3d677 100644 --- a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/README.md +++ b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/README.md @@ -29,14 +29,17 @@ The extension works for all the `HttpData` addresses that contain the "oauth2" p It supports [both types of client credential](https://connect2id.com/products/server/docs/guides/oauth-client-authentication#credential-types): shared secret and private-key based. +### Common properties + +- `oauth2:tokenUrl`: the url where the token will be requested +- `oauth2:scope`: (optional) the requested scope + ### Private-key based client credential This type of client credential is used when the `HttpData` address contains the `oauth2:privateKeyName` property. This type of client credential is considered as more secured as described [here](https://connect2id.com/products/server/docs/guides/oauth-client-authentication#private-key-auth-is-more-secure). The mandatory for working with type of client credentials are: -- `oauth2:clientId`: the client id -- `oauth2:tokenUrl`: the url where the token will be requested - `oauth2:privateKeyName`: the name of the private key used to sign the JWT sent to the Oauth2 server `oauth2:validity`: the validity of the JWT token sent to the Oauth2 server (in seconds) @@ -46,6 +49,5 @@ This type of client credential is used when the `HttpData` address DOES not cont The mandatory for working with type of client credentials are: - `oauth2:clientId`: the client id -- `oauth2:tokenUrl`: the url where the token will be requested - `oauth2:clientSecret`: shared secret for authenticating to the Oauth2 server diff --git a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2CredentialsRequestFactory.java b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2CredentialsRequestFactory.java new file mode 100644 index 00000000000..210423d2fcc --- /dev/null +++ b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2CredentialsRequestFactory.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2023 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.connector.provision.oauth2; + +import org.eclipse.edc.iam.oauth2.spi.Oauth2AssertionDecorator; +import org.eclipse.edc.iam.oauth2.spi.client.Oauth2CredentialsRequest; +import org.eclipse.edc.iam.oauth2.spi.client.PrivateKeyOauth2CredentialsRequest; +import org.eclipse.edc.iam.oauth2.spi.client.SharedSecretOauth2CredentialsRequest; +import org.eclipse.edc.jwt.TokenGenerationServiceImpl; +import org.eclipse.edc.spi.iam.TokenRepresentation; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.security.PrivateKeyResolver; +import org.jetbrains.annotations.NotNull; + +import java.security.PrivateKey; +import java.time.Clock; + +/** + * Factory class that provides methods to build {@link Oauth2CredentialsRequest} instances + */ +public class Oauth2CredentialsRequestFactory { + + private static final String GRANT_CLIENT_CREDENTIALS = "client_credentials"; + private final PrivateKeyResolver privateKeyResolver; + private final Clock clock; + + public Oauth2CredentialsRequestFactory(PrivateKeyResolver privateKeyResolver, Clock clock) { + this.privateKeyResolver = privateKeyResolver; + this.clock = clock; + } + + /** + * Create an {@link Oauth2CredentialsRequest} given a {@link Oauth2ResourceDefinition} + * + * @param resourceDefinition the resource definition + * @return a {@link Result} containing the {@link Oauth2CredentialsRequest} object + */ + public Result create(Oauth2ResourceDefinition resourceDefinition) { + var keySecret = resourceDefinition.getPrivateKeyName(); + return keySecret != null + ? createPrivateKeyBasedRequest(keySecret, resourceDefinition) + : createSharedSecretRequest(resourceDefinition); + } + + @NotNull + private Result createPrivateKeyBasedRequest(String pkSecret, Oauth2ResourceDefinition resourceDefinition) { + return createAssertion(pkSecret, resourceDefinition) + .map(assertion -> PrivateKeyOauth2CredentialsRequest.Builder.newInstance() + .clientAssertion(assertion.getToken()) + .url(resourceDefinition.getTokenUrl()) + .grantType(GRANT_CLIENT_CREDENTIALS) + .scope(resourceDefinition.getScope()) + .build()); + } + + @NotNull + private Result createSharedSecretRequest(Oauth2ResourceDefinition resourceDefinition) { + return Result.success(SharedSecretOauth2CredentialsRequest.Builder.newInstance() + .url(resourceDefinition.getTokenUrl()) + .grantType(GRANT_CLIENT_CREDENTIALS) + .clientId(resourceDefinition.getClientId()) + .clientSecret(resourceDefinition.getClientSecret()) + .scope(resourceDefinition.getScope()) + .build()); + } + + @NotNull + private Result createAssertion(String pkSecret, Oauth2ResourceDefinition resourceDefinition) { + var privateKey = privateKeyResolver.resolvePrivateKey(pkSecret, PrivateKey.class); + if (privateKey == null) { + return Result.failure("Failed to resolve private key with alias: " + pkSecret); + } + var decorator = new Oauth2AssertionDecorator(resourceDefinition.getTokenUrl(), resourceDefinition.getClientId(), clock, resourceDefinition.getValidity()); + var service = new TokenGenerationServiceImpl(privateKey); + return service.generate(decorator); + } +} diff --git a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2DataAddressSchema.java b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2DataAddressSchema.java index d9779932bb3..c483690d053 100644 --- a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2DataAddressSchema.java +++ b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2DataAddressSchema.java @@ -20,4 +20,5 @@ public interface Oauth2DataAddressSchema { String TOKEN_URL = "oauth2:tokenUrl"; String VALIDITY = "oauth2:validity"; String PRIVATE_KEY_NAME = "oauth2:privateKeyName"; + String SCOPE = "oauth2:scope"; } diff --git a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2ProvisionExtension.java b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2ProvisionExtension.java index a097f993705..d2d942a7cab 100644 --- a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2ProvisionExtension.java +++ b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2ProvisionExtension.java @@ -57,7 +57,8 @@ public void initialize(ServiceExtensionContext context) { resourceManifestGenerator.registerGenerator(new Oauth2ProviderResourceDefinitionGenerator()); resourceManifestGenerator.registerGenerator(new Oauth2ConsumerResourceDefinitionGenerator()); - provisionManager.register(new Oauth2Provisioner(client, privateKeyResolver, clock)); + var requestFactory = new Oauth2CredentialsRequestFactory(privateKeyResolver, clock); + provisionManager.register(new Oauth2Provisioner(client, requestFactory)); } } diff --git a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2Provisioner.java b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2Provisioner.java index 4afa8df5f05..ffbcd0d6e82 100644 --- a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2Provisioner.java +++ b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2Provisioner.java @@ -19,22 +19,11 @@ import org.eclipse.edc.connector.transfer.spi.types.ProvisionResponse; import org.eclipse.edc.connector.transfer.spi.types.ProvisionedResource; import org.eclipse.edc.connector.transfer.spi.types.ResourceDefinition; -import org.eclipse.edc.iam.oauth2.spi.Oauth2AssertionDecorator; import org.eclipse.edc.iam.oauth2.spi.client.Oauth2Client; -import org.eclipse.edc.iam.oauth2.spi.client.Oauth2CredentialsRequest; -import org.eclipse.edc.iam.oauth2.spi.client.PrivateKeyOauth2CredentialsRequest; -import org.eclipse.edc.iam.oauth2.spi.client.SharedSecretOauth2CredentialsRequest; -import org.eclipse.edc.jwt.TokenGenerationServiceImpl; import org.eclipse.edc.policy.model.Policy; -import org.eclipse.edc.spi.iam.TokenRepresentation; import org.eclipse.edc.spi.response.StatusResult; -import org.eclipse.edc.spi.result.Result; -import org.eclipse.edc.spi.security.PrivateKeyResolver; import org.eclipse.edc.spi.types.domain.HttpDataAddress; -import org.jetbrains.annotations.NotNull; -import java.security.PrivateKey; -import java.time.Clock; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -46,16 +35,12 @@ */ class Oauth2Provisioner implements Provisioner { - private static final String GRANT_CLIENT_CREDENTIALS = "client_credentials"; - private final Oauth2Client client; - private final PrivateKeyResolver privateKeyResolver; - private final Clock clock; + private final Oauth2CredentialsRequestFactory requestFactory; - Oauth2Provisioner(Oauth2Client client, PrivateKeyResolver privateKeyResolver, Clock clock) { + Oauth2Provisioner(Oauth2Client client, Oauth2CredentialsRequestFactory requestFactory) { this.client = client; - this.privateKeyResolver = privateKeyResolver; - this.clock = clock; + this.requestFactory = requestFactory; } @Override @@ -70,7 +55,7 @@ public boolean canDeprovision(ProvisionedResource resourceDefinition) { @Override public CompletableFuture> provision(Oauth2ResourceDefinition resourceDefinition, Policy policy) { - var request = createRequest(resourceDefinition); + var request = requestFactory.create(resourceDefinition); if (request.failed()) { return completedFuture(StatusResult.failure(FATAL_ERROR, request.getFailureDetail())); } @@ -109,39 +94,4 @@ public CompletableFuture> deprovision(Oauth2 return completedFuture(StatusResult.success(deprovisionedResource)); } - @NotNull - private Result createRequest(Oauth2ResourceDefinition rd) { - var keySecret = rd.getPrivateKeyName(); - return keySecret != null ? createPrivateKeyBasedRequest(keySecret, rd) : createSharedSecretRequest(rd); - } - - @NotNull - private Result createPrivateKeyBasedRequest(String pkSecret, Oauth2ResourceDefinition rd) { - return createAssertion(pkSecret, rd) - .map(assertion -> PrivateKeyOauth2CredentialsRequest.Builder.newInstance() - .clientAssertion(assertion.getToken()) - .url(rd.getTokenUrl()) - .grantType(GRANT_CLIENT_CREDENTIALS) - .build()); - } - - @NotNull - private Result createSharedSecretRequest(Oauth2ResourceDefinition rd) { - return Result.success(SharedSecretOauth2CredentialsRequest.Builder.newInstance() - .url(rd.getTokenUrl()) - .grantType(GRANT_CLIENT_CREDENTIALS) - .clientId(rd.getClientId()) - .clientSecret(rd.getClientSecret()) - .build()); - } - - private Result createAssertion(String pkSecret, Oauth2ResourceDefinition rd) { - var privateKey = privateKeyResolver.resolvePrivateKey(pkSecret, PrivateKey.class); - if (privateKey == null) { - return Result.failure("Failed to resolve private key with alias: " + pkSecret); - } - var decorator = new Oauth2AssertionDecorator(rd.getTokenUrl(), rd.getClientId(), clock, rd.getValidity()); - var service = new TokenGenerationServiceImpl(privateKey); - return service.generate(decorator); - } } diff --git a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2ResourceDefinition.java b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2ResourceDefinition.java index 9fb231bd3b5..c5ec7f1a9e0 100644 --- a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2ResourceDefinition.java +++ b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/main/java/org/eclipse/edc/connector/provision/oauth2/Oauth2ResourceDefinition.java @@ -29,6 +29,7 @@ import static org.eclipse.edc.connector.provision.oauth2.Oauth2DataAddressSchema.CLIENT_ID; import static org.eclipse.edc.connector.provision.oauth2.Oauth2DataAddressSchema.CLIENT_SECRET; import static org.eclipse.edc.connector.provision.oauth2.Oauth2DataAddressSchema.PRIVATE_KEY_NAME; +import static org.eclipse.edc.connector.provision.oauth2.Oauth2DataAddressSchema.SCOPE; import static org.eclipse.edc.connector.provision.oauth2.Oauth2DataAddressSchema.TOKEN_URL; import static org.eclipse.edc.connector.provision.oauth2.Oauth2DataAddressSchema.VALIDITY; @@ -70,6 +71,11 @@ public String getPrivateKeyName() { return dataAddress.getProperty(PRIVATE_KEY_NAME); } + @Nullable + public String getScope() { + return dataAddress.getProperty(SCOPE); + } + public long getValidity() { return Optional.ofNullable(dataAddress.getProperty(VALIDITY)) .map(Long::parseLong) diff --git a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/test/java/org/eclipse/edc/connector/provision/oauth2/Oauth2CredentialsRequestFactoryTest.java b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/test/java/org/eclipse/edc/connector/provision/oauth2/Oauth2CredentialsRequestFactoryTest.java new file mode 100644 index 00000000000..036c71cebc5 --- /dev/null +++ b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/test/java/org/eclipse/edc/connector/provision/oauth2/Oauth2CredentialsRequestFactoryTest.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2023 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.connector.provision.oauth2; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.crypto.RSASSAVerifier; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.SignedJWT; +import org.eclipse.edc.iam.oauth2.spi.client.PrivateKeyOauth2CredentialsRequest; +import org.eclipse.edc.iam.oauth2.spi.client.SharedSecretOauth2CredentialsRequest; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.security.PrivateKeyResolver; +import org.eclipse.edc.spi.types.domain.DataAddress; +import org.eclipse.edc.spi.types.domain.HttpDataAddress; +import org.junit.jupiter.api.Test; + +import java.security.PrivateKey; +import java.sql.Date; +import java.time.Clock; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +import static java.time.ZoneOffset.UTC; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.type; +import static org.eclipse.edc.connector.provision.oauth2.Oauth2DataAddressSchema.CLIENT_ID; +import static org.eclipse.edc.connector.provision.oauth2.Oauth2DataAddressSchema.CLIENT_SECRET; +import static org.eclipse.edc.connector.provision.oauth2.Oauth2DataAddressSchema.PRIVATE_KEY_NAME; +import static org.eclipse.edc.connector.provision.oauth2.Oauth2DataAddressSchema.SCOPE; +import static org.eclipse.edc.connector.provision.oauth2.Oauth2DataAddressSchema.TOKEN_URL; +import static org.eclipse.edc.connector.provision.oauth2.Oauth2DataAddressSchema.VALIDITY; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class Oauth2CredentialsRequestFactoryTest { + + private final Instant now = Instant.now(); + private final Clock clock = Clock.fixed(now, UTC); + private final PrivateKeyResolver privateKeyResolver = mock(PrivateKeyResolver.class); + private final Oauth2CredentialsRequestFactory factory = new Oauth2CredentialsRequestFactory(privateKeyResolver, clock); + + @Test + void shouldCreateSharedSecretRequest_whenPrivateKeyNameIsAbsent() { + var address = defaultAddress() + .property(CLIENT_SECRET, "clientSecret") + .property(SCOPE, "scope") + .build(); + + var result = factory.create(createResourceDefinition(address)); + + assertThat(result).matches(Result::succeeded).extracting(Result::getContent) + .asInstanceOf(type(SharedSecretOauth2CredentialsRequest.class)) + .satisfies(request -> { + assertThat(request.getGrantType()).isEqualTo("client_credentials"); + assertThat(request.getClientId()).isEqualTo("clientId"); + assertThat(request.getClientSecret()).isEqualTo("clientSecret"); + assertThat(request.getUrl()).isEqualTo("http://oauth2-server.com/token"); + assertThat(request.getScope()).isEqualTo("scope"); + }); + verifyNoInteractions(privateKeyResolver); + } + + @Test + void shouldCreatePrivateKeyRequest_whenPrivateKeyNameIsPresent() throws JOSEException { + var keyPair = generateKeyPair(); + when(privateKeyResolver.resolvePrivateKey("pk-test", PrivateKey.class)).thenReturn(keyPair.toPrivateKey()); + + var address = defaultAddress() + .property(PRIVATE_KEY_NAME, "pk-test") + .property(VALIDITY, "600") + .build(); + + var result = factory.create(createResourceDefinition(address)); + + assertThat(result).matches(Result::succeeded).extracting(Result::getContent) + .asInstanceOf(type(PrivateKeyOauth2CredentialsRequest.class)) + .satisfies(request -> { + assertThat(request.getGrantType()).isEqualTo("client_credentials"); + assertThat(request.getUrl()).isEqualTo("http://oauth2-server.com/token"); + assertThat(request.getClientAssertionType()).isEqualTo("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); + assertThat(request.getScope()).isEqualTo(null); + }) + .extracting(PrivateKeyOauth2CredentialsRequest::getClientAssertion) + .satisfies(assertion -> { + var assertionToken = SignedJWT.parse(assertion); + var now = clock.instant().truncatedTo(ChronoUnit.SECONDS); + assertThat(assertionToken.verify(new RSASSAVerifier(keyPair.toRSAPublicKey()))).isTrue(); + assertThat(assertionToken.getJWTClaimsSet().getClaims()) + .hasFieldOrPropertyWithValue("sub", "clientId") + .hasFieldOrPropertyWithValue("iss", "clientId") + .hasFieldOrPropertyWithValue("aud", List.of(address.getProperty(TOKEN_URL))) + .hasFieldOrProperty("jti") + .hasFieldOrPropertyWithValue("iat", Date.from(now)) + .hasFieldOrPropertyWithValue("exp", Date.from(now.plusSeconds(600))); + }); + } + + @Test + void shouldFailIfPrivateKeySecretNotFound() { + when(privateKeyResolver.resolvePrivateKey("pk-test", PrivateKey.class)).thenReturn(null); + + var address = defaultAddress() + .property(PRIVATE_KEY_NAME, "pk-test") + .property(VALIDITY, "600") + .build(); + + var result = factory.create(createResourceDefinition(address)); + + assertThat(result).matches(Result::failed) + .extracting(Result::getFailureDetail).asString().contains("pk-test"); + } + + private Oauth2ResourceDefinition createResourceDefinition(DataAddress address) { + return Oauth2ResourceDefinition.Builder.newInstance() + .id(UUID.randomUUID().toString()) + .transferProcessId(UUID.randomUUID().toString()) + .dataAddress(address) + .build(); + } + + private HttpDataAddress.Builder defaultAddress() { + return HttpDataAddress.Builder.newInstance() + .property(CLIENT_ID, "clientId") + .property(TOKEN_URL, "http://oauth2-server.com/token"); + } + + private RSAKey generateKeyPair() throws JOSEException { + return new RSAKeyGenerator(2048) + .keyUse(KeyUse.SIGNATURE) // indicate the intended use of the key + .keyID(UUID.randomUUID().toString()) // give the key a unique ID + .generate(); + } + +} diff --git a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/test/java/org/eclipse/edc/connector/provision/oauth2/Oauth2ProvisionerTest.java b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/test/java/org/eclipse/edc/connector/provision/oauth2/Oauth2ProvisionerTest.java index 88084231dc8..b9b7e8c075f 100644 --- a/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/test/java/org/eclipse/edc/connector/provision/oauth2/Oauth2ProvisionerTest.java +++ b/extensions/control-plane/provision/provision-oauth2/provision-oauth2-core/src/test/java/org/eclipse/edc/connector/provision/oauth2/Oauth2ProvisionerTest.java @@ -15,57 +15,35 @@ package org.eclipse.edc.connector.provision.oauth2; -import com.nimbusds.jose.JOSEException; -import com.nimbusds.jose.crypto.RSASSAVerifier; -import com.nimbusds.jose.jwk.KeyUse; -import com.nimbusds.jose.jwk.RSAKey; -import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; -import com.nimbusds.jwt.SignedJWT; import org.eclipse.edc.connector.transfer.spi.types.DeprovisionedResource; import org.eclipse.edc.connector.transfer.spi.types.ProvisionedResource; import org.eclipse.edc.connector.transfer.spi.types.ResourceDefinition; import org.eclipse.edc.iam.oauth2.spi.client.Oauth2Client; -import org.eclipse.edc.iam.oauth2.spi.client.Oauth2CredentialsRequest; -import org.eclipse.edc.iam.oauth2.spi.client.PrivateKeyOauth2CredentialsRequest; import org.eclipse.edc.iam.oauth2.spi.client.SharedSecretOauth2CredentialsRequest; import org.eclipse.edc.policy.model.Policy; import org.eclipse.edc.spi.iam.TokenRepresentation; import org.eclipse.edc.spi.response.StatusResult; import org.eclipse.edc.spi.result.AbstractResult; import org.eclipse.edc.spi.result.Result; -import org.eclipse.edc.spi.security.PrivateKeyResolver; -import org.eclipse.edc.spi.types.domain.DataAddress; import org.eclipse.edc.spi.types.domain.HttpDataAddress; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import java.security.PrivateKey; -import java.sql.Date; -import java.text.ParseException; -import java.time.Clock; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; import java.util.UUID; -import static java.time.ZoneOffset.UTC; import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.eclipse.edc.spi.response.ResponseStatus.FATAL_ERROR; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; class Oauth2ProvisionerTest { - private final Instant now = Instant.now(); - private final Clock clock = Clock.fixed(now, UTC); private final Oauth2Client client = mock(Oauth2Client.class); - private final PrivateKeyResolver privateKeyResolver = mock(PrivateKeyResolver.class); - private final Oauth2Provisioner provisioner = new Oauth2Provisioner(client, privateKeyResolver, clock); + private final Oauth2CredentialsRequestFactory requestFactory = mock(Oauth2CredentialsRequestFactory.class); + private final Oauth2Provisioner provisioner = new Oauth2Provisioner(client, requestFactory); @Test void canProvisionOauth2ResourceDefinition() { @@ -80,13 +58,10 @@ void canDeprovisionOauth2ResourceDefinition() { } @Test - void provisionRequestOauth2TokenWithSharedSecretAndReturnsIt() { + void provisionRequestOauth2TokenAndReturnsIt() { + when(requestFactory.create(any())).thenReturn(Result.success(createRequest())); when(client.requestToken(any())).thenReturn(Result.success(TokenRepresentation.Builder.newInstance().token("token-test").build())); - - var address = createDataAddressWithSharedSecret(); - var resourceDefinitionId = UUID.randomUUID().toString(); - var transferProcessId = UUID.randomUUID().toString(); - var resource = createResourceDefinition(address, resourceDefinitionId, transferProcessId); + var resource = createResourceDefinition(); var future = provisioner.provision(resource, simplePolicy()); @@ -95,8 +70,8 @@ void provisionRequestOauth2TokenWithSharedSecretAndReturnsIt() { .satisfies(provisionResponse -> { assertThat(provisionResponse.getResource()).asInstanceOf(type(Oauth2ProvisionedResource.class)) .satisfies(resourceDefinition -> { - assertThat(resourceDefinition.getResourceDefinitionId()).isEqualTo(resourceDefinitionId); - assertThat(resourceDefinition.getTransferProcessId()).isEqualTo(transferProcessId); + assertThat(resourceDefinition.getResourceDefinitionId()).isEqualTo(resource.getId()); + assertThat(resourceDefinition.getTransferProcessId()).isEqualTo(resource.getTransferProcessId()); assertThat(resourceDefinition.hasToken()).isTrue(); assertThat(resourceDefinition.getResourceName()).endsWith("-oauth2"); assertThat(resourceDefinition.getDataAddress()) @@ -105,88 +80,20 @@ void provisionRequestOauth2TokenWithSharedSecretAndReturnsIt() { assertThat(provisionResponse.getSecretToken()).asInstanceOf(type(Oauth2SecretToken.class)) .extracting(Oauth2SecretToken::getToken).isEqualTo("Bearer token-test"); }); - - var captor = ArgumentCaptor.forClass(Oauth2CredentialsRequest.class); - verify(client).requestToken(captor.capture()); - var captured = captor.getValue(); - assertThat(captured) - .isNotNull() - .isInstanceOf(SharedSecretOauth2CredentialsRequest.class); - var request = (SharedSecretOauth2CredentialsRequest) captured; - assertThat(request.getGrantType()).isEqualTo("client_credentials"); - assertThat(request.getClientId()).isEqualTo("clientId"); - assertThat(request.getClientSecret()).isEqualTo("clientSecret"); - assertThat(request.getUrl()).isEqualTo("http://oauth2-server.com:"); - verifyNoInteractions(privateKeyResolver); } @Test - void provisionRequestOauth2TokenWithPrivateKeyKeyAndReturnsIt() throws JOSEException, ParseException { - var keyPair = generateKeyPair(); - when(privateKeyResolver.resolvePrivateKey("pk-test", PrivateKey.class)).thenReturn(keyPair.toPrivateKey()); - when(client.requestToken(any())).thenReturn(Result.success(TokenRepresentation.Builder.newInstance().token("token-test").build())); + void provisionRequestReturnsFailureIfRequestCannotBeCreated() { + when(requestFactory.create(any())).thenReturn(Result.failure("error")); - var address = createDataAddressWithPrivateKey(); - var resourceDefinitionId = UUID.randomUUID().toString(); - var transferProcessId = UUID.randomUUID().toString(); - var resource = createResourceDefinition(address, resourceDefinitionId, transferProcessId); - - var future = provisioner.provision(resource, simplePolicy()); - - assertThat(future).succeedsWithin(10, SECONDS).matches(AbstractResult::succeeded) - .extracting(AbstractResult::getContent) - .satisfies(provisionResponse -> { - assertThat(provisionResponse.getResource()).asInstanceOf(type(Oauth2ProvisionedResource.class)) - .satisfies(resourceDefinition -> { - assertThat(resourceDefinition.getResourceDefinitionId()).isEqualTo(resourceDefinitionId); - assertThat(resourceDefinition.getTransferProcessId()).isEqualTo(transferProcessId); - assertThat(resourceDefinition.hasToken()).isTrue(); - assertThat(resourceDefinition.getResourceName()).endsWith("-oauth2"); - assertThat(resourceDefinition.getDataAddress()) - .extracting(it -> it.getProperty("secretName")).asString().endsWith("-oauth2"); - }); - assertThat(provisionResponse.getSecretToken()).asInstanceOf(type(Oauth2SecretToken.class)) - .extracting(Oauth2SecretToken::getToken).isEqualTo("Bearer token-test"); - }); - - var captor = ArgumentCaptor.forClass(Oauth2CredentialsRequest.class); - verify(client).requestToken(captor.capture()); - var captured = captor.getValue(); - assertThat(captured) - .isNotNull() - .isInstanceOf(PrivateKeyOauth2CredentialsRequest.class); - var request = (PrivateKeyOauth2CredentialsRequest) captured; - assertThat(request.getGrantType()).isEqualTo("client_credentials"); - assertThat(request.getUrl()).isEqualTo("http://oauth2-server.com:"); - assertThat(request.getClientAssertionType()).isEqualTo("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); - - var now = clock.instant().truncatedTo(ChronoUnit.SECONDS); - var assertionToken = SignedJWT.parse(request.getClientAssertion()); - assertThat(assertionToken.verify(new RSASSAVerifier(keyPair.toRSAPublicKey()))).isTrue(); - assertThat(assertionToken.getJWTClaimsSet().getClaims()) - .hasFieldOrPropertyWithValue("sub", "clientId") - .hasFieldOrPropertyWithValue("iss", "clientId") - .hasFieldOrPropertyWithValue("aud", List.of(address.getProperty(Oauth2DataAddressSchema.TOKEN_URL))) - .hasFieldOrProperty("jti") - .hasFieldOrPropertyWithValue("iat", Date.from(now)) - .hasFieldOrPropertyWithValue("exp", Date.from(now.plusSeconds(600))); - } - - @Test - void provisionRequestReturnsFailureIfPrivateKeySecretNotFound() { - when(privateKeyResolver.resolvePrivateKey("pk-test", PrivateKey.class)).thenReturn(null); - - var address = createDataAddressWithPrivateKey(); - var resource = createResourceDefinition(address, UUID.randomUUID().toString(), UUID.randomUUID().toString()); - - var future = provisioner.provision(resource, simplePolicy()); + var future = provisioner.provision(createResourceDefinition(), simplePolicy()); assertThat(future).succeedsWithin(10, SECONDS) .matches(StatusResult::failed) .extracting(StatusResult::getFailure) .satisfies(failure -> { assertThat(failure.status()).isEqualTo(FATAL_ERROR); - assertThat(failure.getFailureDetail()).contains("pk-test"); + assertThat(failure.getFailureDetail()).contains("error"); }); verifyNoInteractions(client); @@ -194,12 +101,10 @@ void provisionRequestReturnsFailureIfPrivateKeySecretNotFound() { @Test void provisionReturnFailureIfServerCallFails() { + when(requestFactory.create(any())).thenReturn(Result.success(createRequest())); when(client.requestToken(any())).thenReturn(Result.failure("error test")); - var address = createDataAddressWithSharedSecret(); - var resource = createResourceDefinition(address, UUID.randomUUID().toString(), UUID.randomUUID().toString()); - - var future = provisioner.provision(resource, simplePolicy()); + var future = provisioner.provision(createResourceDefinition(), simplePolicy()); assertThat(future).succeedsWithin(10, SECONDS) .matches(StatusResult::failed) @@ -218,7 +123,7 @@ void deprovisioningDoesNothingAsTheTokenWillExpireAtCertainPoint() { .resourceDefinitionId(UUID.randomUUID().toString()) .transferProcessId(UUID.randomUUID().toString()) .resourceName("any") - .dataAddress(createDataAddressWithSharedSecret()) + .dataAddress(HttpDataAddress.Builder.newInstance().build()) .hasToken(true) .build(); @@ -226,45 +131,24 @@ void deprovisioningDoesNothingAsTheTokenWillExpireAtCertainPoint() { assertThat(future).succeedsWithin(10, SECONDS).matches(AbstractResult::succeeded) .extracting(AbstractResult::getContent).asInstanceOf(type(DeprovisionedResource.class)) - .satisfies(deprovisioned -> { - assertThat(deprovisioned.getProvisionedResourceId()).isEqualTo(provisionedResourceId); - }); - } - - private Oauth2ResourceDefinition createResourceDefinition(DataAddress address, String resourceDefinitionId, String transferProcessId) { - return Oauth2ResourceDefinition.Builder.newInstance() - .id(resourceDefinitionId) - .transferProcessId(transferProcessId) - .dataAddress(address) - .build(); + .extracting(DeprovisionedResource::getProvisionedResourceId) + .isEqualTo(provisionedResourceId); } - private DataAddress createDataAddressWithSharedSecret() { - return defaultAddress() - .property(Oauth2DataAddressSchema.CLIENT_SECRET, "clientSecret") - .build(); + private SharedSecretOauth2CredentialsRequest createRequest() { + return SharedSecretOauth2CredentialsRequest.Builder.newInstance() + .url("http://any") + .grantType("any") + .clientId("any").clientSecret("any").build(); } - private DataAddress createDataAddressWithPrivateKey() { - return defaultAddress() - .property(Oauth2DataAddressSchema.PRIVATE_KEY_NAME, "pk-test") - .property(Oauth2DataAddressSchema.VALIDITY, "600") + private Oauth2ResourceDefinition createResourceDefinition() { + return Oauth2ResourceDefinition.Builder.newInstance() + .id(UUID.randomUUID().toString()) + .transferProcessId(UUID.randomUUID().toString()) .build(); } - private DataAddress.Builder defaultAddress() { - return HttpDataAddress.Builder.newInstance() - .property(Oauth2DataAddressSchema.CLIENT_ID, "clientId") - .property(Oauth2DataAddressSchema.TOKEN_URL, "http://oauth2-server.com:"); - } - - private RSAKey generateKeyPair() throws JOSEException { - return new RSAKeyGenerator(2048) - .keyUse(KeyUse.SIGNATURE) // indicate the intended use of the key - .keyID(UUID.randomUUID().toString()) // give the key a unique ID - .generate(); - } - private Policy simplePolicy() { return Policy.Builder.newInstance().build(); }