From 4ccb50106a08ea8a84f6dfc7bb6512952589098f Mon Sep 17 00:00:00 2001 From: Douglas Palmer Date: Fri, 28 Mar 2025 08:22:34 -0700 Subject: [PATCH] Add audience to the client-scopes evaluate tab (#38457) * Add audience to the client-scopes evaluate tab #37548 Signed-off-by: Douglas Palmer * Simulate audience parameter in the evaluate tab - polishing Signed-off-by: mposolda --------- Signed-off-by: Douglas Palmer Signed-off-by: mposolda Co-authored-by: mposolda --- .../admin/messages/messages_en.properties | 3 + .../src/clients/scopes/EvaluateScopes.tsx | 17 ++++- .../src/components/client/ClientSelect.tsx | 3 + .../src/resources/clients.ts | 4 +- .../actiontoken/TokenUtils.java | 26 +++++++ .../StandardTokenExchangeProvider.java | 24 +++---- .../admin/ClientScopeEvaluateResource.java | 71 +++++++++++++++---- 7 files changed, 116 insertions(+), 32 deletions(-) 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[] predicates(Predicate... 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");