mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-06 23:19:35 -05:00
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:
+5
-4
@@ -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() {
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
+65
-35
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -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) {
|
||||
|
||||
+13
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user