Skip to content

Commit

Permalink
feat: add JTI validation feature (#4560)
Browse files Browse the repository at this point in the history
* add JTI validation store (wip)

* specify web contexts explicitly

* add inmem implementation

* add SQL store for JTI validation entries

* add test for the JTI validation rule

* add JtiValidationStore to SQL BOM

* add reaper thread to JTI Validation store

* move reaper thread to DCP extension

* STS uses JTI Validation Service, records JTI

* record JTI when creating access tokens

* simplify JTI validation rule

* Update extensions/common/store/sql/jti-validation-store-sql/src/test/java/org/eclipse/edc/edr/store/index/sql/SqlJtiValidationStoreExtensionTest.java

Co-authored-by: Enrico Risa <enrico.risa@gmail.com>

---------

Co-authored-by: Enrico Risa <enrico.risa@gmail.com>
  • Loading branch information
paullatzelsperger and wolf4ood authored Oct 21, 2024
1 parent d04ee6e commit 7f83a70
Show file tree
Hide file tree
Showing 37 changed files with 1,113 additions and 39 deletions.
Original file line number Diff line number Diff line change
@@ -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<String, JtiValidationEntry> jtiValidationEntries = new ConcurrentHashMap<>();

@Override
public StoreResult<Void> 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<Void> 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<Integer> deleteExpired() {
var count = jtiValidationEntries.values().stream().filter(JtiValidationEntry::isExpired).count();
jtiValidationEntries.values().removeIf(JtiValidationEntry::isExpired);
return StoreResult.success((int) count);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,4 +58,9 @@ public TokenDecoratorRegistry tokenDecoratorRegistry() {
public JwsSignerProvider defaultSignerProvider() {
return new DefaultJwsSignerProvider(privateKeyResolver);
}

@Provider(isDefault = true)
public JtiValidationStore inMemoryJtiValidationStore() {
return new InMemoryJtiValidationStore();
}
}
1 change: 1 addition & 0 deletions dist/bom/controlplane-feature-sql-bom/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
* <p>
* Note that this rule can only be implemented after <a href="https://github.com/eclipse-edc/Connector/issues/3749">this related issue</a>
*/
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<Void> checkRule(@NotNull ClaimToken toVerify, @Nullable Map<String, Object> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@
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;
import org.eclipse.edc.runtime.metamodel.annotation.Provider;
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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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());

Expand All @@ -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) {
Expand All @@ -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);
Expand Down
Loading

0 comments on commit 7f83a70

Please sign in to comment.