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 19419851a9474..4dd066ef7d662 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 @@ -1542,6 +1542,16 @@ public static Token fromAudience(String... audience) { @ConfigItem public Optional age = Optional.empty(); + /** + * Require that the token includes a `iat` (issued at) claim + * + * Set this property to `false` if your JWT token does not contain an `iat` (issued at) claim. + * Note that ID token is always required to have an `iat` claim and therefore this property has no impact on the ID + * token verification process. + */ + @ConfigItem(defaultValue = "true") + public boolean issuedAtRequired = true; + /** * Name of the claim which contains a principal name. By default, the `upn`, `preferred_username` and `sub` * claims are @@ -1769,6 +1779,14 @@ public void setAge(Duration age) { this.age = Optional.of(age); } + public boolean isIssuedAtRequired() { + return issuedAtRequired; + } + + public void setIssuedAtRequired(boolean issuedAtRequired) { + this.issuedAtRequired = issuedAtRequired; + } + public Optional getDecryptionKeyLocation() { return decryptionKeyLocation; } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java index 6c93dccc5510e..f180b11454bcd 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java @@ -509,7 +509,8 @@ private Uni resolveJwksAndVerifyTokenUni(TenantConfigCo TokenCredential tokenCred, boolean enforceAudienceVerification, boolean subjectRequired, String nonce) { return resolvedContext.provider - .getKeyResolverAndVerifyJwtToken(tokenCred, enforceAudienceVerification, subjectRequired, nonce) + .getKeyResolverAndVerifyJwtToken(tokenCred, enforceAudienceVerification, subjectRequired, nonce, + (tokenCred instanceof IdTokenCredential)) .onFailure(f -> fallbackToIntrospectionIfNoMatchingKey(f, resolvedContext)) .recoverWithUni(f -> introspectTokenUni(resolvedContext, tokenCred.getToken(), true)); } 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 ecd207f38ded8..fbc6dbbf54132 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 @@ -158,7 +158,7 @@ private Map checkRequiredClaimsProp() { public TokenVerificationResult verifySelfSignedJwtToken(String token) throws InvalidJwtException { return verifyJwtTokenInternal(token, true, false, null, SYMMETRIC_ALGORITHM_CONSTRAINTS, new SymmetricKeyResolver(), - true); + true, oidcConfig.token.isIssuedAtRequired()); } public TokenVerificationResult verifyJwtToken(String token, boolean enforceAudienceVerification, boolean subjectRequired, @@ -166,14 +166,14 @@ public TokenVerificationResult verifyJwtToken(String token, boolean enforceAudie throws InvalidJwtException { return verifyJwtTokenInternal(customizeJwtToken(token), enforceAudienceVerification, subjectRequired, nonce, (requiredAlgorithmConstraints != null ? requiredAlgorithmConstraints : ASYMMETRIC_ALGORITHM_CONSTRAINTS), - asymmetricKeyResolver, true); + asymmetricKeyResolver, true, oidcConfig.token.isIssuedAtRequired()); } public TokenVerificationResult verifyLogoutJwtToken(String token) throws InvalidJwtException { final boolean enforceExpReq = !oidcConfig.token.age.isPresent(); TokenVerificationResult result = verifyJwtTokenInternal(token, true, false, null, ASYMMETRIC_ALGORITHM_CONSTRAINTS, asymmetricKeyResolver, - enforceExpReq); + enforceExpReq, oidcConfig.token.isIssuedAtRequired()); if (!enforceExpReq) { // Expiry check was skipped during the initial verification but if the logout token contains the exp claim // then it must be verified @@ -191,7 +191,8 @@ private TokenVerificationResult verifyJwtTokenInternal(String token, boolean subjectRequired, String nonce, AlgorithmConstraints algConstraints, - VerificationKeyResolver verificationKeyResolver, boolean enforceExpReq) throws InvalidJwtException { + VerificationKeyResolver verificationKeyResolver, boolean enforceExpReq, boolean issuedAtRequired) + throws InvalidJwtException { JwtConsumerBuilder builder = new JwtConsumerBuilder(); builder.setVerificationKeyResolver(verificationKeyResolver); @@ -209,7 +210,9 @@ private TokenVerificationResult verifyJwtTokenInternal(String token, builder.registerValidator(new CustomClaimsValidator(Map.of(OidcConstants.NONCE, nonce))); } - builder.setRequireIssuedAt(); + if (issuedAtRequired) { + builder.setRequireIssuedAt(); + } if (issuer != null) { builder.setExpectedIssuer(issuer); @@ -308,7 +311,7 @@ public Uni apply(Void v) { public Uni getKeyResolverAndVerifyJwtToken(TokenCredential tokenCred, boolean enforceAudienceVerification, - boolean subjectRequired, String nonce) { + boolean subjectRequired, String nonce, boolean issuedAtRequired) { return keyResolverProvider.resolve(tokenCred).onItem() .transformToUni(new Function>() { @@ -321,7 +324,7 @@ public Uni apply(VerificationKeyResolver reso subjectRequired, nonce, (requiredAlgorithmConstraints != null ? requiredAlgorithmConstraints : ASYMMETRIC_ALGORITHM_CONSTRAINTS), - resolver, true)); + resolver, true, issuedAtRequired)); } catch (Throwable t) { return Uni.createFrom().failure(t); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java index 71fc623600191..fd11423fdd54e 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java @@ -318,6 +318,13 @@ private Uni createTenantContext(Vertx vertx, OidcTenantConf } } + if (!oidcConfig.token.isIssuedAtRequired() && oidcConfig.token.getAge().isPresent()) { + throw new ConfigurationException( + "The 'token.issued-at-required' can only be set to false if 'token.age' is not set." + + " Either set 'token.issued-at-required' to true or do not set 'token.age'.", + Set.of("quarkus.oidc.token.issued-at-required", "quarkus.oidc.token.age")); + } + return createOidcProvider(oidcConfig, tlsConfig, vertx) .onItem().transform(new Function() { @Override diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java index 042fd50b869d7..cf8bd8babbefd 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java @@ -1,10 +1,12 @@ package io.quarkus.oidc.runtime; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.Base64; import jakarta.json.Json; @@ -15,6 +17,8 @@ import org.jose4j.jwk.EllipticCurveJsonWebKey; import org.jose4j.jwk.RsaJsonWebKey; import org.jose4j.jwk.RsaJwkGenerator; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; import org.jose4j.jwt.consumer.InvalidJwtException; import org.jose4j.keys.EllipticCurves; import org.jose4j.lang.UnresolvableKeyException; @@ -106,7 +110,6 @@ private static String replaceAlgorithm(String token, String algorithm) { @Test public void testSubject() throws Exception { - RsaJsonWebKey rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048); rsaJsonWebKey.setKeyId("k1"); JsonWebKeySet jwkSet = new JsonWebKeySet("{\"keys\": [" + rsaJsonWebKey.toJson() + "]}"); @@ -134,7 +137,6 @@ public void testSubject() throws Exception { @Test public void testNonce() throws Exception { - RsaJsonWebKey rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048); rsaJsonWebKey.setKeyId("k1"); JsonWebKeySet jwkSet = new JsonWebKeySet("{\"keys\": [" + rsaJsonWebKey.toJson() + "]}"); @@ -159,4 +161,43 @@ public void testNonce() throws Exception { } } } + + @Test + public void testAge() throws Exception { + String tokenPayload = "{\n" + + " \"exp\": " + Instant.now().plusSeconds(1000).getEpochSecond() + "\n" + + "}"; + + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(tokenPayload); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256); + + RsaJsonWebKey rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048); + + jws.setKey(rsaJsonWebKey.getPrivateKey()); + + String token = jws.getCompactSerialization(); + + JsonWebKeySet jwkSet = new JsonWebKeySet("{\"keys\": [" + rsaJsonWebKey.toJson() + "]}"); + + OidcTenantConfig oidcConfig = new OidcTenantConfig(); + oidcConfig.token.issuedAtRequired = false; + + try (OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, 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 { + provider.verifyJwtToken(token, false, false, null); + fail("InvalidJwtException expected"); + } catch (InvalidJwtException ex) { + assertTrue(ex.getMessage().contains("No Issued At (iat) claim present.")); + } + } + } }