Skip to content

Commit

Permalink
feat: validate 'jti' claims in AccessTokenVerifier (#481)
Browse files Browse the repository at this point in the history
* feat: validate Token-IDs in the AccessTokenVerifier

* add test

* record tokens in tests

* run ci
  • Loading branch information
paullatzelsperger authored Oct 22, 2024
1 parent a65acb2 commit ecc3518
Show file tree
Hide file tree
Showing 7 changed files with 98 additions and 5 deletions.
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)) {
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

0 comments on commit ecc3518

Please sign in to comment.