diff --git a/core/identity-hub-core/build.gradle.kts b/core/identity-hub-core/build.gradle.kts index 300061ccd..5f52e2b08 100644 --- a/core/identity-hub-core/build.gradle.kts +++ b/core/identity-hub-core/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(libs.edc.spi.token) implementation(libs.edc.spi.identity.did) implementation(libs.edc.vc.ldp) + implementation(libs.edc.vc.jwt) // JtiValidationRule implementation(libs.edc.core.token) implementation(libs.edc.verifiablecredentials) // revocation list service diff --git a/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java b/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java index db568742b..c3324796a 100644 --- a/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java +++ b/core/identity-hub-core/src/main/java/org/eclipse/edc/identityhub/DefaultServicesExtension.java @@ -32,6 +32,7 @@ import org.eclipse.edc.identityhub.spi.store.KeyPairResourceStore; import org.eclipse.edc.identityhub.spi.store.ParticipantContextStore; 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; @@ -42,6 +43,7 @@ import org.eclipse.edc.spi.system.ServiceExtensionContext; import org.eclipse.edc.spi.types.TypeManager; import org.eclipse.edc.token.spi.TokenValidationRulesRegistry; +import org.eclipse.edc.verifiablecredentials.jwt.rules.JtiValidationRule; import static org.eclipse.edc.identityhub.DefaultServicesExtension.NAME; import static org.eclipse.edc.identityhub.accesstoken.verification.AccessTokenConstants.ACCESS_TOKEN_SCOPE_CLAIM; @@ -56,6 +58,10 @@ public class DefaultServicesExtension implements ServiceExtension { public static final long DEFAULT_REVOCATION_CACHE_VALIDITY_MILLIS = 15 * 60 * 1000L; @Setting(value = "Validity period of cached StatusList2021 credential entries in milliseconds.", defaultValue = DEFAULT_REVOCATION_CACHE_VALIDITY_MILLIS + "", type = "long") public static final String REVOCATION_CACHE_VALIDITY = "edc.iam.credential.revocation.cache.validity"; + + @Setting(value = "Activates the JTI check: access tokens can only be used once to guard against replay attacks", defaultValue = "false", type = "boolean") + public static final String ACCESSTOKEN_JTI_VALIDATION_ACTIVATE = "edc.iam.accesstoken.jti.validation"; + @Inject private TokenValidationRulesRegistry registry; @Inject @@ -63,6 +69,8 @@ public class DefaultServicesExtension implements ServiceExtension { private RevocationServiceRegistry revocationService; @Inject private PrivateKeyResolver privateKeyResolver; + @Inject + private JtiValidationStore jwtValidationStore; @Override public String name() { @@ -77,6 +85,12 @@ public void initialize(ServiceExtensionContext context) { var scopeIsPresentRule = new ClaimIsPresentRule(ACCESS_TOKEN_SCOPE_CLAIM); registry.addRule(DCP_ACCESS_TOKEN_CONTEXT, scopeIsPresentRule); + + if (context.getSetting(ACCESSTOKEN_JTI_VALIDATION_ACTIVATE, false)) { + registry.addRule(DCP_ACCESS_TOKEN_CONTEXT, new JtiValidationRule(jwtValidationStore, context.getMonitor())); + } else { + context.getMonitor().warning("JWT Token ID (\"jti\" claim) Validation is not active. Please consider setting '%s=true' for protection against replay attacks".formatted(ACCESSTOKEN_JTI_VALIDATION_ACTIVATE)); + } } @Provider(isDefault = true) diff --git a/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/DefaultServicesExtensionTest.java b/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/DefaultServicesExtensionTest.java index 9363da8f0..b6a414c96 100644 --- a/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/DefaultServicesExtensionTest.java +++ b/core/identity-hub-core/src/test/java/org/eclipse/edc/identityhub/DefaultServicesExtensionTest.java @@ -18,15 +18,19 @@ import org.eclipse.edc.junit.extensions.DependencyInjectionExtension; import org.eclipse.edc.spi.system.ServiceExtensionContext; import org.eclipse.edc.token.spi.TokenValidationRulesRegistry; +import org.eclipse.edc.verifiablecredentials.jwt.rules.JtiValidationRule; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import static org.eclipse.edc.identityhub.DefaultServicesExtension.ACCESSTOKEN_JTI_VALIDATION_ACTIVATE; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; @ExtendWith(DependencyInjectionExtension.class) class DefaultServicesExtensionTest { @@ -44,4 +48,15 @@ void initialize_verifyTokenRules(DefaultServicesExtension extension, ServiceExte verify(registry).addRule(eq("dcp-access-token"), isA(ClaimIsPresentRule.class)); verifyNoMoreInteractions(registry); } + + @Test + void initialize_verifyTokenRules_withJtiRule(DefaultServicesExtension extension, ServiceExtensionContext context) { + when(context.getSetting(eq(ACCESSTOKEN_JTI_VALIDATION_ACTIVATE), anyBoolean())) + .thenReturn(true); + extension.initialize(context); + verify(registry).addRule(eq("dcp-si"), isA(ClaimIsPresentRule.class)); + verify(registry).addRule(eq("dcp-access-token"), isA(ClaimIsPresentRule.class)); + verify(registry).addRule(eq("dcp-access-token"), isA(JtiValidationRule.class)); + verifyNoMoreInteractions(registry); + } } \ No newline at end of file diff --git a/core/lib/accesstoken-lib/build.gradle.kts b/core/lib/accesstoken-lib/build.gradle.kts index 26c926e83..cfdc6e888 100644 --- a/core/lib/accesstoken-lib/build.gradle.kts +++ b/core/lib/accesstoken-lib/build.gradle.kts @@ -11,5 +11,6 @@ dependencies { testImplementation(libs.edc.junit) testImplementation(libs.edc.core.token) testImplementation(libs.nimbus.jwt) + testImplementation(libs.edc.vc.jwt) // JtiValidationRule testImplementation(testFixtures(project(":spi:verifiable-credential-spi"))) } diff --git a/core/lib/accesstoken-lib/src/test/java/org/eclipse/edc/identityhub/accesstoken/verification/AccessTokenVerifierImplComponentTest.java b/core/lib/accesstoken-lib/src/test/java/org/eclipse/edc/identityhub/accesstoken/verification/AccessTokenVerifierImplComponentTest.java index 4130978b6..407231a03 100644 --- a/core/lib/accesstoken-lib/src/test/java/org/eclipse/edc/identityhub/accesstoken/verification/AccessTokenVerifierImplComponentTest.java +++ b/core/lib/accesstoken-lib/src/test/java/org/eclipse/edc/identityhub/accesstoken/verification/AccessTokenVerifierImplComponentTest.java @@ -26,10 +26,13 @@ import org.eclipse.edc.identityhub.spi.participantcontext.ParticipantContextService; import org.eclipse.edc.identityhub.spi.participantcontext.model.ParticipantContext; import org.eclipse.edc.junit.annotations.ComponentTest; +import org.eclipse.edc.jwt.validation.jti.JtiValidationEntry; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.spi.result.Result; import org.eclipse.edc.spi.result.ServiceResult; import org.eclipse.edc.token.TokenValidationRulesRegistryImpl; import org.eclipse.edc.token.TokenValidationServiceImpl; +import org.eclipse.edc.verifiablecredentials.jwt.rules.JtiValidationRule; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -62,6 +65,7 @@ class AccessTokenVerifierImplComponentTest { private KeyPair stsKeyPair; // this is used to sign the acces token private KeyPair providerKeyPair; // this is used to sign the incoming SI token private KeyPairGenerator generator; + private JtiValidationStore jtiValidationStore; @BeforeEach void setUp() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { @@ -71,7 +75,7 @@ void setUp() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException providerKeyPair = generator.generateKeyPair(); var tokenValidationService = new TokenValidationServiceImpl(); - var ruleRegistry = new TokenValidationRulesRegistryImpl(); + TokenValidationRulesRegistryImpl ruleRegistry = new TokenValidationRulesRegistryImpl(); // would normally get registered in an extension. var accessTokenRule = new ClaimIsPresentRule(TOKEN_CLAIM); @@ -80,6 +84,10 @@ void setUp() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException var scopeIsPresentRule = new ClaimIsPresentRule(ACCESS_TOKEN_SCOPE_CLAIM); ruleRegistry.addRule(DCP_ACCESS_TOKEN_CONTEXT, scopeIsPresentRule); + jtiValidationStore = mock(JtiValidationStore.class); + when(jtiValidationStore.findById(anyString())).thenReturn(new JtiValidationEntry("test-jti", null)); + ruleRegistry.addRule(DCP_ACCESS_TOKEN_CONTEXT, new JtiValidationRule(jtiValidationStore, mock())); + var resolverMock = mock(KeyPairResourcePublicKeyResolver.class); when(resolverMock.resolveKey(anyString(), anyString())).thenReturn(Result.success(stsKeyPair.getPublic())); @@ -87,6 +95,7 @@ void setUp() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException verifier = new AccessTokenVerifierImpl(tokenValidationService, resolverMock, ruleRegistry, (id) -> Result.success(providerKeyPair.getPublic()), participantContextService); } + @Test void selfIssuedTokenNotVerified() { var spoofedKey = generator.generateKeyPair().getPrivate(); @@ -115,6 +124,18 @@ void selfIssuedToken_noAccessTokenAudienceClaim() { .detail().isEqualTo("Mandatory claim 'aud' on 'token' was null."); } + @Test + void validation_successful_withJti() { + var accessToken = createSignedJwt(stsKeyPair.getPrivate(), new JWTClaimsSet.Builder() + .claim("scope", "foobar") + .audience(PARTICIPANT_DID) + .claim("jti", UUID.randomUUID().toString()) + .build()); + var selfIssuedIdToken = createSignedJwt(providerKeyPair.getPrivate(), new JWTClaimsSet.Builder() + .claim("token", accessToken) + .build()); + assertThat(verifier.verify(selfIssuedIdToken, PARTICIPANT_CONTEXT_ID)).isSucceeded(); + } @Test void accessToken_audClaimDoesNotBelongToParticipant() { @@ -188,6 +209,22 @@ void accessToken_noAudClaim() { .detail().isEqualTo("Mandatory claim 'aud' on 'token' was null."); } + @Test + void accessToken_jtiValidationFails() { + when(jtiValidationStore.findById(anyString())).thenReturn(null); //JTI not known + + var accessToken = createSignedJwt(stsKeyPair.getPrivate(), new JWTClaimsSet.Builder() + .claim("scope", "foobar") + .audience(PARTICIPANT_DID) + .claim("jti", UUID.randomUUID().toString()) + .build()); + var selfIssuedIdToken = createSignedJwt(providerKeyPair.getPrivate(), new JWTClaimsSet.Builder() + .claim("token", accessToken) + .build()); + assertThat(verifier.verify(selfIssuedIdToken, PARTICIPANT_CONTEXT_ID)).isFailed() + .detail().matches("The JWT id '.*' was not found"); + } + @Test void assertWarning_whenSubjectClaimsMismatch() { var accessToken = createSignedJwt(stsKeyPair.getPrivate(), new JWTClaimsSet.Builder() diff --git a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/PresentationApiEndToEndTest.java b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/PresentationApiEndToEndTest.java index ae35e7da6..901d8e3f2 100644 --- a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/PresentationApiEndToEndTest.java +++ b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/PresentationApiEndToEndTest.java @@ -48,6 +48,8 @@ import org.eclipse.edc.jsonld.util.JacksonJsonLd; import org.eclipse.edc.junit.annotations.EndToEndTest; import org.eclipse.edc.junit.annotations.PostgresqlIntegrationTest; +import org.eclipse.edc.jwt.validation.jti.JtiValidationEntry; +import org.eclipse.edc.jwt.validation.jti.JtiValidationStore; import org.eclipse.edc.spi.query.QuerySpec; import org.eclipse.edc.spi.result.Result; import org.eclipse.edc.spi.security.Vault; @@ -235,7 +237,7 @@ void query_proofOfPossessionFails_shouldReturn401(IdentityHubEndToEndTestContext var accessToken = generateJwt(CONSUMER_DID, CONSUMER_DID, PROVIDER_DID, Map.of("scope", TEST_SCOPE), CONSUMER_KEY); var token = generateJwt(PROVIDER_DID, PROVIDER_DID, "mismatching", Map.of("client_id", PROVIDER_DID, "token", accessToken), PROVIDER_KEY); - + registerToken(token, context); when(DID_PUBLIC_KEY_RESOLVER.resolveKey(eq("did:web:consumer#key1"))).thenReturn(Result.success(CONSUMER_KEY.toPublicKey())); when(DID_PUBLIC_KEY_RESOLVER.resolveKey(eq("did:web:provider#key1"))).thenReturn(Result.success(PROVIDER_KEY.toPublicKey())); @@ -257,6 +259,7 @@ void query_proofOfPossessionFails_shouldReturn401(IdentityHubEndToEndTestContext void query_credentialQueryResolverFails_shouldReturn403(IdentityHubEndToEndTestContext context, CredentialStore store) throws JOSEException, JsonProcessingException { var token = generateSiToken(); + registerToken(token, context); when(DID_PUBLIC_KEY_RESOLVER.resolveKey(eq("did:web:consumer#key1"))).thenReturn(Result.success(CONSUMER_KEY.toPublicKey())); when(DID_PUBLIC_KEY_RESOLVER.resolveKey(eq("did:web:provider#key1"))).thenReturn(Result.success(PROVIDER_KEY.toPublicKey())); @@ -300,6 +303,7 @@ void query_credentialQueryResolverFails_shouldReturn403(IdentityHubEndToEndTestC void query_success_noCredentials(IdentityHubEndToEndTestContext context) throws JOSEException { var token = generateSiToken(); + registerToken(token, context); when(DID_PUBLIC_KEY_RESOLVER.resolveKey(eq("did:web:consumer#key1"))).thenReturn(Result.success(CONSUMER_KEY.toPublicKey())); when(DID_PUBLIC_KEY_RESOLVER.resolveKey(eq("did:web:provider#key1"))).thenReturn(Result.success(PROVIDER_KEY.toPublicKey())); @@ -311,7 +315,7 @@ void query_success_noCredentials(IdentityHubEndToEndTestContext context) throws .post("/v1/participants/%s/presentations/query".formatted(TEST_PARTICIPANT_CONTEXT_ID_ENCODED)) .then() .statusCode(200) - .log().ifError() + .log().ifValidationFails() .extract().body().as(JsonObject.class); assertThat(response) @@ -335,6 +339,8 @@ void query_success_containsCredential(IdentityHubEndToEndTestContext context, Cr store.create(res); var token = generateSiToken(); + registerToken(token, context); + when(DID_PUBLIC_KEY_RESOLVER.resolveKey(eq("did:web:consumer#key1"))).thenReturn(Result.success(CONSUMER_KEY.toPublicKey())); when(DID_PUBLIC_KEY_RESOLVER.resolveKey(eq("did:web:provider#key1"))).thenReturn(Result.success(PROVIDER_KEY.toPublicKey())); @@ -345,7 +351,7 @@ void query_success_containsCredential(IdentityHubEndToEndTestContext context, Cr .post("/v1/participants/%s/presentations/query".formatted(TEST_PARTICIPANT_CONTEXT_ID_ENCODED)) .then() .statusCode(200) - .log().ifError() + .log().ifValidationFails() .extract().body().as(JsonObject.class); assertThat(response) @@ -399,6 +405,9 @@ void query_shouldFilterOutInvalidCreds(int vcStateCode, IdentityHubEndToEndTestC when(DID_PUBLIC_KEY_RESOLVER.resolveKey(eq("did:web:provider#key1"))).thenReturn(Result.success(PROVIDER_KEY.toPublicKey())); var token = generateSiToken(); + registerToken(token, context); + + var response = context.getPresentationEndpoint().baseRequest() .contentType(JSON) .header(AUTHORIZATION, token) @@ -484,6 +493,21 @@ void query_accessTokenAudienceDoesNotBelongToParticipant_shouldReturn401(Identit .body(Matchers.containsString("The DID associated with the Participant Context ID of this request ('did:web:consumer') must match 'aud' claim in 'access_token' ([did:web:someone_else]).")); } + private void registerToken(String token, IdentityHubEndToEndTestContext context) { + try { + var sj = SignedJWT.parse(token); + var at = sj.getJWTClaimsSet().getStringClaim("token"); + var accessToken = SignedJWT.parse(at); + var jti = accessToken.getJWTClaimsSet().getStringClaim("jti"); + var exp = accessToken.getJWTClaimsSet().getExpirationTime(); + context.getRuntime().getService(JtiValidationStore.class) + .storeEntry(new JtiValidationEntry(jti, exp.getTime())); + + } catch (ParseException e) { + throw new RuntimeException(e); + } + } + /** * extracts a (potentially empty) list of verifiable credentials from a JWT-VP */ diff --git a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/fixtures/IdentityHubRuntimeConfiguration.java b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/fixtures/IdentityHubRuntimeConfiguration.java index 0eea8acc6..a79f9ca82 100644 --- a/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/fixtures/IdentityHubRuntimeConfiguration.java +++ b/e2e-tests/api-tests/src/test/java/org/eclipse/edc/identityhub/tests/fixtures/IdentityHubRuntimeConfiguration.java @@ -50,12 +50,13 @@ public Map config() { put("web.http.identity.path", identityEndpoint.getUrl().getPath()); put("web.http.sts.port", String.valueOf(getFreePort())); put("web.http.sts.path", "/api/sts"); - put("web.http.acounts.port", String.valueOf(getFreePort())); + put("web.http.accounts.port", String.valueOf(getFreePort())); put("web.http.accounts.path", "/api/accounts"); put("edc.runtime.id", name); put("edc.ih.iam.id", "did:web:consumer"); put("edc.sql.schema.autocreate", "true"); put("edc.api.accounts.key", "password"); + put("edc.iam.accesstoken.jti.validation", String.valueOf(true)); } }; }