[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