diff --git a/README.md b/README.md index 0cb996042..4f6962645 100644 --- a/README.md +++ b/README.md @@ -334,8 +334,12 @@ for more info. #### org.apereo.cas.client.validation.CasJWTTicketValidationFilter Validates service tickets that issued by the CAS server as JWTs. + +Supported JWTs are: -At the moment, only JWTs that are first signed and then encrypted (in that order) are supported by this filter. +- The JWT must be signed and encrypted, in that order, or... +- The JWT must be encrypted and signed, in that order, or... +- The JWT must be encrypted. ```xml diff --git a/cas-client-core/src/main/java/org/apereo/cas/client/validation/jwt/CasJWTTicketValidator.java b/cas-client-core/src/main/java/org/apereo/cas/client/validation/jwt/CasJWTTicketValidator.java index b0154c294..acb51d929 100644 --- a/cas-client-core/src/main/java/org/apereo/cas/client/validation/jwt/CasJWTTicketValidator.java +++ b/cas-client-core/src/main/java/org/apereo/cas/client/validation/jwt/CasJWTTicketValidator.java @@ -25,17 +25,28 @@ import org.apereo.cas.client.validation.TicketValidator; import com.nimbusds.jose.EncryptionMethod; +import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JOSEObjectType; import com.nimbusds.jose.JWEAlgorithm; +import com.nimbusds.jose.JWEDecrypter; import com.nimbusds.jose.JWEHeader; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSVerifier; +import com.nimbusds.jose.KeySourceException; import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.BadJWEException; +import com.nimbusds.jose.proc.BadJWSException; import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; import com.nimbusds.jose.proc.JWEDecryptionKeySelector; import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.EncryptedJWT; import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTParser; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.BadJWTException; import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; import com.nimbusds.jwt.proc.DefaultJWTProcessor; @@ -46,9 +57,11 @@ import java.nio.charset.StandardCharsets; import java.security.Key; +import java.text.ParseException; import java.util.Base64; import java.util.HashMap; import java.util.List; +import java.util.ListIterator; import java.util.Set; /** @@ -103,7 +116,7 @@ public Assertion validate(final String ticket, final String service) throws Tick public void initialize() { logger.debug("Initializing JWT processor..."); - this.jwtProcessor = new DefaultJWTProcessor<>(); + this.jwtProcessor = new CasJWTProcessor(); jwtProcessor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(JOSEObjectType.JWT)); final var jweKeySource = new ImmutableSecret<>(new SecretKeySpec( @@ -181,4 +194,95 @@ public void setEncryptionKey(final String encryptionKey) { public void setMaxClockSkew(final int maxClockSkew) { this.maxClockSkew = maxClockSkew; } + + private static class CasJWTProcessor extends DefaultJWTProcessor { + @Override + public JWTClaimsSet process(final SignedJWT signedJWT, final SecurityContext context) throws BadJOSEException, JOSEException { + getJWETypeVerifier().verify(signedJWT.getHeader().getType(), context); + final var keyCandidates = getJWSKeySelector().selectJWSKeys(signedJWT.getHeader(), context); + if (keyCandidates == null || keyCandidates.isEmpty()) { + throw new BadJOSEException("Signed JWT rejected: Another algorithm expected, or no matching key(s) found"); + } + var it = keyCandidates.listIterator(); + while (it.hasNext()) { + final var verifier = getJWSVerifierFactory().createJWSVerifier(signedJWT.getHeader(), it.next()); + if (verifier == null) { + continue; + } + var validSignature = signedJWT.verify(verifier); + if (validSignature) { + try { + if (signedJWT.getPayload() != null && signedJWT.getPayload().toJSONObject() == null) { + try { + var innerJwt = JWTParser.parse(signedJWT.getPayload().toString()); + if (innerJwt instanceof EncryptedJWT encryptedJWT) { + return process(encryptedJWT, context); + } + } catch (final ParseException e) { + throw new BadJWSException("Unable to parse inner JWT", e); + } + } + var claimsSet = signedJWT.getJWTClaimsSet(); + if (getJWTClaimsSetVerifier() != null) { + getJWTClaimsSetVerifier().verify(claimsSet, context); + } + return claimsSet; + } catch (final ParseException e) { + throw new BadJWSException("Unable to parse JWT", e); + } + } + if (!it.hasNext()) { + throw new BadJWSException("Signed JWT rejected: Invalid signature"); + } + } + throw new BadJOSEException("JWS object rejected: No matching verifier(s) found"); + } + + @Override + public JWTClaimsSet process(final EncryptedJWT encryptedJWT, final SecurityContext context) throws BadJOSEException, JOSEException { + getJWETypeVerifier().verify(encryptedJWT.getHeader().getType(), context); + var keyCandidates = getJWEKeySelector().selectJWEKeys(encryptedJWT.getHeader(), context); + if (keyCandidates == null || keyCandidates.isEmpty()) { + throw new BadJOSEException("Encrypted JWT rejected: Another algorithm expected, or no matching key(s) found"); + } + + var it = keyCandidates.listIterator(); + while (it.hasNext()) { + var decrypter = getJWEDecrypterFactory().createJWEDecrypter(encryptedJWT.getHeader(), it.next()); + if (decrypter == null) { + continue; + } + + try { + encryptedJWT.decrypt(decrypter); + } catch (JOSEException e) { + if (it.hasNext()) { + continue; + } + throw new BadJWEException("Encrypted JWT rejected: " + e.getMessage(), e); + } + + if ("JWT".equalsIgnoreCase(encryptedJWT.getHeader().getContentType())) { + var signedJWTPayload = encryptedJWT.getPayload().toSignedJWT(); + if (signedJWTPayload != null) { + return process(signedJWTPayload, context); + } + if (encryptedJWT.getPayload().toJSONObject() == null) { + throw new BadJWTException("The payload is not a nested signed JWT"); + } + } + + try { + var claimsSet = encryptedJWT.getJWTClaimsSet(); + if (getJWTClaimsSetVerifier() != null) { + getJWTClaimsSetVerifier().verify(claimsSet, context); + } + return claimsSet; + } catch (final ParseException e) { + throw new BadJWTException(e.getMessage(), e); + } + } + throw new BadJOSEException("Encrypted JWT rejected: No matching decrypter(s) found"); + } + } } diff --git a/cas-client-core/src/test/java/org/apereo/cas/client/validation/jwt/CasJWTTicketValidatorTests.java b/cas-client-core/src/test/java/org/apereo/cas/client/validation/jwt/CasJWTTicketValidatorTests.java index 78b68bc22..b6a224174 100644 --- a/cas-client-core/src/test/java/org/apereo/cas/client/validation/jwt/CasJWTTicketValidatorTests.java +++ b/cas-client-core/src/test/java/org/apereo/cas/client/validation/jwt/CasJWTTicketValidatorTests.java @@ -6,21 +6,42 @@ public class CasJWTTicketValidatorTests { - @Test - public void verifyAesKeyWithSignedAndEncryptedJWT() throws Exception { + private static CasJWTTicketValidator getValidator(final String url) { var validator = new CasJWTTicketValidator(); validator.setEncryptionKey("GR7E6uL9djKBSH59BN8boYQ68gQgzwehIIp6s1QicPc"); validator.setSigningKey("vTRQaUu8oDlMrsuhsgNgtk6yie2O6XwRsnDS1POstAQkA1_5TI8-mwrqo1wQ1VahGXLgjCtOb9PLOplmvFzvQA"); validator.setExpectedIssuer("https://cas.example.org:8443/cas"); - validator.setExpectedAudience("https://github.com/apereo/cas"); + validator.setExpectedAudience(url); validator.setMaxClockSkew(Integer.MAX_VALUE); - - var jwt = - "eyJ6aXAiOiJERUYiLCJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiY3R5IjoiSldUIiwidHlwIjoiSldUIiwia2lkIjoiNGQ4NjExN2YtZWMyZC00MTY1LThjNDYtY2QyYWI0MDdhYTA5Iiwib3JnLmFwZXJlby5jYXMuc2VydmljZXMuUmVnaXN0ZXJlZFNlcnZpY2UiOiIxIn0..SX4YsSHImUrnFzo5_F_lNw.viFHp1nFcP-LNZlx_ngVEg3H6TIZRRezO88cGe8iVjTG549L5ROkUCu7nCpuc8wiK6KmUQVIjzRLhlWZ3G0kkf0-zMiPT9UQxPlLRrtm0XM2_Okj3DUcK5tRi7TEEn67leDOx6sIKi3I2zA_80Ac84DPSsnuTd-EZwnOE8p3yxN3GVxIq-qzKgaTsl-eaER7fxePkOKION98OxsKiySWriu5UchOA25qpVr4eRq-JJCjPt2pC_DvFQVk_aPBAfsUpQttYvrzvOFN25ylLobQUHs9fGylEt8uAIr0l-Ai4rRyh46RiFEW74iyhUJpa5aPQkMACvRobjcAHVzuGduKMciF-65Ooa7MeDQM3H31hlq3VCu58Jv0AZbQRNz-Fwv7ICeUFQOzMZPzAq0sNi0akYqal-a5Q-mrlWwTABnb7amIP_1i5yXxdRiLlzSeMW3CrfmvKeIlH_ttr3ra3B6Hms23Zsw7qrmJSCFKyuwyGTiAYBJNWH5SjixBb2pLodg9eiQKkrSNHRAB-UE5cfSmm2hfl5yfLh8pLZe2BSr5Ul32UfoP2X3bW8GH_hQ3rbG0E-K5P2qRtDOC6p8yNd-3MwCD1tPKm27E1vAtGsiHlrfu_l2_i2RtzTSo24sF1EcKwfJDpNi9apReZQlaZOZ4vmmS1e7MZPfrQ83qvNGPjHx8-H9dbOWxLEfX0IuoeHwfc095o6gv3PA6rCHv5mlDRLXll31CeJPY8Xd0Xe9l8IzJZ_bF1idz2m-elr9-RXDZgWXgMNj69Vis0TbHUapEksgtLgxcjjA664goGJb87YF4fli6H5JmPSF_gbzW4f1KjVrXtEFHpHamdB3-3_HrW64oTwTLU1irE-5hp5lumk3o9Ixdsn4-Eqo_cXPu2ps8.WcV_CeloEdJ7O4cWDzBXAw"; + return validator; + } + + @Test + public void verifyAesKeyWithSignedAndEncryptedJWT() throws Exception { + var validator = getValidator("https://github.com/apereo/cas"); + var jwt = "eyJ6aXAiOiJERUYiLCJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiY3R5IjoiSldUIiwidHlwIjoiSldUIiwia2lkIjoiNGQ4NjExN2YtZWMyZC00MTY1LThjNDYtY2QyYWI0MDdhYTA5Iiwib3JnLmFwZXJlby5jYXMuc2VydmljZXMuUmVnaXN0ZXJlZFNlcnZpY2UiOiIxIn0..SX4YsSHImUrnFzo5_F_lNw.viFHp1nFcP-LNZlx_ngVEg3H6TIZRRezO88cGe8iVjTG549L5ROkUCu7nCpuc8wiK6KmUQVIjzRLhlWZ3G0kkf0-zMiPT9UQxPlLRrtm0XM2_Okj3DUcK5tRi7TEEn67leDOx6sIKi3I2zA_80Ac84DPSsnuTd-EZwnOE8p3yxN3GVxIq-qzKgaTsl-eaER7fxePkOKION98OxsKiySWriu5UchOA25qpVr4eRq-JJCjPt2pC_DvFQVk_aPBAfsUpQttYvrzvOFN25ylLobQUHs9fGylEt8uAIr0l-Ai4rRyh46RiFEW74iyhUJpa5aPQkMACvRobjcAHVzuGduKMciF-65Ooa7MeDQM3H31hlq3VCu58Jv0AZbQRNz-Fwv7ICeUFQOzMZPzAq0sNi0akYqal-a5Q-mrlWwTABnb7amIP_1i5yXxdRiLlzSeMW3CrfmvKeIlH_ttr3ra3B6Hms23Zsw7qrmJSCFKyuwyGTiAYBJNWH5SjixBb2pLodg9eiQKkrSNHRAB-UE5cfSmm2hfl5yfLh8pLZe2BSr5Ul32UfoP2X3bW8GH_hQ3rbG0E-K5P2qRtDOC6p8yNd-3MwCD1tPKm27E1vAtGsiHlrfu_l2_i2RtzTSo24sF1EcKwfJDpNi9apReZQlaZOZ4vmmS1e7MZPfrQ83qvNGPjHx8-H9dbOWxLEfX0IuoeHwfc095o6gv3PA6rCHv5mlDRLXll31CeJPY8Xd0Xe9l8IzJZ_bF1idz2m-elr9-RXDZgWXgMNj69Vis0TbHUapEksgtLgxcjjA664goGJb87YF4fli6H5JmPSF_gbzW4f1KjVrXtEFHpHamdB3-3_HrW64oTwTLU1irE-5hp5lumk3o9Ixdsn4-Eqo_cXPu2ps8.WcV_CeloEdJ7O4cWDzBXAw"; var assertion = validator.validate(jwt, "https://example.org"); assertEquals("casuser", assertion.getPrincipal().getName()); assertEquals("casuser", assertion.getPrincipal().getAttributes().get("sub")); assertEquals("Static Credentials", assertion.getPrincipal().getAttributes().get("authenticationMethod")); assertEquals("0:0:0:0:0:0:0:1", assertion.getPrincipal().getAttributes().get("clientIpAddress")); } + + @Test + public void verifyAesKeyWithEncryptedAndSignedJWT() throws Exception { + var validator = getValidator("jwtservice"); + var jwt = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCIsImtpZCI6IjU5MDg1MmZlLWE2MWEtNDE4Ni1hYTMyLTE4ZjI1M2ViMTZmOSJ9.ZXlKNmFYQWlPaUpFUlVZaUxDSmhiR2NpT2lKa2FYSWlMQ0psYm1NaU9pSkJNVEk0UTBKRExVaFRNalUySWl3aVkzUjVJam9pU2xkVUlpd2lkSGx3SWpvaVNsZFVJaXdpYTJsa0lqb2lNelpsTkdGbE5HWXRZMkV5TVMwMFpERXdMVGxrWW1JdE5XUmlNV05rTm1NNU5qYzRJbjAuLkRvVmtETV8wU1FaQUxxMEFFejE1UkEuOXg4TlpPbWoyMG8yMWpqb2FOY0ZwX0dzNF9jdHJiTlRtMDBVV1BWS1g1bnBNamJxdjZOTXJoWWhhT3E3N1E0OEpCUF9SZTVXSE9LazA4bEtfZHBuMlBIYlJJT0lZa1V0cjRBNkd3NnZBNnZvT0pud1hZS0pyZUlUeVhuZ3ptdVFjMV9wSmIzTlBpMjN5S010VGx0U2FOam5VODRzUE5fQVJNb0lObGktVGs0ZkowMk0zZzFXdkwzVFVPbHJqaVJzbzFQZXhoMkpTOHlhMUhud2RFZ3FtOEVXVEhpRGJGaXV2VldQMG1WLUJsRmx3TVNFcXR0dC1oc3JXQ3NyRTdKUnlhX0J0dkFnSnVYaklZUjV5SFdpcnI4QTQ0S2xOM21ORkhuLVlYaWViUjguOVJaRUh0czJrVmcteF8ycE56cTRiZw.C-pNsdLn4spTsM6NSvvfTIkSFJnjtCEIy4DmfAPhhnbEwV7Rl_NZ6M2IGxrMSeqOE3ckA65b1NceH6yaA_8IwQ"; + var assertion = validator.validate(jwt, "https://example.org"); + assertEquals("1f43798b-92c5-47f4-a1a9-0fcc51f185a9", assertion.getPrincipal().getName()); + assertEquals("1f43798b-92c5-47f4-a1a9-0fcc51f185a9", assertion.getPrincipal().getAttributes().get("sub")); + } + + @Test + public void verifyAesKeyWithEncryptedJWT() throws Exception { + var validator = getValidator("jwtservice"); + var jwt = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCIsImtpZCI6ImQ4OTQ2MTMyLTRkZjYtNDBmZS05YTc0LWVhOTRkYTliMThjZCJ9.ZXlKNmFYQWlPaUpFUlVZaUxDSmhiR2NpT2lKa2FYSWlMQ0psYm1NaU9pSkJNVEk0UTBKRExVaFRNalUySWl3aVkzUjVJam9pU2xkVUlpd2lkSGx3SWpvaVNsZFVJaXdpYTJsa0lqb2lZamc0T0dZMVltWXRZekkyWVMwMFpEUmtMVGc0WTJZdE4yWTROV05oT0dWaE16WXdJbjAuLjY1TWdBZ1JnRXdGUnNhbmRFdGUwVXcuOHBEc1Bodnh5Q29Cc2ZIeFY3MjNzOUxvdkt0aEgyYkI4aUdsTlpEYXNpX0dQWmh1UHBsbGhNWHZrSTM5Q053Z1drWlRJQWRpOWxQSVk1YWc0RVNweWZDbEJRaUg3THdfaWNqTGhWaUVrY2RXRkx3THNQcFRaWkNUUnFKSTRmNzBQUnBBZmpFd0RKX0xzN204RERyVDRDYmFPalR2Q2JLdVAtYzFScDl0amg3cVFBUG5QcGplVGduQVppMExtaWxDXzlyYnhnZ0s1cmxYeXY5dzRQb0Z5aXR0MlZlRERmZjJGcXFLYlNnQUswZWRhdHV5ZHlqYjlFT1FvZktDdUNiZE1GRXI0TTBjOGtjN3BKU3VDZE1oYjBZUjliS3YySVY2Mks5VGU5em53MDQud0VLcTRRQjRXVlJNOUxIYnlnSW5aUQ.NiL7D5ZmBVOuG5zbgpESH-gwoWZyZwXPi8ueGdOjTYDPX14CdMitRS-827jAyC4o14q4Gdfue39yV1ahENpP4g"; + var assertion = validator.validate(jwt, "https://example.org"); + assertEquals("919d04b9-55c0-43ae-81fa-f5e3a55e6c85", assertion.getPrincipal().getName()); + assertEquals("919d04b9-55c0-43ae-81fa-f5e3a55e6c85", assertion.getPrincipal().getAttributes().get("sub")); + } }