Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: validate 'jti' claims in AccessTokenVerifier #481

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/identity-hub-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -56,13 +58,19 @@ 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
private TypeManager typeManager;
private RevocationServiceRegistry revocationService;
@Inject
private PrivateKeyResolver privateKeyResolver;
@Inject
private JtiValidationStore jwtValidationStore;

@Override
public String name() {
Expand All @@ -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)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

false could be extracted as a constant and used also in the @Setting annotation

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
}
}
1 change: 1 addition & 0 deletions core/lib/accesstoken-lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand All @@ -80,13 +84,18 @@ 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()));

when(participantContextService.getParticipantContext(anyString())).thenReturn(ServiceResult.success(ParticipantContext.Builder.newInstance().did(PARTICIPANT_DID).participantId(PARTICIPANT_CONTEXT_ID).apiTokenAlias("foobar").build()));
verifier = new AccessTokenVerifierImpl(tokenValidationService, resolverMock, ruleRegistry, (id) -> Result.success(providerKeyPair.getPublic()), participantContextService);
}


@Test
void selfIssuedTokenNotVerified() {
var spoofedKey = generator.generateKeyPair().getPrivate();
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()));
Expand All @@ -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()));
Expand Down Expand Up @@ -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()));
Expand All @@ -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)
Expand All @@ -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()));

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,13 @@ public Map<String, String> 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));
}
};
}
Expand Down
Loading