diff --git a/core/common/connector-core/build.gradle.kts b/core/common/connector-core/build.gradle.kts index 43ea18d46ca..8e7734fa5a7 100644 --- a/core/common/connector-core/build.gradle.kts +++ b/core/common/connector-core/build.gradle.kts @@ -34,7 +34,8 @@ dependencies { implementation(libs.dnsOverHttps) implementation(libs.bouncyCastle.bcpkixJdk18on) - + implementation(libs.nimbus.jwt) + testImplementation(project(":core:common:junit")) testImplementation(libs.awaitility) testImplementation(libs.junit.jupiter.api) diff --git a/core/common/connector-core/src/main/java/org/eclipse/edc/connector/core/CoreServicesExtension.java b/core/common/connector-core/src/main/java/org/eclipse/edc/connector/core/CoreServicesExtension.java index 559e63c9f14..9e006762f8f 100644 --- a/core/common/connector-core/src/main/java/org/eclipse/edc/connector/core/CoreServicesExtension.java +++ b/core/common/connector-core/src/main/java/org/eclipse/edc/connector/core/CoreServicesExtension.java @@ -22,6 +22,7 @@ import org.eclipse.edc.connector.core.health.HealthCheckServiceConfiguration; import org.eclipse.edc.connector.core.health.HealthCheckServiceImpl; import org.eclipse.edc.connector.core.security.DefaultPrivateKeyParseFunction; +import org.eclipse.edc.connector.core.security.KeyPairFactoryImpl; import org.eclipse.edc.connector.core.validator.JsonObjectValidatorRegistryImpl; import org.eclipse.edc.core.transform.TypeTransformerRegistryImpl; import org.eclipse.edc.policy.engine.PolicyEngineImpl; @@ -39,7 +40,9 @@ import org.eclipse.edc.spi.command.CommandHandlerRegistry; import org.eclipse.edc.spi.event.EventRouter; import org.eclipse.edc.spi.message.RemoteMessageDispatcherRegistry; +import org.eclipse.edc.spi.security.KeyPairFactory; import org.eclipse.edc.spi.security.PrivateKeyResolver; +import org.eclipse.edc.spi.security.Vault; import org.eclipse.edc.spi.system.ExecutorInstrumentation; import org.eclipse.edc.spi.system.Hostname; import org.eclipse.edc.spi.system.ServiceExtension; @@ -85,6 +88,9 @@ public class CoreServicesExtension implements ServiceExtension { @Inject private PrivateKeyResolver privateKeyResolver; + @Inject + private Vault vault; + @Inject private EventExecutorServiceContainer eventExecutorServiceContainer; @@ -167,6 +173,11 @@ public EventRouter eventRouter(ServiceExtensionContext context) { return new EventRouterImpl(context.getMonitor(), eventExecutorServiceContainer.getExecutorService()); } + @Provider + public KeyPairFactory keyPairFactory() { + return new KeyPairFactoryImpl(privateKeyResolver, vault); + } + @Provider public HealthCheckService healthCheckService() { return healthCheckService; diff --git a/extensions/control-plane/transfer/transfer-data-plane/src/main/java/org/eclipse/edc/connector/transfer/dataplane/security/ConsumerPullKeyPairFactory.java b/core/common/connector-core/src/main/java/org/eclipse/edc/connector/core/security/KeyPairFactoryImpl.java similarity index 90% rename from extensions/control-plane/transfer/transfer-data-plane/src/main/java/org/eclipse/edc/connector/transfer/dataplane/security/ConsumerPullKeyPairFactory.java rename to core/common/connector-core/src/main/java/org/eclipse/edc/connector/core/security/KeyPairFactoryImpl.java index 8644f238ab1..111eb039d9c 100644 --- a/extensions/control-plane/transfer/transfer-data-plane/src/main/java/org/eclipse/edc/connector/transfer/dataplane/security/ConsumerPullKeyPairFactory.java +++ b/core/common/connector-core/src/main/java/org/eclipse/edc/connector/core/security/KeyPairFactoryImpl.java @@ -12,7 +12,7 @@ * */ -package org.eclipse.edc.connector.transfer.dataplane.security; +package org.eclipse.edc.connector.core.security; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.jwk.Curve; @@ -23,6 +23,7 @@ import com.nimbusds.jose.jwk.gen.ECKeyGenerator; import org.eclipse.edc.spi.EdcException; import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.security.KeyPairFactory; import org.eclipse.edc.spi.security.PrivateKeyResolver; import org.eclipse.edc.spi.security.Vault; import org.jetbrains.annotations.NotNull; @@ -33,16 +34,32 @@ import java.util.Optional; import java.util.UUID; -public class ConsumerPullKeyPairFactory { +public class KeyPairFactoryImpl implements KeyPairFactory { private final PrivateKeyResolver privateKeyResolver; private final Vault vault; - public ConsumerPullKeyPairFactory(PrivateKeyResolver privateKeyResolver, Vault vault) { + public KeyPairFactoryImpl(PrivateKeyResolver privateKeyResolver, Vault vault) { this.privateKeyResolver = privateKeyResolver; this.vault = vault; } + @NotNull + private static Result convertPemToPublicKey(String pem) { + try { + var jwk = JWK.parseFromPEMEncodedObjects(pem); + if (jwk instanceof RSAKey) { + return Result.success(jwk.toRSAKey().toPublicKey()); + } else if (jwk instanceof ECKey) { + return Result.success(jwk.toECKey().toPublicKey()); + } else { + return Result.failure(String.format("Public key algorithm %s is not supported", jwk.getAlgorithm().toString())); + } + } catch (JOSEException e) { + return Result.failure("Failed to parse private key: " + e.getMessage()); + } + } + public Result fromConfig(@NotNull String publicKeyAlias, @NotNull String privateKeyAlias) { return publicKey(publicKeyAlias) .compose(publicKey -> privateKey(privateKeyAlias) @@ -64,7 +81,7 @@ public KeyPair defaultKeyPair() { @NotNull private Result publicKey(String alias) { return Optional.ofNullable(vault.resolveSecret(alias)) - .map(ConsumerPullKeyPairFactory::convertPemToPublicKey) + .map(KeyPairFactoryImpl::convertPemToPublicKey) .orElse(Result.failure("Failed to resolve public key with alias: " + alias)); } @@ -74,20 +91,4 @@ private Result privateKey(String alias) { .map(Result::success) .orElse(Result.failure("Failed to resolve private key with alias: " + alias)); } - - @NotNull - private static Result convertPemToPublicKey(String pem) { - try { - var jwk = JWK.parseFromPEMEncodedObjects(pem); - if (jwk instanceof RSAKey) { - return Result.success(jwk.toRSAKey().toPublicKey()); - } else if (jwk instanceof ECKey) { - return Result.success(jwk.toECKey().toPublicKey()); - } else { - return Result.failure(String.format("Public key algorithm %s is not supported", jwk.getAlgorithm().toString())); - } - } catch (JOSEException e) { - return Result.failure("Failed to parse private key: " + e.getMessage()); - } - } } diff --git a/extensions/control-plane/transfer/transfer-data-plane/src/test/java/org/eclipse/edc/connector/transfer/dataplane/security/ConsumerPullKeyPairFactoryTest.java b/core/common/connector-core/src/test/java/org/eclipse/edc/connector/core/security/KeyPairFactoryImplTest.java similarity index 89% rename from extensions/control-plane/transfer/transfer-data-plane/src/test/java/org/eclipse/edc/connector/transfer/dataplane/security/ConsumerPullKeyPairFactoryTest.java rename to core/common/connector-core/src/test/java/org/eclipse/edc/connector/core/security/KeyPairFactoryImplTest.java index 2d6da59bfbd..47c611426b8 100644 --- a/extensions/control-plane/transfer/transfer-data-plane/src/test/java/org/eclipse/edc/connector/transfer/dataplane/security/ConsumerPullKeyPairFactoryTest.java +++ b/core/common/connector-core/src/test/java/org/eclipse/edc/connector/core/security/KeyPairFactoryImplTest.java @@ -12,7 +12,7 @@ * */ -package org.eclipse.edc.connector.transfer.dataplane.security; +package org.eclipse.edc.connector.core.security; import org.eclipse.edc.spi.security.PrivateKeyResolver; import org.eclipse.edc.spi.security.Vault; @@ -29,12 +29,17 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -class ConsumerPullKeyPairFactoryTest { +class KeyPairFactoryImplTest { private final PrivateKeyResolver privateKeyResolver = mock(PrivateKeyResolver.class); private final Vault vault = mock(Vault.class); - private final ConsumerPullKeyPairFactory factory = new ConsumerPullKeyPairFactory(privateKeyResolver, vault); + private final KeyPairFactoryImpl factory = new KeyPairFactoryImpl(privateKeyResolver, vault); + + private static String loadPemFile(String file) throws IOException { + return new String(Objects.requireNonNull(KeyPairFactoryImpl.class.getClassLoader().getResourceAsStream(file)) + .readAllBytes()); + } @ParameterizedTest(name = "{index} {1}") @CsvSource({ "rsa-pubkey.pem, RSA", "ec-pubkey.pem, EC" }) @@ -80,9 +85,4 @@ void fromConfig_failedToRetrievePublicKey() { assertThat(result.failed()).isTrue(); } - - private static String loadPemFile(String file) throws IOException { - return new String(Objects.requireNonNull(ConsumerPullKeyPairFactoryTest.class.getClassLoader().getResourceAsStream(file)) - .readAllBytes()); - } } \ No newline at end of file diff --git a/extensions/control-plane/transfer/transfer-data-plane/src/test/resources/ec-pubkey.pem b/core/common/connector-core/src/test/resources/ec-pubkey.pem similarity index 100% rename from extensions/control-plane/transfer/transfer-data-plane/src/test/resources/ec-pubkey.pem rename to core/common/connector-core/src/test/resources/ec-pubkey.pem diff --git a/core/common/connector-core/src/test/resources/rsa-pubkey.pem b/core/common/connector-core/src/test/resources/rsa-pubkey.pem new file mode 100644 index 00000000000..a840db4c833 --- /dev/null +++ b/core/common/connector-core/src/test/resources/rsa-pubkey.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAi4zQvCGMlQ0N7wsKIU8N +5aExdhxPPiFqUvV27+WtbPdREEVAmOESUeW00+JO2EBSjuIf4ny4yCRhykr8CjqQ +HFN+ehXaTjw8HyOK7izbExdMe0Bb+SoNcduYL6KRLqUp4QF5fym0vTulRPQ/lT3n +IVUfh4BoEasiWc+cP/7y0qDtsjmiDlPUTRi6UJJHDOokS1P800weSRbDMQmX3zFO ++fztK6zklnbBhuZHjmnuIvqKncFvAgs2ZQkuYEhz/dWAqs5Jepyy4S7SZ4stvHzJ +zpwimRHWJEm0XEK56wrGt7V5j63fXvl72KpncWyNHm+2Obru1OrPBGaHm0kNZnCi +jwIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/core/common/jwt-core/build.gradle.kts b/core/common/jwt-core/build.gradle.kts index 1711cad6401..e756376ab74 100644 --- a/core/common/jwt-core/build.gradle.kts +++ b/core/common/jwt-core/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { api(project(":spi:common:jwt-spi")) implementation(libs.nimbus.jwt) + api(libs.bouncyCastle.bcpkixJdk18on) } diff --git a/extensions/common/iam/identity-trust/identity-trust-core/build.gradle.kts b/extensions/common/iam/identity-trust/identity-trust-core/build.gradle.kts index f20aa282cb5..df572ce8ea3 100644 --- a/extensions/common/iam/identity-trust/identity-trust-core/build.gradle.kts +++ b/extensions/common/iam/identity-trust/identity-trust-core/build.gradle.kts @@ -7,10 +7,14 @@ dependencies { api(project(":spi:common:identity-trust-spi")) implementation(project(":spi:common:http-spi")) implementation(project(":core:common:util")) + implementation(project(":core:common:jwt-core")) implementation(project(":extensions:common:crypto:jws2020")) implementation(project(":extensions:common:iam:identity-trust:identity-trust-service")) + implementation(project(":extensions:common:iam:identity-trust:identity-trust-sts-embedded")) implementation(libs.nimbus.jwt) + testImplementation(testFixtures(project(":spi:common:identity-trust-spi"))) testImplementation(project(":core:common:junit")) + testImplementation(libs.nimbus.jwt) } diff --git a/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/IatpDefaultServicesExtension.java b/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/IatpDefaultServicesExtension.java index 5bb85e437c7..4600a2d2625 100644 --- a/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/IatpDefaultServicesExtension.java +++ b/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/IatpDefaultServicesExtension.java @@ -14,28 +14,68 @@ package org.eclipse.edc.iam.identitytrust.core; -import org.eclipse.edc.iam.identitytrust.core.service.EmbeddedSecureTokenService; +import org.eclipse.edc.iam.identitytrust.sts.embedded.EmbeddedSecureTokenService; import org.eclipse.edc.identitytrust.SecureTokenService; +import org.eclipse.edc.jwt.TokenGenerationServiceImpl; import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; import org.eclipse.edc.runtime.metamodel.annotation.Provider; +import org.eclipse.edc.runtime.metamodel.annotation.Setting; +import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.security.KeyPairFactory; import org.eclipse.edc.spi.system.ServiceExtension; import org.eclipse.edc.spi.system.ServiceExtensionContext; +import java.security.KeyPair; +import java.time.Clock; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + @Extension("Identity And Trust Extension to register default services") public class IatpDefaultServicesExtension implements ServiceExtension { + @Setting(value = "Alias of private key used for signing tokens, retrieved from private key resolver", defaultValue = "A random EC private key") + public static final String STS_PRIVATE_KEY_ALIAS = "edc.iam.sts.privatekey.alias"; + @Setting(value = "Alias of public key used for verifying the tokens, retrieved from the vault", defaultValue = "A random EC public key") + public static final String STS_PUBLIC_KEY_ALIAS = "edc.iam.sts.publickey.alias"; // not a setting, it's defined in Oauth2ServiceExtension private static final String OAUTH_TOKENURL_PROPERTY = "edc.oauth.token.url"; + @Setting(value = "Self-issued ID Token expiration in minutes. By default is 5 minutes", defaultValue = "" + IatpDefaultServicesExtension.DEFAULT_STS_TOKEN_EXPIRATION_MIN) + private static final String STS_TOKEN_EXPIRATION = "edc.iam.sts.token.expiration"; // in minutes + + private static final int DEFAULT_STS_TOKEN_EXPIRATION_MIN = 5; + + @Inject + private KeyPairFactory keyPairFactory; + + @Inject + private Clock clock; @Provider(isDefault = true) public SecureTokenService createDefaultTokenService(ServiceExtensionContext context) { context.getMonitor().info("Using the Embedded STS client, as no other implementation was provided."); + var keyPair = keyPairFromConfig(context); + var tokenExpiration = context.getSetting(STS_TOKEN_EXPIRATION, DEFAULT_STS_TOKEN_EXPIRATION_MIN); + if (context.getSetting(OAUTH_TOKENURL_PROPERTY, null) != null) { context.getMonitor().warning("The property '%s' was configured, but no remote SecureTokenService was found on the classpath. ".formatted(OAUTH_TOKENURL_PROPERTY) + "This could be an indicator of a configuration problem."); } - return new EmbeddedSecureTokenService(); + return new EmbeddedSecureTokenService(new TokenGenerationServiceImpl(keyPair.getPrivate()), clock, TimeUnit.MINUTES.toSeconds(tokenExpiration)); + } + + private KeyPair keyPairFromConfig(ServiceExtensionContext context) { + var pubKeyAlias = context.getSetting(STS_PUBLIC_KEY_ALIAS, null); + var privKeyAlias = context.getSetting(STS_PRIVATE_KEY_ALIAS, null); + if (pubKeyAlias == null && privKeyAlias == null) { + context.getMonitor().info(() -> "No public or private key provided for 'STS.' A key pair will be generated (DO NOT USE IN PRODUCTION)"); + return keyPairFactory.defaultKeyPair(); + } + Objects.requireNonNull(pubKeyAlias, "public key alias"); + Objects.requireNonNull(privKeyAlias, "private key alias"); + return keyPairFactory.fromConfig(pubKeyAlias, privKeyAlias) + .orElseThrow(failure -> new EdcException(failure.getFailureDetail())); } } diff --git a/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/IdentityAndTrustExtension.java b/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/IdentityAndTrustExtension.java index b2e62164ddc..bb70cd5311e 100644 --- a/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/IdentityAndTrustExtension.java +++ b/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/IdentityAndTrustExtension.java @@ -55,7 +55,7 @@ public class IdentityAndTrustExtension implements ServiceExtension { @Provider public IdentityService createIdentityService(ServiceExtensionContext context) { - return new IdentityAndTrustService(secureTokenService, getIssuerDid(context), presentationVerifier, + return new IdentityAndTrustService(secureTokenService, getIssuerDid(context), context.getParticipantId(), presentationVerifier, credentialServiceClient, getJwtValidator(), getJwtVerifier()); } diff --git a/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/service/EmbeddedSecureTokenService.java b/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/service/EmbeddedSecureTokenService.java deleted file mode 100644 index 5093a2ccd47..00000000000 --- a/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/service/EmbeddedSecureTokenService.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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.iam.identitytrust.core.service; - -import org.eclipse.edc.identitytrust.SecureTokenService; -import org.eclipse.edc.spi.iam.TokenRepresentation; -import org.eclipse.edc.spi.result.Result; -import org.jetbrains.annotations.Nullable; - -import java.util.Map; - -/** - * Implementation of a {@link SecureTokenService}, that is capable of creating a self-signed ID token ("SI token") completely in-process. - * To that end, it makes use of the Nimbus JOSE/JWT library.
- * As a recommendation, the private key it uses should not be used for anything else. - */ -public class EmbeddedSecureTokenService implements SecureTokenService { - - public EmbeddedSecureTokenService() { - } - - @Override - public Result createToken(Map claims, @Nullable String bearerAccessScope) { - // todo: implement embedded JWT generation - return null; - } -} diff --git a/extensions/common/iam/identity-trust/identity-trust-core/src/test/java/org/eclipse/edc/iam/identitytrust/core/IatpDefaultServicesExtensionTest.java b/extensions/common/iam/identity-trust/identity-trust-core/src/test/java/org/eclipse/edc/iam/identitytrust/core/IatpDefaultServicesExtensionTest.java index dc01482493d..b5c24a372de 100644 --- a/extensions/common/iam/identity-trust/identity-trust-core/src/test/java/org/eclipse/edc/iam/identitytrust/core/IatpDefaultServicesExtensionTest.java +++ b/extensions/common/iam/identity-trust/identity-trust-core/src/test/java/org/eclipse/edc/iam/identitytrust/core/IatpDefaultServicesExtensionTest.java @@ -14,46 +14,83 @@ package org.eclipse.edc.iam.identitytrust.core; -import org.eclipse.edc.iam.identitytrust.core.service.EmbeddedSecureTokenService; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import org.eclipse.edc.iam.identitytrust.sts.embedded.EmbeddedSecureTokenService; import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.security.KeyPairFactory; import org.eclipse.edc.spi.system.ServiceExtensionContext; -import org.eclipse.edc.spi.system.injection.ObjectFactory; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.util.UUID; + import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.iam.identitytrust.core.IatpDefaultServicesExtension.STS_PRIVATE_KEY_ALIAS; +import static org.eclipse.edc.iam.identitytrust.core.IatpDefaultServicesExtension.STS_PUBLIC_KEY_ALIAS; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(DependencyInjectionExtension.class) class IatpDefaultServicesExtensionTest { + private final KeyPairFactory keyPairFactory = mock(); + private final KeyPair keypair = mock(); + + private static PrivateKey privateKey() 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() + .toPrivateKey(); + } + + @BeforeEach + void setup(ServiceExtensionContext context) throws JOSEException { + context.registerService(KeyPairFactory.class, keyPairFactory); + when(keypair.getPrivate()).thenReturn(privateKey()); + } + @Test - void verify_defaultService(ServiceExtensionContext context, ObjectFactory factory) { + void verify_defaultService(ServiceExtensionContext context, IatpDefaultServicesExtension ext) { + var publicAlias = "public"; + var privateAlias = "private"; Monitor mockedMonitor = mock(); context.registerService(Monitor.class, mockedMonitor); - var ext = factory.constructInstance(IatpDefaultServicesExtension.class); + when(context.getSetting(STS_PUBLIC_KEY_ALIAS, null)).thenReturn(publicAlias); + when(context.getSetting(STS_PRIVATE_KEY_ALIAS, null)).thenReturn(privateAlias); + when(keyPairFactory.fromConfig(publicAlias, privateAlias)).thenReturn(Result.success(keypair)); var sts = ext.createDefaultTokenService(context); assertThat(sts).isInstanceOf(EmbeddedSecureTokenService.class); verify(mockedMonitor).info(anyString()); + + verify(keyPairFactory, never()).defaultKeyPair(); } @Test - void verify_defaultServiceWithWarning(ServiceExtensionContext context, ObjectFactory factory) { + void verify_defaultServiceWithWarning(ServiceExtensionContext context, IatpDefaultServicesExtension ext) { Monitor mockedMonitor = mock(); context.registerService(Monitor.class, mockedMonitor); when(context.getSetting(eq("edc.oauth.token.url"), any())).thenReturn("https://some.url"); + when(keyPairFactory.defaultKeyPair()).thenReturn(keypair); - var ext = factory.constructInstance(IatpDefaultServicesExtension.class); - var sts = ext.createDefaultTokenService(context); + ext.createDefaultTokenService(context); verify(mockedMonitor).info(anyString()); verify(mockedMonitor).warning(anyString()); + verify(keyPairFactory, times(1)).defaultKeyPair(); } } \ No newline at end of file diff --git a/extensions/common/iam/identity-trust/identity-trust-service/src/main/java/org/eclipse/edc/iam/identitytrust/IdentityAndTrustService.java b/extensions/common/iam/identity-trust/identity-trust-service/src/main/java/org/eclipse/edc/iam/identitytrust/IdentityAndTrustService.java index b3880e4a371..1ec143699ef 100644 --- a/extensions/common/iam/identity-trust/identity-trust-service/src/main/java/org/eclipse/edc/iam/identitytrust/IdentityAndTrustService.java +++ b/extensions/common/iam/identity-trust/identity-trust-service/src/main/java/org/eclipse/edc/iam/identitytrust/IdentityAndTrustService.java @@ -56,6 +56,7 @@ public class IdentityAndTrustService implements IdentityService { private final SecureTokenService secureTokenService; private final String myOwnDid; + private final String participantId; private final PresentationVerifier presentationVerifier; private final CredentialServiceClient credentialServiceClient; private final JwtValidator jwtValidator; @@ -67,9 +68,12 @@ public class IdentityAndTrustService implements IdentityService { * @param secureTokenService Instance of an STS, which can create SI tokens * @param myOwnDid The DID which belongs to "this connector" */ - public IdentityAndTrustService(SecureTokenService secureTokenService, String myOwnDid, PresentationVerifier presentationVerifier, CredentialServiceClient credentialServiceClient, JwtValidator jwtValidator, JwtVerifier jwtVerifier) { + public IdentityAndTrustService(SecureTokenService secureTokenService, String myOwnDid, String participantId, + PresentationVerifier presentationVerifier, CredentialServiceClient credentialServiceClient, + JwtValidator jwtValidator, JwtVerifier jwtVerifier) { this.secureTokenService = secureTokenService; this.myOwnDid = myOwnDid; + this.participantId = participantId; this.presentationVerifier = presentationVerifier; this.credentialServiceClient = credentialServiceClient; this.jwtValidator = jwtValidator; @@ -84,11 +88,17 @@ public Result obtainClientCredentials(TokenParameters param if (scopeValidationResult.failed()) { return failure(scopeValidationResult.getFailureMessages()); } - + // create claims for the STS - var claims = new HashMap<>(Map.of("iss", myOwnDid, "sub", myOwnDid, "aud", parameters.getAudience())); + var claims = new HashMap(); parameters.getAdditional().forEach((k, v) -> claims.replace(k, v.toString())); + claims.putAll(Map.of( + "iss", myOwnDid, + "sub", myOwnDid, + "aud", parameters.getAudience(), + "client_id", participantId)); + return secureTokenService.createToken(claims, scope); } diff --git a/extensions/common/iam/identity-trust/identity-trust-service/src/test/java/org/eclipse/edc/iam/identitytrust/service/IdentityAndTrustServiceTest.java b/extensions/common/iam/identity-trust/identity-trust-service/src/test/java/org/eclipse/edc/iam/identitytrust/service/IdentityAndTrustServiceTest.java index f0ae57a5483..c11372ab887 100644 --- a/extensions/common/iam/identity-trust/identity-trust-service/src/test/java/org/eclipse/edc/iam/identitytrust/service/IdentityAndTrustServiceTest.java +++ b/extensions/common/iam/identity-trust/identity-trust-service/src/test/java/org/eclipse/edc/iam/identitytrust/service/IdentityAndTrustServiceTest.java @@ -56,13 +56,16 @@ class IdentityAndTrustServiceTest { public static final String EXPECTED_OWN_DID = "did:web:test"; + + public static final String EXPECTED_PARTICIPANT_ID = "participantId"; + public static final String CONSUMER_DID = "did:web:consumer"; private final SecureTokenService mockedSts = mock(); private final PresentationVerifier mockedVerifier = mock(); private final CredentialServiceClient mockedClient = mock(); private final JwtValidator jwtValidatorMock = mock(); private final JwtVerifier jwtVerfierMock = mock(); - private final IdentityAndTrustService service = new IdentityAndTrustService(mockedSts, EXPECTED_OWN_DID, mockedVerifier, mockedClient, jwtValidatorMock, jwtVerfierMock); + private final IdentityAndTrustService service = new IdentityAndTrustService(mockedSts, EXPECTED_OWN_DID, EXPECTED_PARTICIPANT_ID, mockedVerifier, mockedClient, jwtValidatorMock, jwtVerfierMock); @BeforeEach void setup() { @@ -114,7 +117,8 @@ void obtainClientCredentials_stsFails() { assertThat(service.obtainClientCredentials(tp)).isSucceeded(); verify(mockedSts).createToken(argThat(m -> m.get("iss").equals(EXPECTED_OWN_DID) && m.get("sub").equals(EXPECTED_OWN_DID) && - m.get("aud").equals(tp.getAudience())), eq(scope)); + m.get("aud").equals(tp.getAudience()) && + m.get("client_id").equals(EXPECTED_PARTICIPANT_ID)), eq(scope)); } } @@ -208,5 +212,4 @@ void jwtTokenNotVerified() { .containsExactly("test-failure"); } } - } \ No newline at end of file diff --git a/extensions/common/iam/identity-trust/identity-trust-sts-embedded/build.gradle.kts b/extensions/common/iam/identity-trust/identity-trust-sts-embedded/build.gradle.kts new file mode 100644 index 00000000000..62a6830d8c6 --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts-embedded/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + `java-library` + `maven-publish` +} + +dependencies { + api(project(":spi:common:identity-trust-spi")) + api(project(":spi:common:jwt-spi")) + + implementation(project(":core:common:util")) + implementation(project(":extensions:common:iam:identity-trust:identity-trust-service")) + testImplementation(testFixtures(project(":spi:common:identity-trust-spi"))) + testImplementation(project(":core:common:junit")) + testImplementation(project(":core:common:jwt-core")) + testImplementation(libs.nimbus.jwt) +} + diff --git a/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenService.java b/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenService.java new file mode 100644 index 00000000000..98cb8a8b510 --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenService.java @@ -0,0 +1,103 @@ +/* + * 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.iam.identitytrust.sts.embedded; + +import org.eclipse.edc.identitytrust.SecureTokenService; +import org.eclipse.edc.jwt.spi.TokenGenerationService; +import org.eclipse.edc.spi.iam.TokenRepresentation; +import org.eclipse.edc.spi.result.Result; +import org.jetbrains.annotations.Nullable; + +import java.time.Clock; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static java.lang.String.format; +import static java.util.Optional.ofNullable; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.AUDIENCE; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.ISSUER; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.SUBJECT; +import static org.eclipse.edc.spi.result.Result.failure; +import static org.eclipse.edc.spi.result.Result.success; + +/** + * Implementation of a {@link SecureTokenService}, that is capable of creating a self-signed ID token ("SI token") completely in-process. + * To that end, it makes use of the Nimbus JOSE/JWT library.
+ * As a recommendation, the private key it uses should not be used for anything else. + */ +public class EmbeddedSecureTokenService implements SecureTokenService { + + public static final String SCOPE_CLAIM = "scope"; + public static final String ACCESS_TOKEN_CLAIM = "access_token"; + private static final List ACCESS_TOKEN_INHERITED_CLAIMS = List.of(ISSUER); + private final TokenGenerationService tokenGenerationService; + private final Clock clock; + private final long validity; + + public EmbeddedSecureTokenService(TokenGenerationService tokenGenerationService, Clock clock, long validity) { + this.tokenGenerationService = tokenGenerationService; + this.clock = clock; + this.validity = validity; + } + + @Override + public Result createToken(Map claims, @Nullable String bearerAccessScope) { + var selfIssuedClaims = new HashMap<>(claims); + return ofNullable(bearerAccessScope) + .map(scope -> createAndAcceptAccessToken(claims, scope, selfIssuedClaims::put)) + .orElse(success()) + .compose(v -> tokenGenerationService.generate(new SelfIssuedTokenDecorator(selfIssuedClaims, clock, validity))); + } + + private Result createAndAcceptAccessToken(Map claims, String scope, BiConsumer consumer) { + return createAccessToken(claims, scope) + .compose(tokenRepresentation -> success(tokenRepresentation.getToken())) + .onSuccess(withClaim(ACCESS_TOKEN_CLAIM, consumer)) + .mapTo(); + } + + private Result createAccessToken(Map claims, String bearerAccessScope) { + var accessTokenClaims = new HashMap<>(accessTokenInheritedClaims(claims)); + accessTokenClaims.put(SCOPE_CLAIM, bearerAccessScope); + return addClaim(claims, ISSUER, withClaim(AUDIENCE, accessTokenClaims::put)) + .compose(v -> addClaim(claims, AUDIENCE, withClaim(SUBJECT, accessTokenClaims::put))) + .compose(v -> tokenGenerationService.generate(new SelfIssuedTokenDecorator(accessTokenClaims, clock, validity))); + + } + + private Result addClaim(Map claims, String claim, Consumer consumer) { + var claimValue = claims.get(claim); + if (claimValue != null) { + consumer.accept(claimValue); + return success(); + } else { + return failure(format("Missing %s in the input claims", claim)); + } + } + + private Consumer withClaim(String key, BiConsumer consumer) { + return (value) -> consumer.accept(key, value); + } + + private Map accessTokenInheritedClaims(Map claims) { + return claims.entrySet().stream() + .filter(entry -> ACCESS_TOKEN_INHERITED_CLAIMS.contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } +} diff --git a/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/SelfIssuedTokenDecorator.java b/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/SelfIssuedTokenDecorator.java new file mode 100644 index 00000000000..fa0c43817ef --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/SelfIssuedTokenDecorator.java @@ -0,0 +1,58 @@ +/* + * 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.iam.identitytrust.sts.embedded; + +import org.eclipse.edc.jwt.spi.JwtDecorator; + +import java.time.Clock; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static java.util.Collections.emptyMap; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.EXPIRATION_TIME; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.ISSUED_AT; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.JWT_ID; + +/** + * Decorator for Self-Issued ID token and Access Token. It appends input claims and + * generic claims like iat, exp, and jti + */ +class SelfIssuedTokenDecorator implements JwtDecorator { + private final Map claims; + private final Clock clock; + private final long validity; + + SelfIssuedTokenDecorator(Map claims, Clock clock, long validity) { + this.claims = claims; + this.clock = clock; + this.validity = validity; + } + + @Override + public Map claims() { + var claims = new HashMap(this.claims); + claims.put(ISSUED_AT, Date.from(clock.instant())); + claims.put(EXPIRATION_TIME, Date.from(clock.instant().plusSeconds(validity))); + claims.put(JWT_ID, UUID.randomUUID().toString()); + return claims; + } + + @Override + public Map headers() { + return emptyMap(); + } +} diff --git a/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceIntegrationTest.java b/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceIntegrationTest.java new file mode 100644 index 00000000000..89636097a2b --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceIntegrationTest.java @@ -0,0 +1,140 @@ +/* + * 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.iam.identitytrust.sts.embedded; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory; +import com.nimbusds.jwt.SignedJWT; +import org.eclipse.edc.jwt.TokenGenerationServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; + +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.as; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.STRING; +import static org.eclipse.edc.iam.identitytrust.sts.embedded.EmbeddedSecureTokenService.ACCESS_TOKEN_CLAIM; +import static org.eclipse.edc.iam.identitytrust.sts.embedded.EmbeddedSecureTokenService.SCOPE_CLAIM; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.AUDIENCE; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.EXPIRATION_TIME; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.ISSUED_AT; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.ISSUER; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.JWT_ID; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.SUBJECT; + + +public class EmbeddedSecureTokenServiceIntegrationTest { + + private KeyPair keyPair; + private EmbeddedSecureTokenService secureTokenService; + + private static KeyPair generateKeyPair() throws NoSuchAlgorithmException { + var gen = KeyPairGenerator.getInstance("RSA"); + return gen.generateKeyPair(); + } + + @BeforeEach + void setup() throws NoSuchAlgorithmException { + keyPair = generateKeyPair(); + var tokenGenerationService = new TokenGenerationServiceImpl(keyPair.getPrivate()); + secureTokenService = new EmbeddedSecureTokenService(tokenGenerationService, Clock.systemUTC(), 10 * 60); + } + + @Test + void createToken_withoutBearerAccessScope() { + var issuer = "testIssuer"; + + var claims = Map.of(ISSUER, issuer); + var tokenResult = secureTokenService.createToken(claims, null); + + assertThat(tokenResult).isSucceeded() + .satisfies(tokenRepresentation -> { + var jwt = SignedJWT.parse(tokenRepresentation.getToken()); + assertThat(jwt.verify(createVerifier(jwt.getHeader(), keyPair.getPublic()))).isTrue(); + assertThat(jwt.getJWTClaimsSet().getClaims()) + .containsEntry(ISSUER, issuer) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT) + .doesNotContainKey(ACCESS_TOKEN_CLAIM); + }); + + } + + @Test + void createToken_withBearerAccessScope() { + var scopes = "email:read"; + var issuer = "testIssuer"; + var audience = "audience"; + var claims = Map.of(ISSUER, issuer, AUDIENCE, audience); + var tokenResult = secureTokenService.createToken(claims, scopes); + + assertThat(tokenResult).isSucceeded() + .satisfies(tokenRepresentation -> { + var jwt = SignedJWT.parse(tokenRepresentation.getToken()); + assertThat(jwt.verify(createVerifier(jwt.getHeader(), keyPair.getPublic()))).isTrue(); + + assertThat(jwt.getJWTClaimsSet().getClaims()) + .containsEntry(ISSUER, issuer) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT) + .extractingByKey(ACCESS_TOKEN_CLAIM, as(STRING)) + .satisfies(accessToken -> { + var accessTokenJwt = SignedJWT.parse(accessToken); + assertThat(accessTokenJwt.verify(createVerifier(accessTokenJwt.getHeader(), keyPair.getPublic()))).isTrue(); + assertThat(accessTokenJwt.getJWTClaimsSet().getClaims()) + .containsEntry(ISSUER, issuer) + .containsEntry(SUBJECT, audience) + .containsEntry(AUDIENCE, List.of(issuer)) + .containsEntry(SCOPE_CLAIM, scopes) + .containsKeys(JWT_ID, EXPIRATION_TIME, ISSUED_AT); + }); + }); + } + + + @ParameterizedTest + @ArgumentsSource(ClaimsArguments.class) + void createToken_shouldFail_withMissingClaims(Map claims) { + var tokenResult = secureTokenService.createToken(claims, "email:read"); + assertThat(tokenResult).isFailed() + .satisfies(f -> assertThat(f.getFailureDetail()).matches("Missing [a-z]* in the input claims")); + } + + private JWSVerifier createVerifier(JWSHeader header, Key publicKey) throws JOSEException { + return new DefaultJWSVerifierFactory().createJWSVerifier(header, publicKey); + } + + private static class ClaimsArguments implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext extensionContext) throws Exception { + return Stream.of(Map.of(ISSUER, "iss"), Map.of(AUDIENCE, "aud")).map(Arguments::of); + } + } +} diff --git a/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceTest.java b/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceTest.java new file mode 100644 index 00000000000..363a2689387 --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceTest.java @@ -0,0 +1,133 @@ +/* + * 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.iam.identitytrust.sts.embedded; + +import org.eclipse.edc.jwt.spi.JwtDecorator; +import org.eclipse.edc.jwt.spi.TokenGenerationService; +import org.eclipse.edc.spi.iam.TokenRepresentation; +import org.eclipse.edc.spi.result.Result; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Clock; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.AUDIENCE; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.ISSUER; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class EmbeddedSecureTokenServiceTest { + + private final TokenGenerationService tokenGenerationService = mock(); + + @Test + void createToken_withoutBearerAccessScope() { + var sts = new EmbeddedSecureTokenService(tokenGenerationService, Clock.systemUTC(), 10 * 60); + var token = TokenRepresentation.Builder.newInstance().token("test").build(); + + when(tokenGenerationService.generate(any())).thenReturn(Result.success(token)); + var result = sts.createToken(Map.of(), null); + + assertThat(result.succeeded()).isTrue(); + var captor = ArgumentCaptor.forClass(JwtDecorator.class); + + verify(tokenGenerationService).generate(captor.capture()); + + assertThat(captor.getAllValues()).hasSize(1) + .hasOnlyElementsOfType(SelfIssuedTokenDecorator.class); + } + + @Test + void createToken_withBearerAccessScope() { + + var claims = Map.of(ISSUER, "testIssuer", AUDIENCE, "aud"); + var sts = new EmbeddedSecureTokenService(tokenGenerationService, Clock.systemUTC(), 10 * 60); + var token = TokenRepresentation.Builder.newInstance().token("test").build(); + + when(tokenGenerationService.generate(any(JwtDecorator[].class))) + .thenReturn(Result.success(token)) + .thenReturn(Result.success(token)); + + + var result = sts.createToken(claims, "scope:test"); + + assertThat(result.succeeded()).isTrue(); + var captor = ArgumentCaptor.forClass(JwtDecorator[].class); + + verify(tokenGenerationService, times(2)).generate(captor.capture()); + + assertThat(captor.getAllValues()).hasSize(2) + .satisfies(list -> { + assertThat(list.get(0)) + .hasSize(1) + .hasExactlyElementsOfTypes(SelfIssuedTokenDecorator.class); + + assertThat(list.get(1)) + .hasSize(1) + .hasExactlyElementsOfTypes(SelfIssuedTokenDecorator.class); + }); + + } + + @Test + void createToken_error_whenAccessTokenFails() { + + var claims = Map.of(ISSUER, "testIssuer", AUDIENCE, "aud"); + + var sts = new EmbeddedSecureTokenService(tokenGenerationService, Clock.systemUTC(), 10 * 60); + var token = TokenRepresentation.Builder.newInstance().token("test").build(); + + when(tokenGenerationService.generate(any(JwtDecorator[].class))) + .thenReturn(Result.failure("Failed to create access token")) + .thenReturn(Result.success(token)); + + var result = sts.createToken(claims, "scope:test"); + + assertThat(result.failed()).isTrue(); + var captor = ArgumentCaptor.forClass(JwtDecorator[].class); + + verify(tokenGenerationService, times(1)).generate(captor.capture()); + + assertThat(captor.getValue()).hasSize(1) + .hasExactlyElementsOfTypes(SelfIssuedTokenDecorator.class); + + } + + @Test + void createToken_error_whenSelfTokenFails() { + var claims = Map.of(ISSUER, "testIssuer", AUDIENCE, "aud"); + + var sts = new EmbeddedSecureTokenService(tokenGenerationService, Clock.systemUTC(), 10 * 60); + var token = TokenRepresentation.Builder.newInstance().token("test").build(); + + when(tokenGenerationService.generate(any(JwtDecorator[].class))) + .thenReturn(Result.success(token)) + .thenReturn(Result.failure("Failed to create access token")); + + + var result = sts.createToken(claims, "scope:test"); + + assertThat(result.failed()).isTrue(); + + verify(tokenGenerationService, times(2)).generate(any(JwtDecorator[].class)); + + } + +} diff --git a/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/SelfIssuedTokenDecoratorTest.java b/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/SelfIssuedTokenDecoratorTest.java new file mode 100644 index 00000000000..18b55458883 --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/SelfIssuedTokenDecoratorTest.java @@ -0,0 +1,40 @@ +/* + * 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.iam.identitytrust.sts.embedded; + +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.EXPIRATION_TIME; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.ISSUED_AT; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.JWT_ID; + +public class SelfIssuedTokenDecoratorTest { + + @Test + void verifyDecorator() { + + var decorator = new SelfIssuedTokenDecorator(Map.of("iss", "test"), Clock.systemUTC(), 5 * 60); + + assertThat(decorator.claims()) + .containsEntry("iss", "test") + .containsKeys(ISSUED_AT, EXPIRATION_TIME, JWT_ID); + + assertThat(decorator.headers()).isEmpty(); + } +} diff --git a/extensions/control-plane/transfer/transfer-data-plane/src/main/java/org/eclipse/edc/connector/transfer/dataplane/TransferDataPlaneCoreExtension.java b/extensions/control-plane/transfer/transfer-data-plane/src/main/java/org/eclipse/edc/connector/transfer/dataplane/TransferDataPlaneCoreExtension.java index 55433836950..33a7fa5e70e 100644 --- a/extensions/control-plane/transfer/transfer-data-plane/src/main/java/org/eclipse/edc/connector/transfer/dataplane/TransferDataPlaneCoreExtension.java +++ b/extensions/control-plane/transfer/transfer-data-plane/src/main/java/org/eclipse/edc/connector/transfer/dataplane/TransferDataPlaneCoreExtension.java @@ -23,7 +23,6 @@ import org.eclipse.edc.connector.transfer.dataplane.flow.ConsumerPullTransferDataFlowController; import org.eclipse.edc.connector.transfer.dataplane.flow.ProviderPushTransferDataFlowController; import org.eclipse.edc.connector.transfer.dataplane.proxy.ConsumerPullDataPlaneProxyResolver; -import org.eclipse.edc.connector.transfer.dataplane.security.ConsumerPullKeyPairFactory; import org.eclipse.edc.connector.transfer.dataplane.spi.security.DataEncrypter; import org.eclipse.edc.connector.transfer.dataplane.spi.token.ConsumerPullTokenExpirationDateFunction; import org.eclipse.edc.connector.transfer.dataplane.validation.ContractValidationRule; @@ -37,6 +36,7 @@ import org.eclipse.edc.runtime.metamodel.annotation.Extension; import org.eclipse.edc.runtime.metamodel.annotation.Inject; import org.eclipse.edc.spi.EdcException; +import org.eclipse.edc.spi.security.KeyPairFactory; import org.eclipse.edc.spi.security.PrivateKeyResolver; import org.eclipse.edc.spi.security.Vault; import org.eclipse.edc.spi.system.ServiceExtension; @@ -96,6 +96,9 @@ public class TransferDataPlaneCoreExtension implements ServiceExtension { @Inject private TypeManager typeManager; + @Inject + private KeyPairFactory keyPairFactory; + @Override public String name() { return NAME; @@ -106,14 +109,13 @@ public void initialize(ServiceExtensionContext context) { var keyPair = keyPairFromConfig(context); var controller = new ConsumerPullTransferTokenValidationApiController(tokenValidationService(keyPair.getPublic()), dataEncrypter, typeManager); webService.registerResource(controlApiConfiguration.getContextAlias(), controller); - + var resolver = new ConsumerPullDataPlaneProxyResolver(dataEncrypter, typeManager, new TokenGenerationServiceImpl(keyPair.getPrivate()), tokenExpirationDateFunction); dataFlowManager.register(new ConsumerPullTransferDataFlowController(selectorClient, resolver)); dataFlowManager.register(new ProviderPushTransferDataFlowController(callbackUrl, dataPlaneClient)); } private KeyPair keyPairFromConfig(ServiceExtensionContext context) { - var keyPairFactory = new ConsumerPullKeyPairFactory(privateKeyResolver, vault); var pubKeyAlias = context.getSetting(TOKEN_VERIFIER_PUBLIC_KEY_ALIAS, null); var privKeyAlias = context.getSetting(TOKEN_SIGNER_PRIVATE_KEY_ALIAS, null); if (pubKeyAlias == null && privKeyAlias == null) { diff --git a/extensions/control-plane/transfer/transfer-data-plane/src/test/java/org/eclipse/edc/connector/transfer/dataplane/TransferDataPlaneCoreExtensionTest.java b/extensions/control-plane/transfer/transfer-data-plane/src/test/java/org/eclipse/edc/connector/transfer/dataplane/TransferDataPlaneCoreExtensionTest.java index b27ee5f5282..6608e044e0c 100644 --- a/extensions/control-plane/transfer/transfer-data-plane/src/test/java/org/eclipse/edc/connector/transfer/dataplane/TransferDataPlaneCoreExtensionTest.java +++ b/extensions/control-plane/transfer/transfer-data-plane/src/test/java/org/eclipse/edc/connector/transfer/dataplane/TransferDataPlaneCoreExtensionTest.java @@ -28,6 +28,8 @@ import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; import org.eclipse.edc.spi.message.RemoteMessageDispatcherRegistry; import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.security.KeyPairFactory; import org.eclipse.edc.spi.security.PrivateKeyResolver; import org.eclipse.edc.spi.security.Vault; import org.eclipse.edc.spi.system.ServiceExtensionContext; @@ -39,10 +41,12 @@ import org.junit.jupiter.api.extension.ExtendWith; import java.io.IOException; -import java.security.PrivateKey; +import java.security.KeyPair; import java.util.Objects; import java.util.UUID; +import static org.eclipse.edc.connector.transfer.dataplane.TransferDataPlaneConfig.TOKEN_SIGNER_PRIVATE_KEY_ALIAS; +import static org.eclipse.edc.connector.transfer.dataplane.TransferDataPlaneConfig.TOKEN_VERIFIER_PUBLIC_KEY_ALIAS; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -55,16 +59,30 @@ class TransferDataPlaneCoreExtensionTest { private static final String CONTROL_PLANE_API_CONTEXT = "control"; - private final PrivateKeyResolver privateKeyResolver = mock(PrivateKeyResolver.class); private final Vault vault = mock(Vault.class); private final WebService webService = mock(WebService.class); private final DataFlowManager dataFlowManager = mock(DataFlowManager.class); - + private final KeyPairFactory keyPairFactory = mock(); + private KeyPair keypair; private ServiceExtensionContext context; private TransferDataPlaneCoreExtension extension; + private static KeyPair keyPair() 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() + .toKeyPair(); + } + + private static String publicKeyPem() throws IOException { + return new String(Objects.requireNonNull(TransferDataPlaneCoreExtensionTest.class.getClassLoader().getResourceAsStream("rsa-pubkey.pem")) + .readAllBytes()); + } + @BeforeEach - public void setUp(ServiceExtensionContext context, ObjectFactory factory) { + public void setUp(ServiceExtensionContext context, ObjectFactory factory) throws JOSEException { + keypair = keyPair(); var monitor = mock(Monitor.class); var controlApiConfigurationMock = mock(ControlApiConfiguration.class); when(controlApiConfigurationMock.getContextAlias()).thenReturn(CONTROL_PLANE_API_CONTEXT); @@ -79,7 +97,7 @@ public void setUp(ServiceExtensionContext context, ObjectFactory factory) { context.registerService(ControlApiConfiguration.class, controlApiConfigurationMock); context.registerService(DataPlaneClient.class, mock(DataPlaneClient.class)); context.registerService(Vault.class, vault); - context.registerService(PrivateKeyResolver.class, privateKeyResolver); + context.registerService(KeyPairFactory.class, keyPairFactory); this.context = spy(context); //used to inject the config when(this.context.getMonitor()).thenReturn(monitor); @@ -93,10 +111,10 @@ void verifyInitializeSuccess() throws IOException, JOSEException { var privateKeyAlias = "privateKey"; var config = mock(Config.class); when(context.getConfig()).thenReturn(config); - when(config.getString("edc.transfer.proxy.token.verifier.publickey.alias")).thenReturn(publicKeyAlias); - when(config.getString("edc.transfer.proxy.token.signer.privatekey.alias")).thenReturn(privateKeyAlias); + when(config.getString(TOKEN_VERIFIER_PUBLIC_KEY_ALIAS, null)).thenReturn(publicKeyAlias); + when(config.getString(TOKEN_SIGNER_PRIVATE_KEY_ALIAS, null)).thenReturn(privateKeyAlias); when(vault.resolveSecret(publicKeyAlias)).thenReturn(publicKeyPem()); - when(privateKeyResolver.resolvePrivateKey(privateKeyAlias, PrivateKey.class)).thenReturn(privateKey()); + when(keyPairFactory.fromConfig(publicKeyAlias, privateKeyAlias)).thenReturn(Result.success(keypair)); extension.initialize(context); @@ -104,17 +122,4 @@ void verifyInitializeSuccess() throws IOException, JOSEException { verify(dataFlowManager).register(any(ProviderPushTransferDataFlowController.class)); verify(webService).registerResource(eq(CONTROL_PLANE_API_CONTEXT), any(ConsumerPullTransferTokenValidationApiController.class)); } - - private static PrivateKey privateKey() 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() - .toPrivateKey(); - } - - private static String publicKeyPem() throws IOException { - return new String(Objects.requireNonNull(TransferDataPlaneCoreExtensionTest.class.getClassLoader().getResourceAsStream("rsa-pubkey.pem")) - .readAllBytes()); - } } diff --git a/settings.gradle.kts b/settings.gradle.kts index d4891d421de..eecce4e27b5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -114,6 +114,7 @@ include(":extensions:common:iam:identity-trust") include(":extensions:common:iam:identity-trust:identity-trust-transform") include(":extensions:common:iam:identity-trust:identity-trust-service") include(":extensions:common:iam:identity-trust:identity-trust-core") +include(":extensions:common:iam:identity-trust:identity-trust-sts-embedded") include(":extensions:common:json-ld") include(":extensions:common:metrics:micrometer-core") include(":extensions:common:monitor:monitor-jdk-logger") diff --git a/spi/common/core-spi/src/main/java/org/eclipse/edc/spi/security/KeyPairFactory.java b/spi/common/core-spi/src/main/java/org/eclipse/edc/spi/security/KeyPairFactory.java new file mode 100644 index 00000000000..80e2b9f8e69 --- /dev/null +++ b/spi/common/core-spi/src/main/java/org/eclipse/edc/spi/security/KeyPairFactory.java @@ -0,0 +1,45 @@ +/* + * 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.spi.security; + +import org.eclipse.edc.runtime.metamodel.annotation.ExtensionPoint; +import org.eclipse.edc.spi.result.Result; +import org.jetbrains.annotations.NotNull; + +import java.security.KeyPair; + +/** + * Handles generation of a {@link KeyPair} from the public, private key alias + */ +@ExtensionPoint +public interface KeyPairFactory { + + /** + * Creates the {@link KeyPair} + * + * @param publicKeyAlias public key alias. + * @param privateKeyAlias private key alias. + * @return {@link Result} of the fetching and parsing of the {@link KeyPair} from the aliases. + */ + Result fromConfig(@NotNull String publicKeyAlias, @NotNull String privateKeyAlias); + + /** + * Create a default keypair. (suitable for testing) + * + * @return {@link KeyPair} + */ + KeyPair defaultKeyPair(); + +}