From 15b6434a20cf63bdf4e06d4837acd1708632b4ee Mon Sep 17 00:00:00 2001 From: Colm O hEigeartaigh Date: Wed, 22 Jul 2026 10:48:36 +0100 Subject: [PATCH] Perform validation for OIDC self-issued tokens --- .../security/oidc/rp/OidcClaimsValidator.java | 111 +++-- .../oidc/rp/OidcClaimsValidatorTest.java | 420 ++++++++++++++++++ 2 files changed, 492 insertions(+), 39 deletions(-) create mode 100644 rt/rs/security/sso/oidc/src/test/java/org/apache/cxf/rs/security/oidc/rp/OidcClaimsValidatorTest.java diff --git a/rt/rs/security/sso/oidc/src/main/java/org/apache/cxf/rs/security/oidc/rp/OidcClaimsValidator.java b/rt/rs/security/sso/oidc/src/main/java/org/apache/cxf/rs/security/oidc/rp/OidcClaimsValidator.java index 2883c2fed46..abc80390e08 100644 --- a/rt/rs/security/sso/oidc/src/main/java/org/apache/cxf/rs/security/oidc/rp/OidcClaimsValidator.java +++ b/rt/rs/security/sso/oidc/src/main/java/org/apache/cxf/rs/security/oidc/rp/OidcClaimsValidator.java @@ -57,60 +57,93 @@ public void validateJwtClaims(JwtClaims claims, String clientId, boolean validat if (issuer == null && validateClaimsAlways) { throw new OAuthServiceException("Invalid issuer"); } - if (supportSelfIssuedProvider && issuerId == null - && issuer != null && SELF_ISSUED_ISSUER.equals(issuer)) { - validateSelfIssuedProvider(claims, clientId, validateClaimsAlways); + + boolean isSelfIssued = supportSelfIssuedProvider && issuerId == null + && issuer != null && SELF_ISSUED_ISSUER.equals(issuer); + + if (isSelfIssued) { + // Self-issued specific: validate sub_jwk binding (OIDC Core ยง7) + validateSelfIssuedSubJwkBinding(claims); } else { + // Normal issuer validation if (issuer != null && !issuer.equals(issuerId)) { throw new OAuthServiceException("Invalid issuer"); } - // validate subject - if (claims.getSubject() == null) { - throw new OAuthServiceException("Invalid subject"); - } - // validate authorized party + // validate authorized party (not applicable for self-issued) String authorizedParty = (String)claims.getClaim(IdToken.AZP_CLAIM); if (authorizedParty != null && !authorizedParty.equals(clientId)) { throw new OAuthServiceException("Invalid authorized party"); } - // validate audience - List audiences = claims.getAudiences(); - if (StringUtils.isEmpty(audiences) && validateClaimsAlways - || !StringUtils.isEmpty(audiences) && !audiences.contains(clientId)) { - throw new OAuthServiceException("Invalid audience"); - } + } - // If strict time validation: if no issuedTime claim is set then an expiresAt claim must be set - // Otherwise: validate only if expiresAt claim is set - boolean expiredRequired = - validateClaimsAlways || strictTimeValidation && claims.getIssuedAt() == null; - try { - JwtUtils.validateJwtExpiry(claims, getClockOffset(), expiredRequired); - } catch (JwtException ex) { - throw new OAuthServiceException("ID Token has expired", ex); - } + // Common validations for both self-issued and normal issuers + validateSubject(claims); + validateAudience(claims, clientId, validateClaimsAlways); + validateTokenTime(claims, validateClaimsAlways); + } - // If strict time validation: If no expiresAt claim is set then an issuedAt claim must be set - // Otherwise: validate only if issuedAt claim is set - boolean issuedAtRequired = - validateClaimsAlways || strictTimeValidation && claims.getExpiryTime() == null; - try { - JwtUtils.validateJwtIssuedAt(claims, getTtl(), getClockOffset(), issuedAtRequired); - } catch (JwtException ex) { - throw new OAuthServiceException("Invalid issuedAt claim", ex); - } + private void validateSelfIssuedSubJwkBinding(JwtClaims claims) { + String subJwk = (String)claims.getClaim("sub_jwk"); + if (subJwk == null) { + throw new OAuthServiceException("Missing sub_jwk claim for self-issued token"); + } - // Validate nbf - but don't require it to be present - try { - JwtUtils.validateJwtNotBefore(claims, getClockOffset(), false); - } catch (JwtException ex) { - throw new OAuthServiceException("ID Token can not be used yet", ex); + try { + JsonWebKey publicKey = JwkUtils.readJwkKey(subJwk); + String thumbprint = JwkUtils.getThumbprint(publicKey); + String sub = claims.getSubject(); + if (!thumbprint.equals(sub)) { + throw new OAuthServiceException("sub_jwk thumbprint does not match sub claim"); } + } catch (OAuthServiceException ex) { + throw ex; + } catch (RuntimeException ex) { + throw new OAuthServiceException("Invalid sub_jwk claim: " + ex.getMessage(), ex); + } + } + + private void validateSubject(JwtClaims claims) { + if (claims.getSubject() == null) { + throw new OAuthServiceException("Invalid subject"); + } + } + + private void validateAudience(JwtClaims claims, String clientId, boolean validateClaimsAlways) { + List audiences = claims.getAudiences(); + if (StringUtils.isEmpty(audiences) && validateClaimsAlways + || !StringUtils.isEmpty(audiences) && !audiences.contains(clientId)) { + throw new OAuthServiceException("Invalid audience"); } } - private void validateSelfIssuedProvider(JwtClaims claims, String clientId, boolean validateClaimsAlways) { + private void validateTokenTime(JwtClaims claims, boolean validateClaimsAlways) { + // If strict time validation: if no issuedTime claim is set then an expiresAt claim must be set + // Otherwise: validate only if expiresAt claim is set + boolean expiredRequired = + validateClaimsAlways || strictTimeValidation && claims.getIssuedAt() == null; + try { + JwtUtils.validateJwtExpiry(claims, getClockOffset(), expiredRequired); + } catch (JwtException ex) { + throw new OAuthServiceException("ID Token has expired", ex); + } + + // If strict time validation: If no expiresAt claim is set then an issuedAt claim must be set + // Otherwise: validate only if issuedAt claim is set + boolean issuedAtRequired = + validateClaimsAlways || strictTimeValidation && claims.getExpiryTime() == null; + try { + JwtUtils.validateJwtIssuedAt(claims, getTtl(), getClockOffset(), issuedAtRequired); + } catch (JwtException ex) { + throw new OAuthServiceException("Invalid issuedAt claim", ex); + } + + // Validate nbf - but don't require it to be present + try { + JwtUtils.validateJwtNotBefore(claims, getClockOffset(), false); + } catch (JwtException ex) { + throw new OAuthServiceException("ID Token can not be used yet", ex); + } } public void setIssuerId(String issuerId) { @@ -124,7 +157,7 @@ public void setJwkSetClient(WebClient jwkSetClient) { @Override protected JwsSignatureVerifier getInitializedSignatureVerifier(JwtToken jwt) { JsonWebKey key = null; - if (supportSelfIssuedProvider && SELF_ISSUED_ISSUER.equals(jwt.getClaim("issuer"))) { + if (supportSelfIssuedProvider && SELF_ISSUED_ISSUER.equals(jwt.getClaims().getIssuer())) { String publicKeyJson = (String)jwt.getClaim("sub_jwk"); if (publicKeyJson != null) { JsonWebKey publicKey = JwkUtils.readJwkKey(publicKeyJson); diff --git a/rt/rs/security/sso/oidc/src/test/java/org/apache/cxf/rs/security/oidc/rp/OidcClaimsValidatorTest.java b/rt/rs/security/sso/oidc/src/test/java/org/apache/cxf/rs/security/oidc/rp/OidcClaimsValidatorTest.java new file mode 100644 index 00000000000..52b1d8537e0 --- /dev/null +++ b/rt/rs/security/sso/oidc/src/test/java/org/apache/cxf/rs/security/oidc/rp/OidcClaimsValidatorTest.java @@ -0,0 +1,420 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cxf.rs.security.oidc.rp; + +import org.apache.cxf.rs.security.jose.jwa.SignatureAlgorithm; +import org.apache.cxf.rs.security.jose.jwk.JwkUtils; +import org.apache.cxf.rs.security.jose.jws.JwsHeaders; +import org.apache.cxf.rs.security.jose.jws.JwsSignatureVerifier; +import org.apache.cxf.rs.security.jose.jwt.JwtClaims; +import org.apache.cxf.rs.security.jose.jwt.JwtToken; +import org.apache.cxf.rs.security.oauth2.provider.OAuthServiceException; + +import org.junit.Test; + +import static org.junit.Assert.assertNotNull; + +public class OidcClaimsValidatorTest { + + private static final String SELF_ISSUED_ISSUER = "https://self-issued.me"; + private static final String CLIENT_ID = "client-id"; + + // EC P-256 public key for self-issued tests + private static final String EC_256_KEY = "{" + + "\"kty\": \"EC\"," + + "\"x\": \"CEuRLUISufhcjrj-32N0Bvl3KPMiHH9iSw4ohN9jxrA\"," + + "\"y\": \"EldWz_iXSK3l_S7n4w_t3baxos7o9yqX0IjzG959vHc\"," + + "\"crv\": \"P-256\"" + + "}"; + + // --- validateJwtClaims: self-issued path --- + + @Test + public void testSelfIssuedValidTokenAccepted() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setSupportSelfIssuedProvider(true); + + validator.validateJwtClaims(buildValidSelfIssuedClaims(), CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testSelfIssuedMissingSubjectRejected() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setSupportSelfIssuedProvider(true); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer(SELF_ISSUED_ISSUER); + // sub intentionally absent + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testSelfIssuedMissingAudienceRejectedWhenRequired() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setSupportSelfIssuedProvider(true); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer(SELF_ISSUED_ISSUER); + claims.setSubject("key-thumbprint"); + // aud intentionally absent + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testSelfIssuedWrongAudienceRejected() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setSupportSelfIssuedProvider(true); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer(SELF_ISSUED_ISSUER); + claims.setSubject("key-thumbprint"); + claims.setAudience("wrong-client-id"); + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testSelfIssuedExpiredTokenRejected() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setSupportSelfIssuedProvider(true); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer(SELF_ISSUED_ISSUER); + claims.setSubject("key-thumbprint"); + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now - 600); // expired 10 minutes ago + claims.setIssuedAt(now - 700); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testSelfIssuedMissingExpiryRejectedWhenRequired() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setSupportSelfIssuedProvider(true); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer(SELF_ISSUED_ISSUER); + claims.setSubject("key-thumbprint"); + claims.setAudience(CLIENT_ID); + // exp intentionally absent + claims.setIssuedAt(now - 10); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testSelfIssuedMissingIssuedAtRejectedWhenRequired() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setSupportSelfIssuedProvider(true); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer(SELF_ISSUED_ISSUER); + claims.setSubject("key-thumbprint"); + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now + 300); + // iat intentionally absent + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testSelfIssuedMissingSubJwkRejected() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setSupportSelfIssuedProvider(true); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer(SELF_ISSUED_ISSUER); + claims.setSubject("key-thumbprint"); + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + // sub_jwk intentionally absent + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testSelfIssuedSubJwkThumbprintMismatchRejected() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setSupportSelfIssuedProvider(true); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer(SELF_ISSUED_ISSUER); + claims.setSubject("wrong-thumbprint"); + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + claims.setClaim("sub_jwk", EC_256_KEY); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + // --- getInitializedSignatureVerifier: self-issued branch --- + + @Test + public void testSignatureVerifierUsesSubJwkWhenIssIsSelfIssued() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setSupportSelfIssuedProvider(true); + + String thumbprint = JwkUtils.getThumbprint(EC_256_KEY); + + JwtClaims claims = new JwtClaims(); + claims.setIssuer(SELF_ISSUED_ISSUER); + claims.setSubject(thumbprint); + claims.setClaim("sub_jwk", EC_256_KEY); + + JwsHeaders headers = new JwsHeaders(); + headers.setSignatureAlgorithm(SignatureAlgorithm.ES256); + + JwsSignatureVerifier verifier = validator.getInitializedSignatureVerifier(new JwtToken(headers, claims)); + assertNotNull(verifier); + } + + @Test(expected = SecurityException.class) + public void testSignatureVerifierRejectsSubJwkWithWrongThumbprint() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setSupportSelfIssuedProvider(true); + + JwtClaims claims = new JwtClaims(); + claims.setIssuer(SELF_ISSUED_ISSUER); + claims.setSubject("wrong-thumbprint"); + claims.setClaim("sub_jwk", EC_256_KEY); + + JwsHeaders headers = new JwsHeaders(); + headers.setSignatureAlgorithm(SignatureAlgorithm.ES256); + + validator.getInitializedSignatureVerifier(new JwtToken(headers, claims)); + } + + // --- validateJwtClaims: normal issuer path --- + + @Test + public void testNormalIssuerValidTokenAccepted() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setIssuerId("https://issuer.example.com"); + + validator.validateJwtClaims(buildValidNormalIssuerClaims(), CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testNormalIssuerMissingIssuerRejectedWhenRequired() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setIssuerId("https://issuer.example.com"); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + // issuer intentionally absent + claims.setSubject("subject-value"); + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testNormalIssuerWrongIssuerRejected() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setIssuerId("https://issuer.example.com"); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer("https://wrong-issuer.example.com"); + claims.setSubject("subject-value"); + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testNormalIssuerMissingSubjectRejected() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setIssuerId("https://issuer.example.com"); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer("https://issuer.example.com"); + // sub intentionally absent + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testNormalIssuerMissingAudienceRejectedWhenRequired() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setIssuerId("https://issuer.example.com"); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer("https://issuer.example.com"); + claims.setSubject("subject-value"); + // aud intentionally absent + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testNormalIssuerWrongAudienceRejected() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setIssuerId("https://issuer.example.com"); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer("https://issuer.example.com"); + claims.setSubject("subject-value"); + claims.setAudience("wrong-client-id"); + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testNormalIssuerExpiredTokenRejected() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setIssuerId("https://issuer.example.com"); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer("https://issuer.example.com"); + claims.setSubject("subject-value"); + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now - 600); // expired 10 minutes ago + claims.setIssuedAt(now - 700); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testNormalIssuerMissingExpiryRejectedWhenRequired() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setIssuerId("https://issuer.example.com"); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer("https://issuer.example.com"); + claims.setSubject("subject-value"); + claims.setAudience(CLIENT_ID); + // exp intentionally absent + claims.setIssuedAt(now - 10); + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testNormalIssuerMissingIssuedAtRejectedWhenRequired() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setIssuerId("https://issuer.example.com"); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer("https://issuer.example.com"); + claims.setSubject("subject-value"); + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now + 300); + // iat intentionally absent + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test(expected = OAuthServiceException.class) + public void testNormalIssuerWrongAzpRejected() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setIssuerId("https://issuer.example.com"); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer("https://issuer.example.com"); + claims.setSubject("subject-value"); + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + claims.setClaim("azp", "wrong-client-id"); // azp doesn't match clientId + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + @Test + public void testNormalIssuerValidTokenWithAzpAccepted() { + OidcClaimsValidator validator = new OidcClaimsValidator(); + validator.setIssuerId("https://issuer.example.com"); + + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer("https://issuer.example.com"); + claims.setSubject("subject-value"); + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + claims.setClaim("azp", CLIENT_ID); // azp matches clientId + + validator.validateJwtClaims(claims, CLIENT_ID, true); + } + + // --- helper --- + + private static JwtClaims buildValidNormalIssuerClaims() { + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuer("https://issuer.example.com"); + claims.setSubject("subject-value"); + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + return claims; + } + + private static JwtClaims buildValidSelfIssuedClaims() { + long now = System.currentTimeMillis() / 1000; + String thumbprint = JwkUtils.getThumbprint(EC_256_KEY); + JwtClaims claims = new JwtClaims(); + claims.setIssuer(SELF_ISSUED_ISSUER); + claims.setSubject(thumbprint); + claims.setAudience(CLIENT_ID); + claims.setExpiryTime(now + 300); + claims.setIssuedAt(now - 10); + claims.setClaim("sub_jwk", EC_256_KEY); + return claims; + } +}