Support for multiple values of some parameters in the grant SPI

closes #35506

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
mposolda
2024-12-03 11:37:32 +01:00
committed by Marek Posolda
parent 9d47235503
commit 4ad4a8d37b
7 changed files with 113 additions and 42 deletions
@@ -28,6 +28,7 @@ import org.keycloak.services.cors.Cors;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MultivaluedMap;
import java.util.List;
import java.util.Map;
/**
@@ -130,12 +131,12 @@ public class TokenExchangeContext {
return formParams.getFirst(OAuth2Constants.ACTOR_TOKEN_TYPE);
}
public String getAudience() {
return formParams.getFirst(OAuth2Constants.AUDIENCE);
public List<String> getAudience() {
return formParams.get(OAuth2Constants.AUDIENCE);
}
public String getResource() {
return formParams.getFirst(OAuth2Constants.RESOURCE);
public List<String> getResource() {
return formParams.get(OAuth2Constants.RESOURCE);
}
public String getRequestedTokenType() {
@@ -22,7 +22,9 @@ import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import java.util.Collections;
import java.util.Map;
import java.util.Set;
import org.keycloak.common.ClientConnection;
import org.keycloak.events.EventBuilder;
@@ -49,6 +51,14 @@ public interface OAuth2GrantType extends Provider {
*/
EventType getEventType();
/**
* @return request parameters, which can be duplicated for the particular grant type. The grant request is typically rejected if
* request contains multiple values of some parameter, which is not listed here
*/
default Set<String> getSupportedMultivaluedRequestParameters() {
return Collections.emptySet();
}
/**
* Processes grant request.
* @param context grant request context
@@ -77,6 +77,7 @@ import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_CLIENT;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_ID;
import static org.keycloak.models.ImpersonationSessionNote.IMPERSONATOR_USERNAME;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
@@ -99,6 +100,7 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider {
private static final Logger logger = Logger.getLogger(DefaultTokenExchangeProvider.class);
private TokenExchangeContext.Params params;
private MultivaluedMap<String, String> formParams;
private KeycloakSession session;
private Cors cors;
@@ -117,6 +119,7 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider {
@Override
public Response exchange(TokenExchangeContext context) {
this.params = context.getParams();
this.formParams = context.getFormParams();
this.session = context.getSession();
this.cors = context.getCors();
@@ -296,48 +299,75 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider {
}
String audience = formParams.getFirst(OAuth2Constants.AUDIENCE);
List<String> audienceParams = params.getAudience();
ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor());
ClientModel targetClient = client;
if (audience != null) {
targetClient = realm.getClientByClientId(audience);
if (targetClient == null) {
event.detail(Details.REASON, "audience not found");
event.error(Errors.CLIENT_NOT_FOUND);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Audience not found", Response.Status.BAD_REQUEST);
List<ClientModel> targetAudienceClients = new ArrayList<>();
if (audienceParams != null) {
for (String audience : audienceParams) {
ClientModel targetClient = realm.getClientByClientId(audience);
if (targetClient == null) {
event.detail(Details.REASON, "audience not found");
event.detail(Details.AUDIENCE, audience);
event.error(Errors.CLIENT_NOT_FOUND);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Audience not found", Response.Status.BAD_REQUEST);
} else {
targetAudienceClients.add(targetClient);
}
}
}
if (targetClient.isConsentRequired()) {
event.detail(Details.REASON, "audience requires consent");
event.error(Errors.CONSENT_DENIED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client requires user consent", Response.Status.BAD_REQUEST);
for (ClientModel targetClient : targetAudienceClients) {
if (targetClient.isConsentRequired()) {
event.detail(Details.REASON, "audience requires consent");
event.detail(Details.AUDIENCE, targetClient.getClientId());
event.error(Errors.CONSENT_DENIED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client requires user consent", Response.Status.BAD_REQUEST);
}
if (!targetClient.isEnabled()) {
event.detail(Details.REASON, "audience client disabled");
event.detail(Details.AUDIENCE, targetClient.getClientId());
event.error(Errors.CLIENT_DISABLED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client disabled", Response.Status.BAD_REQUEST);
}
}
boolean isClientTheAudience = client.equals(targetClient);
if (isClientTheAudience) {
if (client.isPublicClient()) {
// public clients can only exchange on to themselves if they are the token holder
forbiddenIfClientIsNotTokenHolder(disallowOnHolderOfTokenMismatch, tokenHolder);
} else if (!client.equals(tokenHolder)) {
// confidential clients can only exchange to themselves if they are within the token audience
forbiddenIfClientIsNotWithinTokenAudience(token, tokenHolder);
}
} else {
if (client.isPublicClient()) {
// public clients can not exchange tokens from other client
forbiddenIfClientIsNotTokenHolder(disallowOnHolderOfTokenMismatch, tokenHolder);
}
if (!AdminPermissions.management(session, realm).clients().canExchangeTo(client, targetClient, token)) {
event.detail(Details.REASON, "client not allowed to exchange to audience");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
// Assume client itself is audience in case audience parameter not provided
if (targetAudienceClients.isEmpty()) {
targetAudienceClients.add(client);
}
for (ClientModel targetClient : targetAudienceClients) {
boolean isClientTheAudience = targetClient.equals(client);
if (isClientTheAudience) {
if (client.isPublicClient()) {
// public clients can only exchange on to themselves if they are the token holder
forbiddenIfClientIsNotTokenHolder(disallowOnHolderOfTokenMismatch, tokenHolder);
} else if (!client.equals(tokenHolder)) {
// confidential clients can only exchange to themselves if they are within the token audience
forbiddenIfClientIsNotWithinTokenAudience(token, tokenHolder);
}
} else {
if (client.isPublicClient()) {
// public clients can not exchange tokens from other client
forbiddenIfClientIsNotTokenHolder(disallowOnHolderOfTokenMismatch, tokenHolder);
}
if (!AdminPermissions.management(session, realm).clients().canExchangeTo(client, targetClient, token)) {
event.detail(Details.REASON, "client not allowed to exchange to audience");
event.detail(Details.AUDIENCE, targetClient.getClientId());
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "Client not allowed to exchange", Response.Status.FORBIDDEN);
}
}
}
ClientModel targetClient = targetAudienceClients.get(0);
// TODO Remove once more audiences are properly supported
if (targetAudienceClients.size() > 1) {
logger.warnf("Only one value of audience parameter currently supported for token exchange. Using audience '%s' and ignoring the other audiences provided", targetClient.getClientId());
}
String scope = formParams.getFirst(OAuth2Constants.SCOPE);
if (token != null && token.getScope() != null && scope == null) {
scope = token.getScope();
@@ -369,7 +399,7 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider {
switch (requestedTokenType) {
case OAuth2Constants.ACCESS_TOKEN_TYPE:
case OAuth2Constants.REFRESH_TOKEN_TYPE:
return exchangeClientToOIDCClient(targetUser, targetUserSession, requestedTokenType, targetClient, audience, scope);
return exchangeClientToOIDCClient(targetUser, targetUserSession, requestedTokenType, targetClient, scope);
case OAuth2Constants.SAML2_TOKEN_TYPE:
return exchangeClientToSAML2Client(targetUser, targetUserSession, requestedTokenType, targetClient);
}
@@ -406,7 +436,7 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider {
}
protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionModel targetUserSession, String requestedTokenType,
ClientModel targetClient, String audience, String scope) {
ClientModel targetClient, String scope) {
RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false);
AuthenticationSessionModel authSession = createSessionModel(targetUserSession, rootAuthSession, targetUser, targetClient, scope);
@@ -433,8 +463,8 @@ public class DefaultTokenExchangeProvider implements TokenExchangeProvider {
.generateAccessToken();
responseBuilder.getAccessToken().issuedFor(client.getClientId());
if (audience != null) {
responseBuilder.getAccessToken().addAudience(audience);
if (targetClient != null && !targetClient.equals(client)) {
responseBuilder.getAccessToken().addAudience(targetClient.getClientId());
}
if (formParams.containsKey(OAuth2Constants.REQUESTED_SUBJECT)) {
@@ -212,7 +212,7 @@ public class TokenEndpoint {
private void checkParameterDuplicated() {
for (String key : formParams.keySet()) {
if (formParams.get(key).size() != 1) {
if (formParams.get(key).size() != 1 && !grant.getSupportedMultivaluedRequestParameters().contains(key)) {
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "duplicated parameter",
Response.Status.BAD_REQUEST);
}
@@ -17,9 +17,12 @@
package org.keycloak.protocol.oidc.grants;
import java.util.Set;
import jakarta.ws.rs.InternalServerErrorException;
import jakarta.ws.rs.core.Response;
import org.keycloak.OAuth2Constants;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.protocol.oidc.TokenExchangeContext;
@@ -33,6 +36,8 @@ import org.keycloak.protocol.oidc.TokenExchangeProvider;
*/
public class TokenExchangeGrantType extends OAuth2GrantTypeBase {
private static final Set<String> SUPPORTED_DUPLICATED_PARAMETERS = Set.of(OAuth2Constants.AUDIENCE, OAuth2Constants.RESOURCE);
@Override
public Response process(Context context) {
setContext(context);
@@ -67,4 +72,8 @@ public class TokenExchangeGrantType extends OAuth2GrantTypeBase {
return EventType.TOKEN_EXCHANGE;
}
@Override
public Set<String> getSupportedMultivaluedRequestParameters() {
return SUPPORTED_DUPLICATED_PARAMETERS;
}
}
@@ -647,6 +647,12 @@ public class OAuthClient {
public AccessTokenResponse doTokenExchange(String realm, String token, String targetAudience,
String clientId, String clientSecret, Map<String, String> additionalParams) throws Exception {
List<String> targetAudienceList = targetAudience == null ? null : List.of(targetAudience);
return doTokenExchange(realm, token, targetAudienceList, clientId, clientSecret, additionalParams);
}
public AccessTokenResponse doTokenExchange(String realm, String token, List<String> targetAudiences,
String clientId, String clientSecret, Map<String, String> additionalParams) throws Exception {
try (CloseableHttpClient client = httpClient.get()) {
HttpPost post = new HttpPost(getResourceOwnerPasswordCredentialGrantUrl(realm));
@@ -655,8 +661,10 @@ public class OAuthClient {
parameters.add(new BasicNameValuePair(OAuth2Constants.SUBJECT_TOKEN, token));
parameters.add(new BasicNameValuePair(OAuth2Constants.SUBJECT_TOKEN_TYPE, OAuth2Constants.ACCESS_TOKEN_TYPE));
if (targetAudience != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.AUDIENCE, targetAudience));
if (targetAudiences != null) {
for (String audience : targetAudiences) {
parameters.add(new BasicNameValuePair(OAuth2Constants.AUDIENCE, audience));
}
}
if (additionalParams != null) {
@@ -1009,6 +1009,19 @@ public class ClientTokenExchangeTest extends AbstractKeycloakTest {
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
}
@Test
public void testClientExchangeWithMoreAudiencesNotBreak() throws Exception {
testingClient.server().run(ClientTokenExchangeTest::setupRealm);
oauth.realm(TEST);
oauth.clientId("client-exchanger");
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "user", "password");
String accessToken = response.getAccessToken();
response = oauth.doTokenExchange(TEST, accessToken, List.of("target", "client-exchanger"), "client-exchanger", "secret", null);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
}
@Test
public void testPublicClientNotAllowed() throws Exception {
testingClient.server().run(ClientTokenExchangeTest::setupRealm);