diff --git a/core/common/token-core/src/main/java/org/eclipse/edc/token/InMemoryJtiValidationStore.java b/core/common/token-core/src/main/java/org/eclipse/edc/token/InMemoryJtiValidationStore.java new file mode 100644 index 00000000000..64c1cd12110 --- /dev/null +++ b/core/common/token-core/src/main/java/org/eclipse/edc/token/InMemoryJtiValidationStore.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.token; + +import org.eclipse.edc.jwt.validation.jti.JtiValidationEntry; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; +import org.eclipse.edc.spi.result.StoreResult; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryJtiValidationStore implements JtiValidationStore { + private final Map jtiValidationEntries = new ConcurrentHashMap<>(); + + @Override + public StoreResult storeEntry(JtiValidationEntry entry) { + if (jtiValidationEntries.containsKey(entry.tokenId())) { + return StoreResult.alreadyExists("JTI Validation Entry with ID '%s' already exists".formatted(entry.tokenId())); + } + jtiValidationEntries.put(entry.tokenId(), entry); + return StoreResult.success(); + } + + @Override + public JtiValidationEntry findById(String id, boolean autoRemove) { + return autoRemove ? jtiValidationEntries.remove(id) : jtiValidationEntries.get(id); + } + + @Override + public StoreResult deleteById(String id) { + return jtiValidationEntries.remove(id) == null ? + StoreResult.notFound("JTI Validation Entry with ID '%s' not found".formatted(id)) : StoreResult.success(); + } + + @Override + public StoreResult deleteExpired() { + var count = jtiValidationEntries.values().stream().filter(JtiValidationEntry::isExpired).count(); + jtiValidationEntries.values().removeIf(JtiValidationEntry::isExpired); + return StoreResult.success((int) count); + } +} diff --git a/core/common/token-core/src/main/java/org/eclipse/edc/token/TokenServicesExtension.java b/core/common/token-core/src/main/java/org/eclipse/edc/token/TokenServicesExtension.java index 8b79dafc419..7e7979ad61d 100644 --- a/core/common/token-core/src/main/java/org/eclipse/edc/token/TokenServicesExtension.java +++ b/core/common/token-core/src/main/java/org/eclipse/edc/token/TokenServicesExtension.java @@ -15,6 +15,7 @@ package org.eclipse.edc.token; import org.eclipse.edc.jwt.signer.spi.JwsSignerProvider; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.keys.spi.PrivateKeyResolver; import org.eclipse.edc.runtime.metamodel.annotation.Extension; import org.eclipse.edc.runtime.metamodel.annotation.Inject; @@ -57,4 +58,9 @@ public TokenDecoratorRegistry tokenDecoratorRegistry() { public JwsSignerProvider defaultSignerProvider() { return new DefaultJwsSignerProvider(privateKeyResolver); } + + @Provider(isDefault = true) + public JtiValidationStore inMemoryJtiValidationStore() { + return new InMemoryJtiValidationStore(); + } } diff --git a/dist/bom/controlplane-feature-sql-bom/build.gradle.kts b/dist/bom/controlplane-feature-sql-bom/build.gradle.kts index 59f7306ec68..385d5cb29c0 100644 --- a/dist/bom/controlplane-feature-sql-bom/build.gradle.kts +++ b/dist/bom/controlplane-feature-sql-bom/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { api(project(":extensions:control-plane:store:sql:policy-definition-store-sql")) api(project(":extensions:control-plane:store:sql:transfer-process-store-sql")) api(project(":extensions:common:store:sql:edr-index-sql")) + api(project(":extensions:common:store:sql:jti-validation-store-sql")) api(project(":extensions:data-plane-selector:store:sql:data-plane-instance-store-sql")) // other SQL dependencies - not strictly necessary, but could come in handy for BOM users diff --git a/extensions/common/crypto/jwt-verifiable-credentials/build.gradle.kts b/extensions/common/crypto/jwt-verifiable-credentials/build.gradle.kts index d9711826213..4bec69c6075 100644 --- a/extensions/common/crypto/jwt-verifiable-credentials/build.gradle.kts +++ b/extensions/common/crypto/jwt-verifiable-credentials/build.gradle.kts @@ -29,6 +29,7 @@ dependencies { testImplementation(project(":core:common:lib:json-ld-lib")) testImplementation(project(":core:common:junit")) testImplementation(project(":core:common:lib:crypto-common-lib")) + testImplementation(testFixtures(project(":spi:common:jwt-spi"))) testFixturesImplementation(libs.nimbus.jwt) testFixturesImplementation(project(":spi:common:identity-did-spi")) } \ No newline at end of file diff --git a/extensions/common/crypto/jwt-verifiable-credentials/src/main/java/org/eclipse/edc/verifiablecredentials/jwt/rules/JtiValidationRule.java b/extensions/common/crypto/jwt-verifiable-credentials/src/main/java/org/eclipse/edc/verifiablecredentials/jwt/rules/JtiValidationRule.java index e216a885f93..6a36befcf3a 100644 --- a/extensions/common/crypto/jwt-verifiable-credentials/src/main/java/org/eclipse/edc/verifiablecredentials/jwt/rules/JtiValidationRule.java +++ b/extensions/common/crypto/jwt-verifiable-credentials/src/main/java/org/eclipse/edc/verifiablecredentials/jwt/rules/JtiValidationRule.java @@ -14,7 +14,10 @@ package org.eclipse.edc.verifiablecredentials.jwt.rules; +import org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.spi.iam.ClaimToken; +import org.eclipse.edc.spi.monitor.Monitor; import org.eclipse.edc.spi.result.Result; import org.eclipse.edc.token.spi.TokenValidationRule; import org.jetbrains.annotations.NotNull; @@ -25,12 +28,29 @@ /** * This rule checks that the JTI claim is valid, that means that the same JTI claim has not been encountered within the token's lifetime. *

- * Note that this rule can only be implemented after this related issue */ public class JtiValidationRule implements TokenValidationRule { + private final JtiValidationStore jtiValidationStore; + private final Monitor monitor; + + public JtiValidationRule(JtiValidationStore jtiValidationStore, Monitor monitor) { + this.jtiValidationStore = jtiValidationStore; + this.monitor = monitor; + } + @Override public Result checkRule(@NotNull ClaimToken toVerify, @Nullable Map additional) { + var jti = toVerify.getStringClaim(JwtRegisteredClaimNames.JWT_ID); + if (jti != null) { + var entry = jtiValidationStore.findById(jti); + if (entry == null) { + return Result.failure("The JWT id '%s' was not found".formatted(jti)); + } + if (entry.isExpired()) { + monitor.warning("JTI Validation entry with id " + jti + " is expired"); + } + } return Result.success(); } } diff --git a/extensions/common/crypto/jwt-verifiable-credentials/src/test/java/org/eclipse/edc/verifiablecredentials/jwt/InMemoryJtiValidationStoreTest.java b/extensions/common/crypto/jwt-verifiable-credentials/src/test/java/org/eclipse/edc/verifiablecredentials/jwt/InMemoryJtiValidationStoreTest.java new file mode 100644 index 00000000000..7fb855aa0b3 --- /dev/null +++ b/extensions/common/crypto/jwt-verifiable-credentials/src/test/java/org/eclipse/edc/verifiablecredentials/jwt/InMemoryJtiValidationStoreTest.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.verifiablecredentials.jwt; + +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStoreTestBase; +import org.eclipse.edc.token.InMemoryJtiValidationStore; + +class InMemoryJtiValidationStoreTest extends JtiValidationStoreTestBase { + + private final InMemoryJtiValidationStore store = new InMemoryJtiValidationStore(); + + @Override + protected JtiValidationStore getStore() { + return store; + } +} \ No newline at end of file diff --git a/extensions/common/crypto/jwt-verifiable-credentials/src/test/java/org/eclipse/edc/verifiablecredentials/jwt/rules/JtiValidationRuleTest.java b/extensions/common/crypto/jwt-verifiable-credentials/src/test/java/org/eclipse/edc/verifiablecredentials/jwt/rules/JtiValidationRuleTest.java new file mode 100644 index 00000000000..cc85db83f9e --- /dev/null +++ b/extensions/common/crypto/jwt-verifiable-credentials/src/test/java/org/eclipse/edc/verifiablecredentials/jwt/rules/JtiValidationRuleTest.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.verifiablecredentials.jwt.rules; + +import org.eclipse.edc.jwt.validation.jti.JtiValidationEntry; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; +import org.eclipse.edc.spi.iam.ClaimToken; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class JtiValidationRuleTest { + + private final JtiValidationStore store = mock(); + private final JtiValidationRule rule = new JtiValidationRule(store, mock()); + + @Test + void checkRule_noExpiration_success() { + when(store.findById(eq("test-id"))).thenReturn(new JtiValidationEntry("test-id")); + assertThat(rule.checkRule(ClaimToken.Builder.newInstance().claim("jti", "test-id").build(), Map.of())).isSucceeded(); + } + + @Test + void checkRule_withExpiration_success() { + when(store.findById(eq("test-id"))).thenReturn(new JtiValidationEntry("test-id", Instant.now().plusSeconds(3600).toEpochMilli())); + assertThat(rule.checkRule(ClaimToken.Builder.newInstance().claim("jti", "test-id").build(), Map.of())).isSucceeded(); + } + + @Test + void checkRule_withExpiration_alreadyExpired() { + when(store.findById(eq("test-id"))).thenReturn(new JtiValidationEntry("test-id", Instant.now().minusSeconds(3600).toEpochMilli())); + assertThat(rule.checkRule(ClaimToken.Builder.newInstance().claim("jti", "test-id").build(), Map.of())).isSucceeded(); + } + + @Test + void checkRule_entryNotFound_success() { + when(store.findById(eq("test-id"))).thenReturn(null); + assertThat(rule.checkRule(ClaimToken.Builder.newInstance().claim("jti", "test-id").build(), Map.of())).isFailed() + .detail().isEqualTo("The JWT id 'test-id' was not found"); + } +} \ No newline at end of file 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 32205c8498c..9b18214bee3 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 @@ -28,5 +28,6 @@ dependencies { testImplementation(project(":core:common:lib:json-ld-lib")) testImplementation(project(":extensions:common:json-ld")) testImplementation(libs.nimbus.jwt) + testImplementation(libs.awaitility) } diff --git a/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/DcpDefaultServicesExtension.java b/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/DcpDefaultServicesExtension.java index 212285d3b63..afc48d4b62d 100644 --- a/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/DcpDefaultServicesExtension.java +++ b/extensions/common/iam/identity-trust/identity-trust-core/src/main/java/org/eclipse/edc/iam/identitytrust/core/DcpDefaultServicesExtension.java @@ -26,6 +26,7 @@ import org.eclipse.edc.iam.identitytrust.sts.embedded.EmbeddedSecureTokenService; import org.eclipse.edc.iam.verifiablecredentials.spi.validation.TrustedIssuerRegistry; import org.eclipse.edc.jwt.signer.spi.JwsSignerProvider; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.runtime.metamodel.annotation.Extension; import org.eclipse.edc.runtime.metamodel.annotation.Inject; import org.eclipse.edc.runtime.metamodel.annotation.Provider; @@ -61,6 +62,8 @@ public class DcpDefaultServicesExtension implements ServiceExtension { private Clock clock; @Inject private JwsSignerProvider externalSigner; + @Inject + private JtiValidationStore jtiValidationStore; @Provider(isDefault = true) public SecureTokenService createDefaultTokenService(ServiceExtensionContext context) { @@ -70,14 +73,14 @@ public SecureTokenService createDefaultTokenService(ServiceExtensionContext cont 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."); + "This could be an indicator of a configuration problem."); } var publicKeyId = context.getSetting(STS_PUBLIC_KEY_ID, null); var privateKeyAlias = context.getSetting(STS_PRIVATE_KEY_ALIAS, null); - return new EmbeddedSecureTokenService(new JwtGenerationService(externalSigner), () -> privateKeyAlias, () -> publicKeyId, clock, TimeUnit.MINUTES.toSeconds(tokenExpiration)); + return new EmbeddedSecureTokenService(new JwtGenerationService(externalSigner), () -> privateKeyAlias, () -> publicKeyId, clock, TimeUnit.MINUTES.toSeconds(tokenExpiration), jtiValidationStore); } @Provider(isDefault = true) 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 42214033564..47169c90cdb 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 @@ -37,6 +37,7 @@ import org.eclipse.edc.iam.verifiablecredentials.spi.validation.PresentationVerifier; import org.eclipse.edc.iam.verifiablecredentials.spi.validation.TrustedIssuerRegistry; import org.eclipse.edc.jsonld.spi.JsonLd; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.participant.spi.ParticipantAgentService; import org.eclipse.edc.runtime.metamodel.annotation.Extension; import org.eclipse.edc.runtime.metamodel.annotation.Inject; @@ -44,6 +45,7 @@ import org.eclipse.edc.runtime.metamodel.annotation.Setting; import org.eclipse.edc.security.signature.jws2020.Jws2020SignatureSuite; import org.eclipse.edc.spi.iam.IdentityService; +import org.eclipse.edc.spi.system.ExecutorInstrumentation; import org.eclipse.edc.spi.system.ServiceExtension; import org.eclipse.edc.spi.system.ServiceExtensionContext; import org.eclipse.edc.spi.types.TypeManager; @@ -55,7 +57,6 @@ import org.eclipse.edc.verifiablecredentials.jwt.JwtPresentationVerifier; import org.eclipse.edc.verifiablecredentials.jwt.rules.HasSubjectRule; import org.eclipse.edc.verifiablecredentials.jwt.rules.IssuerEqualsSubjectRule; -import org.eclipse.edc.verifiablecredentials.jwt.rules.JtiValidationRule; import org.eclipse.edc.verifiablecredentials.jwt.rules.SubJwkIsNullRule; import org.eclipse.edc.verifiablecredentials.jwt.rules.TokenNotNullRule; import org.eclipse.edc.verifiablecredentials.linkeddata.DidMethodResolver; @@ -65,6 +66,9 @@ import java.net.URISyntaxException; import java.time.Clock; import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import static org.eclipse.edc.iam.verifiablecredentials.spi.VcConstants.STATUSLIST_2021_URL; import static org.eclipse.edc.spi.constants.CoreConstants.JSON_LD; @@ -82,6 +86,9 @@ public class IdentityAndTrustExtension implements ServiceExtension { public static final String JSON_2020_SIGNATURE_SUITE = "JsonWebSignature2020"; + public static final long DEFAULT_CLEANUP_PERIOD_SECONDS = 60; + @Setting(value = "The period of the JTI entry reaper thread in seconds", defaultValue = DEFAULT_CLEANUP_PERIOD_SECONDS + "") + public static final String CLEANUP_PERIOD = "edc.sql.store.jti.cleanup.period"; @Inject private SecureTokenService secureTokenService; @@ -129,8 +136,14 @@ public class IdentityAndTrustExtension implements ServiceExtension { @Inject private RevocationServiceRegistry revocationServiceRegistry; + @Inject + private JtiValidationStore jtiValidationStore; + @Inject + private ExecutorInstrumentation executorInstrumentation; private PresentationVerifier presentationVerifier; private CredentialServiceClient credentialServiceClient; + private long reaperThreadPeriod; + private ScheduledFuture jtiEntryReaperThread; @Override public void initialize(ServiceExtensionContext context) { @@ -139,8 +152,6 @@ public void initialize(ServiceExtensionContext context) { rulesRegistry.addRule(DCP_SELF_ISSUED_TOKEN_CONTEXT, new IssuerEqualsSubjectRule()); rulesRegistry.addRule(DCP_SELF_ISSUED_TOKEN_CONTEXT, new SubJwkIsNullRule()); rulesRegistry.addRule(DCP_SELF_ISSUED_TOKEN_CONTEXT, new AudienceValidationRule(getOwnDid(context))); - context.getMonitor().warning("The JTI Validation rule is not yet implemented as it depends on https://github.com/eclipse-edc/Connector/issues/3749."); - rulesRegistry.addRule(DCP_SELF_ISSUED_TOKEN_CONTEXT, new JtiValidationRule()); rulesRegistry.addRule(DCP_SELF_ISSUED_TOKEN_CONTEXT, new ExpirationIssuedAtValidationRule(clock, 5)); rulesRegistry.addRule(DCP_SELF_ISSUED_TOKEN_CONTEXT, new TokenNotNullRule()); @@ -150,6 +161,8 @@ public void initialize(ServiceExtensionContext context) { // TODO move in a separated extension? signatureSuiteRegistry.register(JSON_2020_SIGNATURE_SUITE, new Jws2020SignatureSuite(typeManager.getMapper(JSON_LD))); + reaperThreadPeriod = context.getSetting(CLEANUP_PERIOD, DEFAULT_CLEANUP_PERIOD_SECONDS); + try { jsonLd.registerCachedDocument(STATUSLIST_2021_URL, getClass().getClassLoader().getResource("statuslist2021.json").toURI()); } catch (URISyntaxException e) { @@ -164,6 +177,17 @@ public void initialize(ServiceExtensionContext context) { revocationServiceRegistry.addService(BitstringStatusListStatus.TYPE, new BitstringStatusListRevocationService(typeManager.getMapper(), validity)); } + @Override + public void start() { + jtiEntryReaperThread = executorInstrumentation.instrument(Executors.newSingleThreadScheduledExecutor(), "JTI Validation Entry Reaper Thread") + .scheduleAtFixedRate(jtiValidationStore::deleteExpired, reaperThreadPeriod, reaperThreadPeriod, TimeUnit.SECONDS); + } + + @Override + public void shutdown() { + jtiEntryReaperThread.cancel(true); + } + @Provider public IdentityService createIdentityService(ServiceExtensionContext context) { var credentialServiceUrlResolver = new DidCredentialServiceUrlResolver(didResolverRegistry); diff --git a/extensions/common/iam/identity-trust/identity-trust-core/src/test/java/org/eclipse/edc/iam/identitytrust/core/IdentityAndTrustExtensionTest.java b/extensions/common/iam/identity-trust/identity-trust-core/src/test/java/org/eclipse/edc/iam/identitytrust/core/IdentityAndTrustExtensionTest.java index cb88ccbdff6..b35a6d25b2c 100644 --- a/extensions/common/iam/identity-trust/identity-trust-core/src/test/java/org/eclipse/edc/iam/identitytrust/core/IdentityAndTrustExtensionTest.java +++ b/extensions/common/iam/identity-trust/identity-trust-core/src/test/java/org/eclipse/edc/iam/identitytrust/core/IdentityAndTrustExtensionTest.java @@ -18,6 +18,8 @@ import org.eclipse.edc.iam.identitytrust.spi.SecureTokenService; import org.eclipse.edc.json.JacksonTypeManager; import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; +import org.eclipse.edc.spi.system.ExecutorInstrumentation; import org.eclipse.edc.spi.system.ServiceExtensionContext; import org.eclipse.edc.spi.system.configuration.Config; import org.eclipse.edc.spi.types.TypeManager; @@ -25,7 +27,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import java.time.Duration; + import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.eclipse.edc.iam.identitytrust.core.IdentityAndTrustExtension.CLEANUP_PERIOD; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.atLeastOnce; @@ -36,10 +43,14 @@ @ExtendWith(DependencyInjectionExtension.class) class IdentityAndTrustExtensionTest { + private final JtiValidationStore storeMock = mock(); + @BeforeEach void setUp(ServiceExtensionContext context) { context.registerService(SecureTokenService.class, mock()); context.registerService(TypeManager.class, new JacksonTypeManager()); + context.registerService(JtiValidationStore.class, storeMock); + context.registerService(ExecutorInstrumentation.class, ExecutorInstrumentation.noop()); } @Test @@ -53,4 +64,16 @@ void verifyCorrectService(IdentityAndTrustExtension extension, ServiceExtensionC assertThat(is).isInstanceOf(IdentityAndTrustService.class); verify(configMock, atLeastOnce()).getString(eq(IdentityAndTrustExtension.CONNECTOR_DID_PROPERTY), isNull()); } + + @Test + void assertReaperThreadRunning(IdentityAndTrustExtension extension, ServiceExtensionContext context) { + when(context.getSetting(eq(CLEANUP_PERIOD), anyLong())).thenReturn(1L); + + extension.initialize(context); + extension.start(); + + await().atLeast(Duration.ofSeconds(1)) // that's the initial delay + .untilAsserted(() -> verify(storeMock, atLeastOnce()).deleteExpired()); + } + } diff --git a/extensions/common/iam/identity-trust/identity-trust-service/src/main/java/org/eclipse/edc/iam/identitytrust/service/IdentityAndTrustService.java b/extensions/common/iam/identity-trust/identity-trust-service/src/main/java/org/eclipse/edc/iam/identitytrust/service/IdentityAndTrustService.java index faae8416a95..70169f67eb0 100644 --- a/extensions/common/iam/identity-trust/identity-trust-service/src/main/java/org/eclipse/edc/iam/identitytrust/service/IdentityAndTrustService.java +++ b/extensions/common/iam/identity-trust/identity-trust-service/src/main/java/org/eclipse/edc/iam/identitytrust/service/IdentityAndTrustService.java @@ -126,7 +126,7 @@ public Result verifyJwtToken(TokenRepresentation tokenRepresentation var claimTokenResult = tokenValidationAction.apply(tokenRepresentation); if (claimTokenResult.failed()) { - return claimTokenResult.mapTo(); + return claimTokenResult.mapEmpty(); } // create our own SI token, to request the VPs @@ -151,7 +151,7 @@ public Result verifyJwtToken(TokenRepresentation tokenRepresentation .compose(url -> credentialServiceClient.requestPresentation(url, siTokenString, context.getScopes().stream().toList())); if (vpResponse.failed()) { - return vpResponse.mapTo(); + return vpResponse.mapEmpty(); } var presentations = vpResponse.getContent(); diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/defaults/StsDefaultServicesExtension.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/defaults/StsDefaultServicesExtension.java index eea5446a77e..42e051bd5a7 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/defaults/StsDefaultServicesExtension.java +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/defaults/StsDefaultServicesExtension.java @@ -22,6 +22,7 @@ import org.eclipse.edc.iam.identitytrust.sts.spi.service.StsClientTokenGeneratorService; import org.eclipse.edc.iam.identitytrust.sts.spi.store.StsAccountStore; import org.eclipse.edc.jwt.signer.spi.JwsSignerProvider; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.runtime.metamodel.annotation.Extension; import org.eclipse.edc.runtime.metamodel.annotation.Inject; import org.eclipse.edc.runtime.metamodel.annotation.Provider; @@ -64,6 +65,9 @@ public class StsDefaultServicesExtension implements ServiceExtension { @Inject(required = false) private StsClientSecretGenerator stsClientSecretGenerator; + @Inject + private JtiValidationStore jtiValidationStore; + @Override public String name() { return NAME; @@ -76,7 +80,8 @@ public StsClientTokenGeneratorService clientTokenService(ServiceExtensionContext (client) -> new JwtGenerationService(jwsSignerProvider), StsAccount::getPrivateKeyAlias, clock, - TimeUnit.MINUTES.toSeconds(tokenExpiration)); + TimeUnit.MINUTES.toSeconds(tokenExpiration), + jtiValidationStore); } @Provider @@ -84,6 +89,7 @@ public StsAccountService clientService() { return new StsAccountServiceImpl(clientStore, vault, transactionContext, stsClientSecretGenerator()); } + private StsClientSecretGenerator stsClientSecretGenerator() { return ofNullable(stsClientSecretGenerator) .orElseGet(RandomStringGenerator::new); diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/defaults/service/StsClientTokenGeneratorServiceImpl.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/defaults/service/StsClientTokenGeneratorServiceImpl.java index cdb27211e7f..c0a23b27bc1 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/defaults/service/StsClientTokenGeneratorServiceImpl.java +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/main/java/org/eclipse/edc/iam/identitytrust/sts/defaults/service/StsClientTokenGeneratorServiceImpl.java @@ -19,6 +19,7 @@ import org.eclipse.edc.iam.identitytrust.sts.spi.model.StsAccountTokenAdditionalParams; import org.eclipse.edc.iam.identitytrust.sts.spi.service.StsClientTokenGeneratorService; import org.eclipse.edc.iam.identitytrust.sts.spi.service.StsTokenGenerationProvider; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.spi.iam.TokenRepresentation; import org.eclipse.edc.spi.result.ServiceResult; @@ -43,19 +44,21 @@ public class StsClientTokenGeneratorServiceImpl implements StsClientTokenGenerat private final StsTokenGenerationProvider tokenGenerationProvider; private final Function keyFunction; private final Clock clock; + private final JtiValidationStore jtiValidationStore; - public StsClientTokenGeneratorServiceImpl(StsTokenGenerationProvider tokenGenerationProvider, Function keyFunction, Clock clock, long tokenExpiration) { + public StsClientTokenGeneratorServiceImpl(StsTokenGenerationProvider tokenGenerationProvider, Function keyFunction, Clock clock, long tokenExpiration, JtiValidationStore jtiValidationStore) { this.tokenGenerationProvider = tokenGenerationProvider; this.keyFunction = keyFunction; this.clock = clock; this.tokenExpiration = tokenExpiration; + this.jtiValidationStore = jtiValidationStore; } @Override public ServiceResult tokenFor(StsAccount client, StsAccountTokenAdditionalParams additionalParams) { var embeddedTokenGenerator = new EmbeddedSecureTokenService(tokenGenerationProvider.tokenGeneratorFor(client), () -> keyFunction.apply(client), client::getPublicKeyReference, - clock, tokenExpiration); + clock, tokenExpiration, jtiValidationStore); var initialClaims = Map.of( ISSUER, client.getDid(), diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/defaults/StsAccountTokenIssuanceIntegrationTest.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/defaults/StsAccountTokenIssuanceIntegrationTest.java index d77759f323b..c2e4ec7cb64 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/defaults/StsAccountTokenIssuanceIntegrationTest.java +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/defaults/StsAccountTokenIssuanceIntegrationTest.java @@ -23,6 +23,7 @@ import org.eclipse.edc.iam.identitytrust.sts.spi.model.StsAccount; import org.eclipse.edc.iam.identitytrust.sts.spi.model.StsAccountTokenAdditionalParams; import org.eclipse.edc.junit.annotations.ComponentTest; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.keys.KeyParserRegistryImpl; import org.eclipse.edc.keys.VaultPrivateKeyResolver; import org.eclipse.edc.keys.keyparsers.JwkParser; @@ -31,6 +32,7 @@ import org.eclipse.edc.keys.spi.PrivateKeyResolver; import org.eclipse.edc.query.CriterionOperatorRegistryImpl; import org.eclipse.edc.security.token.jwt.DefaultJwsSignerProvider; +import org.eclipse.edc.spi.result.StoreResult; import org.eclipse.edc.spi.security.Vault; import org.eclipse.edc.token.JwtGenerationService; import org.eclipse.edc.transaction.spi.NoopTransactionContext; @@ -52,7 +54,9 @@ import static org.eclipse.edc.iam.identitytrust.spi.SelfIssuedTokenConstants.PRESENTATION_TOKEN_CLAIM; import static org.eclipse.edc.iam.identitytrust.sts.spi.store.fixtures.TestFunctions.createClientBuilder; import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.CLIENT_ID; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; @ComponentTest public class StsAccountTokenIssuanceIntegrationTest { @@ -60,6 +64,7 @@ public class StsAccountTokenIssuanceIntegrationTest { private final InMemoryStsAccountStore clientStore = new InMemoryStsAccountStore(CriterionOperatorRegistryImpl.ofDefaults()); private final Vault vault = new InMemoryVault(mock()); private final KeyParserRegistry keyParserRegistry = new KeyParserRegistryImpl(); + private final JtiValidationStore jtiValidationStore = mock(); private StsAccountServiceImpl clientService; private StsClientTokenGeneratorServiceImpl tokenGeneratorService; private PrivateKeyResolver privateKeyResolver; @@ -72,10 +77,12 @@ void setup() { keyParserRegistry.register(new JwkParser(new ObjectMapper(), mock())); privateKeyResolver = new VaultPrivateKeyResolver(keyParserRegistry, vault, mock(), mock()); + when(jtiValidationStore.storeEntry(any())).thenReturn(StoreResult.success()); tokenGeneratorService = new StsClientTokenGeneratorServiceImpl( client -> new JwtGenerationService(new DefaultJwsSignerProvider(privateKeyResolver)), StsAccount::getPrivateKeyAlias, - Clock.systemUTC(), 60 * 5); + Clock.systemUTC(), 60 * 5, + jtiValidationStore); } diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/defaults/service/StsAccountTokenGeneratorServiceImplTest.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/defaults/service/StsAccountTokenGeneratorServiceImplTest.java index d02b0cb0f2b..b9e52ff152f 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/defaults/service/StsAccountTokenGeneratorServiceImplTest.java +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-core/src/test/java/org/eclipse/edc/iam/identitytrust/sts/defaults/service/StsAccountTokenGeneratorServiceImplTest.java @@ -17,9 +17,11 @@ import org.eclipse.edc.iam.identitytrust.sts.spi.model.StsAccountTokenAdditionalParams; import org.eclipse.edc.iam.identitytrust.sts.spi.service.StsTokenGenerationProvider; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.spi.iam.TokenRepresentation; import org.eclipse.edc.spi.result.Result; import org.eclipse.edc.spi.result.ServiceFailure; +import org.eclipse.edc.spi.result.StoreResult; import org.eclipse.edc.token.spi.TokenDecorator; import org.eclipse.edc.token.spi.TokenGenerationService; import org.junit.jupiter.api.BeforeEach; @@ -39,11 +41,13 @@ public class StsAccountTokenGeneratorServiceImplTest { public static final long TOKEN_EXPIRATION = 60 * 5; private final StsTokenGenerationProvider tokenGenerationProvider = mock(); private final TokenGenerationService tokenGenerator = mock(); + private final JtiValidationStore jtiValidationStore = mock(); private StsClientTokenGeneratorServiceImpl clientTokenService; @BeforeEach void setup() { - clientTokenService = new StsClientTokenGeneratorServiceImpl(tokenGenerationProvider, (client) -> "test-key-id", Clock.systemUTC(), TOKEN_EXPIRATION); + when(jtiValidationStore.storeEntry(any())).thenReturn(StoreResult.success()); + clientTokenService = new StsClientTokenGeneratorServiceImpl(tokenGenerationProvider, (client) -> "test-key-id", Clock.systemUTC(), TOKEN_EXPIRATION, jtiValidationStore); } @Test diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/AccessTokenDecorator.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/AccessTokenDecorator.java new file mode 100644 index 00000000000..eb397bf7cd1 --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/AccessTokenDecorator.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.iam.identitytrust.sts.embedded; + +import org.eclipse.edc.spi.iam.TokenParameters; +import org.eclipse.edc.token.spi.TokenDecorator; + +import java.time.Instant; +import java.util.Date; +import java.util.Map; + +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; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.NOT_BEFORE; + +/** + * Opinionated decorator that adds "jti", "iat", "nbf" and "exp" claims to an existing set of claims, overwriting if the aforementioned + * are already contained in the map. + */ +public class AccessTokenDecorator implements TokenDecorator { + + private final String jti; + private final Instant now; + private final Instant expiration; + private final Map claims; + + public AccessTokenDecorator(String jti, Instant now, Instant expiration, Map claims) { + this.jti = jti; + this.now = now; + this.expiration = expiration; + this.claims = claims; + } + + @Override + public TokenParameters.Builder decorate(TokenParameters.Builder tokenParameters) { + this.claims.forEach(tokenParameters::claims); + return tokenParameters + .claims(ISSUED_AT, Date.from(now)) + .claims(NOT_BEFORE, Date.from(now)) + .claims(EXPIRATION_TIME, Date.from(expiration)) + .claims(JWT_ID, jti); + } +} diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/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/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenService.java index a224475e10e..26959c20d22 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/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/identity-trust-sts-embedded/src/main/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenService.java @@ -15,6 +15,8 @@ package org.eclipse.edc.iam.identitytrust.sts.embedded; import org.eclipse.edc.iam.identitytrust.spi.SecureTokenService; +import org.eclipse.edc.jwt.validation.jti.JtiValidationEntry; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.spi.iam.TokenRepresentation; import org.eclipse.edc.spi.result.Result; import org.eclipse.edc.token.spi.KeyIdDecorator; @@ -25,6 +27,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Supplier; @@ -53,13 +56,15 @@ public class EmbeddedSecureTokenService implements SecureTokenService { private final Supplier publicKeyIdSupplier; private final Clock clock; private final long validity; + private final JtiValidationStore jtiValidationStore; - public EmbeddedSecureTokenService(TokenGenerationService tokenGenerationService, Supplier privateKeyIdSupplier, Supplier publicKeyIdSupplier, Clock clock, long validity) { + public EmbeddedSecureTokenService(TokenGenerationService tokenGenerationService, Supplier privateKeyIdSupplier, Supplier publicKeyIdSupplier, Clock clock, long validity, JtiValidationStore jtiValidationStore) { this.tokenGenerationService = tokenGenerationService; this.privateKeyIdSupplier = privateKeyIdSupplier; this.publicKeyIdSupplier = publicKeyIdSupplier; this.clock = clock; this.validity = validity; + this.jtiValidationStore = jtiValidationStore; } @Override @@ -74,6 +79,13 @@ public Result createToken(Map claims, @Null }); } + private Result recordToken(String jti, Long exp) { + var storeResult = jtiValidationStore.storeEntry(new JtiValidationEntry(jti, exp)); + return storeResult.succeeded() + ? Result.success() + : failure("error storing JTI for later validation: %s".formatted(storeResult.getFailureDetail())); + } + private Result createAndAcceptAccessToken(Map claims, String scope, BiConsumer consumer) { return createAccessToken(claims, scope) .compose(tokenRepresentation -> success(tokenRepresentation.getToken())) @@ -83,13 +95,18 @@ private Result createAndAcceptAccessToken(Map claims, Stri private Result createAccessToken(Map claims, String bearerAccessScope) { var accessTokenClaims = new HashMap<>(accessTokenInheritedClaims(claims)); + var now = clock.instant(); + var exp = now.plusSeconds(validity); + var jti = "accesstoken-%s".formatted(UUID.randomUUID()); + accessTokenClaims.put(SCOPE, bearerAccessScope); + return addClaim(claims, ISSUER, withClaim(AUDIENCE, accessTokenClaims::put)) .compose(v -> addClaim(claims, AUDIENCE, withClaim(SUBJECT, accessTokenClaims::put))) .compose(v -> { var keyIdDecorator = new KeyIdDecorator(publicKeyIdSupplier.get()); - return tokenGenerationService.generate(privateKeyIdSupplier.get(), keyIdDecorator, new SelfIssuedTokenDecorator(accessTokenClaims, clock, validity)); - }); + return tokenGenerationService.generate(privateKeyIdSupplier.get(), keyIdDecorator, new AccessTokenDecorator(jti, now, exp, accessTokenClaims)); + }).compose(tr -> recordToken(jti, exp.toEpochMilli()).map(v -> tr)); } diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/AccessTokenDecoratorTest.java b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/AccessTokenDecoratorTest.java new file mode 100644 index 00000000000..82e7ef50c5e --- /dev/null +++ b/extensions/common/iam/identity-trust/identity-trust-sts/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/AccessTokenDecoratorTest.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.iam.identitytrust.sts.embedded; + +import org.eclipse.edc.spi.iam.TokenParameters; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +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; +import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.NOT_BEFORE; + +class AccessTokenDecoratorTest { + + @Test + void verifyExpectedClaims() { + var builder = TokenParameters.Builder.newInstance(); + var now = Instant.now(); + var decorator = new AccessTokenDecorator("test-id", now, now.plusSeconds(5), Map.of("claim1", "value1")); + decorator.decorate(builder); + + var tokenParams = builder.build(); + assertThat(tokenParams.getClaims()) + .containsEntry("claim1", "value1") + .containsEntry(JWT_ID, "test-id") + .containsKeys(ISSUED_AT, EXPIRATION_TIME, NOT_BEFORE); + } +} \ No newline at end of file diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/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/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceIntegrationTest.java index 58758c7910d..5f28889c7c5 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/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/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceIntegrationTest.java @@ -20,7 +20,9 @@ import com.nimbusds.jose.crypto.RSASSASigner; import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory; import com.nimbusds.jwt.SignedJWT; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.result.StoreResult; import org.eclipse.edc.token.JwtGenerationService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -51,9 +53,13 @@ import static org.eclipse.edc.iam.identitytrust.spi.SelfIssuedTokenConstants.PRESENTATION_TOKEN_CLAIM; import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; import static org.eclipse.edc.jwt.spi.JwtRegisteredClaimNames.SCOPE; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class EmbeddedSecureTokenServiceIntegrationTest { + private final JtiValidationStore jtiValidationStore = mock(); private KeyPair keyPair; private EmbeddedSecureTokenService secureTokenService; @@ -66,7 +72,8 @@ private static KeyPair generateKeyPair() throws NoSuchAlgorithmException { void setup() throws NoSuchAlgorithmException { keyPair = generateKeyPair(); var tokenGenerationService = new JwtGenerationService(s -> Result.success(new RSASSASigner(keyPair.getPrivate()))); - secureTokenService = new EmbeddedSecureTokenService(tokenGenerationService, () -> "test-private-keyid", () -> "test-keyid", Clock.systemUTC(), 10 * 60); + when(jtiValidationStore.storeEntry(any())).thenReturn(StoreResult.success()); + secureTokenService = new EmbeddedSecureTokenService(tokenGenerationService, () -> "test-private-keyid", () -> "test-keyid", Clock.systemUTC(), 10 * 60, jtiValidationStore); } @Test diff --git a/extensions/common/iam/identity-trust/identity-trust-sts/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/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceTest.java index 09e7ef4ad74..e7bffb3d3ae 100644 --- a/extensions/common/iam/identity-trust/identity-trust-sts/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/identity-trust-sts-embedded/src/test/java/org/eclipse/edc/iam/identitytrust/sts/embedded/EmbeddedSecureTokenServiceTest.java @@ -14,11 +14,14 @@ package org.eclipse.edc.iam.identitytrust.sts.embedded; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.spi.iam.TokenRepresentation; import org.eclipse.edc.spi.result.Result; +import org.eclipse.edc.spi.result.StoreResult; import org.eclipse.edc.token.spi.KeyIdDecorator; import org.eclipse.edc.token.spi.TokenDecorator; import org.eclipse.edc.token.spi.TokenGenerationService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -41,10 +44,16 @@ public class EmbeddedSecureTokenServiceTest { public static final String TEST_PRIVATEKEY_ID = "test-privatekey-id"; private final TokenGenerationService tokenGenerationService = mock(); private final Supplier keySupplier = () -> TEST_PRIVATEKEY_ID; + private final JtiValidationStore jtiValidationStore = mock(); + + @BeforeEach + void setup() { + when(jtiValidationStore.storeEntry(any())).thenReturn(StoreResult.success()); + } @Test void createToken_withoutBearerAccessScope() { - var sts = new EmbeddedSecureTokenService(tokenGenerationService, keySupplier, () -> "test-key", Clock.systemUTC(), 10 * 60); + var sts = new EmbeddedSecureTokenService(tokenGenerationService, keySupplier, () -> "test-key", Clock.systemUTC(), 10 * 60, jtiValidationStore); var token = TokenRepresentation.Builder.newInstance().token("test").build(); when(tokenGenerationService.generate(eq(TEST_PRIVATEKEY_ID), any(TokenDecorator[].class))).thenReturn(Result.success(token)); @@ -67,7 +76,7 @@ void createToken_withoutBearerAccessScope() { void createToken_withBearerAccessScope() { var claims = Map.of(ISSUER, "testIssuer", AUDIENCE, "aud"); - var sts = new EmbeddedSecureTokenService(tokenGenerationService, keySupplier, () -> "test-key", Clock.systemUTC(), 10 * 60); + var sts = new EmbeddedSecureTokenService(tokenGenerationService, keySupplier, () -> "test-key", Clock.systemUTC(), 10 * 60, jtiValidationStore); var token = TokenRepresentation.Builder.newInstance().token("test").build(); when(tokenGenerationService.generate(eq(TEST_PRIVATEKEY_ID), any(TokenDecorator[].class))) @@ -86,11 +95,11 @@ void createToken_withBearerAccessScope() { .satisfies(decorators -> { assertThat(decorators.get(0)) .hasSize(2) - .hasOnlyElementsOfTypes(KeyIdDecorator.class, SelfIssuedTokenDecorator.class); + .hasOnlyElementsOfTypes(KeyIdDecorator.class, AccessTokenDecorator.class, SelfIssuedTokenDecorator.class); assertThat(decorators.get(1)) .hasSize(2) - .hasOnlyElementsOfTypes(KeyIdDecorator.class, SelfIssuedTokenDecorator.class); + .hasOnlyElementsOfTypes(KeyIdDecorator.class, AccessTokenDecorator.class, SelfIssuedTokenDecorator.class); }); } @@ -100,7 +109,7 @@ void createToken_error_whenAccessTokenFails() { var claims = Map.of(ISSUER, "testIssuer", AUDIENCE, "aud"); - var sts = new EmbeddedSecureTokenService(tokenGenerationService, keySupplier, () -> "test-key", Clock.systemUTC(), 10 * 60); + var sts = new EmbeddedSecureTokenService(tokenGenerationService, keySupplier, () -> "test-key", Clock.systemUTC(), 10 * 60, jtiValidationStore); var token = TokenRepresentation.Builder.newInstance().token("test").build(); when(tokenGenerationService.generate(eq(TEST_PRIVATEKEY_ID), any(TokenDecorator[].class))) @@ -116,7 +125,7 @@ void createToken_error_whenAccessTokenFails() { assertThat(captor.getValue()) .hasSize(2) - .hasOnlyElementsOfTypes(SelfIssuedTokenDecorator.class, KeyIdDecorator.class); + .hasOnlyElementsOfTypes(SelfIssuedTokenDecorator.class, AccessTokenDecorator.class, KeyIdDecorator.class); } @@ -124,7 +133,7 @@ void createToken_error_whenAccessTokenFails() { void createToken_error_whenSelfTokenFails() { var claims = Map.of(ISSUER, "testIssuer", AUDIENCE, "aud"); - var sts = new EmbeddedSecureTokenService(tokenGenerationService, keySupplier, () -> "test-key", Clock.systemUTC(), 10 * 60); + var sts = new EmbeddedSecureTokenService(tokenGenerationService, keySupplier, () -> "test-key", Clock.systemUTC(), 10 * 60, jtiValidationStore); var token = TokenRepresentation.Builder.newInstance().token("test").build(); when(tokenGenerationService.generate(eq(TEST_PRIVATEKEY_ID), any(TokenDecorator[].class))) diff --git a/extensions/common/store/sql/jti-validation-store-sql/build.gradle.kts b/extensions/common/store/sql/jti-validation-store-sql/build.gradle.kts new file mode 100644 index 00000000000..1649ed8187a --- /dev/null +++ b/extensions/common/store/sql/jti-validation-store-sql/build.gradle.kts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +plugins { + `java-library` +} + +dependencies { + api(project(":spi:common:jwt-spi")) + api(project(":spi:common:transaction-spi")) + + implementation(project(":extensions:common:sql:sql-core")) + implementation(project(":extensions:common:sql:sql-bootstrapper")) + implementation(project(":spi:common:transaction-datasource-spi")) + testImplementation(project(":core:common:junit")) + testImplementation(testFixtures(project(":extensions:common:sql:sql-core"))) + testImplementation(testFixtures(project(":spi:common:jwt-spi"))) + +} \ No newline at end of file diff --git a/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/SqlJtiValidationStore.java b/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/SqlJtiValidationStore.java new file mode 100644 index 00000000000..96df97576f9 --- /dev/null +++ b/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/SqlJtiValidationStore.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.edr.store.index; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.edc.edr.store.index.sql.schema.JtiValidationStoreStatements; +import org.eclipse.edc.jwt.validation.jti.JtiValidationEntry; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; +import org.eclipse.edc.spi.monitor.Monitor; +import org.eclipse.edc.spi.persistence.EdcPersistenceException; +import org.eclipse.edc.spi.result.StoreResult; +import org.eclipse.edc.sql.QueryExecutor; +import org.eclipse.edc.sql.store.AbstractSqlStore; +import org.eclipse.edc.transaction.datasource.spi.DataSourceRegistry; +import org.eclipse.edc.transaction.spi.TransactionContext; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; + +public class SqlJtiValidationStore extends AbstractSqlStore implements JtiValidationStore { + + private final JtiValidationStoreStatements statements; + private final Monitor monitor; + + public SqlJtiValidationStore(DataSourceRegistry dataSourceRegistry, String dataSourceName, TransactionContext transactionContext, + ObjectMapper objectMapper, JtiValidationStoreStatements statements, QueryExecutor queryExecutor, Monitor monitor) { + super(dataSourceRegistry, dataSourceName, transactionContext, objectMapper, queryExecutor); + this.statements = statements; + this.monitor = monitor; + } + + @Override + public StoreResult storeEntry(JtiValidationEntry entry) { + return transactionContext.execute(() -> { + var stmt = statements.getInsertTemplate(); + try (var connection = getConnection()) { + + if (findByIdInternal(connection, entry.tokenId()) != null) { + return StoreResult.alreadyExists("JTI Validation Entry with ID '%s' already exists".formatted(entry.tokenId())); + } + queryExecutor.execute(connection, stmt, entry.tokenId(), entry.expirationTimestamp()); + return StoreResult.success(); + } catch (SQLException e) { + throw new EdcPersistenceException(e); + } + }); + } + + @Override + public JtiValidationEntry findById(String id, boolean autoRemove) { + return transactionContext.execute(() -> { + try (var connection = getConnection()) { + + var entry = findByIdInternal(connection, id); + if (entry != null && autoRemove) { + // a failing delete should not impact the lookup + deleteById(id).onFailure(f -> monitor.warning("Error deleting entry after lookup: '%s'".formatted(id))); + } + return entry; + } catch (SQLException e) { + throw new EdcPersistenceException(e); + } + }); + } + + @Override + public StoreResult deleteById(String id) { + return transactionContext.execute(() -> { + try (var connection = getConnection()) { + if (findByIdInternal(connection, id) == null) { + return StoreResult.notFound("JTI Validation Entry with ID '%s' not found".formatted(id)); + } + var stmt = statements.getDeleteByIdTemplate(); + queryExecutor.execute(connection, stmt, id); + return StoreResult.success(); + } catch (SQLException e) { + throw new EdcPersistenceException(e); + } + }); + } + + @Override + public StoreResult deleteExpired() { + return transactionContext.execute(() -> { + try (var connection = getConnection()) { + var stmt = statements.deleteWhereExpiredTemplate(); + var rows = queryExecutor.execute(connection, stmt, Instant.now().toEpochMilli()); + return StoreResult.success(rows); + } catch (SQLException e) { + throw new EdcPersistenceException(e); + } + }); + } + + private JtiValidationEntry findByIdInternal(Connection connection, String id) { + var stmt = statements.getFindByTemplate(); + return queryExecutor.single(connection, false, this::mapResultSet, stmt, id); + } + + private JtiValidationEntry mapResultSet(ResultSet resultSet) throws Exception { + var expiresAt = resultSet.getLong(statements.getExpirationTimeColumn()); + + return new JtiValidationEntry(resultSet.getString(statements.getTokenIdColumn()), expiresAt); + } +} diff --git a/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/SqlJtiValidationStoreExtension.java b/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/SqlJtiValidationStoreExtension.java new file mode 100644 index 00000000000..27f06d59a0a --- /dev/null +++ b/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/SqlJtiValidationStoreExtension.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.edr.store.index; + + +import org.eclipse.edc.edr.store.index.sql.schema.JtiValidationStoreStatements; +import org.eclipse.edc.edr.store.index.sql.schema.postgres.PostgresDialectStatements; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; +import org.eclipse.edc.runtime.metamodel.annotation.Extension; +import org.eclipse.edc.runtime.metamodel.annotation.Inject; +import org.eclipse.edc.runtime.metamodel.annotation.Provides; +import org.eclipse.edc.runtime.metamodel.annotation.Setting; +import org.eclipse.edc.spi.system.ServiceExtension; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.spi.types.TypeManager; +import org.eclipse.edc.sql.QueryExecutor; +import org.eclipse.edc.sql.bootstrapper.SqlSchemaBootstrapper; +import org.eclipse.edc.transaction.datasource.spi.DataSourceRegistry; +import org.eclipse.edc.transaction.spi.TransactionContext; + +@Provides({ JtiValidationStore.class }) +@Extension(value = "SQL JTI Validation store") +public class SqlJtiValidationStoreExtension implements ServiceExtension { + + + @Setting(value = "The datasource to be used", defaultValue = DataSourceRegistry.DEFAULT_DATASOURCE) + public static final String DATASOURCE_NAME = "edc.sql.store.jti.datasource"; + + + @Inject + private DataSourceRegistry dataSourceRegistry; + + @Inject + private TransactionContext transactionContext; + + @Inject(required = false) + private JtiValidationStoreStatements statements; + + @Inject + private QueryExecutor queryExecutor; + + @Inject + private TypeManager typeManager; + + @Inject + private SqlSchemaBootstrapper sqlSchemaBootstrapper; + + + @Override + public void initialize(ServiceExtensionContext context) { + var dataSourceName = context.getConfig().getString(DATASOURCE_NAME, DataSourceRegistry.DEFAULT_DATASOURCE); + + var sqlStore = new SqlJtiValidationStore(dataSourceRegistry, dataSourceName, transactionContext, typeManager.getMapper(), + getStatementImpl(), queryExecutor, context.getMonitor()); + + context.registerService(JtiValidationStore.class, sqlStore); + sqlSchemaBootstrapper.addStatementFromResource(dataSourceName, "jti-validation-schema.sql"); + } + + private JtiValidationStoreStatements getStatementImpl() { + return statements == null ? new PostgresDialectStatements() : statements; + } + +} diff --git a/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/sql/schema/BaseSqlDialectStatements.java b/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/sql/schema/BaseSqlDialectStatements.java new file mode 100644 index 00000000000..8a62efffdb4 --- /dev/null +++ b/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/sql/schema/BaseSqlDialectStatements.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.edr.store.index.sql.schema; + +import org.eclipse.edc.spi.query.Criterion; +import org.eclipse.edc.sql.translation.SqlOperatorTranslator; + +import static java.lang.String.format; + +public class BaseSqlDialectStatements implements JtiValidationStoreStatements { + + protected final SqlOperatorTranslator operatorTranslator; + + public BaseSqlDialectStatements(SqlOperatorTranslator operatorTranslator) { + this.operatorTranslator = operatorTranslator; + } + + @Override + public String getDeleteByIdTemplate() { + return executeStatement().delete(getJtiValidationTable(), getTokenIdColumn()); + } + + + @Override + public String getFindByTemplate() { + return format("SELECT * FROM %s WHERE %s = ?", getJtiValidationTable(), getTokenIdColumn()); + } + + @Override + public String getInsertTemplate() { + return executeStatement() + .column(getTokenIdColumn()) + .column(getExpirationTimeColumn()) + .insertInto(getJtiValidationTable()); + } + + @Override + public String deleteWhereExpiredTemplate() { + return executeStatement().delete(getJtiValidationTable(), new Criterion(getExpirationTimeColumn(), "<", "?")); + } + +} diff --git a/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/sql/schema/JtiValidationStoreStatements.java b/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/sql/schema/JtiValidationStoreStatements.java new file mode 100644 index 00000000000..56dd4083a47 --- /dev/null +++ b/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/sql/schema/JtiValidationStoreStatements.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.edr.store.index.sql.schema; + +import org.eclipse.edc.sql.statement.SqlStatements; + +/** + * Defines all statements that are needed for the {@link org.eclipse.edc.jwt.validation.jti.JtiValidationEntry} store + */ +public interface JtiValidationStoreStatements extends SqlStatements { + default String getTokenIdColumn() { + return "token_id"; + } + + default String getExpirationTimeColumn() { + return "expires_at"; + } + + default String getJtiValidationTable() { + return "edc_jti_validation"; + } + + String getDeleteByIdTemplate(); + + String getFindByTemplate(); + + String getInsertTemplate(); + + String deleteWhereExpiredTemplate(); +} diff --git a/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/sql/schema/postgres/PostgresDialectStatements.java b/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/sql/schema/postgres/PostgresDialectStatements.java new file mode 100644 index 00000000000..f440a42344a --- /dev/null +++ b/extensions/common/store/sql/jti-validation-store-sql/src/main/java/org/eclipse/edc/edr/store/index/sql/schema/postgres/PostgresDialectStatements.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.edr.store.index.sql.schema.postgres; + +import org.eclipse.edc.edr.store.index.sql.schema.BaseSqlDialectStatements; +import org.eclipse.edc.sql.dialect.PostgresDialect; +import org.eclipse.edc.sql.translation.PostgresqlOperatorTranslator; + +/** + * Contains Postgres-specific SQL statements + */ +public class PostgresDialectStatements extends BaseSqlDialectStatements { + + public PostgresDialectStatements() { + super(new PostgresqlOperatorTranslator()); + } + + @Override + public String getFormatAsJsonOperator() { + return PostgresDialect.getJsonCastOperator(); + } + +} diff --git a/extensions/common/store/sql/jti-validation-store-sql/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension b/extensions/common/store/sql/jti-validation-store-sql/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension new file mode 100644 index 00000000000..b48e15a54c9 --- /dev/null +++ b/extensions/common/store/sql/jti-validation-store-sql/src/main/resources/META-INF/services/org.eclipse.edc.spi.system.ServiceExtension @@ -0,0 +1,15 @@ +# +# Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# This program and the accompanying materials are made available under the +# terms of the Apache License, Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# +# Contributors: +# Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation +# +# + +org.eclipse.edc.edr.store.index.SqlJtiValidationStoreExtension \ No newline at end of file diff --git a/extensions/common/store/sql/jti-validation-store-sql/src/main/resources/jti-validation-schema.sql b/extensions/common/store/sql/jti-validation-store-sql/src/main/resources/jti-validation-schema.sql new file mode 100644 index 00000000000..af364f00844 --- /dev/null +++ b/extensions/common/store/sql/jti-validation-store-sql/src/main/resources/jti-validation-schema.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS edc_jti_validation +( + token_id VARCHAR NOT NULL PRIMARY KEY, + expires_at BIGINT -- expiry time in epoch millis +); + + diff --git a/extensions/common/store/sql/jti-validation-store-sql/src/test/java/org/eclipse/edc/edr/store/index/sql/SqlJtiValidationStoreExtensionTest.java b/extensions/common/store/sql/jti-validation-store-sql/src/test/java/org/eclipse/edc/edr/store/index/sql/SqlJtiValidationStoreExtensionTest.java new file mode 100644 index 00000000000..cd5e82e83bd --- /dev/null +++ b/extensions/common/store/sql/jti-validation-store-sql/src/test/java/org/eclipse/edc/edr/store/index/sql/SqlJtiValidationStoreExtensionTest.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.edr.store.index.sql; + +import org.eclipse.edc.edr.store.index.SqlJtiValidationStore; +import org.eclipse.edc.edr.store.index.SqlJtiValidationStoreExtension; +import org.eclipse.edc.json.JacksonTypeManager; +import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; +import org.eclipse.edc.spi.system.ServiceExtensionContext; +import org.eclipse.edc.spi.system.configuration.Config; +import org.eclipse.edc.spi.types.TypeManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.edr.store.index.SqlJtiValidationStoreExtension.DATASOURCE_NAME; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(DependencyInjectionExtension.class) +public class SqlJtiValidationStoreExtensionTest { + + @BeforeEach + void setUp(ServiceExtensionContext context) { + context.registerService(TypeManager.class, new JacksonTypeManager()); + } + + @Test + void shouldInitializeTheStore(SqlJtiValidationStoreExtension extension, ServiceExtensionContext context) { + var config = mock(Config.class); + when(context.getConfig()).thenReturn(config); + when(config.getString(any(), any())).thenReturn("test"); + + extension.initialize(context); + + var service = context.getService(JtiValidationStore.class); + assertThat(service).isInstanceOf(SqlJtiValidationStore.class); + + verify(config).getString(eq(DATASOURCE_NAME), any()); + } +} diff --git a/extensions/common/store/sql/jti-validation-store-sql/src/test/java/org/eclipse/edc/edr/store/index/sql/SqlJtiValidationStoreTest.java b/extensions/common/store/sql/jti-validation-store-sql/src/test/java/org/eclipse/edc/edr/store/index/sql/SqlJtiValidationStoreTest.java new file mode 100644 index 00000000000..7024a8d67b5 --- /dev/null +++ b/extensions/common/store/sql/jti-validation-store-sql/src/test/java/org/eclipse/edc/edr/store/index/sql/SqlJtiValidationStoreTest.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.edr.store.index.sql; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.eclipse.edc.edr.store.index.SqlJtiValidationStore; +import org.eclipse.edc.edr.store.index.sql.schema.BaseSqlDialectStatements; +import org.eclipse.edc.edr.store.index.sql.schema.postgres.PostgresDialectStatements; +import org.eclipse.edc.junit.annotations.ComponentTest; +import org.eclipse.edc.junit.testfixtures.TestUtils; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStoreTestBase; +import org.eclipse.edc.sql.QueryExecutor; +import org.eclipse.edc.sql.testfixtures.PostgresqlStoreSetupExtension; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.mockito.Mockito.mock; + +@ComponentTest +@ExtendWith(PostgresqlStoreSetupExtension.class) +public class SqlJtiValidationStoreTest extends JtiValidationStoreTestBase { + + private final BaseSqlDialectStatements statements = new PostgresDialectStatements(); + + private SqlJtiValidationStore store; + + @BeforeEach + void setUp(PostgresqlStoreSetupExtension extension, QueryExecutor queryExecutor) { + store = new SqlJtiValidationStore(extension.getDataSourceRegistry(), extension.getDatasourceName(), + extension.getTransactionContext(), new ObjectMapper(), statements, queryExecutor, mock()); + var schema = TestUtils.getResourceFileContentAsString("jti-validation-schema.sql"); + extension.runQuery(schema); + } + + @AfterEach + void tearDown(PostgresqlStoreSetupExtension extension) { + extension.runQuery("DROP TABLE " + statements.getJtiValidationTable() + " CASCADE"); + } + + + @Override + protected JtiValidationStore getStore() { + return store; + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 40deb599ecb..503be31ab3b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -162,6 +162,7 @@ include(":extensions:common:validator:validator-data-address-http-data") include(":extensions:common:validator:validator-data-address-kafka") include(":extensions:common:vault:vault-hashicorp") include(":extensions:common:store:sql:edr-index-sql") +include(":extensions:common:store:sql:jti-validation-store-sql") include(":extensions:common:store:sql:sts-client-store-sql") include(":extensions:common:api:control-api-configuration") diff --git a/spi/common/jwt-spi/build.gradle.kts b/spi/common/jwt-spi/build.gradle.kts index 10454fad295..75e5d3aaafe 100644 --- a/spi/common/jwt-spi/build.gradle.kts +++ b/spi/common/jwt-spi/build.gradle.kts @@ -15,10 +15,15 @@ plugins { `java-library` `maven-publish` + `java-test-fixtures` } dependencies { implementation(libs.edc.runtime.metamodel) + api(project(":spi:common:core-spi")) + testFixturesImplementation(libs.bundles.jupiter) + testFixturesImplementation(libs.assertj) + testFixturesImplementation(project(":tests:junit-base")) } diff --git a/spi/common/jwt-spi/src/main/java/org/eclipse/edc/jwt/validation/jti/JtiValidationEntry.java b/spi/common/jwt-spi/src/main/java/org/eclipse/edc/jwt/validation/jti/JtiValidationEntry.java new file mode 100644 index 00000000000..5bf1d77724d --- /dev/null +++ b/spi/common/jwt-spi/src/main/java/org/eclipse/edc/jwt/validation/jti/JtiValidationEntry.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.jwt.validation.jti; + +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; + +/** + * Represents one database row to track JTI entries. + * + * @param tokenId The JWT Token ID (="jti") + * @param expirationTimestamp optional timestamp (epoch millis), to enable auto-cleanup + */ +public record JtiValidationEntry(String tokenId, @Nullable Long expirationTimestamp) { + public JtiValidationEntry(String tokenId) { + this(tokenId, null); + } + + /** + * checks whether the token is expired or not. If no expirationTimestamp was specified, the token never expires and this method always returns {@code false} + */ + public boolean isExpired() { + if (expirationTimestamp == null) { + return false; + } + + return Instant.ofEpochMilli(expirationTimestamp).isBefore(Instant.now()); + } + + public @Nullable Instant expirationTimestampAsInstant() { + return expirationTimestamp != null ? Instant.ofEpochMilli(expirationTimestamp) : null; + } +} diff --git a/spi/common/jwt-spi/src/main/java/org/eclipse/edc/jwt/validation/jti/JtiValidationStore.java b/spi/common/jwt-spi/src/main/java/org/eclipse/edc/jwt/validation/jti/JtiValidationStore.java new file mode 100644 index 00000000000..c000365b2c1 --- /dev/null +++ b/spi/common/jwt-spi/src/main/java/org/eclipse/edc/jwt/validation/jti/JtiValidationStore.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.jwt.validation.jti; + +import org.eclipse.edc.runtime.metamodel.annotation.ExtensionPoint; +import org.eclipse.edc.spi.result.StoreResult; + +@ExtensionPoint +public interface JtiValidationStore { + + StoreResult storeEntry(JtiValidationEntry entry); + + JtiValidationEntry findById(String id, boolean autoRemove); + + default JtiValidationEntry findById(String id) { + return findById(id, true); + } + + StoreResult deleteById(String id); + + StoreResult deleteExpired(); +} diff --git a/spi/common/jwt-spi/src/testFixtures/java/org/eclipse/edc/jwt/validation/jti/JtiValidationStoreTestBase.java b/spi/common/jwt-spi/src/testFixtures/java/org/eclipse/edc/jwt/validation/jti/JtiValidationStoreTestBase.java new file mode 100644 index 00000000000..6c5d1c4c4fc --- /dev/null +++ b/spi/common/jwt-spi/src/testFixtures/java/org/eclipse/edc/jwt/validation/jti/JtiValidationStoreTestBase.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) + * + * This program and the accompanying materials are made available under the + * terms of the Apache License, Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + * + * Contributors: + * Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation + * + */ + +package org.eclipse.edc.jwt.validation.jti; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static java.util.stream.IntStream.range; +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.edc.junit.assertions.AbstractResultAssert.assertThat; + +public abstract class JtiValidationStoreTestBase { + @Test + void storeEntry() { + assertThat(getStore().storeEntry(new JtiValidationEntry("test-id", Instant.now().plusSeconds(10).toEpochMilli()))).isSucceeded(); + } + + @Test + void storeEntry_noExpiresAt() { + assertThat(getStore().storeEntry(new JtiValidationEntry("test-id"))).isSucceeded(); + } + + @Test + void storeEntry_alreadyExists() { + getStore().storeEntry(new JtiValidationEntry("test-id", Instant.now().plusSeconds(10).toEpochMilli())); + assertThat(getStore().storeEntry(new JtiValidationEntry("test-id", Instant.now().plusSeconds(10).toEpochMilli()))) + .isFailed() + .detail().isEqualTo("JTI Validation Entry with ID 'test-id' already exists"); + } + + @Test + void findById() { + var entry = new JtiValidationEntry("test-id", Instant.now().plusSeconds(10).toEpochMilli()); + getStore().storeEntry(entry); + assertThat(getStore().findById("test-id")).usingRecursiveComparison().isEqualTo(entry); + assertThat(getStore().findById("test-id")).isNull(); + } + + @Test + void findById_noAutoRemove() { + var entry = new JtiValidationEntry("test-id", Instant.now().plusSeconds(10).toEpochMilli()); + getStore().storeEntry(entry); + assertThat(getStore().findById("test-id", false)).usingRecursiveComparison().isEqualTo(entry); + assertThat(getStore().findById("test-id", false)).usingRecursiveComparison().isEqualTo(entry); + } + + @Test + void findById_notFound() { + assertThat(getStore().findById("test-id")).isNull(); + } + + @Test + void deleteById() { + var entry = new JtiValidationEntry("test-id", Instant.now().plusSeconds(10).toEpochMilli()); + getStore().storeEntry(entry); + assertThat(getStore().deleteById("test-id")).isSucceeded(); + } + + @Test + void deleteById_notFound() { + assertThat(getStore().deleteById("test-id")).isFailed() + .detail().isEqualTo("JTI Validation Entry with ID 'test-id' not found"); + } + + @Test + void deleteExpired_noExpiredEntries() { + + assertThat(getStore().deleteExpired()).isSucceeded().isEqualTo(0); + + range(0, 10).forEach(i -> assertThat(getStore().findById("test-id" + i)).isNull()); + } + + @Test + void deleteExpired() { + range(0, 10).forEach(i -> getStore().storeEntry(new JtiValidationEntry("test-id" + i, Instant.now().minusSeconds(100).toEpochMilli()))); + getStore().storeEntry(new JtiValidationEntry("some-other-entry1")); + getStore().storeEntry(new JtiValidationEntry("some-other-entry2")); + + assertThat(getStore().deleteExpired()).isSucceeded().isEqualTo(10); + + range(0, 10).forEach(i -> assertThat(getStore().findById("test-id" + i)).isNull()); + assertThat(getStore().findById("some-other-entry1")).isNotNull(); + assertThat(getStore().findById("some-other-entry2")).isNotNull(); + } + + protected abstract JtiValidationStore getStore(); +} diff --git a/system-tests/bom-tests/src/test/java/org/eclipse/edc/test/bom/BomSmokeTests.java b/system-tests/bom-tests/src/test/java/org/eclipse/edc/test/bom/BomSmokeTests.java index 5231e68f86b..1670391596c 100644 --- a/system-tests/bom-tests/src/test/java/org/eclipse/edc/test/bom/BomSmokeTests.java +++ b/system-tests/bom-tests/src/test/java/org/eclipse/edc/test/bom/BomSmokeTests.java @@ -70,6 +70,8 @@ class ControlPlaneDcp extends SmokeTest { "edc.iam.sts.oauth.client.secret.alias", "test-alias", "web.http.port", DEFAULT_PORT, "web.http.path", DEFAULT_PATH, + "web.http.version.port", String.valueOf(getFreePort()), + "web.http.version.path", "/api/version", "web.http.management.port", "8081", "web.http.management.path", "/api/management"), ":dist:bom:controlplane-dcp-bom" @@ -81,22 +83,32 @@ class ControlPlaneDcp extends SmokeTest { class ControlPlaneOauth2 extends SmokeTest { @RegisterExtension - protected static RuntimeExtension runtime = - new RuntimePerMethodExtension(new EmbeddedRuntime("control-plane-oauth2-bom", - Map.of( - "edc.oauth.token.url", "https://oauth2.com/token", - "edc.oauth.certificate.alias", "test-alias", - "edc.oauth.private.key.alias", "private-test-alias", - "web.http.management.port", "8081", - "web.http.management.path", "/api/management", - "web.http.port", DEFAULT_PORT, - "web.http.path", DEFAULT_PATH, - "edc.oauth.provider.jwks.url", "http://localhost:9999/jwks", - "edc.oauth.client.id", "test-client"), - ":dist:bom:controlplane-oauth2-bom" - )); + protected static RuntimeExtension runtime; private static ClientAndServer jwksServer; + static { + var stringStringMap = new java.util.HashMap() { + { + put("edc.oauth.token.url", "https://oauth2.com/token"); + put("edc.oauth.certificate.alias", "test-alias"); + put("edc.oauth.private.key.alias", "private-test-alias"); + put("web.http.management.port", "8081"); + put("web.http.management.path", "/api/management"); + put("web.http.port", DEFAULT_PORT); + put("web.http.path", DEFAULT_PATH); + put("web.http.version.port", String.valueOf(getFreePort())); + put("web.http.version.path", "/api/version"); + + put("edc.oauth.provider.jwks.url", "http://localhost:9999/jwks"); + put("edc.oauth.client.id", "test-client"); + } + }; + runtime = new RuntimePerMethodExtension(new EmbeddedRuntime("control-plane-oauth2-bom", + stringStringMap, + ":dist:bom:controlplane-oauth2-bom" + )); + } + @BeforeAll static void setup() { var v = new InMemoryVault(mock()); @@ -129,6 +141,8 @@ public class DataPlaneBase extends SmokeTest { "edc.dpf.selector.url", "http://localhost:%s/selector".formatted(server.getPort()), "web.http.control.port", "8081", "web.http.control.path", "/api/control", + "web.http.version.port", String.valueOf(getFreePort()), + "web.http.version.path", "/api/version", "web.http.port", DEFAULT_PORT, "web.http.path", DEFAULT_PATH), ":dist:bom:dataplane-base-bom" @@ -153,10 +167,16 @@ class StsFeature extends SmokeTest { @RegisterExtension protected RuntimeExtension runtime = - new RuntimePerMethodExtension(new EmbeddedRuntime("data-plane-base-bom", + new RuntimePerMethodExtension(new EmbeddedRuntime("sts-feature-bom", Map.of( "web.http.port", DEFAULT_PORT, "web.http.path", DEFAULT_PATH, + "web.http.version.port", String.valueOf(getFreePort()), + "web.http.version.path", "/api/version", + "web.http.sts.port", String.valueOf(getFreePort()), + "web.http.sts.path", "/api/sts", + "web.http.accounts.port", String.valueOf(getFreePort()), + "web.http.accounts.path", "/api/sts/accounts", "edc.api.accounts.key", "password"), ":dist:bom:sts-feature-bom" ));