From dea15e25daa0a33813e44f990a1dc6f8f8d157dd Mon Sep 17 00:00:00 2001 From: rmartinc Date: Sat, 17 Feb 2024 19:35:39 +0100 Subject: [PATCH] Only add the nonce claim to the ID Token (mapper for backwards compatibility) Closes #26893 Signed-off-by: rmartinc --- .../topics/keycloak/changes-25_0_0.adoc | 5 + .../upgrading/topics/keycloak/changes.adoc | 4 + .../keycloak/protocol/oidc/TokenManager.java | 7 +- .../NonceBackwardsCompatibleMapper.java | 111 +++++++++++ .../org.keycloak.protocol.ProtocolMapper | 3 +- .../updaters/ClientAttributeUpdater.java | 5 + .../concurrency/ConcurrentLoginTest.java | 21 ++- .../testsuite/oauth/RefreshTokenTest.java | 31 ++- .../AuthorizationTokenResponseModeTest.java | 3 +- .../oidc/LightWeightAccessTokenTest.java | 12 +- .../NonceBackwardsCompatibleMapperTest.java | 177 ++++++++++++++++++ 11 files changed, 351 insertions(+), 28 deletions(-) create mode 100644 docs/documentation/upgrading/topics/keycloak/changes-25_0_0.adoc create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/mappers/NonceBackwardsCompatibleMapper.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/NonceBackwardsCompatibleMapperTest.java diff --git a/docs/documentation/upgrading/topics/keycloak/changes-25_0_0.adoc b/docs/documentation/upgrading/topics/keycloak/changes-25_0_0.adoc new file mode 100644 index 0000000000..77679bb59c --- /dev/null +++ b/docs/documentation/upgrading/topics/keycloak/changes-25_0_0.adoc @@ -0,0 +1,5 @@ += Nonce claim is only added to the ID token + +The nonce claim is now only added to the ID token strictly following the OpenID Connect Core 1.0 specification. As indicated in the specification, the claim is compulsory inside the https://openid.net/specs/openid-connect-core-1_0.html#IDToken[ID token] when the same parameter was sent in the authorization request. The specification also recommends to not add the `nonce` after a https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse[refresh request]. Previously, the claim was set to all the tokens (Access, Refresh and ID) in all the responses (refresh included). + +A new `Nonce backwards compatible` mapper is also included in the software that can be assigned to client scopes to revert to the old behavior. For example, the JS adapter checked the returned `nonce` claim in all the tokens before fixing issue https://github.com/keycloak/keycloak/issues/26651[#26651] in version 24.0.0. Therefore, if an old version of the JS adapter is used, the mapper should be added to the required clients by using client scopes. diff --git a/docs/documentation/upgrading/topics/keycloak/changes.adoc b/docs/documentation/upgrading/topics/keycloak/changes.adoc index 1f299f46a6..49eb853d79 100644 --- a/docs/documentation/upgrading/topics/keycloak/changes.adoc +++ b/docs/documentation/upgrading/topics/keycloak/changes.adoc @@ -1,5 +1,9 @@ == Migration Changes +=== Migrating to 25.0.0 + +include::changes-25_0_0.adoc[leveloffset=3] + === Migrating to 24.0.0 include::changes-24_0_0.adoc[leveloffset=3] diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 3c26aa1940..0271481295 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -229,7 +229,9 @@ public class TokenManager { throw new OAuthErrorException(OAuthErrorException.INVALID_SCOPE, "Client no longer has requested consent from user"); } - clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, oldToken.getNonce()); + if (oldToken.getNonce() != null) { + clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, oldToken.getNonce()); + } // recreate token. AccessToken newToken = createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx); @@ -984,7 +986,6 @@ public class TokenManager { AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession(); token.issuer(clientSession.getNote(OIDCLoginProtocol.ISSUER)); - token.setNonce(clientSessionCtx.getAttribute(OIDCLoginProtocol.NONCE_PARAM, String.class)); token.setScope(clientSessionCtx.getScopeString()); // Backwards compatibility behaviour prior step-up authentication was introduced @@ -1192,7 +1193,7 @@ public class TokenManager { idToken.issuedNow(); idToken.issuedFor(accessToken.getIssuedFor()); idToken.issuer(accessToken.getIssuer()); - idToken.setNonce(accessToken.getNonce()); + idToken.setNonce(clientSessionCtx.getAttribute(OIDCLoginProtocol.NONCE_PARAM, String.class)); idToken.setAuthTime(accessToken.getAuthTime()); idToken.setSessionState(accessToken.getSessionState()); idToken.expiration(accessToken.getExpiration()); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/mappers/NonceBackwardsCompatibleMapper.java b/services/src/main/java/org/keycloak/protocol/oidc/mappers/NonceBackwardsCompatibleMapper.java new file mode 100644 index 0000000000..4c4aee27b7 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/mappers/NonceBackwardsCompatibleMapper.java @@ -0,0 +1,111 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.protocol.oidc.mappers; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.keycloak.Config; +import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.ProtocolMapper; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.representations.AccessToken; + +/** + *

