Fix expires_in in internal to external token exchange

Closes #35704

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2024-12-12 10:34:34 +01:00 committed by Marek Posolda
parent b633d352db
commit e7e6185175
4 changed files with 118 additions and 17 deletions

View File

@ -55,6 +55,8 @@ public class IdentityProviderModel implements Serializable {
public static final String POST_BROKER_LOGIN_FLOW_ID = "postBrokerLoginFlowId";
public static final String SEARCH = "search";
public static final String SYNC_MODE = "syncMode";
public static final String MIN_VALIDITY_TOKEN = "minValidityToken";
public static final int DEFAULT_MIN_VALIDITY_TOKEN = 5;
private String internalId;
@ -344,6 +346,25 @@ public class IdentityProviderModel implements Serializable {
getConfig().put(CASE_SENSITIVE_ORIGINAL_USERNAME, Boolean.valueOf(caseSensitive).toString());
}
public void setMinValidityToken(int minValidityToken) {
getConfig().put(MIN_VALIDITY_TOKEN, Integer.toString(minValidityToken));
}
public int getMinValidityToken() {
String minValidityTokenString = getConfig().get(MIN_VALIDITY_TOKEN);
if (minValidityTokenString != null) {
try {
int minValidityToken = Integer.parseInt(minValidityTokenString);
if (minValidityToken > 0) {
return minValidityToken;
}
} catch (NumberFormatException e) {
// no-op return default
}
}
return DEFAULT_MIN_VALIDITY_TOKEN;
}
@Override
public int hashCode() {
int hash = 5;

View File

@ -203,7 +203,8 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
String modelTokenString = model.getToken();
AccessTokenResponse tokenResponse = JsonSerialization.readValue(modelTokenString, AccessTokenResponse.class);
Integer exp = (Integer) tokenResponse.getOtherClaims().get(ACCESS_TOKEN_EXPIRATION);
if (exp != null && exp < Time.currentTime()) {
final int currentTime = Time.currentTime();
if (exp != null && exp < currentTime + getConfig().getMinValidityToken()) {
if (tokenResponse.getRefreshToken() == null) {
return exchangeTokenExpired(uriInfo, authorizedClient, tokenUserSession, tokenSubject);
}
@ -219,7 +220,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
AccessTokenResponse newResponse = JsonSerialization.readValue(response, AccessTokenResponse.class);
if (newResponse.getExpiresIn() > 0) {
int accessTokenExpiration = Time.currentTime() + (int) newResponse.getExpiresIn();
int accessTokenExpiration = currentTime + (int) newResponse.getExpiresIn();
newResponse.getOtherClaims().put(ACCESS_TOKEN_EXPIRATION, accessTokenExpiration);
}
@ -231,7 +232,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
String oldToken = tokenUserSession.getNote(FEDERATED_ACCESS_TOKEN);
if (oldToken != null && oldToken.equals(tokenResponse.getToken())) {
int accessTokenExpiration = newResponse.getExpiresIn() > 0 ? Time.currentTime() + (int) newResponse.getExpiresIn() : 0;
int accessTokenExpiration = newResponse.getExpiresIn() > 0 ? currentTime + (int) newResponse.getExpiresIn() : 0;
tokenUserSession.setNote(FEDERATED_TOKEN_EXPIRATION, Long.toString(accessTokenExpiration));
tokenUserSession.setNote(FEDERATED_REFRESH_TOKEN, newResponse.getRefreshToken());
tokenUserSession.setNote(FEDERATED_ACCESS_TOKEN, newResponse.getToken());
@ -241,7 +242,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
model.setToken(response);
tokenResponse = newResponse;
} else if (exp != null) {
tokenResponse.setExpiresIn(exp - Time.currentTime());
tokenResponse.setExpiresIn(exp - currentTime);
}
tokenResponse.setIdToken(null);
tokenResponse.setRefreshToken(null);
@ -275,7 +276,6 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
protected Response exchangeSessionToken(UriInfo uriInfo, EventBuilder event, ClientModel authorizedClient, UserSessionModel tokenUserSession, UserModel tokenSubject) {
String refreshToken = tokenUserSession.getNote(FEDERATED_REFRESH_TOKEN);
String accessToken = tokenUserSession.getNote(FEDERATED_ACCESS_TOKEN);
String idToken = tokenUserSession.getNote(FEDERATED_ID_TOKEN);
if (accessToken == null) {
event.detail(Details.REASON, "requested_issuer is not linked");
@ -284,9 +284,10 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
try (VaultStringSecret vaultStringSecret = session.vault().getStringSecret(getConfig().getClientSecret())) {
long expiration = Long.parseLong(tokenUserSession.getNote(FEDERATED_TOKEN_EXPIRATION));
if (expiration == 0 || expiration > Time.currentTime()) {
final int currentTime = Time.currentTime();
if (expiration == 0 || expiration > currentTime + getConfig().getMinValidityToken()) {
AccessTokenResponse tokenResponse = new AccessTokenResponse();
tokenResponse.setExpiresIn(expiration);
tokenResponse.setExpiresIn(expiration > 0? expiration - currentTime : 0);
tokenResponse.setToken(accessToken);
tokenResponse.setIdToken(null);
tokenResponse.setRefreshToken(null);
@ -304,7 +305,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
return exchangeTokenExpired(uriInfo, authorizedClient, tokenUserSession, tokenSubject);
}
AccessTokenResponse newResponse = JsonSerialization.readValue(response, AccessTokenResponse.class);
long accessTokenExpiration = newResponse.getExpiresIn() > 0 ? Time.currentTime() + newResponse.getExpiresIn() : 0;
long accessTokenExpiration = newResponse.getExpiresIn() > 0 ? currentTime + newResponse.getExpiresIn() : 0;
tokenUserSession.setNote(FEDERATED_TOKEN_EXPIRATION, Long.toString(accessTokenExpiration));
tokenUserSession.setNote(FEDERATED_REFRESH_TOKEN, newResponse.getRefreshToken());
tokenUserSession.setNote(FEDERATED_ACCESS_TOKEN, newResponse.getToken());

View File

@ -44,6 +44,11 @@ public class IdentityProviderAttributeUpdater {
return this;
}
public IdentityProviderAttributeUpdater setStoreToken(boolean storeToken) {
rep.setStoreToken(storeToken);
return this;
}
public Closeable update() {
identityProviderResource.update(rep);

View File

@ -19,12 +19,14 @@ package org.keycloak.testsuite.broker;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.lessThanOrEqualTo;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS;
import static org.keycloak.testsuite.util.ProtocolMapperUtil.createHardcodedClaim;
import java.util.concurrent.TimeUnit;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.client.WebTarget;
@ -32,6 +34,8 @@ import jakarta.ws.rs.core.Form;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import java.io.Closeable;
import java.util.Map;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.ClientResource;
@ -50,6 +54,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
@ -58,12 +63,13 @@ import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation
import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement;
import org.keycloak.services.resources.admin.permissions.AdminPermissions;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeatures;
import org.keycloak.testsuite.updaters.IdentityProviderAttributeUpdater;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.util.BasicAuthHelper;
import com.google.common.collect.ImmutableMap;
@EnableFeatures({@EnableFeature(Profile.Feature.TOKEN_EXCHANGE), @EnableFeature(Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ)})
public final class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBaseBrokerTest {
@ -87,11 +93,10 @@ public final class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBase
hardCodedSessionNoteMapper.setName("hard-coded");
hardCodedSessionNoteMapper.setIdentityProviderAlias(bc.getIDPAlias());
hardCodedSessionNoteMapper.setIdentityProviderMapper(UserAttributeMapper.PROVIDER_ID);
hardCodedSessionNoteMapper.setConfig(ImmutableMap.<String, String>builder()
.put(IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.INHERIT.toString())
.put(UserAttributeMapper.USER_ATTRIBUTE, "mapped-from-claim")
.put(UserAttributeMapper.CLAIM, "hard-coded")
.build());
hardCodedSessionNoteMapper.setConfig(Map.of(
IdentityProviderMapperModel.SYNC_MODE, IdentityProviderMapperSyncMode.INHERIT.toString(),
UserAttributeMapper.USER_ATTRIBUTE, "mapped-from-claim",
UserAttributeMapper.CLAIM, "hard-coded"));
RealmResource consumerRealm = realmsResouce().realm(bc.consumerRealmName());
IdentityProviderResource identityProviderResource = consumerRealm.identityProviders().get(bc.getIDPAlias());
@ -188,6 +193,30 @@ public final class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBase
}
}
@Test
public void testInternalExternalTokenExchangeSessionToken() throws Exception {
testingClient.server(bc.consumerRealmName()).run(KcOidcBrokerTokenExchangeTest::setupRealm);
try (Closeable idpUpdater = new IdentityProviderAttributeUpdater(
realmsResouce().realm(bc.consumerRealmName()).identityProviders().get(bc.getIDPAlias()))
.setStoreToken(false)
.update()) {
testInternalExternalTokenExchange();
}
}
@Test
public void testInternalExternalTokenExchangeStoredToken() throws Exception {
testingClient.server(bc.consumerRealmName()).run(KcOidcBrokerTokenExchangeTest::setupRealm);
try (Closeable idpUpdater = new IdentityProviderAttributeUpdater(
realmsResouce().realm(bc.consumerRealmName()).identityProviders().get(bc.getIDPAlias()))
.setStoreToken(true)
.update()) {
testInternalExternalTokenExchange();
}
}
private static void setupRealm(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
IdentityProviderModel idp = session.identityProviders().getByAlias(IDP_OIDC_ALIAS);
@ -201,12 +230,13 @@ public final class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBase
client.setSecret("secret");
client.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
client.setFullScopeAllowed(false);
ClientModel brokerApp = realm.getClientByClientId("broker-app");
AdminPermissionManagement management = AdminPermissions.management(session, realm);
management.idps().setPermissionsEnabled(idp, true);
ClientPolicyRepresentation clientRep = new ClientPolicyRepresentation();
clientRep.setName("toIdp");
clientRep.addClient(client.getId());
clientRep.addClient(client.getId(), brokerApp.getId());
ResourceServer server = management.realmResourceServer();
Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep);
management.idps().exchangeToPermission(idp).addAssociatedPolicy(clientPolicy);
@ -216,4 +246,48 @@ public final class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBase
client.addRedirectUri(OAuthClient.APP_ROOT + "/auth");
client.setAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, OAuthClient.APP_ROOT + "/admin/backchannelLogout");
}
private void testInternalExternalTokenExchange() throws Exception {
final RealmResource consumerRealm = realmsResouce().realm(bc.consumerRealmName());
final int expires = realmsResouce().realm(bc.providerRealmName()).toRepresentation().getAccessTokenLifespan();
final ClientRepresentation brokerApp = ApiUtil.findClientByClientId(consumerRealm, "broker-app").toRepresentation();
logInAsUserInIDPForFirstTimeAndAssertSuccess();
final String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, brokerApp.getSecret());
assertThat(tokenResponse.getError(), nullValue());
assertThat(tokenResponse.getAccessToken(), notNullValue());
exchangeToIdP(brokerApp, tokenResponse.getAccessToken(), expires);
setTimeOffset(expires - IdentityProviderModel.DEFAULT_MIN_VALIDITY_TOKEN);
tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken(), brokerApp.getSecret());
assertThat(tokenResponse.getError(), nullValue());
assertThat(tokenResponse.getAccessToken(), notNullValue());
exchangeToIdP(brokerApp, tokenResponse.getAccessToken(), expires);
}
private void exchangeToIdP(ClientRepresentation brokerApp, String subjectToken, long expires) {
try (Client httpClient = AdminClientUtil.createResteasyClient();
Response response = httpClient.target(OAuthClient.AUTH_SERVER_ROOT)
.path("/realms").path(bc.consumerRealmName()).path("protocol/openid-connect/token")
.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader(brokerApp.getClientId(), brokerApp.getSecret()))
.post(Entity.form(new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.SUBJECT_TOKEN, subjectToken)
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE)
.param(OAuth2Constants.REQUESTED_ISSUER, bc.getIDPAlias()))
)) {
assertThat(response.getStatus(), is(Response.Status.OK.getStatusCode()));
AccessTokenResponse exchangeResponse = response.readEntity(AccessTokenResponse.class);
assertThat(exchangeResponse.getError(), nullValue());
assertThat(exchangeResponse.getToken(), notNullValue());
assertThat(exchangeResponse.getExpiresIn(), greaterThan((long) IdentityProviderModel.DEFAULT_MIN_VALIDITY_TOKEN));
assertThat(exchangeResponse.getExpiresIn(), lessThanOrEqualTo(expires));
}
}
}