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 authored and sberyozkin committed Mar 31, 2024
1 parent eb56608 commit 812d263
Show file tree
Hide file tree
Showing 10 changed files with 308 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,45 @@ 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"}
====

[[jose4j-validator]]
=== Jose4j Validator

Check warning on line 356 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.Headings] Use sentence-style capitalization in 'Jose4j Validator'. Raw Output: {"message": "[Quarkus.Headings] Use sentence-style capitalization in 'Jose4j Validator'.", "location": {"path": "docs/src/main/asciidoc/security-oidc-bearer-token-authentication.adoc", "range": {"start": {"line": 356, "column": 5}}}, "severity": "INFO"}

You can register a custom https://www.javadoc.io/doc/org.bitbucket.b_c/jose4j/latest/org/jose4j/jwt/consumer/class-use/Validator.html[Jose4j Validator] to customize the JWT claim verification process, before `org.eclipse.microprofile.jwt.JsonWebToken` is initialized.
For example:

[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"; <2>
}
return null; <3>
}
}
----
<1> Register Jose4j Validator to verify JWT tokens for all OIDC tenants.
<2> Return the claim verification error description.
<3> Return `null` to confirm that this Validator has successfully verified the token.

TIP: Use a `@quarkus.oidc.TenantFeature` annotation to bind a custom Validator to a specific OIDC tenant only.

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,11 @@ For more information about using the default token cache or registering a custom
For information about the claim verification, including the `iss` (issuer) claim, see the xref:security-oidc-bearer-token-authentication.adoc#jwt-claim-verification[JSON Web Token claim verification] section.
It applies to ID tokens and also to access tokens in a JWT format, if the `web-app` application has requested the access token verification.

[[jose4j-validator]]
=== Jose4j Validator

Check warning on line 566 in docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Headings] Use sentence-style capitalization in 'Jose4j Validator'. Raw Output: {"message": "[Quarkus.Headings] Use sentence-style capitalization in 'Jose4j Validator'.", "location": {"path": "docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc", "range": {"start": {"line": 566, "column": 5}}}, "severity": "INFO"}

You can register a custom [Jose4j Validator] to customize the JWT claim verification process. See xref:security-oidc-bearer-token-authentication.adoc#jose4j-validator[Jose4j] section for more information.

==== Further security with Proof Key for Code Exchange (PKCE)

link:https://datatracker.ietf.org/doc/html/rfc7636[Proof Key for Code Exchange] (PKCE) minimizes the risk of authorization code interception.
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;
}
}

0 comments on commit 812d263

Please sign in to comment.