diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties
index 39b2eda80e1..c2db1477b62 100644
--- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties
+++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties
@@ -3438,6 +3438,9 @@ evaluation=Evaluation
addSubFlowTo=Add sub-flow to {{name}}
addExecutionTo=Add execution to {{name}}
addConditionTo=Add condition to {{name}}
+targetAudience=Target audience
+targetAudienceHelp=Configure target audience. This will be the same as using the parameter 'audience' in the token endpoint grant request. Note that the 'audience' parameter is available just for the token-exchange grant at this moment. To simulate any other grant or to simulate token-exchange grant without 'audience' parameter, it is recommended to leave this parameter empty.
+targetAudiencePlaceHolder=Select target audience for token exchange
permissionsEvaluationInstructions=Select a user to check if the user has a specific access to the specified resource. After clicking the Evaluate button below, the result will be displayed on the right panel.
permissionEvaluationPreview=Permission evaluation preview
noPermissionsEvaluationResults=No evaluation
diff --git a/js/apps/admin-ui/src/clients/scopes/EvaluateScopes.tsx b/js/apps/admin-ui/src/clients/scopes/EvaluateScopes.tsx
index bbef6e0f15b..90a5ea6e836 100644
--- a/js/apps/admin-ui/src/clients/scopes/EvaluateScopes.tsx
+++ b/js/apps/admin-ui/src/clients/scopes/EvaluateScopes.tsx
@@ -40,6 +40,7 @@ import { prettyPrintJSON } from "../../util";
import { GeneratedCodeTab } from "./GeneratedCodeTab";
import "./evaluate.css";
+import { ClientSelect } from "../../components/client/ClientSelect";
export type EvaluateScopesProps = {
clientId: string;
@@ -150,6 +151,8 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => {
const tabContent5 = useRef(null);
const form = useForm();
+ const { watch } = form;
+ const selectedAudience: string[] = watch("targetAudience");
const { hasAccess } = useAccess();
const hasViewUsers = hasAccess("view-users");
@@ -201,12 +204,14 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => {
const scope = selected.join(" ");
const user = form.getValues("user");
if (!user) return [];
+ const audience = selectedAudience.join(" ");
return await Promise.all([
adminClient.clients.evaluateGenerateAccessToken({
id: clientId,
userId: user[0],
scope,
+ audience,
}),
adminClient.clients.evaluateGenerateUserInfo({
id: clientId,
@@ -225,7 +230,7 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => {
setUserInfo(prettyPrintJSON(userInfo));
setIdToken(prettyPrintJSON(idToken));
},
- [form.getValues("user"), selected],
+ [form.getValues("user"), selected, selectedAudience],
);
return (
@@ -297,6 +302,16 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => {
/>
)}
+
+
+
diff --git a/js/apps/admin-ui/src/components/client/ClientSelect.tsx b/js/apps/admin-ui/src/components/client/ClientSelect.tsx
index ded83f4ae7f..be8b7f2eeb6 100644
--- a/js/apps/admin-ui/src/components/client/ClientSelect.tsx
+++ b/js/apps/admin-ui/src/components/client/ClientSelect.tsx
@@ -16,6 +16,7 @@ type ClientSelectProps = Omit & {
variant?: `${SelectVariant}`;
isRequired?: boolean;
clientKey?: keyof ClientRepresentation;
+ placeholderText?: string;
};
export const ClientSelect = ({
@@ -27,6 +28,7 @@ export const ClientSelect = ({
isRequired,
variant = "typeahead",
clientKey = "clientId",
+ placeholderText,
}: ClientSelectProps) => {
const { adminClient } = useAdminClient();
@@ -72,6 +74,7 @@ export const ClientSelect = ({
key: client[clientKey] as string,
value: client.clientId!,
}))}
+ placeholderText={placeholderText}
/>
);
};
diff --git a/js/libs/keycloak-admin-client/src/resources/clients.ts b/js/libs/keycloak-admin-client/src/resources/clients.ts
index 8e2083e266a..ec5ac644950 100644
--- a/js/libs/keycloak-admin-client/src/resources/clients.ts
+++ b/js/libs/keycloak-admin-client/src/resources/clients.ts
@@ -420,13 +420,13 @@ export class Clients extends Resource<{ realm?: string }> {
});
public evaluateGenerateAccessToken = this.makeRequest<
- { id: string; scope: string; userId: string },
+ { id: string; scope: string; userId: string; audience: string },
Record
>({
method: "GET",
path: "/{id}/evaluate-scopes/generate-example-access-token",
urlParamKeys: ["id"],
- queryParamKeys: ["scope", "userId"],
+ queryParamKeys: ["scope", "userId", "audience"],
});
public evaluateGenerateUserInfo = this.makeRequest<
diff --git a/services/src/main/java/org/keycloak/authentication/actiontoken/TokenUtils.java b/services/src/main/java/org/keycloak/authentication/actiontoken/TokenUtils.java
index bdaa80415c0..71badbb04ed 100644
--- a/services/src/main/java/org/keycloak/authentication/actiontoken/TokenUtils.java
+++ b/services/src/main/java/org/keycloak/authentication/actiontoken/TokenUtils.java
@@ -19,6 +19,11 @@ package org.keycloak.authentication.actiontoken;
import org.keycloak.TokenVerifier;
import org.keycloak.TokenVerifier.Predicate;
import org.keycloak.representations.JsonWebToken;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
import java.util.function.BooleanSupplier;
/**
@@ -82,4 +87,25 @@ public class TokenUtils {
public static Predicate super T>[] predicates(Predicate super T>... predicate) {
return predicate;
}
+
+ /**
+ * Check that all requested audiences from parameter "requestedAudience" are available in the accessToken. If some are missing, return the missing audiences.
+ * Assumption is, that token does not contain any additional audiences, which is true for example during token-exchange
+ *
+ * @param token token to check
+ * @param requestedAudience requested audiences
+ * @return set of audiences, which are requested, but are missing from the token
+ */
+ public static Set checkRequestedAudiences(JsonWebToken token, List requestedAudience) {
+ if (requestedAudience != null && (token.getAudience() == null ||
+ token.getAudience().length < requestedAudience.size())) {
+ final Set missingAudience = new HashSet<>(requestedAudience);
+ if (token.getAudience() != null) {
+ missingAudience.removeAll(Set.of(token.getAudience()));
+ }
+ return missingAudience;
+ } else {
+ return Collections.emptySet();
+ }
+ }
}
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java
index 13d05b9e831..7d81181ddfc 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java
@@ -23,13 +23,12 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Arrays;
-import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
+import org.keycloak.authentication.actiontoken.TokenUtils;
import org.keycloak.common.Profile;
-import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.CollectionUtil;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
@@ -50,7 +49,6 @@ import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager;
-import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.services.util.AuthorizationContextUtil;
import org.keycloak.services.util.UserSessionUtil;
import org.keycloak.sessions.AuthenticationSessionModel;
@@ -324,19 +322,13 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
}
protected void checkRequestedAudiences(TokenManager.AccessTokenResponseBuilder responseBuilder) {
- if (params.getAudience() != null && (responseBuilder.getAccessToken().getAudience() == null ||
- responseBuilder.getAccessToken().getAudience().length < params.getAudience().size())) {
- final Set missingAudience = new HashSet<>(params.getAudience());
- if (responseBuilder.getAccessToken().getAudience() != null) {
- missingAudience.removeAll(Set.of(responseBuilder.getAccessToken().getAudience()));
- }
- if (!missingAudience.isEmpty()) {
- final String missingAudienceString = CollectionUtil.join(missingAudience);
- event.detail(Details.REASON, "Requested audience not available: " + missingAudienceString);
- event.error(Errors.INVALID_REQUEST);
- throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
- "Requested audience not available: " + missingAudienceString, Response.Status.BAD_REQUEST);
- }
+ Set missingAudience = TokenUtils.checkRequestedAudiences(responseBuilder.getAccessToken(), params.getAudience());
+ if (!missingAudience.isEmpty()) {
+ final String missingAudienceString = CollectionUtil.join(missingAudience);
+ event.detail(Details.REASON, "Requested audience not available: " + missingAudienceString);
+ event.error(Errors.INVALID_REQUEST);
+ throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
+ "Requested audience not available: " + missingAudienceString, Response.Status.BAD_REQUEST);
}
}
diff --git a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeEvaluateResource.java b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeEvaluateResource.java
index 1b15e71c2be..358ae4b3b90 100644
--- a/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeEvaluateResource.java
+++ b/services/src/main/java/org/keycloak/services/resources/admin/ClientScopeEvaluateResource.java
@@ -19,9 +19,12 @@ package org.keycloak.services.resources.admin;
import static org.keycloak.protocol.ProtocolMapperUtils.isEnabled;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
import java.util.Objects;
-import java.util.function.BiFunction;
+import java.util.Set;
+import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.microprofile.openapi.annotations.Operation;
@@ -45,10 +48,14 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.NoCache;
+import org.keycloak.authentication.actiontoken.TokenUtils;
import org.keycloak.common.ClientConnection;
+import org.keycloak.common.util.CollectionUtil;
+import org.keycloak.common.util.TriFunction;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
+import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
@@ -185,7 +192,7 @@ public class ClientScopeEvaluateResource {
logger.debugf("generateExampleUserinfo invoked. User: %s", user.getUsername());
- return sessionAware(user, scopeParam, (userSession, clientSessionCtx) -> {
+ return sessionAware(user, scopeParam, "", (userSession, clientSessionCtx, audienceClients) -> {
AccessToken userInfo = new AccessToken();
TokenManager tokenManager = new TokenManager();
@@ -210,18 +217,25 @@ public class ClientScopeEvaluateResource {
@APIResponse(responseCode = "403", description = "Forbidden"),
@APIResponse(responseCode = "404", description = "Not Found")
})
- public IDToken generateExampleIdToken(@QueryParam("scope") String scopeParam, @QueryParam("userId") String userId) {
+ public IDToken generateExampleIdToken(@QueryParam("scope") String scopeParam, @QueryParam("userId") String userId, @QueryParam("audience") String audience) {
auth.clients().requireView(client);
UserModel user = getUserModel(userId);
- logger.debugf("generateExampleIdToken invoked. User: %s, Scope param: %s", user.getUsername(), scopeParam);
+ logger.debugf("generateExampleIdToken invoked. User: %s, Scope param: %s, Target Audience: %s", user.getUsername(), scopeParam);
- return sessionAware(user, scopeParam, (userSession, clientSessionCtx) ->
+ return sessionAware(user, scopeParam, audience, (userSession, clientSessionCtx, audienceClients) ->
{
TokenManager tokenManager = new TokenManager();
- return tokenManager.responseBuilder(realm, client, null, session, userSession, clientSessionCtx)
- .generateAccessToken().generateIDToken().getIdToken();
+ TokenManager.AccessTokenResponseBuilder response = tokenManager.responseBuilder(realm, client, null, session, userSession, clientSessionCtx)
+ .generateAccessToken().generateIDToken();
+ IDToken idToken = response.getIdToken();
+
+ // Retrieve accessToken just to check the audience
+ AccessToken accessToken = response.getAccessToken();
+ validateAudience(accessToken, audienceClients);
+
+ return idToken;
});
}
@@ -241,22 +255,24 @@ public class ClientScopeEvaluateResource {
@APIResponse(responseCode = "403", description = "Forbidden"),
@APIResponse(responseCode = "404", description = "Not Found")
})
- public AccessToken generateExampleAccessToken(@QueryParam("scope") String scopeParam, @QueryParam("userId") String userId) {
+ public AccessToken generateExampleAccessToken(@QueryParam("scope") String scopeParam, @QueryParam("userId") String userId, @QueryParam("audience") String audience) {
auth.clients().requireView(client);
UserModel user = getUserModel(userId);
- logger.debugf("generateExampleAccessToken invoked. User: %s, Scope param: %s", user.getUsername(), scopeParam);
+ logger.debugf("generateExampleAccessToken invoked. User: %s, Scope param: %s, Target Audience: %s", user.getUsername(), scopeParam, audience);
- return sessionAware(user, scopeParam, (userSession, clientSessionCtx) ->
+ return sessionAware(user, scopeParam, audience, (userSession, clientSessionCtx, audienceClients) ->
{
TokenManager tokenManager = new TokenManager();
- return tokenManager.responseBuilder(realm, client, null, session, userSession, clientSessionCtx)
+ AccessToken accessToken = tokenManager.responseBuilder(realm, client, null, session, userSession, clientSessionCtx)
.generateAccessToken().getAccessToken();
+ validateAudience(accessToken, audienceClients);
+ return accessToken;
});
}
- private R sessionAware(UserModel user, String scopeParam, BiFunction function) {
+ private R sessionAware(UserModel user, String scopeParam, String audienceParam, TriFunction function) {
AuthenticationSessionModel authSession = null;
AuthenticationSessionManager authSessionManager = new AuthenticationSessionManager(session);
@@ -275,7 +291,12 @@ public class ClientScopeEvaluateResource {
AuthenticationManager.setClientScopesInSession(session, authSession);
ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(session, userSession, authSession);
- return function.apply(userSession, clientSessionCtx);
+ ClientModel[] audienceClients = getClients(audienceParam);
+ if (audienceClients.length > 0) {
+ clientSessionCtx.setAttribute(Constants.REQUESTED_AUDIENCE_CLIENTS, audienceClients);
+ }
+
+ return function.apply(userSession, clientSessionCtx, audienceClients);
} finally {
if (authSession != null) {
@@ -284,6 +305,30 @@ public class ClientScopeEvaluateResource {
}
}
+ private ClientModel[] getClients(String clientsStr) {
+ List clients = new ArrayList<>();
+ if(clientsStr != null && !clientsStr.isEmpty()) {
+ for (String clientId : clientsStr.split("\\s+")) {
+ ClientModel client = realm.getClientByClientId(clientId);
+ if (client != null) {
+ clients.add(client);
+ }
+ }
+ }
+ return clients.toArray(ClientModel[]::new);
+ }
+
+ private void validateAudience(AccessToken accessToken, ClientModel[] requestedAudience) {
+ List requestedAudienceClientIds = Stream.of(requestedAudience)
+ .map(ClientModel::getClientId)
+ .collect(Collectors.toList());
+ Set missingAudience = TokenUtils.checkRequestedAudiences(accessToken, requestedAudienceClientIds);
+ if (!missingAudience.isEmpty()) {
+ String missingAudienceStr = CollectionUtil.join(missingAudience);
+ throw new NotFoundException("Requested audience not available: " + missingAudienceStr);
+ }
+ }
+
private UserModel getUserModel(String userId) {
if (userId == null) {
throw new NotFoundException("No userId provided");