Simple mapper that adds the nonce claim into the access token as before. + * Just adding the mapper reverts back to full old behavior in all the + * tokens (access, refresh and ID).

+ * + * @author rmartinc + */ +public class NonceBackwardsCompatibleMapper implements OIDCAccessTokenMapper, ProtocolMapper { + + public static final String PROVIDER_ID = "oidc-nonce-backwards-compatible-mapper"; + + @Override + public String getProtocol() { + return OIDCLoginProtocol.LOGIN_PROTOCOL; + } + + @Override + public void close() { + // no-op + } + + @Override + public final ProtocolMapper create(KeycloakSession session) { + return new NonceBackwardsCompatibleMapper(); + } + + @Override + public void init(Config.Scope config) { + // no-op + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Nonce backwards compatible"; + } + + @Override + public String getDisplayCategory() { + return AbstractOIDCProtocolMapper.TOKEN_MAPPER_CATEGORY; + } + + @Override + public String getHelpText() { + return "Adds the nonce claim to Access, Refresh and ID token"; + } + + @Override + public List getConfigProperties() { + return ProviderConfigurationBuilder.create().build(); + } + + @Override + public AccessToken transformAccessToken(AccessToken token, ProtocolMapperModel mappingModel, KeycloakSession session, UserSessionModel userSession, ClientSessionContext clientSessionCtx) { + token.setNonce(clientSessionCtx.getAttribute(OIDCLoginProtocol.NONCE_PARAM, String.class)); + return token; + } + + public static ProtocolMapperModel create(String name) { + ProtocolMapperModel mapper = new ProtocolMapperModel(); + mapper.setName(name); + mapper.setProtocolMapper(PROVIDER_ID); + mapper.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + Map config = new HashMap<>(); + mapper.setConfig(config); + return mapper; + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper index 544200a95b..1ae40e8b16 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper @@ -45,4 +45,5 @@ org.keycloak.protocol.saml.mappers.SAMLAudienceProtocolMapper org.keycloak.protocol.saml.mappers.SAMLAudienceResolveProtocolMapper org.keycloak.protocol.oidc.mappers.ClaimsParameterTokenMapper org.keycloak.protocol.saml.mappers.UserAttributeNameIdMapper -org.keycloak.protocol.oidc.mappers.ClaimsParameterWithValueIdTokenMapper \ No newline at end of file +org.keycloak.protocol.oidc.mappers.ClaimsParameterWithValueIdTokenMapper +org.keycloak.protocol.oidc.mappers.NonceBackwardsCompatibleMapper diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/ClientAttributeUpdater.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/ClientAttributeUpdater.java index bb5d87e362..7e04bb8cb8 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/ClientAttributeUpdater.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/updaters/ClientAttributeUpdater.java @@ -106,6 +106,11 @@ public class ClientAttributeUpdater extends ServerResourceUpdater defaultClientScopes) { rep.setDefaultClientScopes(defaultClientScopes); return this; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/concurrency/ConcurrentLoginTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/concurrency/ConcurrentLoginTest.java index 27bf39272e..da6dad21cd 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/concurrency/ConcurrentLoginTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/concurrency/ConcurrentLoginTest.java @@ -405,6 +405,16 @@ public class ConcurrentLoginTest extends AbstractConcurrencyTest { OAuthClient.AccessTokenResponse accessRes = oauth1.doAccessTokenRequest(code, "password"); Assert.assertEquals("AccessTokenResponse: client: " + oauth1.getClientId() + ", error: '" + accessRes.getError() + "' desc: '" + accessRes.getErrorDescription() + "'", 200, accessRes.getStatusCode()); + + AccessToken token = JsonSerialization.readValue(new JWSInput(accessRes.getAccessToken()).getContent(), AccessToken.class); + Assert.assertNull(token.getNonce()); + + AccessToken refreshedToken = JsonSerialization.readValue(new JWSInput(accessRes.getRefreshToken()).getContent(), AccessToken.class); + Assert.assertNull(refreshedToken.getNonce()); + + AccessToken idToken = JsonSerialization.readValue(new JWSInput(accessRes.getIdToken()).getContent(), AccessToken.class); + Assert.assertEquals(oauth1.getNonce(), idToken.getNonce()); + accessResRef.set(accessRes); // Refresh access + refresh token using refresh token @@ -420,11 +430,14 @@ public class ConcurrentLoginTest extends AbstractConcurrencyTest { retryHistogram[invocationIndex].incrementAndGet(); - AccessToken token = JsonSerialization.readValue(new JWSInput(accessResRef.get().getAccessToken()).getContent(), AccessToken.class); - Assert.assertEquals("Invalid nonce.", token.getNonce(), oauth1.getNonce()); + token = JsonSerialization.readValue(new JWSInput(accessResRef.get().getAccessToken()).getContent(), AccessToken.class); + Assert.assertNull(token.getNonce()); - AccessToken refreshedToken = JsonSerialization.readValue(new JWSInput(refreshResRef.get().getAccessToken()).getContent(), AccessToken.class); - Assert.assertEquals("Invalid nonce.", refreshedToken.getNonce(), oauth1.getNonce()); + refreshedToken = JsonSerialization.readValue(new JWSInput(refreshResRef.get().getRefreshToken()).getContent(), AccessToken.class); + Assert.assertNull(refreshedToken.getNonce()); + + idToken = JsonSerialization.readValue(new JWSInput(refreshResRef.get().getIdToken()).getContent(), AccessToken.class); + Assert.assertNull(idToken.getNonce()); if (userSessionId.get() == null) { userSessionId.set(token.getSessionState()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java index e9aeecee03..de2e0ecfaa 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java @@ -204,7 +204,10 @@ public class RefreshTokenTest extends AbstractKeycloakTest { OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); - assertEquals("123456", token.getNonce()); + assertNull(token.getNonce()); + + IDToken idToken = oauth.verifyToken(tokenResponse.getIdToken()); + assertEquals("123456", idToken.getNonce()); String refreshTokenString = tokenResponse.getRefreshToken(); RefreshToken refreshToken = oauth.parseRefreshToken(refreshTokenString); @@ -213,7 +216,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest { assertNotNull(refreshTokenString); - assertEquals("123456", refreshToken.getNonce()); + assertNull(refreshToken.getNonce()); assertNull("RealmAccess should be null for RefreshTokens", refreshToken.getRealmAccess()); assertTrue("ResourceAccess should be null for RefreshTokens", refreshToken.getResourceAccess().isEmpty()); } @@ -232,15 +235,16 @@ public class RefreshTokenTest extends AbstractKeycloakTest { OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); - assertEquals("123456", token.getNonce()); + assertNull(token.getNonce()); - String refreshTokenString = tokenResponse.getRefreshToken(); - RefreshToken refreshToken = oauth.parseRefreshToken(refreshTokenString); + IDToken idToken = oauth.verifyToken(tokenResponse.getIdToken(), IDToken.class); + assertEquals("123456", idToken.getNonce()); + + assertNotNull(tokenResponse.getRefreshToken()); + RefreshToken refreshToken = oauth.parseRefreshToken(tokenResponse.getRefreshToken()); EventRepresentation tokenEvent = events.expectCodeToToken(codeId, sessionId).assertEvent(); - assertNotNull(refreshTokenString); - assertEquals("Bearer", tokenResponse.getTokenType()); assertThat(token.getExpiration() - getCurrentTime(), allOf(greaterThanOrEqualTo(200), lessThanOrEqualTo(350))); @@ -248,8 +252,9 @@ public class RefreshTokenTest extends AbstractKeycloakTest { assertThat(actual, allOf(greaterThanOrEqualTo(1799 - ALLOWED_CLOCK_SKEW), lessThanOrEqualTo(1800 + ALLOWED_CLOCK_SKEW))); assertEquals(sessionId, refreshToken.getSessionState()); + assertNull(refreshToken.getNonce()); - OAuthClient.AccessTokenResponse response = oauth.doRefreshTokenRequest(refreshTokenString, "password"); + OAuthClient.AccessTokenResponse response = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken(), "password"); AccessToken refreshedToken = oauth.verifyToken(response.getAccessToken()); RefreshToken refreshedRefreshToken = oauth.parseRefreshToken(response.getRefreshToken()); @@ -286,7 +291,15 @@ public class RefreshTokenTest extends AbstractKeycloakTest { Assert.assertNotEquals(tokenEvent.getDetails().get(Details.TOKEN_ID), refreshEvent.getDetails().get(Details.TOKEN_ID)); Assert.assertNotEquals(tokenEvent.getDetails().get(Details.REFRESH_TOKEN_ID), refreshEvent.getDetails().get(Details.UPDATED_REFRESH_TOKEN_ID)); - assertEquals("123456", refreshedToken.getNonce()); + assertNull(refreshedToken.getNonce()); + + idToken = oauth.verifyToken(response.getIdToken(), IDToken.class); + assertNull(idToken.getNonce()); // null after refresh as recommended by spec + + assertNotNull(response.getRefreshToken()); + refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); + assertEquals(sessionId, refreshToken.getSessionState()); + assertNull(refreshToken.getNonce()); } @Test diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AuthorizationTokenResponseModeTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AuthorizationTokenResponseModeTest.java index dd0833a8da..2f9c242cef 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AuthorizationTokenResponseModeTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/AuthorizationTokenResponseModeTest.java @@ -42,6 +42,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class AuthorizationTokenResponseModeTest extends AbstractTestRealmKeycloakTest { @@ -193,7 +194,7 @@ public class AuthorizationTokenResponseModeTest extends AbstractTestRealmKeycloa Assert.assertNotNull(responseToken.getOtherClaims().get("access_token")); String accessTokenEncoded = (String) responseToken.getOtherClaims().get("access_token"); AccessToken accessToken = oauth.verifyToken(accessTokenEncoded); - assertEquals("123456", accessToken.getNonce()); + assertNull(accessToken.getNonce()); URI currentUri = new URI(driver.getCurrentUrl()); Assert.assertNull(currentUri.getRawQuery()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/LightWeightAccessTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/LightWeightAccessTokenTest.java index 43724d291b..a24158e46c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/LightWeightAccessTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/LightWeightAccessTokenTest.java @@ -458,16 +458,8 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest { Assert.assertNotNull(token.getOtherClaims().get("token_type")); } - private void assertNonce(AccessToken token, boolean isAuthCodeFlow, boolean exchangeToken) { - if (isAuthCodeFlow && !exchangeToken) { - Assert.assertNotNull(token.getNonce()); - } else { - Assert.assertNull(token.getNonce()); - } - } - private void assertAccessToken(AccessToken token, boolean isAuthCodeFlow, boolean isAddToAccessToken) { - assertNonce(token, isAuthCodeFlow, false); + Assert.assertNull(token.getNonce()); assertMapperClaims(token, isAddToAccessToken, isAuthCodeFlow); assertInitClaims(token, isAuthCodeFlow); } @@ -477,7 +469,7 @@ public class LightWeightAccessTokenTest extends AbstractClientPoliciesTest { } private void assertTokenIntrospectionResponse(AccessToken token, boolean isAuthCodeFlow, boolean isAddToIntrospect, boolean exchangeToken) { - assertNonce(token, isAuthCodeFlow, exchangeToken); + Assert.assertNull(token.getNonce()); assertMapperClaims(token, isAddToIntrospect, isAuthCodeFlow); assertInitClaims(token, isAuthCodeFlow); assertIntrospectClaims(token); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/NonceBackwardsCompatibleMapperTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/NonceBackwardsCompatibleMapperTest.java new file mode 100644 index 0000000000..1f68b50e50 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/NonceBackwardsCompatibleMapperTest.java @@ -0,0 +1,177 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.oidc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.core.Response; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.events.Details; +import org.keycloak.models.ProtocolMapperModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.models.utils.ModelToRepresentation; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.mappers.NonceBackwardsCompatibleMapper; +import org.keycloak.protocol.oidc.utils.OIDCResponseMode; +import org.keycloak.protocol.oidc.utils.OIDCResponseType; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.AuthorizationResponseToken; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.RefreshToken; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.representations.idm.ProtocolMapperRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.updaters.ClientAttributeUpdater; +import org.keycloak.testsuite.util.OAuthClient; + +/** + * + * @author rmartinc + */ +public class NonceBackwardsCompatibleMapperTest extends AbstractTestRealmKeycloakTest { + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + } + + @Test + public void testNonceWithoutMapper() throws JsonProcessingException { + testNonce(false); + } + + @Test + public void testNonceWithMapper() throws JsonProcessingException { + ClientResource testApp = ApiUtil.findClientByClientId(testRealm(), "test-app"); + String mapperId = createNonceMapper(testApp); + try { + testNonce(true); + } finally { + testApp.getProtocolMappers().delete(mapperId); + } + } + + @Test + public void testImplicitFlowWithoutMapper() throws Exception { + try (ClientAttributeUpdater client = ClientAttributeUpdater.forClient(adminClient, TEST_REALM_NAME, "test-app") + .setImplicitFlowEnabled(true) + .update()) { + testNonceImplicit(false); + } + } + + @Test + public void testImplicitFlowWithMapper() throws Exception { + ClientResource testApp = ApiUtil.findClientByClientId(testRealm(), "test-app"); + String mapperId = createNonceMapper(testApp); + try (ClientAttributeUpdater client = ClientAttributeUpdater.forClient(adminClient, TEST_REALM_NAME, "test-app") + .setImplicitFlowEnabled(true) + .update()) { + testNonceImplicit(true); + } finally { + testApp.getProtocolMappers().delete(mapperId); + } + } + + private String createNonceMapper(ClientResource testApp) { + ProtocolMapperModel nonceMapper = NonceBackwardsCompatibleMapper.create("nonce"); + ProtocolMapperRepresentation nonceMapperRep = ModelToRepresentation.toRepresentation(nonceMapper); + try (Response res = testApp.getProtocolMappers().createMapper(nonceMapperRep)) { + Assert.assertEquals(Response.Status.CREATED.getStatusCode(), res.getStatus()); + return ApiUtil.getCreatedId(res); + } + } + + private void checkNonce(String expectedNonce, String nonce, boolean expected) { + if (expected) { + Assert.assertEquals(expectedNonce, nonce); + } else { + Assert.assertNull(nonce); + } + } + + private void testIntrospection(String accessToken, String expectedNonce, boolean expected) throws JsonProcessingException { + String tokenResponse = oauth.introspectAccessTokenWithClientCredential("test-app", "password", accessToken); + JsonNode nonce = new ObjectMapper().readTree(tokenResponse).get(OIDCLoginProtocol.NONCE_PARAM); + checkNonce(expectedNonce, nonce != null? nonce.asText() : null, expected); + } + + private void testNonceImplicit(boolean mapper) throws JsonProcessingException { + String nonce = KeycloakModelUtils.generateId(); + oauth.nonce(nonce); + oauth.responseMode(OIDCResponseMode.JWT.value()); + oauth.responseType(OIDCResponseType.TOKEN + " " + OIDCResponseType.ID_TOKEN); + OAuthClient.AuthorizationEndpointResponse response = oauth.doLogin("test-user@localhost", "password"); + + Assert.assertTrue(response.isRedirected()); + AuthorizationResponseToken responseToken = oauth.verifyAuthorizationResponseToken(response.getResponse()); + + String accessTokenString = (String) responseToken.getOtherClaims().get("access_token"); + AccessToken token = oauth.verifyToken(accessTokenString); + checkNonce(nonce, token.getNonce(), mapper); + String idTokenString = (String) responseToken.getOtherClaims().get("id_token"); + IDToken idToken = oauth.verifyToken(idTokenString, IDToken.class); + checkNonce(nonce, idToken.getNonce(), true); + + testIntrospection(accessTokenString, nonce, mapper); + testIntrospection(idTokenString, nonce, true); + } + + private void testNonce(boolean mapper) throws JsonProcessingException { + String nonce = KeycloakModelUtils.generateId(); + oauth.nonce(nonce); + oauth.doLogin("test-user@localhost", "password"); + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password"); + + AccessToken token = oauth.verifyToken(response.getAccessToken()); + checkNonce(nonce, token.getNonce(), mapper); + IDToken idToken = oauth.verifyToken(response.getIdToken(), IDToken.class); + checkNonce(nonce, idToken.getNonce(), true); + RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); + checkNonce(nonce, refreshToken.getNonce(), mapper); + + EventRepresentation tokenEvent = events.expectCodeToToken(loginEvent.getDetails().get(Details.CODE_ID), + loginEvent.getSessionId()).assertEvent(); + + response = oauth.doRefreshTokenRequest(response.getRefreshToken(), "password"); + events.expectRefresh(tokenEvent.getDetails().get(Details.REFRESH_TOKEN_ID), + loginEvent.getSessionId()).assertEvent(); + + token = oauth.verifyToken(response.getAccessToken()); + checkNonce(nonce, token.getNonce(), mapper); + idToken = oauth.verifyToken(response.getIdToken(), IDToken.class); + checkNonce(nonce, idToken.getNonce(), mapper); + refreshToken = oauth.parseRefreshToken(response.getRefreshToken()); + checkNonce(nonce, refreshToken.getNonce(), mapper); + + testIntrospection(response.getAccessToken(), nonce, mapper); + } +}