Restrict the token types that can be verified when not using the user info endpoint (#146) (#28866)

Closes #47

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>

Conflicts:
	core/src/main/java/org/keycloak/util/TokenUtil.java
	testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientTokenExchangeTest.java

Co-authored-by: Pedro Igor <pigor.craveiro@gmail.com>
This commit is contained in:
Stian Thorgersen 2024-04-18 14:11:05 +02:00 committed by GitHub
parent cbc4a8c305
commit 0d60e58029
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 133 additions and 8 deletions

View file

@ -42,6 +42,10 @@ public class TokenUtil {
public static final String TOKEN_TYPE_DPOP = "DPoP"; public static final String TOKEN_TYPE_DPOP = "DPoP";
// JWT Access Token types from https://datatracker.ietf.org/doc/html/rfc9068#section-2.1
public static final String TOKEN_TYPE_JWT_ACCESS_TOKEN = "at+jwt";
public static final String TOKEN_TYPE_JWT_ACCESS_TOKEN_PREFIXED = "application/" + TOKEN_TYPE_JWT_ACCESS_TOKEN;
public static final String TOKEN_TYPE_KEYCLOAK_ID = "Serialized-ID"; public static final String TOKEN_TYPE_KEYCLOAK_ID = "Serialized-ID";
public static final String TOKEN_TYPE_ID = "ID"; public static final String TOKEN_TYPE_ID = "ID";

View file

@ -64,6 +64,7 @@ import org.keycloak.services.resources.IdentityBrokerService;
import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.resources.RealmsResource;
import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
import org.keycloak.vault.VaultStringSecret; import org.keycloak.vault.VaultStringSecret;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
@ -79,6 +80,7 @@ import jakarta.ws.rs.core.UriInfo;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -97,6 +99,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
public static final String EXCHANGE_PROVIDER = "EXCHANGE_PROVIDER"; public static final String EXCHANGE_PROVIDER = "EXCHANGE_PROVIDER";
public static final String VALIDATED_ACCESS_TOKEN = "VALIDATED_ACCESS_TOKEN"; public static final String VALIDATED_ACCESS_TOKEN = "VALIDATED_ACCESS_TOKEN";
private static final String BROKER_NONCE_PARAM = "BROKER_NONCE"; private static final String BROKER_NONCE_PARAM = "BROKER_NONCE";
private static final List<String> SUPPORTED_TOKEN_TYPES = Arrays.asList(TokenUtil.TOKEN_TYPE_ID, TokenUtil.TOKEN_TYPE_BEARER, TokenUtil.TOKEN_TYPE_JWT_ACCESS_TOKEN, TokenUtil.TOKEN_TYPE_JWT_ACCESS_TOKEN_PREFIXED);
public OIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) { public OIDCIdentityProvider(KeycloakSession session, OIDCIdentityProviderConfig config) {
super(session, config); super(session, config);
@ -849,7 +852,7 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
throw new ErrorResponseException(Errors.INVALID_CONFIG, "Invalid server config", Response.Status.BAD_REQUEST); throw new ErrorResponseException(Errors.INVALID_CONFIG, "Invalid server config", Response.Status.BAD_REQUEST);
} }
JsonWebToken parsedToken = null; JsonWebToken parsedToken;
try { try {
parsedToken = validateToken(subjectToken, true); parsedToken = validateToken(subjectToken, true);
} catch (IdentityBrokerException e) { } catch (IdentityBrokerException e) {
@ -860,7 +863,9 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
} }
try { try {
if (!isTokenTypeSupported(parsedToken)) {
throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "token type not supported", Response.Status.BAD_REQUEST);
}
boolean idTokenType = OAuth2Constants.ID_TOKEN_TYPE.equals(subjectTokenType); boolean idTokenType = OAuth2Constants.ID_TOKEN_TYPE.equals(subjectTokenType);
BrokeredIdentityContext context = extractIdentity(null, idTokenType ? null : subjectToken, parsedToken); BrokeredIdentityContext context = extractIdentity(null, idTokenType ? null : subjectToken, parsedToken);
if (context == null) { if (context == null) {
@ -888,6 +893,10 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
} }
protected static boolean isTokenTypeSupported(JsonWebToken parsedToken) {
return SUPPORTED_TOKEN_TYPES.contains(parsedToken.getType());
}
@Override @Override
protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap<String, String> params) { protected BrokeredIdentityContext exchangeExternalImpl(EventBuilder event, MultivaluedMap<String, String> params) {
if (!supportsExternalExchange()) return null; if (!supportsExternalExchange()) return null;

View file

@ -59,7 +59,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
private final BlockingQueue<LogoutAction> adminLogoutActions; private final BlockingQueue<LogoutAction> adminLogoutActions;
private final BlockingQueue<LogoutToken> frontChannelLogoutTokens; private final BlockingQueue<LogoutToken> frontChannelLogoutTokens;
private final BlockingQueue<LogoutToken> backChannelLogoutTokens; private final BlockingQueue<String> backChannelLogoutTokens;
private final BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions; private final BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions;
private final BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction; private final BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction;
private final TestApplicationResourceProviderFactory.OIDCClientData oidcClientData; private final TestApplicationResourceProviderFactory.OIDCClientData oidcClientData;
@ -71,7 +71,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
private final HttpRequest request; private final HttpRequest request;
public TestApplicationResourceProvider(KeycloakSession session, BlockingQueue<LogoutAction> adminLogoutActions, public TestApplicationResourceProvider(KeycloakSession session, BlockingQueue<LogoutAction> adminLogoutActions,
BlockingQueue<LogoutToken> backChannelLogoutTokens, BlockingQueue<String> backChannelLogoutTokens,
BlockingQueue<LogoutToken> frontChannelLogoutTokens, BlockingQueue<LogoutToken> frontChannelLogoutTokens,
BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions, BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions,
BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction, BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction,
@ -102,8 +102,8 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
@POST @POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Path("/admin/backchannelLogout") @Path("/admin/backchannelLogout")
public void backchannelLogout() throws JWSInputException { public void backchannelLogout() {
backChannelLogoutTokens.add(new JWSInput(request.getDecodedFormParameters().getFirst(OAuth2Constants.LOGOUT_TOKEN)).readJsonContent(LogoutToken.class)); backChannelLogoutTokens.add(request.getDecodedFormParameters().getFirst(OAuth2Constants.LOGOUT_TOKEN));
} }
@GET @GET
@ -139,7 +139,14 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Path("/poll-backchannel-logout") @Path("/poll-backchannel-logout")
public LogoutToken getBackChannelLogoutAction() throws InterruptedException { public LogoutToken getBackChannelLogoutAction() throws InterruptedException, JWSInputException {
return new JWSInput(backChannelLogoutTokens.poll(20, TimeUnit.SECONDS)).readJsonContent(LogoutToken.class);
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/poll-backchannel-raw-logout")
public String getBackChanneRawlLogoutAction() throws InterruptedException {
return backChannelLogoutTokens.poll(20, TimeUnit.SECONDS); return backChannelLogoutTokens.poll(20, TimeUnit.SECONDS);
} }

View file

@ -45,7 +45,7 @@ import java.util.concurrent.LinkedBlockingDeque;
public class TestApplicationResourceProviderFactory implements RealmResourceProviderFactory { public class TestApplicationResourceProviderFactory implements RealmResourceProviderFactory {
private BlockingQueue<LogoutAction> adminLogoutActions = new LinkedBlockingDeque<>(); private BlockingQueue<LogoutAction> adminLogoutActions = new LinkedBlockingDeque<>();
private BlockingQueue<LogoutToken> backChannelLogoutTokens = new LinkedBlockingDeque<>(); private BlockingQueue<String> backChannelLogoutTokens = new LinkedBlockingDeque<>();
private BlockingQueue<LogoutToken> frontChannelLogoutTokens = new LinkedBlockingDeque<>(); private BlockingQueue<LogoutToken> frontChannelLogoutTokens = new LinkedBlockingDeque<>();
private BlockingQueue<PushNotBeforeAction> pushNotBeforeActions = new LinkedBlockingDeque<>(); private BlockingQueue<PushNotBeforeAction> pushNotBeforeActions = new LinkedBlockingDeque<>();
private BlockingQueue<TestAvailabilityAction> testAvailabilityActions = new LinkedBlockingDeque<>(); private BlockingQueue<TestAvailabilityAction> testAvailabilityActions = new LinkedBlockingDeque<>();

View file

@ -45,6 +45,11 @@ public interface TestApplicationResource {
@Path("/poll-backchannel-logout") @Path("/poll-backchannel-logout")
LogoutToken getBackChannelLogoutToken(); LogoutToken getBackChannelLogoutToken();
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("/poll-backchannel-raw-logout")
String getBackChannelRawLogoutToken();
@GET @GET
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Path("/poll-frontchannel-logout") @Path("/poll-frontchannel-logout")

View file

@ -23,12 +23,15 @@ import static org.hamcrest.Matchers.notNullValue;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS; import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS;
import static org.keycloak.testsuite.util.ProtocolMapperUtil.createHardcodedClaim; import static org.keycloak.testsuite.util.ProtocolMapperUtil.createHardcodedClaim;
import java.util.concurrent.TimeUnit;
import jakarta.ws.rs.client.Client; import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.client.Entity;
import jakarta.ws.rs.client.WebTarget; import jakarta.ws.rs.client.WebTarget;
import jakarta.ws.rs.core.Form; import jakarta.ws.rs.core.Form;
import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import org.junit.Test; import org.junit.Test;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.ClientResource;
@ -45,13 +48,16 @@ import org.keycloak.models.IdentityProviderMapperSyncMode;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.IdentityProviderMapperRepresentation; import org.keycloak.representations.idm.IdentityProviderMapperRepresentation;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation; import org.keycloak.representations.idm.authorization.ClientPolicyRepresentation;
import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement; import org.keycloak.services.resources.admin.permissions.AdminPermissionManagement;
import org.keycloak.services.resources.admin.permissions.AdminPermissions; import org.keycloak.services.resources.admin.permissions.AdminPermissions;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature; import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.arquillian.annotation.EnableFeatures; import org.keycloak.testsuite.arquillian.annotation.EnableFeatures;
import org.keycloak.testsuite.util.AdminClientUtil; import org.keycloak.testsuite.util.AdminClientUtil;
@ -127,6 +133,61 @@ public final class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBase
} }
} }
@Test
public void testSupportedTokenTypesWhenValidatingSubjectToken() throws Exception {
testingClient.server().run(KcOidcBrokerTokenExchangeTest::setupRealm);
RealmResource providerRealm = realmsResouce().realm(bc.providerRealmName());
ClientsResource clients = providerRealm.clients();
ClientRepresentation brokerApp = clients.findByClientId("brokerapp").get(0);
brokerApp.setDirectAccessGrantsEnabled(true);
ClientResource brokerAppResource = providerRealm.clients().get(brokerApp.getId());
brokerAppResource.update(brokerApp);
RealmResource consumerRealm = realmsResouce().realm(bc.consumerRealmName());
IdentityProviderResource identityProviderResource = consumerRealm.identityProviders().get(bc.getIDPAlias());
IdentityProviderRepresentation idpRep = identityProviderResource.toRepresentation();
idpRep.getConfig().put("disableUserInfo", "true");
identityProviderResource.update(idpRep);
getCleanup().addCleanup(() -> {
idpRep.getConfig().put("disableUserInfo", "false");
identityProviderResource.update(idpRep);
});
OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest(bc.providerRealmName(), bc.getUserLogin(), bc.getUserPassword(), null, brokerApp.getClientId(), brokerApp.getSecret());
assertThat(tokenResponse.getIdToken(), notNullValue());
String idTokenString = tokenResponse.getIdToken();
oauth.realm(bc.providerRealmName());
String logoutUrl = oauth.getLogoutUrl().idTokenHint(idTokenString)
.postLogoutRedirectUri(oauth.APP_AUTH_ROOT).build();
driver.navigate().to(logoutUrl);
String logoutToken = testingClient.testApp().getBackChannelRawLogoutToken();
Assert.assertNotNull(logoutToken);
Client httpClient = AdminClientUtil.createResteasyClient();
try {
WebTarget exchangeUrl = httpClient.target(OAuthClient.AUTH_SERVER_ROOT)
.path("/realms")
.path(bc.consumerRealmName())
.path("protocol/openid-connect/token");
// test user info validation.
try (Response response = exchangeUrl.request()
.header(HttpHeaders.AUTHORIZATION, BasicAuthHelper.createHeader(
"test-app", "secret"))
.post(Entity.form(
new Form()
.param(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE)
.param(OAuth2Constants.SUBJECT_TOKEN, logoutToken)
.param(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.JWT_TOKEN_TYPE)
.param(OAuth2Constants.SUBJECT_ISSUER, bc.getIDPAlias())
.param(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID)
))) {
assertThat(response.getStatus(), equalTo(Status.BAD_REQUEST.getStatusCode()));
}
} finally {
httpClient.close();
}
}
private static void setupRealm(KeycloakSession session) { private static void setupRealm(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(BrokerTestConstants.REALM_CONS_NAME); RealmModel realm = session.realms().getRealmByName(BrokerTestConstants.REALM_CONS_NAME);
IdentityProviderModel idp = realm.getIdentityProviderByAlias(IDP_OIDC_ALIAS); IdentityProviderModel idp = realm.getIdentityProviderByAlias(IDP_OIDC_ALIAS);
@ -149,5 +210,10 @@ public final class KcOidcBrokerTokenExchangeTest extends AbstractInitializedBase
ResourceServer server = management.realmResourceServer(); ResourceServer server = management.realmResourceServer();
Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep); Policy clientPolicy = management.authz().getStoreFactory().getPolicyStore().create(server, clientRep);
management.idps().exchangeToPermission(idp).addAssociatedPolicy(clientPolicy); management.idps().exchangeToPermission(idp).addAssociatedPolicy(clientPolicy);
realm = session.realms().getRealmByName(BrokerTestConstants.REALM_PROV_NAME);
client = realm.getClientByClientId("brokerapp");
client.addRedirectUri(OAuthClient.APP_ROOT + "/auth");
client.setAttribute(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, OAuthClient.APP_ROOT + "/admin/backchannelLogout");
} }
} }

View file

@ -17,11 +17,13 @@
package org.keycloak.testsuite.oauth; package org.keycloak.testsuite.oauth;
import jakarta.ws.rs.core.Response.Status;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier; import org.keycloak.TokenVerifier;
import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.ClientsResource;
import org.keycloak.authorization.model.Policy; import org.keycloak.authorization.model.Policy;
import org.keycloak.authorization.model.ResourceServer; import org.keycloak.authorization.model.ResourceServer;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
@ -65,6 +67,7 @@ import jakarta.ws.rs.core.Response;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.instanceOf;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@ -179,6 +182,7 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
directLegal.setSecret("secret"); directLegal.setSecret("secret");
directLegal.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); directLegal.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
directLegal.setFullScopeAllowed(false); directLegal.setFullScopeAllowed(false);
directLegal.addRedirectUri(OAuthClient.APP_ROOT + "/auth");
ClientModel directPublic = realm.addClient("direct-public"); ClientModel directPublic = realm.addClient("direct-public");
directPublic.setClientId("direct-public"); directPublic.setClientId("direct-public");
@ -1017,6 +1021,36 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
testExchange(); testExchange();
} }
@Test
public void testSupportedTokenTypesWhenValidatingSubjectToken() throws Exception {
testingClient.server().run(ClientTokenExchangeTest::setupRealm);
oauth.realm(TEST);
oauth.clientId("direct-legal");
oauth.scope(OAuth2Constants.SCOPE_OPENID);
ClientsResource clients = adminClient.realm(oauth.getRealm()).clients();
ClientRepresentation rep = clients.findByClientId(oauth.getClientId()).get(0);
rep.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, oauth.APP_ROOT + "/admin/backchannelLogout");
getCleanup().addCleanup(() -> {
rep.getAttributes().put(OIDCConfigAttributes.BACKCHANNEL_LOGOUT_URL, "");
clients.get(rep.getId()).update(rep);
});
clients.get(rep.getId()).update(rep);
String logoutToken;
oauth.clientSessionState("client-session");
oauth.doLogin("user", "password");
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "secret");
String idTokenString = tokenResponse.getIdToken();
String logoutUrl = oauth.getLogoutUrl().idTokenHint(idTokenString)
.postLogoutRedirectUri(oauth.APP_AUTH_ROOT).build();
driver.navigate().to(logoutUrl);
logoutToken = testingClient.testApp().getBackChannelRawLogoutToken();
Assert.assertNotNull(logoutToken);
OAuthClient.AccessTokenResponse response = oauth.doTokenExchange(TEST, logoutToken, "target", "direct-legal", "secret");
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
}
private static void addDirectExchanger(KeycloakSession session) { private static void addDirectExchanger(KeycloakSession session) {
RealmModel realm = session.realms().getRealmByName(TEST); RealmModel realm = session.realms().getRealmByName(TEST);
RoleModel exampleRole = realm.addRole("example"); RoleModel exampleRole = realm.addRole("example");