From d2684f5996cd5d7c4217af6535f38c470d49d3d8 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sun, 4 Oct 2026 13:01:12 +0300 Subject: [PATCH 1/2] [#243] Check the id_token nonce in getProfile only for OpenID Connect providers getProfile, used by social registration and account linking, checked the nonce of any id_token the token endpoint returned, while getAuthToken, used by login, checks it only for OPENID_CONNECT. An OAUTH provider that returns an id_token without the request nonce (LinkedIn with the openid scope) passed login but failed registration with 400 "Nonce provided does not match claim". Check the nonce in getProfile only for OPENID_CONNECT, and test both methods for both provider types. Fixes #243 --- .../openidm/idp/client/OAuthHttpClient.java | 6 +- .../idp/client/OAuthHttpClientTest.java | 150 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 openidm-identity-provider/src/test/java/org/forgerock/openidm/idp/client/OAuthHttpClientTest.java diff --git a/openidm-identity-provider/src/main/java/org/forgerock/openidm/idp/client/OAuthHttpClient.java b/openidm-identity-provider/src/main/java/org/forgerock/openidm/idp/client/OAuthHttpClient.java index f5a8b9cbde..3c38221650 100644 --- a/openidm-identity-provider/src/main/java/org/forgerock/openidm/idp/client/OAuthHttpClient.java +++ b/openidm-identity-provider/src/main/java/org/forgerock/openidm/idp/client/OAuthHttpClient.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.openidm.idp.client; @@ -159,7 +160,10 @@ public JsonValue getProfile( JwtClaimsSet jwtClaimSet = null; try { jwtClaimSet = getClaims(jwtReconstruction, getJwtToken(tokenEndpointResponse)); - checkNonce(jwtClaimSet, nonce); + // as in getAuthToken, only an OpenID Connect provider must echo the request nonce + if (OPENID_CONNECT.equals(config.getType())) { + checkNonce(jwtClaimSet, nonce); + } } catch (NotFoundException nfe) { // unable to get id_token; likely non-OIDC provider } diff --git a/openidm-identity-provider/src/test/java/org/forgerock/openidm/idp/client/OAuthHttpClientTest.java b/openidm-identity-provider/src/test/java/org/forgerock/openidm/idp/client/OAuthHttpClientTest.java new file mode 100644 index 0000000000..ea39ddf825 --- /dev/null +++ b/openidm-identity-provider/src/test/java/org/forgerock/openidm/idp/client/OAuthHttpClientTest.java @@ -0,0 +1,150 @@ +/* + * 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.openidm.idp.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.forgerock.json.JsonValue.field; +import static org.forgerock.json.JsonValue.object; +import static org.forgerock.util.promise.Promises.newResultPromise; + +import java.util.Arrays; + +import org.forgerock.http.Client; +import org.forgerock.http.Handler; +import org.forgerock.http.protocol.Request; +import org.forgerock.http.protocol.Response; +import org.forgerock.http.protocol.Status; +import org.forgerock.json.JsonValue; +import org.forgerock.json.jose.builders.JwtBuilderFactory; +import org.forgerock.json.jose.common.JwtReconstruction; +import org.forgerock.json.jose.jws.JwsAlgorithm; +import org.forgerock.json.jose.jws.SigningManager; +import org.forgerock.json.jose.jwt.JwtClaimsSet; +import org.forgerock.json.resource.BadRequestException; +import org.forgerock.openidm.idp.config.ProviderConfig; +import org.forgerock.services.context.Context; +import org.forgerock.util.promise.NeverThrowsException; +import org.forgerock.util.promise.Promise; +import org.testng.annotations.Test; + +/** + * Tests the nonce check of {@link OAuthHttpClient}. + */ +public class OAuthHttpClientTest { + + private static final String TOKEN_ENDPOINT = "https://idp.example.com/token"; + private static final String USERINFO_ENDPOINT = "https://idp.example.com/userinfo"; + private static final String NONCE = "request-nonce"; + private static final String REDIRECT_URI = "https://openidm.example.com/"; + + @Test + public void testGetProfileIgnoresNonceForOAuth() throws Exception { + final OAuthHttpClient client = newClient("OAUTH", USERINFO_ENDPOINT, idToken(null)); + + final JsonValue profile = client.getProfile(new JwtReconstruction(), "code", NONCE, REDIRECT_URI); + + assertThat(profile.get("sub").asString()).isEqualTo("userinfo-subject"); + } + + @Test(expectedExceptions = BadRequestException.class) + public void testGetProfileRejectsMissingNonceForOpenIdConnect() throws Exception { + final OAuthHttpClient client = newClient("OPENID_CONNECT", USERINFO_ENDPOINT, idToken(null)); + + client.getProfile(new JwtReconstruction(), "code", NONCE, REDIRECT_URI); + } + + @Test(expectedExceptions = BadRequestException.class) + public void testGetProfileRejectsWrongNonceForOpenIdConnect() throws Exception { + final OAuthHttpClient client = newClient("OPENID_CONNECT", null, idToken("other-nonce")); + + client.getProfile(new JwtReconstruction(), "code", NONCE, REDIRECT_URI); + } + + @Test + public void testGetProfileAcceptsMatchingNonceForOpenIdConnect() throws Exception { + final OAuthHttpClient client = newClient("OPENID_CONNECT", null, idToken(NONCE)); + + final JsonValue profile = client.getProfile(new JwtReconstruction(), "code", NONCE, REDIRECT_URI); + + assertThat(profile.get("sub").asString()).isEqualTo("id-token-subject"); + } + + @Test + public void testGetAuthTokenIgnoresIdTokenForOAuth() throws Exception { + final OAuthHttpClient client = newClient("OAUTH", USERINFO_ENDPOINT, idToken(null)); + + assertThat(client.getAuthToken(new JwtReconstruction(), "code", NONCE, REDIRECT_URI).getOrThrow()) + .isEqualTo("access-token"); + } + + @Test(expectedExceptions = BadRequestException.class) + public void testGetAuthTokenRejectsMissingNonceForOpenIdConnect() throws Exception { + final OAuthHttpClient client = newClient("OPENID_CONNECT", USERINFO_ENDPOINT, idToken(null)); + + client.getAuthToken(new JwtReconstruction(), "code", NONCE, REDIRECT_URI).getOrThrow(); + } + + /** + * Builds a client whose provider answers the token endpoint with an access token and the given + * id_token, and the userinfo endpoint with a profile of its own. + */ + private static OAuthHttpClient newClient(final String type, final String userInfoEndpoint, + final String idToken) { + final ProviderConfig config = new ProviderConfig(); + config.setName("test"); + config.setType(type); + config.setTokenEndpoint(TOKEN_ENDPOINT); + config.setUserInfoEndpoint(userInfoEndpoint); + config.setClientId("client-id"); + config.setClientSecret("client-secret"); + config.setScope(Arrays.asList("openid", "profile", "email")); + + final Handler provider = new Handler() { + @Override + public Promise handle(final Context context, final Request request) { + final Response response = new Response(Status.OK); + if (TOKEN_ENDPOINT.equals(request.getUri().toString())) { + response.setEntity(object( + field("access_token", "access-token"), + field("token_type", "Bearer"), + field("id_token", idToken))); + } else if (USERINFO_ENDPOINT.equals(request.getUri().toString())) { + response.setEntity(object(field("sub", "userinfo-subject"))); + } else { + response.setStatus(Status.NOT_FOUND); + } + return newResultPromise(response); + } + }; + return new OAuthHttpClient(config, new Client(provider)); + } + + /** Returns a signed id_token, with the given nonce claim unless it is {@code null}. */ + private static String idToken(final String nonce) { + final JwtClaimsSet claims = new JwtBuilderFactory().claims() + .iss("https://idp.example.com") + .sub("id-token-subject") + .build(); + if (nonce != null) { + claims.setClaim(OAuthHttpClient.NONCE, nonce); + } + return new JwtBuilderFactory() + .jws(new SigningManager().newHmacSigningHandler("0123456789abcdef0123456789abcdef".getBytes())) + .headers().alg(JwsAlgorithm.HS256).done() + .claims(claims) + .build(); + } +} From 6d779c8649979fc9dab9a97e250bf062a1b16655 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Tue, 6 Oct 2026 10:31:13 +0300 Subject: [PATCH 2/2] [#243] Exempt only OAUTH from the getProfile nonce check Review of #244: requiring OPENID_CONNECT skipped the nonce check for every other type string, including a null or hand-written type, which getAuthToken rejects at login. Skip the check for OAUTH only, so that registration and linking keep failing closed on an unknown type as before. Pin the behaviour the description promised but no test covered: an OAUTH provider without a userinfo endpoint gets its profile from the id_token claims even when the nonce differs, and getAuthToken returns the id_token of an OpenID Connect provider whose nonce matches. --- .../openidm/idp/client/OAuthHttpClient.java | 5 ++-- .../idp/client/OAuthHttpClientTest.java | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/openidm-identity-provider/src/main/java/org/forgerock/openidm/idp/client/OAuthHttpClient.java b/openidm-identity-provider/src/main/java/org/forgerock/openidm/idp/client/OAuthHttpClient.java index 3c38221650..749b9e679e 100644 --- a/openidm-identity-provider/src/main/java/org/forgerock/openidm/idp/client/OAuthHttpClient.java +++ b/openidm-identity-provider/src/main/java/org/forgerock/openidm/idp/client/OAuthHttpClient.java @@ -160,8 +160,9 @@ public JsonValue getProfile( JwtClaimsSet jwtClaimSet = null; try { jwtClaimSet = getClaims(jwtReconstruction, getJwtToken(tokenEndpointResponse)); - // as in getAuthToken, only an OpenID Connect provider must echo the request nonce - if (OPENID_CONNECT.equals(config.getType())) { + // every provider type but OAUTH must echo the request nonce; getAuthToken does not + // read the id_token of an OAUTH provider either + if (!OAUTH.equals(config.getType())) { checkNonce(jwtClaimSet, nonce); } } catch (NotFoundException nfe) { diff --git a/openidm-identity-provider/src/test/java/org/forgerock/openidm/idp/client/OAuthHttpClientTest.java b/openidm-identity-provider/src/test/java/org/forgerock/openidm/idp/client/OAuthHttpClientTest.java index ea39ddf825..0d097ac93a 100644 --- a/openidm-identity-provider/src/test/java/org/forgerock/openidm/idp/client/OAuthHttpClientTest.java +++ b/openidm-identity-provider/src/test/java/org/forgerock/openidm/idp/client/OAuthHttpClientTest.java @@ -59,6 +59,22 @@ public void testGetProfileIgnoresNonceForOAuth() throws Exception { assertThat(profile.get("sub").asString()).isEqualTo("userinfo-subject"); } + @Test + public void testGetProfileReadsIdTokenClaimsForOAuthWithoutUserInfo() throws Exception { + final OAuthHttpClient client = newClient("OAUTH", null, idToken("other-nonce")); + + final JsonValue profile = client.getProfile(new JwtReconstruction(), "code", NONCE, REDIRECT_URI); + + assertThat(profile.get("sub").asString()).isEqualTo("id-token-subject"); + } + + @Test(expectedExceptions = BadRequestException.class) + public void testGetProfileRejectsWrongNonceForUnknownType() throws Exception { + final OAuthHttpClient client = newClient("openid_connect", null, idToken("other-nonce")); + + client.getProfile(new JwtReconstruction(), "code", NONCE, REDIRECT_URI); + } + @Test(expectedExceptions = BadRequestException.class) public void testGetProfileRejectsMissingNonceForOpenIdConnect() throws Exception { final OAuthHttpClient client = newClient("OPENID_CONNECT", USERINFO_ENDPOINT, idToken(null)); @@ -90,6 +106,15 @@ public void testGetAuthTokenIgnoresIdTokenForOAuth() throws Exception { .isEqualTo("access-token"); } + @Test + public void testGetAuthTokenAcceptsMatchingNonceForOpenIdConnect() throws Exception { + final String idToken = idToken(NONCE); + final OAuthHttpClient client = newClient("OPENID_CONNECT", USERINFO_ENDPOINT, idToken); + + assertThat(client.getAuthToken(new JwtReconstruction(), "code", NONCE, REDIRECT_URI).getOrThrow()) + .isEqualTo(idToken); + } + @Test(expectedExceptions = BadRequestException.class) public void testGetAuthTokenRejectsMissingNonceForOpenIdConnect() throws Exception { final OAuthHttpClient client = newClient("OPENID_CONNECT", USERINFO_ENDPOINT, idToken(null));