diff --git a/openam-oauth2/pom.xml b/openam-oauth2/pom.xml
index 0d05abd169..cce6a1c790 100644
--- a/openam-oauth2/pom.xml
+++ b/openam-oauth2/pom.xml
@@ -166,6 +166,12 @@
test-jar
test
+
+ org.openidentityplatform.openam
+ openam-core
+ test-jar
+ test
+
org.openidentityplatform.openam
openam-notifications-integration
diff --git a/openam-oauth2/src/main/java/org/forgerock/oauth2/core/OAuth2Jwt.java b/openam-oauth2/src/main/java/org/forgerock/oauth2/core/OAuth2Jwt.java
index 69dcda0a6a..b6d618de15 100644
--- a/openam-oauth2/src/main/java/org/forgerock/oauth2/core/OAuth2Jwt.java
+++ b/openam-oauth2/src/main/java/org/forgerock/oauth2/core/OAuth2Jwt.java
@@ -13,12 +13,14 @@
*
* Copyright 2014-2016 ForgeRock AS.
* Portions Copyrighted 2015 Nomura Research Institute, Ltd.
+ * Portions Copyrighted 2026 3A Systems, LLC.
*/
package org.forgerock.oauth2.core;
import com.google.common.annotations.VisibleForTesting;
import org.forgerock.json.jose.common.JwtReconstruction;
+import org.forgerock.json.jose.jws.JwsAlgorithm;
import org.forgerock.json.jose.jws.SignedJwt;
import org.forgerock.json.jose.jws.handlers.SigningHandler;
import org.forgerock.util.time.TimeService;
@@ -157,4 +159,19 @@ public String getSubject() {
public SignedJwt getSignedJwt() {
return jwt;
}
+
+ /**
+ * Gets the signing algorithm named in the JWS header.
+ *
+ * @return The signing algorithm, or {@code null} if the header names one this server does not
+ * know, which includes the RFC 7518 spelling {@code "none"}: {@code JwsHeader.getAlgorithm()}
+ * is {@code JwsAlgorithm.valueOf(alg)} and only maps the enum's own upper-case names.
+ */
+ public JwsAlgorithm getSigningAlgorithm() {
+ try {
+ return jwt.getHeader().getAlgorithm();
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
}
diff --git a/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/AgentClientRegistration.java b/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/AgentClientRegistration.java
index a7bdf9d395..14ae9520bd 100644
--- a/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/AgentClientRegistration.java
+++ b/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/AgentClientRegistration.java
@@ -12,6 +12,7 @@
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2016 ForgeRock AS.
+ * Portions Copyrighted 2026 3A Systems, LLC.
*/
package org.forgerock.openam.oauth2;
@@ -146,6 +147,11 @@ public boolean verifyJwtIdentity(OAuth2Jwt jwt) {
return false;
}
+ @Override
+ public boolean verifyIdTokenIdentity(OAuth2Jwt idToken) {
+ return false;
+ }
+
@Override
public String getIDTokenSignedResponseAlgorithm() {
return "HS256";
diff --git a/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/OpenAMClientRegistration.java b/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/OpenAMClientRegistration.java
index 846f06a26c..e66390f0b6 100644
--- a/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/OpenAMClientRegistration.java
+++ b/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/OpenAMClientRegistration.java
@@ -60,6 +60,7 @@
import org.forgerock.jaspi.modules.openid.resolvers.SharedSecretOpenIdResolverImpl;
import org.forgerock.jaspi.modules.openid.resolvers.service.OpenIdResolverService;
import org.forgerock.json.jose.exceptions.JweException;
+import org.forgerock.json.jose.exceptions.JwsException;
import org.forgerock.json.jose.jwe.EncryptionMethod;
import org.forgerock.json.jose.jwe.JweAlgorithm;
import org.forgerock.json.jose.jwk.JWKSet;
@@ -94,6 +95,9 @@
*/
public class OpenAMClientRegistration implements OpenIdConnectClientRegistration {
+ /** Default of com.forgerock.openam.oauth2provider.idTokenSignedResponseAlg in AgentService.xml. */
+ private static final String ID_TOKEN_SIGNED_RESPONSE_ALG_DEFAULT = "HS256";
+
private static final String DELIMITER = "\\|";
/** Read/connect timeouts (ms) used when fetching a client's {@code jwks_uri}.
@@ -512,10 +516,11 @@ public String getIDTokenSignedResponseAlgorithm() {
} catch (Exception e) {
throw Utils.createException(OAuth2Constants.OAuth2Client.IDTOKEN_SIGNED_RESPONSE_ALG, e, logger);
}
- if (set.iterator().hasNext()){
- return set.iterator().next();
- }
- return null;
+ // AgentsRepo reads agent attributes without schema defaults, so a client created through
+ // the realm-config REST endpoint or ssoadm may have nothing persisted here. Fall back to
+ // the same default the schema, the console and dynamic registration use.
+ final String algorithm = CollectionUtils.getFirstItem(set);
+ return StringUtils.isEmpty(algorithm) ? ID_TOKEN_SIGNED_RESPONSE_ALG_DEFAULT : algorithm;
}
@Override
@@ -652,12 +657,28 @@ public String getSubjectType() {
@Override
public boolean verifyJwtIdentity(final OAuth2Jwt jwt) {
- final JwsAlgorithm signatureAlgorithm = JwsAlgorithm.valueOf(getIDTokenSignedResponseAlgorithm());
+ // The client signed this JWT (client_secret_jwt, private_key_jwt, jwt-bearer), so the JWS
+ // header states how; id_token_signed_response_alg only describes the ID tokens we issue.
+ // HMAC verification uses the client secret only and the asymmetric branch the client's
+ // registered keys only, so the header cannot steer one key type into the other's verifier.
+ // An algorithm outside the JwsAlgorithm enum (including the wire spelling "none") is null.
+ final JwsAlgorithm signatureAlgorithm = jwt.getSigningAlgorithm();
+ if (signatureAlgorithm == null || signatureAlgorithm.getAlgorithmType() == JwsAlgorithmType.NONE) {
+ return false;
+ }
if (signatureAlgorithm.getAlgorithmType() == JwsAlgorithmType.HMAC) {
return verifyJwtBySharedSecret(jwt);
} else {
+ // Any client_id can be sent an assertion with this alg, so what the client has (not)
+ // registered decides between invalid_client and server_error: no key location, no key
+ // material behind it, or a key that cannot verify this alg is false; only failures to
+ // read the registration or fetch the JWKS are server errors.
+ final Client.PublicKeySelector selector = getClientPublicKeySelector();
+ if (selector == null) {
+ return false;
+ }
try {
- switch (getClientPublicKeySelector()) {
+ switch (selector) {
case JWKS:
return byJWKs(jwt);
case JWKS_URI:
@@ -665,20 +686,51 @@ public boolean verifyJwtIdentity(final OAuth2Jwt jwt) {
default:
return byX509Key(jwt);
}
+ } catch (JwsException | IllegalArgumentException e) {
+ // Wrong key type, an alg the key cannot verify (RSA key vs. ES256, a symmetric key
+ // served at jwks_uri) or a signature the key cannot even parse (JwsVerifyingException,
+ // RSA: wrong length): the client cannot be verified with what it registered. Message
+ // level, so a client that stops authenticating after a key rotation leaves a trace
+ // without an error and a stack trace per unauthenticated request.
+ if (logger.messageEnabled()) {
+ logger.message("Client {} assertion with alg {} cannot be verified with its registered key: {}",
+ getClientId(), signatureAlgorithm, e.toString());
+ }
+ return false;
} catch (Exception e) {
throw Utils.createException("Client Bearer Jwt Public key", e, logger);
}
}
}
+ @Override
+ public boolean verifyIdTokenIdentity(final OAuth2Jwt idToken) {
+ // We issued the ID token with id_token_signed_response_alg, so the header must say so;
+ // the algorithm, and hence the key the token is verified with, is not the presenter's to pick.
+ // idTokenSignedResponseAlg is a free-text attribute; StatefulTokenStore upper-cases it when
+ // issuing, so the same spelling verifies, and a value we could not have issued with is not ours.
+ final JwsAlgorithm configured;
+ try {
+ configured = JwsAlgorithm.valueOf(getIDTokenSignedResponseAlgorithm().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ return idToken.getSigningAlgorithm() == configured && verifyJwtIdentity(idToken);
+ }
+
@Override
public boolean isConsentImplied() {
return Boolean.parseBoolean(getAttribute(OAuth2Constants.OAuth2Client.IS_CONSENT_IMPLIED));
}
private boolean verifyJwtBySharedSecret(final OAuth2Jwt jwt) {
+ final String clientSecret = getClientSecret();
+ if (StringUtils.isEmpty(clientSecret)) {
+ // A public client has no secret to verify an HMAC assertion with.
+ return false;
+ }
final String issuer = jwt.getSignedJwt().getClaimsSet().getIssuer();
- OpenIdResolver resolver = new SharedSecretOpenIdResolverImpl(issuer, getClientSecret());
+ OpenIdResolver resolver = new SharedSecretOpenIdResolverImpl(issuer, clientSecret);
try {
resolver.validateIdentity(jwt.getSignedJwt());
return jwt.isContentValid() && jwt.isIntendedForAudience(getClientId());
@@ -692,9 +744,8 @@ private boolean byJWKs(OAuth2Jwt jwt) throws IdRepoException, SSOException,
Set set = amIdentity.getAttribute(OAuth2Constants.OAuth2Client.JWKS);
final String jwkSetStr = CollectionUtils.getFirstItem(set);
- if (jwkSetStr == null) {
- throw OAuthProblemException.OAuthError.SERVER_ERROR.handle(Request.getCurrent(),
- "No Client Bearer JWK set.");
+ if (StringUtils.isEmpty(jwkSetStr)) {
+ return false;
}
final JWKSet jwkSet = new JWKSet(JsonValueBuilder.toJsonValue(jwkSetStr)
@@ -705,7 +756,9 @@ private boolean byJWKs(OAuth2Jwt jwt) throws IdRepoException, SSOException,
final Key key = jwkMap.get(jwt.getSignedJwt().getHeader().getKeyId());
- return key != null && jwt.isValid(getSigningHandlerForKey(key));
+ // Only asymmetric algorithms reach this branch; an oct JWK would be handed to an HMAC
+ // handler, which cannot verify them and would surface as a server error.
+ return key != null && !(key instanceof SecretKey) && jwt.isValid(getSigningHandlerForKey(key));
}
/**
@@ -731,13 +784,13 @@ private SigningHandler getSigningHandlerForKey(final Key key) {
private boolean byJWKsURI(OAuth2Jwt jwt) throws IdRepoException, SSOException, MalformedURLException {
final Set set = amIdentity.getAttribute(OAuth2Constants.OAuth2Client.JWKS_URI);
- if (set == null || set.isEmpty()) {
- throw OAuthProblemException.OAuthError.SERVER_ERROR.handle(Request.getCurrent(),
- "No Client Bearer JWKs_URI set.");
+ // The schema default of publicKeyLocation is jwks_uri, so a client created through the
+ // console or ssoadm typically carries the selector and no URI until one is configured.
+ final String url = CollectionUtils.getFirstItem(set);
+ if (StringUtils.isEmpty(url)) {
+ return false;
}
- final String url = set.iterator().next();
-
// GHSA-f2cx-463q-7m2c: the resolver cache MUST be keyed by something tied to the
// client registration, not by the attacker-controlled JWT 'iss' claim. Otherwise a
// resolver seeded by one client (with its own keys) can be reused to validate a
@@ -790,12 +843,10 @@ private boolean byX509Key(OAuth2Jwt jwt) throws IdRepoException, SSOException, C
Set set = amIdentity.getAttribute(OAuth2Constants.OAuth2Client.CLIENT_JWT_PUBLIC_KEY);
- if (set == null || set.isEmpty()) {
- throw OAuthProblemException.OAuthError.SERVER_ERROR.handle(Request.getCurrent(),
- "No Client Bearer Jwt Public key certificate set");
+ String encodedCert = CollectionUtils.getFirstItem(set);
+ if (StringUtils.isEmpty(encodedCert)) {
+ return false;
}
-
- String encodedCert = set.iterator().next();
X509Certificate certificate = pemDecoder.decodeX509Certificate(encodedCert);
return jwt.isValid(signingManager.newRsaSigningHandler(certificate.getPublicKey()));
@@ -814,7 +865,9 @@ private Client.PublicKeySelector getClientPublicKeySelector() {
} catch (IdRepoException | SSOException e) {
throw Utils.createException(OAuth2Constants.OAuth2Client.PUBLIC_KEY_SELECTOR, e, logger);
}
- return Client.PublicKeySelector.fromString(set.iterator().next());
+ // As with idTokenSignedResponseAlg, the schema default is not applied to a client created
+ // through the realm-config REST endpoint or ssoadm; fromString() is null for an unknown value.
+ return Client.PublicKeySelector.fromString(CollectionUtils.getFirstItem(set));
}
/**
diff --git a/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/Utils.java b/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/Utils.java
index 8f97278d4a..303e51186a 100644
--- a/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/Utils.java
+++ b/openam-oauth2/src/main/java/org/forgerock/openam/oauth2/Utils.java
@@ -12,6 +12,7 @@
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2016 ForgeRock AS.
+ * Portions Copyrighted 2026 3A Systems, LLC.
*/
package org.forgerock.openam.oauth2;
@@ -19,6 +20,7 @@
import java.util.HashSet;
import java.util.Set;
+import org.forgerock.openam.utils.CollectionUtils;
import org.restlet.Request;
import com.sun.identity.idm.AMIdentity;
@@ -103,7 +105,7 @@ static Set stripAttributeNameFromValue(Set attributeValues) {
* @param identity The identity with the attribute.
* @param attributeName The name of the attribute.
* @param logger The logger used to log eventual exceptions.
- * @return The attribute value.
+ * @return The attribute value, or {@code null} if the identity has no value for the attribute.
*/
static String getAttributeValueFromSet(AMIdentity identity, String attributeName, Debug logger) {
Set values;
@@ -112,7 +114,7 @@ static String getAttributeValueFromSet(AMIdentity identity, String attributeName
} catch (Exception e) {
throw createException(attributeName, e, logger);
}
- return values.iterator().next();
+ return CollectionUtils.getFirstItem(values);
}
/**
diff --git a/openam-oauth2/src/main/java/org/forgerock/openidconnect/OpenIdConnectClientRegistration.java b/openam-oauth2/src/main/java/org/forgerock/openidconnect/OpenIdConnectClientRegistration.java
index 1ac25a8451..158bf926e0 100644
--- a/openam-oauth2/src/main/java/org/forgerock/openidconnect/OpenIdConnectClientRegistration.java
+++ b/openam-oauth2/src/main/java/org/forgerock/openidconnect/OpenIdConnectClientRegistration.java
@@ -13,6 +13,7 @@
*
* Copyright 2014-2016 ForgeRock AS.
* Portions Copyrighted 2015 Nomura Research Institute, Ltd.
+ * Portions Copyrighted 2026 3A Systems, LLC.
*/
package org.forgerock.openidconnect;
@@ -21,6 +22,7 @@
import java.security.Key;
import org.forgerock.oauth2.core.ClientRegistration;
+import org.forgerock.oauth2.core.OAuth2Jwt;
import org.forgerock.oauth2.core.OAuth2ProviderSettings;
import org.forgerock.oauth2.core.exceptions.ServerException;
@@ -38,6 +40,17 @@ public interface OpenIdConnectClientRegistration extends ClientRegistration {
*/
String getIDTokenSignedResponseAlgorithm();
+ /**
+ * Verifies that the supplied JWT is an ID token this server issued for this client: signed with
+ * the client's {@link #getIDTokenSignedResponseAlgorithm() id_token_signed_response_alg} and
+ * verifiable with the key that algorithm implies. Unlike {@link #verifyJwtIdentity(OAuth2Jwt)},
+ * which checks a JWT the client signed, the algorithm is not the sender's to choose.
+ *
+ * @param idToken The ID token.
+ * @return {@code true} if the ID token was issued for this client.
+ */
+ boolean verifyIdTokenIdentity(OAuth2Jwt idToken);
+
/**
* Determines if ID token encryption is enabled.
*
diff --git a/openam-oauth2/src/main/java/org/forgerock/openidconnect/restlet/IdTokenInfo.java b/openam-oauth2/src/main/java/org/forgerock/openidconnect/restlet/IdTokenInfo.java
index 010584d0d8..8944add862 100644
--- a/openam-oauth2/src/main/java/org/forgerock/openidconnect/restlet/IdTokenInfo.java
+++ b/openam-oauth2/src/main/java/org/forgerock/openidconnect/restlet/IdTokenInfo.java
@@ -12,7 +12,7 @@
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2016 ForgeRock AS.
- * Portions copyright 2025 3A Systems LLC.
+ * Portions copyright 2025-2026 3A Systems LLC.
*/
package org.forgerock.openidconnect.restlet;
@@ -159,7 +159,13 @@ OAuth2Jwt validateIdToken(OAuth2Request request) throws OAuth2Exception, RealmLo
final OpenIdConnectClientRegistration clientRegistration = clientRegistrationStore.get(clientId,
new ValidateIdTokenRequest(request, realm));
- JwsAlgorithm algorithm = JwsAlgorithm.valueOf(clientRegistration.getIDTokenSignedResponseAlgorithm());
+ // A free-text attribute, upper-cased as the token store does when issuing.
+ final JwsAlgorithm algorithm;
+ try {
+ algorithm = JwsAlgorithm.valueOf(clientRegistration.getIDTokenSignedResponseAlgorithm().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ throw new BadRequestException("unsupported id_token_signed_response_alg");
+ }
boolean requiresClientAuthentication =
providerSettingsFactory.get(request).isIdTokenInfoClientAuthenticationEnabled();
@@ -171,7 +177,7 @@ OAuth2Jwt validateIdToken(OAuth2Request request) throws OAuth2Exception, RealmLo
throw new BadRequestException("id_token has expired");
}
- if (!clientRegistration.verifyJwtIdentity(idToken)) {
+ if (!clientRegistration.verifyIdTokenIdentity(idToken)) {
throw new BadRequestException("invalid id_token");
}
diff --git a/openam-oauth2/src/main/java/org/forgerock/openidconnect/ssoprovider/OpenIdConnectSSOProvider.java b/openam-oauth2/src/main/java/org/forgerock/openidconnect/ssoprovider/OpenIdConnectSSOProvider.java
index 3b2b6eecf4..9183c6dbea 100644
--- a/openam-oauth2/src/main/java/org/forgerock/openidconnect/ssoprovider/OpenIdConnectSSOProvider.java
+++ b/openam-oauth2/src/main/java/org/forgerock/openidconnect/ssoprovider/OpenIdConnectSSOProvider.java
@@ -12,7 +12,7 @@
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2016 ForgeRock AS.
- * Portions copyright 2025 3A Systems LLC.
+ * Portions copyright 2025-2026 3A Systems LLC.
*/
package org.forgerock.openidconnect.ssoprovider;
@@ -260,7 +260,7 @@ public String load(final @Nonnull String idTokenString) throws SSOException {
throw new SSOException(e);
}
- if (!clientRegistration.verifyJwtIdentity(idToken)) {
+ if (!clientRegistration.verifyIdTokenIdentity(idToken)) {
throw new SSOException("invalid id_token");
}
diff --git a/openam-oauth2/src/test/java/org/forgerock/oauth2/core/OAuth2JwtTest.java b/openam-oauth2/src/test/java/org/forgerock/oauth2/core/OAuth2JwtTest.java
new file mode 100644
index 0000000000..c0a6bfa2ef
--- /dev/null
+++ b/openam-oauth2/src/test/java/org/forgerock/oauth2/core/OAuth2JwtTest.java
@@ -0,0 +1,59 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+
+package org.forgerock.oauth2.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.nio.charset.StandardCharsets;
+
+import org.forgerock.json.jose.builders.JwtBuilderFactory;
+import org.forgerock.json.jose.jws.JwsAlgorithm;
+import org.forgerock.json.jose.jws.SigningManager;
+import org.forgerock.util.encode.Base64url;
+import org.testng.annotations.Test;
+
+public class OAuth2JwtTest {
+
+ @Test
+ public void getSigningAlgorithmReturnsHeaderAlgorithm() {
+ String jwt = new JwtBuilderFactory()
+ .jws(new SigningManager().newHmacSigningHandler("secret".getBytes(StandardCharsets.UTF_8)))
+ .headers().alg(JwsAlgorithm.HS256).done()
+ .claims(new JwtBuilderFactory().claims().sub("s").build())
+ .build();
+
+ assertThat(OAuth2Jwt.create(jwt).getSigningAlgorithm()).isEqualTo(JwsAlgorithm.HS256);
+ }
+
+ /**
+ * {@code JwsHeader.getAlgorithm()} is {@code JwsAlgorithm.valueOf(alg)} and throws for the RFC
+ * spelling {@code "none"}, for lower case and for anything it does not know; that is "no usable
+ * algorithm", not a server error.
+ */
+ @Test
+ public void getSigningAlgorithmReturnsNullForAlgorithmOutsideTheEnum() {
+ assertThat(OAuth2Jwt.create(rawJwt("none")).getSigningAlgorithm()).isNull();
+ assertThat(OAuth2Jwt.create(rawJwt("hs256")).getSigningAlgorithm()).isNull();
+ assertThat(OAuth2Jwt.create(rawJwt("PS256")).getSigningAlgorithm()).isNull();
+ }
+
+ private static String rawJwt(String alg) {
+ String header = Base64url.encode(("{\"typ\":\"JWT\",\"alg\":\"" + alg + "\"}").getBytes(StandardCharsets.UTF_8));
+ String payload = Base64url.encode("{\"sub\":\"s\"}".getBytes(StandardCharsets.UTF_8));
+ return header + "." + payload + ".";
+ }
+}
diff --git a/openam-oauth2/src/test/java/org/forgerock/openam/oauth2/OpenAMClientRegistrationTest.java b/openam-oauth2/src/test/java/org/forgerock/openam/oauth2/OpenAMClientRegistrationTest.java
index 66354d4f9e..3084153f77 100644
--- a/openam-oauth2/src/test/java/org/forgerock/openam/oauth2/OpenAMClientRegistrationTest.java
+++ b/openam-oauth2/src/test/java/org/forgerock/openam/oauth2/OpenAMClientRegistrationTest.java
@@ -13,6 +13,7 @@
*
* Copyright 2014-2016 ForgeRock AS.
* Portions Copyrighted 2015 Nomura Research Institute, Ltd.
+ * Portions Copyrighted 2026 3A Systems, LLC.
*/
package org.forgerock.openam.oauth2;
@@ -30,24 +31,40 @@
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.Key;
+import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.PublicKey;
+import java.security.spec.ECGenParameterSpec;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
import java.util.Arrays;
import java.util.Collections;
+import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
+import java.util.UUID;
import javax.crypto.spec.SecretKeySpec;
+import org.forgerock.jaspi.modules.openid.resolvers.SharedSecretOpenIdResolverImpl;
import org.forgerock.jaspi.modules.openid.resolvers.service.OpenIdResolverService;
+import org.forgerock.json.jose.builders.JwsHeaderBuilder;
+import org.forgerock.json.jose.builders.JwtBuilderFactory;
import org.forgerock.json.jose.jwe.JweAlgorithm;
+import org.forgerock.json.jose.jws.JwsAlgorithm;
+import org.forgerock.json.jose.jws.SigningManager;
+import org.forgerock.json.jose.jws.handlers.SigningHandler;
+import org.forgerock.json.jose.jwt.JwtClaimsSet;
+import org.forgerock.oauth2.core.OAuth2Jwt;
import org.forgerock.oauth2.core.OAuth2ProviderSettings;
import org.forgerock.oauth2.core.PEMDecoder;
import org.forgerock.oauth2.core.exceptions.ClientAuthenticationFailureFactory;
+import org.forgerock.util.encode.Base64url;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.testng.annotations.BeforeClass;
@@ -328,4 +345,382 @@ private void setUpAgentToThrowExceptionForAttribute(String attributeName) throws
given(amIdentity.getAttribute(attributeName))
.willThrow(new SSOException("exception!"));
}
+
+ // --- #1130: id_token_signed_response_alg default and client-assertion dispatch ----------
+
+ /**
+ * AgentsRepo reads agent attributes without schema defaults, so a client created without
+ * the attribute (realm-config PUT, ssoadm) has no value persisted; the token endpoint then
+ * NPEs. The schema, the console and dynamic registration all default to HS256.
+ */
+ @Test
+ public void idTokenSignedResponseAlgorithmDefaultsToHs256WhenUnset() throws Exception {
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(Collections.emptySet());
+ assertThat(clientRegistration.getIDTokenSignedResponseAlgorithm()).isEqualTo("HS256");
+
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(null);
+ assertThat(clientRegistration.getIDTokenSignedResponseAlgorithm()).isEqualTo("HS256");
+
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("RS256"));
+ assertThat(clientRegistration.getIDTokenSignedResponseAlgorithm()).isEqualTo("RS256");
+ }
+
+ /**
+ * A client_secret_jwt assertion is verified with the client secret whatever algorithm the
+ * client asked for its (outgoing) ID tokens.
+ */
+ @Test
+ public void verifyJwtIdentityUsesClientSecretForHmacAssertionRegardlessOfIdTokenAlg() throws Exception {
+ String clientId = "client1";
+ String secret = "a-client-secret-of-sufficient-length";
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton(secret));
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("RS256"));
+
+ SigningHandler signer = new SigningManager().newHmacSigningHandler(secret.getBytes(StandardCharsets.UTF_8));
+ OAuth2Jwt assertion = assertion(clientId, signer, JwsAlgorithm.HS256, null);
+
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isTrue();
+
+ SigningHandler wrongSigner = new SigningManager().newHmacSigningHandler("wrong".getBytes(StandardCharsets.UTF_8));
+ assertThat(clientRegistration.verifyJwtIdentity(assertion(clientId, wrongSigner, JwsAlgorithm.HS256, null)))
+ .isFalse();
+ }
+
+ /**
+ * A private_key_jwt assertion is verified with the client's registered public keys whatever
+ * algorithm the client asked for its (outgoing) ID tokens.
+ */
+ @Test
+ public void verifyJwtIdentityUsesPublicKeysForAsymmetricAssertionRegardlessOfIdTokenAlg() throws Exception {
+ String clientId = "client1";
+ KeyPair clientKeys = generateRsaKeyPair();
+ String kid = UUID.randomUUID().toString();
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton("unused-secret"));
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+ given(amIdentity.getAttribute(PUBLIC_KEY_SELECTOR)).willReturn(singleton("jwks"));
+ given(amIdentity.getAttribute(JWKS)).willReturn(singleton(jwks((RSAPublicKey) clientKeys.getPublic(), kid)));
+
+ SigningHandler signer = new SigningManager().newRsaSigningHandler((RSAPrivateKey) clientKeys.getPrivate());
+ assertThat(clientRegistration.verifyJwtIdentity(assertion(clientId, signer, JwsAlgorithm.RS256, kid))).isTrue();
+
+ KeyPair otherKeys = generateRsaKeyPair();
+ SigningHandler otherSigner = new SigningManager().newRsaSigningHandler((RSAPrivateKey) otherKeys.getPrivate());
+ assertThat(clientRegistration.verifyJwtIdentity(assertion(clientId, otherSigner, JwsAlgorithm.RS256, kid)))
+ .isFalse();
+ }
+
+ /**
+ * {@code JwsHeader.getAlgorithm()} is {@code JwsAlgorithm.valueOf(alg)}: the RFC spelling
+ * {@code "none"} (or anything outside the enum) throws rather than returning {@code NONE}, so
+ * the assertion is built as a raw compact serialisation, not through the builder, which would
+ * emit the enum name {@code "NONE"} that never occurs on the wire.
+ */
+ @Test
+ public void verifyJwtIdentityRejectsWireAlgNone() throws Exception {
+ String clientId = "client1";
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton("a-client-secret"));
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+
+ assertThat(clientRegistration.verifyJwtIdentity(rawAssertion(clientId, "none"))).isFalse();
+ }
+
+ @Test
+ public void verifyJwtIdentityRejectsUnknownAlg() throws Exception {
+ String clientId = "client1";
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton("a-client-secret"));
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+
+ assertThat(clientRegistration.verifyJwtIdentity(rawAssertion(clientId, "hs256"))).isFalse();
+ }
+
+ /** A public client has no userpassword at all; an HMAC assertion is invalid_client, not an NPE. */
+ @Test
+ public void verifyJwtIdentityReturnsFalseForHmacAssertionWhenClientHasNoSecret() throws Exception {
+ String clientId = "client1";
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+ SigningHandler signer = new SigningManager().newHmacSigningHandler("any".getBytes(StandardCharsets.UTF_8));
+ OAuth2Jwt assertion = assertion(clientId, signer, JwsAlgorithm.HS256, null);
+
+ given(amIdentity.getAttribute("userpassword")).willReturn(null);
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isFalse();
+
+ given(amIdentity.getAttribute("userpassword")).willReturn(Collections.emptySet());
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isFalse();
+ }
+
+ /**
+ * AgentsRepo does not apply the schema default of publicKeyLocation either; a client with no
+ * registered key location cannot verify an asymmetric assertion, which is invalid_client rather
+ * than a server_error.
+ */
+ @Test
+ public void verifyJwtIdentityReturnsFalseForAsymmetricAssertionWithoutPublicKeyLocation() throws Exception {
+ String clientId = "client1";
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton("a-client-secret"));
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+ KeyPair clientKeys = generateRsaKeyPair();
+ SigningHandler signer = new SigningManager().newRsaSigningHandler((RSAPrivateKey) clientKeys.getPrivate());
+ OAuth2Jwt assertion = assertion(clientId, signer, JwsAlgorithm.RS256, "kid");
+
+ given(amIdentity.getAttribute(PUBLIC_KEY_SELECTOR)).willReturn(null);
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isFalse();
+
+ given(amIdentity.getAttribute(PUBLIC_KEY_SELECTOR)).willReturn(Collections.emptySet());
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isFalse();
+
+ given(amIdentity.getAttribute(PUBLIC_KEY_SELECTOR)).willReturn(singleton("not-a-selector"));
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isFalse();
+ }
+
+ /**
+ * A symmetric (oct) JWK can never verify an RS/ES-signed assertion; it must not be tried as an
+ * HMAC key. (JWKLookup keys an oct JWK's {@code alg} by the JCA name, hence {@code HmacSHA256}.)
+ */
+ @Test
+ public void verifyJwtIdentityRejectsAsymmetricAssertionAgainstSymmetricJwk() throws Exception {
+ String clientId = "client1";
+ String kid = UUID.randomUUID().toString();
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton("a-client-secret"));
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+ given(amIdentity.getAttribute(PUBLIC_KEY_SELECTOR)).willReturn(singleton("jwks"));
+ given(amIdentity.getAttribute(JWKS)).willReturn(singleton("{\"keys\":[{\"kty\":\"oct\",\"alg\":\"HmacSHA256\",\"kid\":\"" + kid
+ + "\",\"k\":\"" + Base64url.encode("a-shared-key-of-sufficient-length".getBytes(StandardCharsets.UTF_8))
+ + "\"}]}"));
+ KeyPair clientKeys = generateRsaKeyPair();
+ SigningHandler signer = new SigningManager().newRsaSigningHandler((RSAPrivateKey) clientKeys.getPrivate());
+
+ assertThat(clientRegistration.verifyJwtIdentity(assertion(clientId, signer, JwsAlgorithm.RS256, kid))).isFalse();
+ }
+
+ /**
+ * An ID token this server issued carries id_token_signed_response_alg in its header; one signed
+ * with any other algorithm is not ours, whatever key it would otherwise verify with.
+ */
+ @Test
+ public void verifyIdTokenIdentityRejectsAlgorithmOtherThanConfigured() throws Exception {
+ String clientId = "client1";
+ String secret = "a-client-secret-of-sufficient-length";
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton(secret));
+ SigningHandler signer = new SigningManager().newHmacSigningHandler(secret.getBytes(StandardCharsets.UTF_8));
+ OAuth2Jwt idToken = assertion(clientId, signer, JwsAlgorithm.HS256, null);
+
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+ assertThat(clientRegistration.verifyIdTokenIdentity(idToken)).isTrue();
+
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("RS256"));
+ assertThat(clientRegistration.verifyIdTokenIdentity(idToken)).isFalse();
+ }
+
+ @Test
+ public void verifyIdTokenIdentityRejectsWireAlgNone() throws Exception {
+ String clientId = "client1";
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton("a-client-secret"));
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+
+ assertThat(clientRegistration.verifyIdTokenIdentity(rawAssertion(clientId, "none"))).isFalse();
+ }
+
+ /**
+ * AgentConfiguration.createAgent persists the schema defaults, so a client created through the
+ * console, ssoadm or /json/agents carries publicKeyLocation=jwks_uri and no jwks_uri. Nothing
+ * registered behind the selector is invalid_client, not server_error with a logged stack trace.
+ */
+ @Test
+ public void verifyJwtIdentityReturnsFalseWhenRegisteredKeyLocationHasNoMaterial() throws Exception {
+ String clientId = "client1";
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton("a-client-secret"));
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+ KeyPair clientKeys = generateRsaKeyPair();
+ SigningHandler signer = new SigningManager().newRsaSigningHandler((RSAPrivateKey) clientKeys.getPrivate());
+ OAuth2Jwt assertion = assertion(clientId, signer, JwsAlgorithm.RS256, "kid");
+
+ given(amIdentity.getAttribute(PUBLIC_KEY_SELECTOR)).willReturn(singleton("jwks_uri"));
+ given(amIdentity.getAttribute(JWKS_URI)).willReturn(null);
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isFalse();
+ given(amIdentity.getAttribute(JWKS_URI)).willReturn(Collections.emptySet());
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isFalse();
+ given(amIdentity.getAttribute(JWKS_URI)).willReturn(singleton(""));
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isFalse();
+
+ given(amIdentity.getAttribute(PUBLIC_KEY_SELECTOR)).willReturn(singleton("x509"));
+ given(amIdentity.getAttribute(CLIENT_JWT_PUBLIC_KEY)).willReturn(null);
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isFalse();
+ given(amIdentity.getAttribute(CLIENT_JWT_PUBLIC_KEY)).willReturn(singleton(""));
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isFalse();
+
+ given(amIdentity.getAttribute(PUBLIC_KEY_SELECTOR)).willReturn(singleton("jwks"));
+ given(amIdentity.getAttribute(JWKS)).willReturn(null);
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isFalse();
+ given(amIdentity.getAttribute(JWKS)).willReturn(singleton(""));
+ assertThat(clientRegistration.verifyJwtIdentity(assertion)).isFalse();
+ }
+
+ /** A registered key that cannot verify the header's algorithm (RSA key, ES256 header) is invalid_client. */
+ @Test
+ public void verifyJwtIdentityReturnsFalseWhenRegisteredKeyCannotVerifyAlgorithm() throws Exception {
+ String clientId = "client1";
+ String kid = UUID.randomUUID().toString();
+ KeyPair rsaKeys = generateRsaKeyPair();
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton("a-client-secret"));
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+ given(amIdentity.getAttribute(PUBLIC_KEY_SELECTOR)).willReturn(singleton("jwks"));
+ given(amIdentity.getAttribute(JWKS)).willReturn(singleton(jwks((RSAPublicKey) rsaKeys.getPublic(), kid)));
+
+ KeyPairGenerator gen = KeyPairGenerator.getInstance("EC");
+ gen.initialize(new ECGenParameterSpec("secp256r1"));
+ SigningHandler ecSigner = new SigningManager().newEcdsaSigningHandler((ECPrivateKey) gen.generateKeyPair().getPrivate());
+
+ assertThat(clientRegistration.verifyJwtIdentity(assertion(clientId, ecSigner, JwsAlgorithm.ES256, kid))).isFalse();
+ }
+
+ /**
+ * RSASigningHandler wraps the JDK's "Bad signature length" in JwsVerifyingException, a sibling of
+ * JwsSigningException: a client that rotated to a longer key, or any sender choosing the
+ * signature length, must be invalid_client rather than server_error with a logged stack trace.
+ */
+ @Test
+ public void verifyJwtIdentityReturnsFalseForRsaSignatureOfWrongLength() throws Exception {
+ String clientId = "client1";
+ String kid = UUID.randomUUID().toString();
+ KeyPair registeredKeys = generateRsaKeyPair(2048);
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton("a-client-secret"));
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+ given(amIdentity.getAttribute(PUBLIC_KEY_SELECTOR)).willReturn(singleton("jwks"));
+ given(amIdentity.getAttribute(JWKS)).willReturn(singleton(jwks((RSAPublicKey) registeredKeys.getPublic(), kid)));
+
+ KeyPair rotatedKeys = generateRsaKeyPair(3072);
+ SigningHandler signer = new SigningManager().newRsaSigningHandler((RSAPrivateKey) rotatedKeys.getPrivate());
+
+ assertThat(clientRegistration.verifyJwtIdentity(assertion(clientId, signer, JwsAlgorithm.RS256, kid))).isFalse();
+ }
+
+ /**
+ * A symmetric key served at jwks_uri reaches an HMAC handler inside the commons resolver, which
+ * cannot verify an RS256 assertion; that is invalid_client, not server_error. The resolver is
+ * seeded through the cache so the test needs no HTTP server.
+ */
+ @Test
+ public void verifyJwtIdentityReturnsFalseForSymmetricKeyBehindJwksUri() throws Exception {
+ String clientId = "client1";
+ String url = "https://jwks.invalid/" + UUID.randomUUID();
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton("a-client-secret"));
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+ given(amIdentity.getAttribute(PUBLIC_KEY_SELECTOR)).willReturn(singleton("jwks_uri"));
+ given(amIdentity.getAttribute(JWKS_URI)).willReturn(singleton(url));
+ KeyPair clientKeys = generateRsaKeyPair();
+ SigningHandler signer = new SigningManager().newRsaSigningHandler((RSAPrivateKey) clientKeys.getPrivate());
+ ClientJwksResolverCache.putIfAbsent(clientId + "|" + url,
+ new SharedSecretOpenIdResolverImpl(clientId, "a-shared-key-of-sufficient-length"));
+ try {
+ assertThat(clientRegistration.verifyJwtIdentity(assertion(clientId, signer, JwsAlgorithm.RS256, "kid")))
+ .isFalse();
+ } finally {
+ ClientJwksResolverCache.resetForTest();
+ }
+ }
+
+ /**
+ * A header without alg is what JwsHeader.getAlgorithm() maps to NONE, and the enum name "NONE"
+ * is the other spelling that reaches that arm; neither is a signed assertion.
+ */
+ @Test
+ public void verifyJwtIdentityRejectsAssertionWithoutAlgOrWithEnumSpelledNone() throws Exception {
+ String clientId = "client1";
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton("a-client-secret"));
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("HS256"));
+
+ assertThat(clientRegistration.verifyJwtIdentity(rawAssertion(clientId, null))).isFalse();
+ assertThat(clientRegistration.verifyJwtIdentity(rawAssertion(clientId, "NONE"))).isFalse();
+ }
+
+ /**
+ * idTokenSignedResponseAlg is a free-text attribute; StatefulTokenStore upper-cases it when
+ * issuing, so the verifier must accept the same spelling, and an unknown value is not ours.
+ */
+ @Test
+ public void verifyIdTokenIdentityNormalisesConfiguredAlgorithmAndRejectsUnknown() throws Exception {
+ String clientId = "client1";
+ String secret = "a-client-secret-of-sufficient-length";
+ given(amIdentity.getName()).willReturn(clientId);
+ given(amIdentity.getAttribute("userpassword")).willReturn(singleton(secret));
+ SigningHandler signer = new SigningManager().newHmacSigningHandler(secret.getBytes(StandardCharsets.UTF_8));
+ OAuth2Jwt idToken = assertion(clientId, signer, JwsAlgorithm.HS256, null);
+
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("hs256"));
+ assertThat(clientRegistration.verifyIdTokenIdentity(idToken)).isTrue();
+
+ given(amIdentity.getAttribute(IDTOKEN_SIGNED_RESPONSE_ALG)).willReturn(singleton("not-an-alg"));
+ assertThat(clientRegistration.verifyIdTokenIdentity(idToken)).isFalse();
+ }
+
+ /**
+ * Compact serialisation with the given literal {@code alg} ({@code null}: no alg member) and an
+ * empty signature part.
+ */
+ private static OAuth2Jwt rawAssertion(String clientId, String alg) {
+ JwtClaimsSet claims = new JwtBuilderFactory().claims()
+ .iss(clientId)
+ .sub(clientId)
+ .aud(Collections.singletonList(clientId))
+ .exp(new Date(System.currentTimeMillis() + 60_000L))
+ .iat(new Date())
+ .build();
+ String headerJson = alg == null ? "{\"typ\":\"JWT\"}" : "{\"typ\":\"JWT\",\"alg\":\"" + alg + "\"}";
+ String header = Base64url.encode(headerJson.getBytes(StandardCharsets.UTF_8));
+ String payload = Base64url.encode(claims.build().getBytes(StandardCharsets.UTF_8));
+ return OAuth2Jwt.create(header + "." + payload + ".");
+ }
+
+ private static OAuth2Jwt assertion(String clientId, SigningHandler signer, JwsAlgorithm alg, String kid) {
+ JwtClaimsSet claims = new JwtBuilderFactory().claims()
+ .iss(clientId)
+ .sub(clientId)
+ .aud(Collections.singletonList(clientId))
+ .exp(new Date(System.currentTimeMillis() + 60_000L))
+ .iat(new Date())
+ .build();
+ JwsHeaderBuilder headers = new JwtBuilderFactory().jws(signer).headers().alg(alg);
+ if (kid != null) {
+ headers = headers.kid(kid);
+ }
+ return OAuth2Jwt.create(headers.done().claims(claims).build());
+ }
+
+ private static KeyPair generateRsaKeyPair() throws Exception {
+ return generateRsaKeyPair(2048);
+ }
+
+ private static KeyPair generateRsaKeyPair(int bits) throws Exception {
+ KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
+ gen.initialize(bits);
+ return gen.generateKeyPair();
+ }
+
+ private static String jwks(RSAPublicKey pk, String kid) {
+ return "{\"keys\":[{\"kty\":\"RSA\",\"use\":\"sig\",\"alg\":\"RS256\",\"kid\":\"" + kid + "\","
+ + "\"n\":\"" + base64UrlUnsigned(pk.getModulus()) + "\","
+ + "\"e\":\"" + base64UrlUnsigned(pk.getPublicExponent()) + "\"}]}";
+ }
+
+ private static String base64UrlUnsigned(java.math.BigInteger bi) {
+ byte[] full = bi.toByteArray();
+ if (full.length > 1 && full[0] == 0) {
+ full = Arrays.copyOfRange(full, 1, full.length);
+ }
+ return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(full);
+ }
}
\ No newline at end of file
diff --git a/openam-oauth2/src/test/java/org/forgerock/openidconnect/restlet/IdTokenInfoTest.java b/openam-oauth2/src/test/java/org/forgerock/openidconnect/restlet/IdTokenInfoTest.java
new file mode 100644
index 0000000000..2fe6dcd6d4
--- /dev/null
+++ b/openam-oauth2/src/test/java/org/forgerock/openidconnect/restlet/IdTokenInfoTest.java
@@ -0,0 +1,173 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+
+package org.forgerock.openidconnect.restlet;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Date;
+import java.util.concurrent.ConcurrentHashMap;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.forgerock.json.jose.builders.JwtBuilderFactory;
+import org.forgerock.json.jose.jws.JwsAlgorithm;
+import org.forgerock.json.jose.jws.SigningManager;
+import org.forgerock.oauth2.core.ClientAuthenticator;
+import org.forgerock.oauth2.core.OAuth2Jwt;
+import org.forgerock.oauth2.core.OAuth2ProviderSettings;
+import org.forgerock.oauth2.core.OAuth2ProviderSettingsFactory;
+import org.forgerock.oauth2.core.OAuth2Request;
+import org.forgerock.oauth2.core.OAuth2RequestFactory;
+import org.forgerock.oauth2.core.exceptions.BadRequestException;
+import org.forgerock.oauth2.restlet.ExceptionHandler;
+import org.forgerock.openam.core.realms.RealmTestHelper;
+import org.forgerock.openam.oauth2.OAuth2Constants;
+import org.forgerock.openam.oauth2.OAuth2UrisFactory;
+import org.forgerock.openam.rest.jakarta.servlet.internal.ServletCall;
+import org.forgerock.openidconnect.OpenIdConnectClientRegistration;
+import org.forgerock.openidconnect.OpenIdConnectClientRegistrationStore;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.restlet.engine.adapter.HttpRequest;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+public class IdTokenInfoTest {
+
+ private static final String CLIENT_ID = "client1";
+
+ @Mock
+ private OpenIdConnectClientRegistrationStore clientRegistrationStore;
+ @Mock
+ private OAuth2RequestFactory requestFactory;
+ @Mock
+ private ExceptionHandler exceptionHandler;
+ @Mock
+ private ClientAuthenticator clientAuthenticator;
+ @Mock
+ private OAuth2UrisFactory urisFactory;
+ @Mock
+ private OAuth2ProviderSettingsFactory providerSettingsFactory;
+ @Mock
+ private OAuth2ProviderSettings providerSettings;
+ @Mock
+ private OpenIdConnectClientRegistration clientRegistration;
+ @Mock
+ private OAuth2Request request;
+
+ private RealmTestHelper realmTestHelper;
+ private IdTokenInfo idTokenInfo;
+
+ @BeforeMethod
+ public void setup() throws Exception {
+ MockitoAnnotations.initMocks(this);
+ realmTestHelper = new RealmTestHelper();
+ realmTestHelper.setupRealmClass();
+ idTokenInfo = new IdTokenInfo(clientRegistrationStore, requestFactory, exceptionHandler,
+ clientAuthenticator, urisFactory, providerSettingsFactory);
+
+ // setRealmOnRequest() needs a restlet request with attributes and the servlet request behind it.
+ HttpRequest restletRequest = mock(HttpRequest.class);
+ ServletCall servletCall = mock(ServletCall.class);
+ given(restletRequest.getAttributes()).willReturn(new ConcurrentHashMap());
+ given(restletRequest.getHttpCall()).willReturn(servletCall);
+ given(servletCall.getRequest()).willReturn(mock(HttpServletRequest.class));
+ given(request.getRequest()).willReturn(restletRequest);
+
+ given(clientRegistrationStore.get(eq(CLIENT_ID), any(OAuth2Request.class))).willReturn(clientRegistration);
+ given(clientRegistration.getIDTokenSignedResponseAlgorithm()).willReturn("HS256");
+ given(providerSettingsFactory.get(request)).willReturn(providerSettings);
+ given(providerSettings.isIdTokenInfoClientAuthenticationEnabled()).willReturn(false);
+ }
+
+ @AfterMethod
+ public void tearDown() {
+ realmTestHelper.tearDownRealmClass();
+ }
+
+ @Test
+ public void shouldReturnIdTokenIssuedForTheClient() throws Exception {
+ given(request.getParameter(OAuth2Constants.JWTTokenParams.ID_TOKEN)).willReturn(idToken());
+ given(clientRegistration.verifyIdTokenIdentity(any(OAuth2Jwt.class))).willReturn(true);
+
+ OAuth2Jwt result = idTokenInfo.validateIdToken(request);
+
+ assertThat(result.getSignedJwt().getClaimsSet().getAudience()).containsExactly(CLIENT_ID);
+ verify(clientRegistration).verifyIdTokenIdentity(any(OAuth2Jwt.class));
+ }
+
+ /**
+ * The id_token is checked as an ID token this server issued (header alg pinned to the client's
+ * id_token_signed_response_alg), not as a client assertion: a token that only verifies as the
+ * latter is rejected.
+ */
+ @Test(expectedExceptions = BadRequestException.class, expectedExceptionsMessageRegExp = "invalid id_token")
+ public void shouldRejectIdTokenThatVerifiesOnlyAsClientAssertion() throws Exception {
+ given(request.getParameter(OAuth2Constants.JWTTokenParams.ID_TOKEN)).willReturn(idToken());
+ given(clientRegistration.verifyJwtIdentity(any(OAuth2Jwt.class))).willReturn(true);
+ given(clientRegistration.verifyIdTokenIdentity(any(OAuth2Jwt.class))).willReturn(false);
+
+ idTokenInfo.validateIdToken(request);
+ }
+
+ /**
+ * id_token_signed_response_alg is a free-text attribute; the token store upper-cases it when
+ * issuing, so the same spelling must pass the client-authentication gate here.
+ */
+ @Test
+ public void shouldAcceptLowerCaseConfiguredAlgorithm() throws Exception {
+ given(request.getParameter(OAuth2Constants.JWTTokenParams.ID_TOKEN)).willReturn(idToken());
+ given(clientRegistration.getIDTokenSignedResponseAlgorithm()).willReturn("hs256");
+ given(clientRegistration.verifyIdTokenIdentity(any(OAuth2Jwt.class))).willReturn(true);
+
+ OAuth2Jwt result = idTokenInfo.validateIdToken(request);
+
+ assertThat(result.getSignedJwt().getClaimsSet().getAudience()).containsExactly(CLIENT_ID);
+ }
+
+ @Test(expectedExceptions = BadRequestException.class,
+ expectedExceptionsMessageRegExp = "unsupported id_token_signed_response_alg")
+ public void shouldRejectUnknownConfiguredAlgorithmAsBadRequest() throws Exception {
+ given(request.getParameter(OAuth2Constants.JWTTokenParams.ID_TOKEN)).willReturn(idToken());
+ given(clientRegistration.getIDTokenSignedResponseAlgorithm()).willReturn("not-an-alg");
+ given(clientRegistration.verifyIdTokenIdentity(any(OAuth2Jwt.class))).willReturn(true);
+
+ idTokenInfo.validateIdToken(request);
+ }
+
+ private static String idToken() {
+ return new JwtBuilderFactory()
+ .jws(new SigningManager().newHmacSigningHandler("secret".getBytes(StandardCharsets.UTF_8)))
+ .headers().alg(JwsAlgorithm.HS256).done()
+ .claims(new JwtBuilderFactory().claims()
+ .iss("https://openam.example.com/openam/oauth2")
+ .sub("user1")
+ .aud(Collections.singletonList(CLIENT_ID))
+ .exp(new Date(System.currentTimeMillis() + 60_000L))
+ .iat(new Date())
+ .build())
+ .build();
+ }
+}
diff --git a/openam-oauth2/src/test/java/org/forgerock/openidconnect/ssoprovider/OpenIdConnectSSOProviderTest.java b/openam-oauth2/src/test/java/org/forgerock/openidconnect/ssoprovider/OpenIdConnectSSOProviderTest.java
index 47d62cd3a1..7f0c19c63b 100644
--- a/openam-oauth2/src/test/java/org/forgerock/openidconnect/ssoprovider/OpenIdConnectSSOProviderTest.java
+++ b/openam-oauth2/src/test/java/org/forgerock/openidconnect/ssoprovider/OpenIdConnectSSOProviderTest.java
@@ -12,7 +12,7 @@
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2016 ForgeRock AS.
- * Portions copyright 2025 3A Systems LLC.
+ * Portions copyright 2025-2026 3A Systems LLC.
*/
package org.forgerock.openidconnect.ssoprovider;
@@ -202,7 +202,32 @@ public void shouldRejectInvalidSignatures() throws Exception {
given(mockProviderSettings.isOpenIDConnectSSOProviderEnabled()).willReturn(true);
claimsSet.addAudience(clientId);
given(mockClientStore.get(clientId, "/", null)).willReturn(mockClient);
- given(mockClient.verifyJwtIdentity(mockJwt)).willReturn(false);
+ given(mockClient.verifyIdTokenIdentity(mockJwt)).willReturn(false);
+
+ // When
+ ssoProvider.createSSOToken(tokenId);
+
+ // Then - exception
+ }
+
+ /**
+ * The id_token was issued by this server for the client, so it is checked as an ID token
+ * (header alg must be the client's id_token_signed_response_alg), not as a client assertion.
+ */
+ @Test(expectedExceptions = SSOException.class, expectedExceptionsMessageRegExp = "invalid id_token")
+ public void shouldNotAcceptIdTokenVerifiedOnlyAsClientAssertion() throws Exception {
+ // Given
+ String tokenId = "a jwt signed with a key the client may use for assertions";
+ String clientId = "client_id";
+ given(mockTokenParser.parse(tokenId)).willReturn(mockJwt);
+ given(mockJwt.isExpired()).willReturn(false);
+ given(mockProviderSettingsFactory.getRealmProviderSettings("/")).willReturn(mockProviderSettings);
+ given(mockProviderSettings.isOpenIDConnectSSOProviderEnabled()).willReturn(true);
+ claimsSet.addAudience(clientId);
+ claimsSet.setClaim(OPS, "session identifier");
+ given(mockClientStore.get(clientId, "/", null)).willReturn(mockClient);
+ given(mockClient.verifyJwtIdentity(mockJwt)).willReturn(true);
+ given(mockClient.verifyIdTokenIdentity(mockJwt)).willReturn(false);
// When
ssoProvider.createSSOToken(tokenId);
@@ -222,7 +247,7 @@ public void shouldRejectJwtWithNoOpsClaim() throws Exception {
claimsSet.addAudience(clientId);
// no OPS claim
given(mockClientStore.get(clientId, "/", null)).willReturn(mockClient);
- given(mockClient.verifyJwtIdentity(mockJwt)).willReturn(true);
+ given(mockClient.verifyIdTokenIdentity(mockJwt)).willReturn(true);
// When
ssoProvider.createSSOToken(tokenId);
@@ -243,7 +268,7 @@ public void shouldRejectJwtIfSessionNotFound() throws Exception {
claimsSet.addAudience(clientId);
claimsSet.setClaim(OPS, ops);
given(mockClientStore.get(clientId, "/", null)).willReturn(mockClient);
- given(mockClient.verifyJwtIdentity(mockJwt)).willReturn(true);
+ given(mockClient.verifyIdTokenIdentity(mockJwt)).willReturn(true);
given(mockTokenStore.read(ops)).willReturn(null);
// When
@@ -266,7 +291,7 @@ public void shouldRejectJwtIfNoSessionLinked() throws Exception {
claimsSet.addAudience(clientId);
claimsSet.setClaim(OPS, ops);
given(mockClientStore.get(clientId, "/", null)).willReturn(mockClient);
- given(mockClient.verifyJwtIdentity(mockJwt)).willReturn(true);
+ given(mockClient.verifyIdTokenIdentity(mockJwt)).willReturn(true);
given(mockTokenStore.read(ops)).willReturn(token);
// When
@@ -290,7 +315,7 @@ public void shouldUseStoredSessionIdWhenFound() throws Exception {
claimsSet.addAudience(clientId);
claimsSet.setClaim(OPS, ops);
given(mockClientStore.get(clientId, "/", null)).willReturn(mockClient);
- given(mockClient.verifyJwtIdentity(mockJwt)).willReturn(true);
+ given(mockClient.verifyIdTokenIdentity(mockJwt)).willReturn(true);
given(mockTokenStore.read(ops)).willReturn(json(object(field(LEGACY_OPS, asList(sessionId)))));
given(mockTokenManager.createSSOToken(sessionId)).willReturn(mockSsoToken);
@@ -315,7 +340,7 @@ public void shouldUseSSOTokenClaimWhenPresent() throws Exception {
claimsSet.addAudience(clientId);
claimsSet.setClaim(SSOTOKEN, sessionId);
given(mockClientStore.get(clientId, "/", null)).willReturn(mockClient);
- given(mockClient.verifyJwtIdentity(mockJwt)).willReturn(true);
+ given(mockClient.verifyIdTokenIdentity(mockJwt)).willReturn(true);
given(mockTokenManager.createSSOToken(sessionId)).willReturn(mockSsoToken);
// When
diff --git a/openam-uma/src/main/java/org/forgerock/openam/uma/IdTokenClaimGatherer.java b/openam-uma/src/main/java/org/forgerock/openam/uma/IdTokenClaimGatherer.java
index c770569353..f5fa9946d2 100644
--- a/openam-uma/src/main/java/org/forgerock/openam/uma/IdTokenClaimGatherer.java
+++ b/openam-uma/src/main/java/org/forgerock/openam/uma/IdTokenClaimGatherer.java
@@ -12,6 +12,7 @@
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2015-2016 ForgeRock AS.
+ * Portions Copyrighted 2026 3A Systems, LLC.
*/
package org.forgerock.openam.uma;
@@ -83,8 +84,10 @@ public String getRequestingPartyId(OAuth2Request oAuth2Request, AccessToken auth
OAuth2ProviderSettings oAuth2ProviderSettings = oauth2ProviderSettingsFactory.get(oAuth2Request);
OAuth2Uris oAuth2Uris = oAuth2UrisFactory.get(oAuth2Request);
- byte[] clientSecret = clientRegistrationStore.get(authorizationApiToken.getClientId(), oAuth2Request)
- .getClientSecret().getBytes(Utils.CHARSET);
+ // A public client has no secret; verify() only needs it for an HMAC-signed id_token.
+ String secret = clientRegistrationStore.get(authorizationApiToken.getClientId(), oAuth2Request)
+ .getClientSecret();
+ byte[] clientSecret = secret == null ? null : secret.getBytes(Utils.CHARSET);
KeyPair keyPair = oAuth2ProviderSettings.getSigningKeyPair(idToken.getHeader().getAlgorithm());
if (!idToken.getClaimsSet().getIssuer().equals(oAuth2Uris.getIssuer())) {
@@ -122,6 +125,8 @@ private boolean verify(byte[] clientSecret, KeyPair keyPair, SignedJwt signedJwt
SigningHandler signingHandler;
if (JwsAlgorithmType.RSA.equals(jwsAlgorithm.getAlgorithmType())) {
signingHandler = signingManager.newRsaSigningHandler(keyPair.getPublic());
+ } else if (clientSecret == null) {
+ return false;
} else {
signingHandler = signingManager.newHmacSigningHandler(clientSecret);
}
diff --git a/openam-uma/src/test/java/org/forgerock/openam/uma/IdTokenClaimGathererTest.java b/openam-uma/src/test/java/org/forgerock/openam/uma/IdTokenClaimGathererTest.java
index 83d7e9a879..1113b17d21 100644
--- a/openam-uma/src/test/java/org/forgerock/openam/uma/IdTokenClaimGathererTest.java
+++ b/openam-uma/src/test/java/org/forgerock/openam/uma/IdTokenClaimGathererTest.java
@@ -160,6 +160,42 @@ public void shouldNotGatherIdTokenClaimTokenWhichIsIncorrectlySigned() {
assertThat(requestingPartyId).isNull();
}
+ /** A public client has no secret; an RS256 id_token is verified with the provider's key alone. */
+ @Test
+ public void shouldGatherRsaSignedIdTokenClaimTokenForClientWithoutSecret() {
+
+ //Given
+ AccessToken authorizationApiToken = mockAuthorizationApiToken();
+ given(clientRegistration.getClientSecret()).willReturn(null);
+ JsonValue claimToken = mockRsaIdTokenClaimToken("ISSUER");
+
+ setIdTokenAndOAuth2ProviderIssuers("ISSUER");
+
+ //When
+ String requestingPartyId = claimGatherer.getRequestingPartyId(oAuth2Request, authorizationApiToken, claimToken);
+
+ //Then
+ assertThat(requestingPartyId).isEqualTo("REQUESTING_PARTY_ID");
+ }
+
+ /** An HMAC id_token from a client without a secret cannot be verified; that is a rejection, not an NPE. */
+ @Test
+ public void shouldNotGatherHmacSignedIdTokenClaimTokenForClientWithoutSecret() {
+
+ //Given
+ AccessToken authorizationApiToken = mockAuthorizationApiToken();
+ given(clientRegistration.getClientSecret()).willReturn(null);
+ JsonValue claimToken = mockIdTokenClaimToken("ISSUER");
+
+ setIdTokenAndOAuth2ProviderIssuers("ISSUER");
+
+ //When
+ String requestingPartyId = claimGatherer.getRequestingPartyId(oAuth2Request, authorizationApiToken, claimToken);
+
+ //Then
+ assertThat(requestingPartyId).isNull();
+ }
+
private AccessToken mockAuthorizationApiToken() {
AccessToken authorizationApiToken = mock(AccessToken.class);
given(authorizationApiToken.getClientId()).willReturn("CLIENT_ID");
@@ -180,6 +216,17 @@ private JsonValue mockInvalidIdTokenClaimToken(String issuer) {
return json("ID_TOKEN");
}
+ private JsonValue mockRsaIdTokenClaimToken(String issuer) {
+ given(jwsHeader.getAlgorithm()).willReturn(JwsAlgorithm.RS256);
+ given(idToken.getHeader()).willReturn(jwsHeader);
+ SigningHandler signingHandler = mock(SigningHandler.class);
+ given(signingManager.newRsaSigningHandler(any(java.security.Key.class))).willReturn(signingHandler);
+ given(idToken.verify(signingHandler)).willReturn(true);
+ given(claimsSet.getSubject()).willReturn("REQUESTING_PARTY_ID");
+ given(claimsSet.getIssuer()).willReturn(issuer);
+ return json("ID_TOKEN");
+ }
+
private void mockIdToken(boolean isValid) {
given(jwsHeader.getAlgorithm()).willReturn(JwsAlgorithm.HS256);
given(idToken.getHeader()).willReturn(jwsHeader);