Skip to content

Commit

Permalink
Support for verifying OIDC JWT claims with custom Jose4j Validator
Browse files Browse the repository at this point in the history
  • Loading branch information
michalvavrik committed Mar 30, 2024
1 parent eb56608 commit 9c74b2c
Show file tree
Hide file tree
Showing 9 changed files with 297 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,39 @@ public class IssuerValidator implements ContainerRequestFilter {
Consider using the `quarkus.oidc.token.audience` property to verify the token `aud` (`audience`) claim value.

Check warning on line 352 in docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'by using' or 'that uses' rather than 'using'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'by using' or 'that uses' rather than 'using'.", "location": {"path": "docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc", "range": {"start": {"line": 352, "column": 9}}}, "severity": "INFO"}
====

Sometimes, it's helpful to register your own JWT token's validator like in the example below:

[source, java]
----
package org.acme.security.openid.connect;
import static org.eclipse.microprofile.jwt.Claims.iss;
import io.quarkus.arc.Unremovable;
import jakarta.enterprise.context.ApplicationScoped;
import org.jose4j.jwt.MalformedClaimException;
import org.jose4j.jwt.consumer.JwtContext;
import org.jose4j.jwt.consumer.Validator;
@Unremovable
@ApplicationScoped
public class IssuerValidator implements Validator { <1>
@Override
public String validate(JwtContext jwtContext) throws MalformedClaimException {
if (jwtContext.getJwtClaims().hasClaim(iss.name())
&& "my-issuer".equals(jwtContext.getJwtClaims().getClaimValueAsString(iss.name()))) {
return "wrong issuer";
}
return null;
}
}
----
<1> Register JWT token's validator for every OIDC tenant.

TIP: Annotate the validator with a `@TenantFeature` annotation to make this validator tenant-specific.

[[single-page-applications]]
=== Single-page applications

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import java.util.function.Function;

import jakarta.enterprise.inject.Default;
import jakarta.json.JsonObject;

import org.eclipse.microprofile.jwt.Claims;
Expand All @@ -30,12 +32,14 @@
import org.jose4j.lang.InvalidAlgorithmException;
import org.jose4j.lang.UnresolvableKeyException;

import io.quarkus.arc.Arc;
import io.quarkus.logging.Log;
import io.quarkus.oidc.AuthorizationCodeTokens;
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.OidcConfigurationMetadata;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.OidcTenantConfig.CertificateChain;
import io.quarkus.oidc.TenantFeature.TenantFeatureLiteral;
import io.quarkus.oidc.TokenCustomizer;
import io.quarkus.oidc.TokenIntrospection;
import io.quarkus.oidc.UserInfo;
Expand Down Expand Up @@ -67,6 +71,7 @@ public class OidcProvider implements Closeable {
private static final AlgorithmConstraints SYMMETRIC_ALGORITHM_CONSTRAINTS = new AlgorithmConstraints(
AlgorithmConstraints.ConstraintType.PERMIT, SignatureAlgorithm.HS256.getAlgorithm());

private final List<Validator> customValidators;
final OidcProviderClient client;
final RefreshableVerificationKeyResolver asymmetricKeyResolver;
final DynamicVerificationKeyResolver keyResolverProvider;
Expand All @@ -79,11 +84,12 @@ public class OidcProvider implements Closeable {
final AlgorithmConstraints requiredAlgorithmConstraints;

public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, Key tokenDecryptionKey) {
this(client, oidcConfig, jwks, TokenCustomizerFinder.find(oidcConfig), tokenDecryptionKey);
this(client, oidcConfig, jwks, TokenCustomizerFinder.find(oidcConfig), tokenDecryptionKey,
getCustomValidators(oidcConfig));
}

public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks,
TokenCustomizer tokenCustomizer, Key tokenDecryptionKey) {
TokenCustomizer tokenCustomizer, Key tokenDecryptionKey, List<Validator> customValidators) {
this.client = client;
this.oidcConfig = oidcConfig;
this.tokenCustomizer = tokenCustomizer;
Expand All @@ -106,6 +112,12 @@ public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, Json
this.requiredClaims = checkRequiredClaimsProp();
this.tokenDecryptionKey = tokenDecryptionKey;
this.requiredAlgorithmConstraints = checkSignatureAlgorithm();

if (customValidators != null && !customValidators.isEmpty()) {
this.customValidators = customValidators;
} else {
this.customValidators = null;
}
}

public OidcProvider(String publicKeyEnc, OidcTenantConfig oidcConfig, Key tokenDecryptionKey) {
Expand All @@ -125,6 +137,7 @@ public OidcProvider(String publicKeyEnc, OidcTenantConfig oidcConfig, Key tokenD
this.requiredClaims = checkRequiredClaimsProp();
this.tokenDecryptionKey = tokenDecryptionKey;
this.requiredAlgorithmConstraints = checkSignatureAlgorithm();
this.customValidators = getCustomValidators(oidcConfig);
}

private AlgorithmConstraints checkSignatureAlgorithm() {
Expand Down Expand Up @@ -210,6 +223,12 @@ private TokenVerificationResult verifyJwtTokenInternal(String token,
builder.registerValidator(new CustomClaimsValidator(Map.of(OidcConstants.NONCE, nonce)));
}

if (customValidators != null) {
for (Validator customValidator : customValidators) {
builder.registerValidator(customValidator);
}
}

if (issuedAtRequired) {
builder.setRequireIssuedAt();
}
Expand Down Expand Up @@ -598,4 +617,25 @@ public String validate(JwtContext jwtContext) throws MalformedClaimException {
return null;
}
}

private static List<Validator> getCustomValidators(OidcTenantConfig oidcTenantConfig) {
if (oidcTenantConfig != null && oidcTenantConfig.tenantId.isPresent()) {
var tenantsValidators = new ArrayList<Validator>();
for (var instance : Arc.container().listAll(Validator.class, Default.Literal.INSTANCE)) {
if (instance.isAvailable()) {
tenantsValidators.add(instance.get());
}
}
for (var instance : Arc.container().listAll(Validator.class,
TenantFeatureLiteral.of(oidcTenantConfig.tenantId.get()))) {
if (instance.isAvailable()) {
tenantsValidators.add(instance.get());
}
}
if (!tenantsValidators.isEmpty()) {
return List.copyOf(tenantsValidators);
}
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.List;

import jakarta.json.Json;
import jakarta.json.JsonObject;
Expand All @@ -19,7 +20,10 @@
import org.jose4j.jwk.RsaJwkGenerator;
import org.jose4j.jws.AlgorithmIdentifiers;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.jwt.MalformedClaimException;
import org.jose4j.jwt.consumer.InvalidJwtException;
import org.jose4j.jwt.consumer.JwtContext;
import org.jose4j.jwt.consumer.Validator;
import org.jose4j.keys.EllipticCurves;
import org.jose4j.lang.UnresolvableKeyException;
import org.junit.jupiter.api.Test;
Expand All @@ -41,7 +45,7 @@ public void testAlgorithmCustomizer() throws Exception {
JsonWebKeySet jwkSet = new JsonWebKeySet("{\"keys\": [" + rsaJsonWebKey.toJson() + "]}");
OidcTenantConfig oidcConfig = new OidcTenantConfig();

try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null)) {
try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null)) {
try {
provider.verifyJwtToken(newToken, false, false, null);
fail("InvalidJwtException expected");
Expand All @@ -57,7 +61,7 @@ public JsonObject customizeHeaders(JsonObject headers) {
return Json.createObjectBuilder(headers).add("alg", "RS256").build();
}

}, null)) {
}, null, null)) {
TokenVerificationResult result = provider.verifyJwtToken(newToken, false, false, null);
assertEquals("http://keycloak/realm", result.localVerificationResult.getString("iss"));
}
Expand All @@ -72,7 +76,7 @@ public void testTokenWithoutKidSingleRsaJwkWithoutKid() throws Exception {

final String token = Jwt.issuer("http://keycloak/realm").sign(rsaJsonWebKey.getPrivateKey());

try (OidcProvider provider = new OidcProvider(null, new OidcTenantConfig(), jwkSet, null, null)) {
try (OidcProvider provider = new OidcProvider(null, new OidcTenantConfig(), jwkSet, null)) {
TokenVerificationResult result = provider.verifyJwtToken(token, false, false, null);
assertEquals("http://keycloak/realm", result.localVerificationResult.getString("iss"));
}
Expand All @@ -87,7 +91,7 @@ public void testTokenWithoutKidMultipleRSAJwkWithoutKid() throws Exception {

final String token = Jwt.issuer("http://keycloak/realm").sign(rsaJsonWebKey1.getPrivateKey());

try (OidcProvider provider = new OidcProvider(null, new OidcTenantConfig(), jwkSet, null, null)) {
try (OidcProvider provider = new OidcProvider(null, new OidcTenantConfig(), jwkSet, null)) {
try {
provider.verifyJwtToken(token, false, false, null);
fail("InvalidJwtException expected");
Expand Down Expand Up @@ -119,13 +123,13 @@ public void testSubject() throws Exception {

final String tokenWithSub = Jwt.subject("subject").jws().keyId("k1").sign(rsaJsonWebKey.getPrivateKey());

try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null)) {
try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null)) {
TokenVerificationResult result = provider.verifyJwtToken(tokenWithSub, false, true, null);
assertEquals("subject", result.localVerificationResult.getString(Claims.sub.name()));
}

final String tokenWithoutSub = Jwt.claims().jws().keyId("k1").sign(rsaJsonWebKey.getPrivateKey());
try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null)) {
try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null)) {
try {
provider.verifyJwtToken(tokenWithoutSub, false, true, null);
fail("InvalidJwtException expected");
Expand All @@ -146,13 +150,13 @@ public void testNonce() throws Exception {

final String tokenWithNonce = Jwt.claim("nonce", "123456").jws().keyId("k1").sign(rsaJsonWebKey.getPrivateKey());

try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null)) {
try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null)) {
TokenVerificationResult result = provider.verifyJwtToken(tokenWithNonce, false, false, "123456");
assertEquals("123456", result.localVerificationResult.getString(Claims.nonce.name()));
}

final String tokenWithoutNonce = Jwt.claims().jws().keyId("k1").sign(rsaJsonWebKey.getPrivateKey());
try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null)) {
try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null)) {
try {
provider.verifyJwtToken(tokenWithoutNonce, false, false, "123456");
fail("InvalidJwtException expected");
Expand Down Expand Up @@ -183,15 +187,15 @@ public void testAge() throws Exception {
OidcTenantConfig oidcConfig = new OidcTenantConfig();
oidcConfig.token.issuedAtRequired = false;

try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null)) {
try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null)) {
TokenVerificationResult result = provider.verifyJwtToken(token, false, false, null);
assertNull(result.localVerificationResult.getString(Claims.iat.name()));
}

