mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-25 16:42:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user