Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions openam-oauth2/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openidentityplatform.openam</groupId>
<artifactId>openam-core</artifactId>
<type>test-jar</type>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openidentityplatform.openam</groupId>
<artifactId>openam-notifications-integration</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -652,33 +657,80 @@ 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:
return byJWKsURI(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());
Expand All @@ -692,9 +744,8 @@ private boolean byJWKs(OAuth2Jwt jwt) throws IdRepoException, SSOException,
Set<String> 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)
Expand All @@ -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));
}

/**
Expand All @@ -731,13 +784,13 @@ private SigningHandler getSigningHandlerForKey(final Key key) {
private boolean byJWKsURI(OAuth2Jwt jwt) throws IdRepoException, SSOException, MalformedURLException {
final Set<String> 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
Expand Down Expand Up @@ -790,12 +843,10 @@ private boolean byX509Key(OAuth2Jwt jwt) throws IdRepoException, SSOException, C

Set<String> 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()));
Expand All @@ -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));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2016 ForgeRock AS.
* Portions Copyrighted 2026 3A Systems, LLC.
*/
package org.forgerock.openam.oauth2;

import java.net.URI;
import java.util.HashSet;
import java.util.Set;

import org.forgerock.openam.utils.CollectionUtils;
import org.restlet.Request;

import com.sun.identity.idm.AMIdentity;
Expand Down Expand Up @@ -103,7 +105,7 @@ static Set<String> stripAttributeNameFromValue(Set<String> 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<String> values;
Expand All @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand All @@ -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");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}

Expand Down
Loading
Loading