[java-idp-plugin-oidc-rp] branch main updated: JOIDCRP-74 - Improve audience handling in JWT client authentication

Phil Smart philip.smart at jisc.ac.uk
Thu May 15 09:21:20 UTC 2025


This is an automated email from the git hooks/post-receive script.

philsmart pushed a commit to branch main
in repository java-idp-plugin-oidc-rp.

View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-oidc-rp.git;a=commit;h=77c5584be3bc62d6679ac4241abdb2a1bb5601d1

The following commit(s) were added to refs/heads/main by this push:
     new 77c5584  JOIDCRP-74 - Improve audience handling in JWT client authentication
77c5584 is described below

commit 77c5584be3bc62d6679ac4241abdb2a1bb5601d1
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Thu May 15 10:21:16 2025 +0100

    JOIDCRP-74 - Improve audience handling in JWT client authentication
    
     - Add the issuer identifier of the OP as the default audience claim
    value for a JWT client assertion
     - Add a flag to revert to the previous, insecure, value (token endpoint
    URL)
    
    https://shibboleth.atlassian.net/browse/JOIDCRP-74
---
 ...izeOAuth2ClientAuthenticationMethodHandler.java | 101 +++++++++++++++--
 .../oidc-relying-party-authn-beans.xml             |   3 +-
 .../authn/oidc/rp/conf/authn/oidc-rp.properties    |   5 +
 ...Auth2ClientAuthenticationMethodHandlerTest.java | 121 ++++++++++++++++++++-
 4 files changed, 219 insertions(+), 11 deletions(-)

diff --git a/idp-oidc-rp-impl/src/main/java/net/shibboleth/idp/plugin/authn/oidc/rp/impl/InitializeOAuth2ClientAuthenticationMethodHandler.java b/idp-oidc-rp-impl/src/main/java/net/shibboleth/idp/plugin/authn/oidc/rp/impl/InitializeOAuth2ClientAuthenticationMethodHandler.java
index 4ad13a0..cb61a3f 100644
--- a/idp-oidc-rp-impl/src/main/java/net/shibboleth/idp/plugin/authn/oidc/rp/impl/InitializeOAuth2ClientAuthenticationMethodHandler.java
+++ b/idp-oidc-rp-impl/src/main/java/net/shibboleth/idp/plugin/authn/oidc/rp/impl/InitializeOAuth2ClientAuthenticationMethodHandler.java
@@ -18,6 +18,7 @@ import java.time.Duration;
 import java.time.Instant;
 import java.util.Date;
 import java.util.function.Function;
+import java.util.function.Predicate;
 
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
@@ -59,7 +60,9 @@ import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
 import net.shibboleth.profile.context.RelyingPartyContext;
 import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
 import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.PredicateSupport;
 import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.primitive.StringSupport;
 
 /**
  * An {@link AbstractMessageHandler action} that resolves the Client Authentication method for the chosen 
@@ -101,6 +104,9 @@ public class InitializeOAuth2ClientAuthenticationMethodHandler extends AbstractM
     /** Lookup function for relying party context. */
     @Nonnull private Function<ProfileRequestContext,RelyingPartyContext> relyingPartyContextLookupStrategy;
     
+    /** Strategy to resolve the OIDC entity context class.*/
+    @Nonnull private Function<MessageContext, OIDCPeerEntityContext> oidcPeerEntityContextLookupStrategy;
+    
     /** Applicable stashed profile configuration. */
     @NonnullBeforeExec private OIDCAuthenticationRelyingPartyProfileConfiguration profileConfiguration;
     
@@ -127,6 +133,15 @@ public class InitializeOAuth2ClientAuthenticationMethodHandler extends AbstractM
     
     /** The stashed client identifier for this request.*/
     @Nullable private String clientId;
+    
+    /** The stashed peer entity context.*/
+    @Nullable private OIDCPeerEntityContext peerEntityContext;
+    
+    /** 
+     * Should the audience claim of a JWT client assertion (if used) be the token endpoint? Defaults to false: 
+     * the audience will be the issuer identifier of the OP.
+     */
+    @Nonnull private Predicate<ProfileRequestContext> tokenEndpointAsAudience;
 
     
     /** Constructor.*/