OidcTenantConfig oidcConfigRequireAge = new OidcTenantConfig();
oidcConfigRequireAge.token.issuedAtRequired = true;

try (OidcProvider provider = new OidcProvider(null, oidcConfigRequireAge, jwkSet, null, null)) {
try (OidcProvider provider = new OidcProvider(null, oidcConfigRequireAge, jwkSet, null)) {
try {
provider.verifyJwtToken(token, false, false, null);
fail("InvalidJwtException expected");
Expand All @@ -200,4 +204,72 @@ public void testAge() throws Exception {
}
}
}

@Test
public void testJwtValidators() throws Exception {
RsaJsonWebKey rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048);
rsaJsonWebKey.setKeyId("k1");
JsonWebKeySet jwkSet = new JsonWebKeySet("{\"keys\": [" + rsaJsonWebKey.toJson() + "]}");

OidcTenantConfig oidcConfig = new OidcTenantConfig();

String token = Jwt.claim("claim1", "claimValue1").claim("claim2", "claimValue2").jws().keyId("k1")
.sign(rsaJsonWebKey.getPrivateKey());

// no validators
try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null, null)) {
TokenVerificationResult result = provider.verifyJwtToken(token, false, false, null);
assertEquals("claimValue1", result.localVerificationResult.getString("claim1"));
assertEquals("claimValue2", result.localVerificationResult.getString("claim2"));
}

