Add audience to the client-scopes evaluate tab (#38457)

* Add audience to the client-scopes evaluate tab #37548

Signed-off-by: Douglas Palmer <dpalmer@redhat.com>

* Simulate audience parameter in the evaluate tab - polishing

Signed-off-by: mposolda <mposolda@gmail.com>

---------

Signed-off-by: Douglas Palmer <dpalmer@redhat.com>
Signed-off-by: mposolda <mposolda@gmail.com>
Co-authored-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Douglas Palmer
2025-03-28 08:22:34 -07:00
committed by GitHub
parent a420ecddb1
commit 4ccb50106a
7 changed files with 116 additions and 32 deletions

View File

@@ -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

View File

@@ -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) => {
/>
</FormProvider>
)}
<FormProvider {...form}>
<ClientSelect
name="targetAudience"
label={t("targetAudience")}
helpText={t("targetAudienceHelp")}
defaultValue={[]}
variant="typeaheadMulti"
placeholderText={t("targetAudiencePlaceHolder")}
/>
</FormProvider>
</Form>
</PageSection>

View File

@@ -16,6 +16,7 @@ type ClientSelectProps = Omit<ComponentProps, "convertToName"> & {
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}
/>
);
};

View File

@@ -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<string, unknown>
>({
method: "GET",
path: "/{id}/evaluate-scopes/generate-example-access-token",
urlParamKeys: ["id"],
queryParamKeys: ["scope", "userId"],
queryParamKeys: ["scope", "userId", "audience"],
});
public evaluateGenerateUserInfo = this.makeRequest<

View File

@@ -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 <T extends JsonWebToken> 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<String> checkRequestedAudiences(JsonWebToken token, List<String> requestedAudience) {
if (requestedAudience != null && (token.getAudience() == null ||
token.getAudience().length < requestedAudience.size())) {
final Set<String> missingAudience = new HashSet<>(requestedAudience);
if (token.getAudience() != null) {
missingAudience.removeAll(Set.of(token.getAudience()));
}
return missingAudience;
} else {
return Collections.emptySet();
}
}
}

View File

@@ -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<String> 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<String> 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);
}
}

View File

@@ -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> R sessionAware(UserModel user, String scopeParam, BiFunction<UserSessionModel, ClientSessionContext,R> function) {
private<R> R sessionAware(UserModel user, String scopeParam, String audienceParam, TriFunction<UserSessionModel, ClientSessionContext, ClientModel[], R> 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<ClientModel> 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<String> requestedAudienceClientIds = Stream.of(requestedAudience)
.map(ClientModel::getClientId)
.collect(Collectors.toList());
Set<String> 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");