@@ -140,6 +155,22 @@ public class InitializeOAuth2ClientAuthenticationMethodHandler extends AbstractM
         providerMetadataLookupStrategy = new ChildContextLookup<>(OIDCProviderMetadataContext.class).compose(
                 new ChildContextLookup<>(OIDCPeerEntityContext.class));
         jwtBearerExpiryOffset = Duration.ofSeconds(30);
+        tokenEndpointAsAudience = PredicateSupport.alwaysFalse();
+        oidcPeerEntityContextLookupStrategy = new ChildContextLookup<>(OIDCPeerEntityContext.class);
+    }
+    
+    /**
+     * Set the lookup strategy to find the {@link OIDCPeerEntityContext}.
+     * 
+     * @param strategy the strategy to set.
+     * 
+     * @since 2.3.0
+     */
+    public void setOidcPeerEntityContextLookupStrategy(
+            final Function<MessageContext, OIDCPeerEntityContext> strategy) {
+        checkSetterPreconditions();
+        oidcPeerEntityContextLookupStrategy = Constraint.isNotNull(strategy,
+                "PeerEntity Context Lookup Strategy can not be null");
     }
     
     /**
@@ -207,6 +238,33 @@ public class InitializeOAuth2ClientAuthenticationMethodHandler extends AbstractM
                 "OAuth2 client authentication context lookup strategy cannot be null");
     }
     
+    /**
+     * Set a condition that determines if the client assertion JWT audience claim should be based on the token 
+     * endpoint URL? if false, the issuer identifier of the OP will be used (recommended).
+     * 
+     * @param condition the condition to evaluate
+     * 
+     * @since 2.3.0
+     */
+    public void setTokenEndpointAsAudience(final Predicate<ProfileRequestContext> condition) {
+        checkSetterPreconditions();
+        tokenEndpointAsAudience = Constraint.isNotNull(condition, "AudienceAsTokenEndpoint can not be null");
+    }
+    
+    /**
+     * Should the client assertion JWT audience claim be based on the token endpoint URL? if false, the issuer 
+     * identifier of the OP will be used (recommended).
+     * 
+     * @param flag the flag to set
+     * 
+     * @since 2.3.0
+     */
+    public void setTokenEndpointAsAudience(final boolean flag) {
+        checkSetterPreconditions();
+        tokenEndpointAsAudience = flag ? PredicateSupport.alwaysTrue() : PredicateSupport.alwaysFalse();
+    }
+    
+    
     /** {@inheritDoc} */
     @Override
     protected boolean doPreInvoke(@Nonnull final MessageContext messageContext) throws MessageHandlerException {
@@ -235,6 +293,9 @@ public class InitializeOAuth2ClientAuthenticationMethodHandler extends AbstractM
         // Can be null
         jwtBearerClientAuthSecurityParameters = securityParametersContextLookupStrategy.apply(messageContext);
         
+        // Can be null
+        peerEntityContext = oidcPeerEntityContextLookupStrategy.apply(messageContext);
+        
         final OIDCProviderMetadataContext providerCtx = providerMetadataLookupStrategy.apply(messageContext);
         if (providerCtx == null || providerCtx.getProviderInformation() == null) {
             log.error("{} Provider metadata not found", getLogPrefix());            
@@ -291,10 +352,10 @@ public class InitializeOAuth2ClientAuthenticationMethodHandler extends AbstractM
             
         } else if (method.equals(ClientAuthenticationMethod.CLIENT_SECRET_JWT)) {
             verifySuitableClientSecretJWTSecurityContext();
-            clientAuthentication = new ClientSecretJWT(buildClientAuthenticationJwt());
+            clientAuthentication = new ClientSecretJWT(buildClientAuthenticationJwt(messageContext));
         } else if (method.equals(ClientAuthenticationMethod.PRIVATE_KEY_JWT)) {
             verifySuitablePrivateKetJWTSecurityContext();
-            clientAuthentication = new PrivateKeyJWT(buildClientAuthenticationJwt());
+            clientAuthentication = new PrivateKeyJWT(buildClientAuthenticationJwt(messageContext));
         } else {
             log.warn("{}: Client authentication method '{}' not supported for client '{}'", getLogPrefix(), 
                     method, clientId);
@@ -368,18 +429,39 @@ public class InitializeOAuth2ClientAuthenticationMethodHandler extends AbstractM
     }
 
     /**
-     * Build the claim values required for a client authentication bearer JWT.
+     * Build the claim values required for a client authentication bearer JWT. By default, the audience is set to the
+     * issuer identifier of the OP, but there is a flag that allows this to be changed to the token endpoint URL, 
+     * which was the previous default.  
+     * 
+     * @param messageContext the message context
      * 
      * @return the constructed JWT claims set
      */
-    @Nonnull private JWTClaimsSet buildClientAuthenticationJwtClaims() {        
+    @Nonnull private JWTClaimsSet buildClientAuthenticationJwtClaims(@Nonnull final MessageContext context) 
+            throws MessageHandlerException{
+        
+        String audience;
+        if (tokenEndpointAsAudience.test(PRC_LOOKUP.apply(context))) {
+            audience = providerMetadata.getTokenEndpointURI().toString();
+        } else {
+            final var localPeerEntityCtx = peerEntityContext;
+            if (localPeerEntityCtx == null) {
+                throw new MessageHandlerException("OIDC Peer entity context can not be null to use "
+                        + "JWT based client authentication");
+            }
+            audience = localPeerEntityCtx.getIdentifier();
+        }
+        if (StringSupport.trimOrNull(audience) == null) {
+            throw new MessageHandlerException("JWT audience can not be null");
+        }
+        
         final JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
                 .subject(clientId)
                 .issuer(clientId)
-                .audience(providerMetadata.getTokenEndpointURI().toString())
+                .audience(audience)
                 .jwtID(OIDCProxySupport.generateNonce(32))
                 .issueTime(Date.from(Instant.now()))
-                .expirationTime(Date.from(Instant.now().plus(jwtBearerExpiryOffset)))                
+                .expirationTime(Date.from(Instant.now().plus(jwtBearerExpiryOffset)))  
                 .build();
         assert claimsSet != null;
         return claimsSet;
@@ -390,11 +472,14 @@ public class InitializeOAuth2ClientAuthenticationMethodHandler extends AbstractM
      * the correct 'alg' and credential existing in the security context ahead of time for the correct SignedJWT to be
      * returned e.g. for either client_secret_jwt or private_key_jwt.
      * 
+     * @param messageContext the message context
+     * 
      * @return a signed JWT bearer token, or throws an exception if there was an error during construction
      * 
      * @throws MessageHandlerException on error constructing the JWT
      */
-    @Nonnull private SignedJWT buildClientAuthenticationJwt() throws MessageHandlerException {
+    @Nonnull private SignedJWT buildClientAuthenticationJwt(@Nonnull final MessageContext messageContext) 
+            throws MessageHandlerException {
         
         final SecurityParametersContext bearerSecurityParams = jwtBearerClientAuthSecurityParameters; 
         if (bearerSecurityParams == null) {
@@ -406,7 +491,7 @@ public class InitializeOAuth2ClientAuthenticationMethodHandler extends AbstractM
             throw new MessageHandlerException("Requested client_secret_jwt client authentication, but "
                     + "signing parameters could not be found");
         }
-        final JWTClaimsSet claims = buildClientAuthenticationJwtClaims();
+        final JWTClaimsSet claims = buildClientAuthenticationJwtClaims(messageContext);
         
         try {
             final JWSTokenSigner signer = new JWSTokenSigner(signingParams);
diff --git a/idp-oidc-rp-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/OIDCRelyingParty/oidc-relying-party-authn-beans.xml b/idp-oidc-rp-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/OIDCRelyingParty/oidc-relying-party-authn-beans.xml
index f9c56bf..ae77c9b 100644
--- a/idp-oidc-rp-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/OIDCRelyingParty/oidc-relying-party-authn-beans.xml
+++ b/idp-oidc-rp-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/OIDCRelyingParty/oidc-relying-party-authn-beans.xml
@@ -404,7 +404,8 @@
                          <bean id="InitializeOAuth2ClientAuthenticationMethodHandler" scope="prototype"
                             class="net.shibboleth.idp.plugin.authn.oidc.rp.impl.InitializeOAuth2ClientAuthenticationMethodHandler"
                             p:securityParametersContextLookupStrategy-ref="shibboleth.ChildLookupOrCreate.SecurityParametersFromOAuth2ClientAuthenticationContext"
-                            p:jwtBearerExpiryOffset="%{idp.authn.oidc.rp.client.authenticationMethod.jwt.expiryOffset:PT30S}"/>
+                            p:jwtBearerExpiryOffset="%{idp.authn.oidc.rp.client.authenticationMethod.jwt.expiryOffset:PT30S}"
+                            p:tokenEndpointAsAudience="%{idp.authn.oidc.rp.client.authenticationMethod.tokenEndpointAsAudience:false}"/>
                     </list>
                 </property>            
             </bean>
diff --git a/idp-oidc-rp-impl/src/main/resources/net/shibboleth/idp/plugin/authn/oidc/rp/conf/authn/oidc-rp.properties b/idp-oidc-rp-impl/src/main/resources/net/shibboleth/idp/plugin/authn/oidc/rp/conf/authn/oidc-rp.properties
index d3dae91..e0fd8af 100644
--- a/idp-oidc-rp-impl/src/main/resources/net/shibboleth/idp/plugin/authn/oidc/rp/conf/authn/oidc-rp.properties
+++ b/idp-oidc-rp-impl/src/main/resources/net/shibboleth/idp/plugin/authn/oidc/rp/conf/authn/oidc-rp.properties
@@ -18,6 +18,11 @@ idp.authn.oidc.rp.client.redirecturl.allowedOrigins = https://localhost:8443
 ## Client authentication method.
 #idp.authn.oidc.rp.client.authenticationMethod = client_secret_basic
 #idp.authn.oidc.rp.client.authenticationMethod.jwt.expiryOffset = PT30S
+
+## Should the client assertion JWT audience claim be based on the token endpoint URL? The issuer identifier of the OP 
+## is used by default (when set to false). This is an override to revert to the previous, insecure, value.
+#idp.authn.oidc.rp.client.authenticationMethod.tokenEndpointAsAudience = false
+
 ## Comma seperated list of additional scopes e.g. profile or email. The openid scope is added by default
 #idp.authn.oidc.rp.client.scopes =
 
diff --git a/idp-oidc-rp-impl/src/test/java/net/shibboleth/idp/plugin/authn/oidc/rp/impl/InitializeOAuth2ClientAuthenticationMethodHandlerTest.java b/idp-oidc-rp-impl/src/test/java/net/shibboleth/idp/plugin/authn/oidc/rp/impl/InitializeOAuth2ClientAuthenticationMethodHandlerTest.java
index 934d344..f000c4c 100644
--- a/idp-oidc-rp-impl/src/test/java/net/shibboleth/idp/plugin/authn/oidc/rp/impl/InitializeOAuth2ClientAuthenticationMethodHandlerTest.java
+++ b/idp-oidc-rp-impl/src/test/java/net/shibboleth/idp/plugin/authn/oidc/rp/impl/InitializeOAuth2ClientAuthenticationMethodHandlerTest.java
@@ -123,13 +123,56 @@ public class InitializeOAuth2ClientAuthenticationMethodHandlerTest extends Abstr
         
         final var outboundMsgCtx = getOutboundMessageContextFailIfNull(prc);
         
+        final var peerEntityCtx = outboundMsgCtx.getSubcontext(OIDCPeerEntityContext.class);        
+        assert peerEntityCtx != null;
+        peerEntityCtx.setIdentifier("https://op.example.com");
+        
         outboundMsgCtx.addSubcontext(secContext);
         
         handler.initialize();
         handler.invoke(outboundMsgCtx);
         
-        final var peerEntityCtx = outboundMsgCtx.getSubcontext(OIDCPeerEntityContext.class);
+        final var context = peerEntityCtx.getSubcontext(OAuth2ClientAuthenticationContext.class);
+        assert context != null;
+        assertNotNull(context);
+        assertNotNull(context.getClientAuthentication());
+        assertTrue(context.getClientAuthentication() instanceof ClientSecretJWT);
+        final var clientSecretJwt = (ClientSecretJWT) context.getClientAuthentication();
+        assert clientSecretJwt != null;
+        assertNotNull(clientSecretJwt);
+        assertNotNull(clientSecretJwt.getJWTAuthenticationClaimsSet());
+        assertNotNull(clientSecretJwt.getClientAssertion());
+        assertEquals(clientSecretJwt.getJWTAuthenticationClaimsSet().getClientID().toString(), CLIENT_ID);
+        assertEquals(clientSecretJwt.getJWTAuthenticationClaimsSet().getSubject().toString(), CLIENT_ID);
+        assertEquals(clientSecretJwt.getJWTAuthenticationClaimsSet().getIssuer().toString(), CLIENT_ID);        
+        assertEquals(clientSecretJwt.getJWTAuthenticationClaimsSet().getAudience().size(),1);
+        assertEquals(clientSecretJwt.getJWTAuthenticationClaimsSet().getAudience().get(0).toString(),
+                "https://op.example.com");
+        assertNotNull(clientSecretJwt.getJWTAuthenticationClaimsSet().getJWTID());
+        assertNotNull(clientSecretJwt.getJWTAuthenticationClaimsSet().getExpirationTime());
+    }
+    
+    @Test
+    public void testInitialiseClientSecretJWT_TokenEndpointURL_Success() throws Exception {
+        partyConfig.setTokenEndpointAuthMethod("client_secret_jwt");
+        
+        final SecurityParametersContext secContext = new SecurityParametersContext();
+        final SignatureSigningParameters secParams = new SignatureSigningParameters();
+        secParams.setSignatureAlgorithm("HS256");
+        secContext.setSignatureSigningParameters(secParams);
+        secParams.setSigningCredential(new DefaultClientSecretCredential(CLIENT_SECRET).toSigningCredential());
+        
+        final var outboundMsgCtx = getOutboundMessageContextFailIfNull(prc);
+        
+        final var peerEntityCtx = outboundMsgCtx.getSubcontext(OIDCPeerEntityContext.class);        
         assert peerEntityCtx != null;
+        peerEntityCtx.setIdentifier("https://op.example.com");
+        handler.setTokenEndpointAsAudience(true);
+        
+        outboundMsgCtx.addSubcontext(secContext);
+        
+        handler.initialize();
+        handler.invoke(outboundMsgCtx);
         
         final var context = peerEntityCtx.getSubcontext(OAuth2ClientAuthenticationContext.class);
         assert context != null;
@@ -151,6 +194,30 @@ public class InitializeOAuth2ClientAuthenticationMethodHandlerTest extends Abstr
         assertNotNull(clientSecretJwt.getJWTAuthenticationClaimsSet().getExpirationTime());
     }
     
+    @Test(expectedExceptions = MessageHandlerException.class)
+    public void testInitialiseClientSecretJWT_NoAudience_Fail() throws Exception {
+        partyConfig.setTokenEndpointAuthMethod("client_secret_jwt");
+        
+        final SecurityParametersContext secContext = new SecurityParametersContext();
+        final SignatureSigningParameters secParams = new SignatureSigningParameters();
+        secParams.setSignatureAlgorithm("HS256");
+        secContext.setSignatureSigningParameters(secParams);
+        secParams.setSigningCredential(new DefaultClientSecretCredential(CLIENT_SECRET).toSigningCredential());
+        
+        final var outboundMsgCtx = getOutboundMessageContextFailIfNull(prc);
+        
+        final var peerEntityCtx = outboundMsgCtx.getSubcontext(OIDCPeerEntityContext.class);        
+        assert peerEntityCtx != null;
+        // Should not be empty
+        peerEntityCtx.setIdentifier("");
+        
+        outboundMsgCtx.addSubcontext(secContext);
+        
+        handler.initialize();
+        handler.invoke(outboundMsgCtx);
+
+    }
+    
     
     @Test(expectedExceptions = MessageHandlerException.class)
     public void testInitialiseClientSecretJWT_NoSecurityParams() throws Exception {
@@ -179,11 +246,61 @@ public class InitializeOAuth2ClientAuthenticationMethodHandlerTest extends Abstr
         final var outboundMsgCtx = getOutboundMessageContextFailIfNull(prc);
         outboundMsgCtx.addSubcontext(secContext);
         
+        final var peerEntityCtx = outboundMsgCtx.getSubcontext(OIDCPeerEntityContext.class);
+        assert peerEntityCtx != null;
+        peerEntityCtx.setIdentifier("https://op.example.com");
+        
         handler.initialize();
-        handler.invoke(outboundMsgCtx);
+        handler.invoke(outboundMsgCtx);        
+        
+        final var context = peerEntityCtx.getSubcontext(OAuth2ClientAuthenticationContext.class);
+        assert context != null;
+        assertNotNull(context);
+        assertNotNull(context.getClientAuthentication());
+        assertTrue(context.getClientAuthentication() instanceof PrivateKeyJWT);
+        final var privateKeyJwt = (PrivateKeyJWT) context.getClientAuthentication();
+        assert privateKeyJwt != null;
+        assertNotNull(privateKeyJwt);
+        assertNotNull(privateKeyJwt.getJWTAuthenticationClaimsSet());
+        assertNotNull(privateKeyJwt.getClientAssertion());
+        assertEquals(privateKeyJwt.getJWTAuthenticationClaimsSet().getClientID().toString(), CLIENT_ID);
+        assertEquals(privateKeyJwt.getJWTAuthenticationClaimsSet().getSubject().toString(), CLIENT_ID);
+        assertEquals(privateKeyJwt.getJWTAuthenticationClaimsSet().getIssuer().toString(), CLIENT_ID);        
+        assertEquals(privateKeyJwt.getJWTAuthenticationClaimsSet().getAudience().size(),1);
+        assertEquals(privateKeyJwt.getJWTAuthenticationClaimsSet().getAudience().get(0).toString(),
+                "https://op.example.com");
+        assertNotNull(privateKeyJwt.getJWTAuthenticationClaimsSet().getJWTID());
+        assertNotNull(privateKeyJwt.getJWTAuthenticationClaimsSet().getExpirationTime());
+    }
+    
+    @Test
+    public void testInitialisePrivateKeyJWT_TokenEndpointURL_Success() throws Exception {
+        partyConfig.setTokenEndpointAuthMethod("private_key_jwt");
+        
+        final SecurityParametersContext secContext = new SecurityParametersContext();
+        final SignatureSigningParameters secParams = new SignatureSigningParameters();
+        secParams.setSignatureAlgorithm("RS256");
+        secContext.setSignatureSigningParameters(secParams);
+        final RSAKey key = new RSAKeyGenerator(2048)
+                .keyID("1")
+                .keyUse(KeyUse.ENCRYPTION)
+                .generate();
+        final var publicKey = key.toPublicKey();
+        assert publicKey != null;
+        secParams.setSigningCredential(new BasicCredential(publicKey, key.toPrivateKey()));
+        
+        final var outboundMsgCtx = getOutboundMessageContextFailIfNull(prc);
+        outboundMsgCtx.addSubcontext(secContext);
         
         final var peerEntityCtx = outboundMsgCtx.getSubcontext(OIDCPeerEntityContext.class);
         assert peerEntityCtx != null;
+        peerEntityCtx.setIdentifier("https://op.example.com");
+        handler.setTokenEndpointAsAudience(true);
+        
+        handler.initialize();
+        handler.invoke(outboundMsgCtx);
+        
+        
         
         final var context = peerEntityCtx.getSubcontext(OAuth2ClientAuthenticationContext.class);
         assert context != null;

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list