From a7b9c2e94c0b7785e0052dd811a175e16e771498 Mon Sep 17 00:00:00 2001 From: Dheeraj Nalluri Date: Wed, 10 Aug 2022 15:39:29 -0500 Subject: [PATCH] Add config property for custom claim verification Co-authored-by: sberyozkin --- .../io/quarkus/oidc/OidcTenantConfig.java | 21 +++++++++ .../io/quarkus/oidc/runtime/OidcProvider.java | 43 +++++++++++++++++++ .../src/main/resources/application.properties | 4 ++ .../BearerTokenAuthorizationTest.java | 18 ++++++++ 4 files changed, 86 insertions(+) diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index cdeabd872aabe..2e0022e7210ae 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -2,6 +2,7 @@ import java.time.Duration; import java.util.Arrays; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -10,6 +11,7 @@ import io.quarkus.oidc.common.runtime.OidcCommonConfig; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.oidc.runtime.OidcConfig; +import io.quarkus.runtime.annotations.ConfigDocMapKey; import io.quarkus.runtime.annotations.ConfigGroup; import io.quarkus.runtime.annotations.ConfigItem; @@ -950,6 +952,17 @@ public static Token fromAudience(String... audience) { @ConfigItem public Optional> audience = Optional.empty(); + /** + * A map of required claims and their expected values. + * For example, `quarkus.oidc.token.required-claims.org_id = org_xyz` would require tokens to have the `org_id` claim to + * be present and set to `org_xyz`. + * Strings are the only supported types. Use {@linkplain SecurityIdentityAugmentor} to verify claims of other types or + * complex claims. + */ + @ConfigItem + @ConfigDocMapKey("claim-name") + public Map requiredClaims = new HashMap<>(); + /** * Expected token type */ @@ -1167,6 +1180,14 @@ public Optional getDecryptionKeyLocation() { public void setDecryptionKeyLocation(String decryptionKeyLocation) { this.decryptionKeyLocation = Optional.of(decryptionKeyLocation); } + + public Map getRequiredClaims() { + return requiredClaims; + } + + public void setRequiredClaims(Map requiredClaims) { + this.requiredClaims = requiredClaims; + } } public static enum ApplicationType { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java index fab586d25f041..b61825efa7963 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java @@ -4,6 +4,7 @@ import java.security.Key; import java.time.Duration; import java.util.List; +import java.util.Map; import java.util.function.BiFunction; import java.util.function.Function; @@ -11,11 +12,14 @@ import org.jboss.logging.Logger; import org.jose4j.jwa.AlgorithmConstraints; import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwt.MalformedClaimException; import org.jose4j.jwt.consumer.ErrorCodeValidator; import org.jose4j.jwt.consumer.ErrorCodes; import org.jose4j.jwt.consumer.InvalidJwtException; import org.jose4j.jwt.consumer.JwtConsumer; import org.jose4j.jwt.consumer.JwtConsumerBuilder; +import org.jose4j.jwt.consumer.JwtContext; +import org.jose4j.jwt.consumer.Validator; import org.jose4j.jwx.HeaderParameterNames; import org.jose4j.jwx.JsonWebStructure; import org.jose4j.keys.resolvers.VerificationKeyResolver; @@ -53,6 +57,7 @@ public class OidcProvider implements Closeable { final OidcTenantConfig oidcConfig; final String issuer; final String[] audience; + final Map requiredClaims; final Key tokenDecryptionKey; public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, Key tokenDecryptionKey) { @@ -63,6 +68,7 @@ public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, Json this.issuer = checkIssuerProp(); this.audience = checkAudienceProp(); + this.requiredClaims = checkRequiredClaimsProp(); this.tokenDecryptionKey = tokenDecryptionKey; } @@ -72,6 +78,7 @@ public OidcProvider(String publicKeyEnc, OidcTenantConfig oidcConfig, Key tokenD this.asymmetricKeyResolver = new LocalPublicKeyResolver(publicKeyEnc); this.issuer = checkIssuerProp(); this.audience = checkAudienceProp(); + this.requiredClaims = checkRequiredClaimsProp(); this.tokenDecryptionKey = tokenDecryptionKey; } @@ -91,6 +98,10 @@ private String[] checkAudienceProp() { return audienceProp != null ? audienceProp.toArray(new String[] {}) : null; } + private Map checkRequiredClaimsProp() { + return oidcConfig != null ? oidcConfig.token.requiredClaims : null; + } + public TokenVerificationResult verifySelfSignedJwtToken(String token) throws InvalidJwtException { return verifyJwtTokenInternal(token, SYMMETRIC_ALGORITHM_CONSTRAINTS, new SymmetricKeyResolver(), true); } @@ -135,6 +146,9 @@ private TokenVerificationResult verifyJwtTokenInternal(String token, AlgorithmCo } else { builder.setSkipDefaultAudienceValidation(); } + if (requiredClaims != null) { + builder.registerValidator(new CustomClaimsValidator(requiredClaims)); + } if (oidcConfig.token.lifespanGrace.isPresent()) { final int lifespanGrace = oidcConfig.token.lifespanGrace.getAsInt(); @@ -383,4 +397,33 @@ default Uni refresh() { return Uni.createFrom().voidItem(); } } + + private static class CustomClaimsValidator implements Validator { + + private final Map customClaims; + + public CustomClaimsValidator(Map customClaims) { + this.customClaims = customClaims; + } + + @Override + public String validate(JwtContext jwtContext) throws MalformedClaimException { + var claims = jwtContext.getJwtClaims(); + for (var targetClaim : customClaims.entrySet()) { + var claimName = targetClaim.getKey(); + if (!claims.hasClaim(claimName)) { + return "claim " + claimName + " is missing"; + } + if (!claims.isClaimValueString(claimName)) { + throw new MalformedClaimException("expected claim " + claimName + " to be a string"); + } + var claimValue = claims.getStringClaimValue(claimName); + var targetValue = targetClaim.getValue(); + if (!claimValue.equals(targetValue)) { + return "claim " + claimName + "does not match expected value of " + targetValue; + } + } + return null; + } + } } diff --git a/integration-tests/oidc-tenancy/src/main/resources/application.properties b/integration-tests/oidc-tenancy/src/main/resources/application.properties index 671d1b748f0f4..b23930d586a2c 100644 --- a/integration-tests/oidc-tenancy/src/main/resources/application.properties +++ b/integration-tests/oidc-tenancy/src/main/resources/application.properties @@ -95,6 +95,10 @@ quarkus.oidc.tenant-customheader.credentials.secret=secret quarkus.oidc.tenant-customheader.token.header=X-Forwarded-Authorization quarkus.oidc.tenant-customheader.application-type=service +# Required claim (Uses tenant-b settings as it has multiple clients) +quarkus.oidc.tenant-requiredclaim.auth-server-url=${keycloak.url}/realms/quarkus-b +quarkus.oidc.tenant-requiredclaim.application-type=service +quarkus.oidc.tenant-requiredclaim.token.required-claims.azp=quarkus-app-b quarkus.oidc.tenant-public-key.client-id=test quarkus.oidc.tenant-public-key.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlivFI8qB4D0y2jy0CfEqFyy46R0o7S8TKpsx5xbHKoU1VWg6QkQm+ntyIv1p4kE1sPEQO73+HY8+Bzs75XwRTYL1BmR1w8J5hmjVWjc6R2BTBGAYRPFRhor3kpM6ni2SPmNNhurEAHw7TaqszP5eUF/F9+KEBWkwVta+PZ37bwqSE4sCb1soZFrVz/UT/LF4tYpuVYt3YbqToZ3pZOZ9AX2o1GCG3xwOjkc4x0W7ezbQZdC9iftPxVHR8irOijJRRjcPDtA6vPKpzLl6CyYnsIYPd99ltwxTHjr3npfv/3Lw50bAkbT4HeLFxTx4flEoZLKO/g0bAoV2uqBhkA9xnQIDAQAB diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index a724be11c1db1..191ac584630fa 100644 --- a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -499,6 +499,24 @@ public void testResolveTenantIdentifierWebAppDynamic() throws IOException { } } + @Test + public void testRequiredClaimPass() { + //Client id should match the required azp claim + RestAssured.given().auth().oauth2(getAccessToken("alice", "b", "b")) + .when().get("/tenant/tenant-requiredclaim/api/user") + .then() + .statusCode(200); + } + + @Test + public void testRequiredClaimFail() { + //Client id does not match required azp claim + RestAssured.given().auth().oauth2(getAccessToken("alice", "b", "b2")) + .when().get("/tenant/tenant-requiredclaim/api/user") + .then() + .statusCode(401); + } + private String getAccessToken(String userName, String clientId) { return getAccessToken(userName, clientId, clientId); }