// one validator
Validator validator1 = new Validator() {
@Override
public String validate(JwtContext jwtContext) throws MalformedClaimException {
if (jwtContext.getJwtClaims().hasClaim("claim1")) {
return "Claim1 is not allowed!";
}
return null;
}
};
try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null, List.of(validator1))) {
try {
provider.verifyJwtToken(token, false, false, null);
fail("InvalidJwtException expected");
} catch (InvalidJwtException ex) {
assertTrue(ex.getMessage().contains("Claim1 is not allowed!"));
}
}

// two validators
Validator validator2 = new Validator() {
@Override
public String validate(JwtContext jwtContext) throws MalformedClaimException {
if (jwtContext.getJwtClaims().hasClaim("claim2")) {
return "Claim2 is not allowed!";
}
return null;
}
};
// check the first validator is still run
try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null, List.of(validator1, validator2))) {
try {
provider.verifyJwtToken(token, false, false, null);
fail("InvalidJwtException expected");
} catch (InvalidJwtException ex) {
assertTrue(ex.getMessage().contains("Claim1 is not allowed!"));
}
}
// check the second validator is applied
token = Jwt.claim("claim2", "claimValue2").jws().keyId("k1").sign(rsaJsonWebKey.getPrivateKey());
try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null, List.of(validator1, validator2))) {
try {
provider.verifyJwtToken(token, false, false, null);
fail("InvalidJwtException expected");
} catch (InvalidJwtException ex) {
assertTrue(ex.getMessage().contains("Claim2 is not allowed!"));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.quarkus.it.keycloak;

import jakarta.enterprise.context.Dependent;

import org.jose4j.jwt.MalformedClaimException;
import org.jose4j.jwt.consumer.JwtContext;
import org.jose4j.jwt.consumer.Validator;

import io.quarkus.arc.Unremovable;

@Unremovable
@Dependent
public class GlobalJwtPreferredNameValidator implements Validator {

@Override
public String validate(JwtContext jwtContext) throws MalformedClaimException {
if (jwtContext.getJwtClaims().hasClaim("preferred_username")
&& jwtContext.getJwtClaims().isClaimValueString("preferred_username")
&& jwtContext.getJwtClaims().getClaimValueAsString("preferred_username").contains("jdoe")) {
return "scope validation failed, the 'fail-validation' scope is not allowed";
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.quarkus.it.keycloak;

import static org.eclipse.microprofile.jwt.Claims.iss;

import jakarta.enterprise.context.ApplicationScoped;

import org.jose4j.jwt.MalformedClaimException;
import org.jose4j.jwt.consumer.JwtContext;
import org.jose4j.jwt.consumer.Validator;

import io.quarkus.arc.Unremovable;
import io.quarkus.oidc.TenantFeature;

@Unremovable
@ApplicationScoped
@TenantFeature("tenant-public-key")
public class IssuerValidator implements Validator {

@Override
public String validate(JwtContext jwtContext) throws MalformedClaimException {
if (jwtContext.getJwtClaims().hasClaim(iss.name())
&& "unacceptable-issuer".equals(jwtContext.getJwtClaims().getClaimValueAsString(iss.name()))) {
// issuer matched
return "The 'unacceptable-issuer' is not allowed";
}
return null;
}
}

0 comments on commit 9c74b2c

Please sign in to comment.