Only add the nonce claim to the ID Token (mapper for backwards compatibility)

Closes #26893

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2024-02-17 19:35:39 +01:00 committed by Marek Posolda
parent e7ff12e010
commit dea15e25da
11 changed files with 351 additions and 28 deletions

View file

@ -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.

View file

@ -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]

View file

@ -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());

View file

@ -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;
/**
* <p>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).</p>
*
* @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<ProviderConfigProperty> 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<String, String> config = new HashMap<>();
mapper.setConfig(config);
return mapper;
}
}

View file

@ -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
org.keycloak.protocol.oidc.mappers.ClaimsParameterWithValueIdTokenMapper
org.keycloak.protocol.oidc.mappers.NonceBackwardsCompatibleMapper

View file

@ -106,6 +106,11 @@ public class ClientAttributeUpdater extends ServerResourceUpdater<ClientAttribut
return this;
}
public ClientAttributeUpdater setImplicitFlowEnabled(Boolean implicitFlowEnabled) {
rep.setImplicitFlowEnabled(implicitFlowEnabled);
return this;
}
public ClientAttributeUpdater setDefaultClientScopes(List<String> defaultClientScopes) {
rep.setDefaultClientScopes(defaultClientScopes);
return this;

View file

@ -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());

View file

@ -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

View file

@ -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());

View file

@ -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);

View file

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