From 5258fbaf588d5e566067f8beb73219494ad61021 Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Fri, 4 Jul 2025 19:56:33 +0200 Subject: [PATCH] Add SAML response inspection for clients with scope mappings Add ability to inspect generated SAML response for SAML Clients with client scope mappings. We also consider default SAML scopes mappers as well as custom scope mappings. Fixes #30328 Signed-off-by: Thomas Darimont (cherry picked from commit b014dd5251a2db4910d87c8db54b3c329006d742) Signed-off-by: Thomas Darimont --- .../admin/messages/messages_en.properties | 3 + .../src/clients/scopes/EvaluateScopes.tsx | 414 +++++++++++------- .../src/resources/clients.ts | 10 + .../keycloak/protocol/oidc/TokenManager.java | 3 +- .../admin/ClientScopeEvaluateResource.java | 114 ++++- 5 files changed, 375 insertions(+), 169 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 0c4056691dd8..f54b5d54c114 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 @@ -1196,6 +1196,9 @@ deleteClientPolicyProfileConfirm=This action will permanently delete {{profileNa deleteExecutorProfileConfirm=The action will permanently delete {{executorName}}. This cannot be undone. confirmClientSecretBody=If you regenerate the secret, the Keycloak database will be updated and you will need to download a new adapter for this client. keysList=Keys list +generatedSamlResponse=Generated SAML response +generatedSamlResponseIsDisabled=Generated SAML response is disabled when no user is selected +generatedSamlResponseNo=No generated SAML response generatedUserInfo=Generated user info clientRegistration=Client registration masterSamlProcessingUrl=Master SAML Processing URL diff --git a/js/apps/admin-ui/src/clients/scopes/EvaluateScopes.tsx b/js/apps/admin-ui/src/clients/scopes/EvaluateScopes.tsx index 883194a5053d..cbc1fcff840f 100644 --- a/js/apps/admin-ui/src/clients/scopes/EvaluateScopes.tsx +++ b/js/apps/admin-ui/src/clients/scopes/EvaluateScopes.tsx @@ -119,17 +119,31 @@ const EffectiveRoles = ({ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => { const { adminClient } = useAdminClient(); - const prefix = "openid"; + const openidPrefix = "openid"; const { t } = useTranslation(); const { enabled } = useHelp(); const { realm } = useRealm(); const mapperTypes = useServerInfo().protocolMapperTypes![protocol]; + const supportsScopeSelection = (proto: string) => { + return proto !== "saml"; + }; + + const isOpenIdConnectProtocol = () => { + return protocol === "openid-connect"; + }; + + const isSamlProtocol = () => { + return protocol === "saml"; + }; + const [selectableScopes, setSelectableScopes] = useState< ClientScopeRepresentation[] >([]); const [isScopeOpen, setIsScopeOpen] = useState(false); - const [selected, setSelected] = useState([prefix]); + const [selected, setSelected] = useState( + isOpenIdConnectProtocol() ? [openidPrefix] : [], + ); const [activeTab, setActiveTab] = useState(0); const [key, setKey] = useState(""); @@ -143,12 +157,14 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => { const [accessToken, setAccessToken] = useState(""); const [userInfo, setUserInfo] = useState(""); const [idToken, setIdToken] = useState(""); + const [samlResponse, setSamlResponse] = useState(""); const tabContent1 = useRef(null); const tabContent2 = useRef(null); const tabContent3 = useRef(null); const tabContent4 = useRef(null); const tabContent5 = useRef(null); + const tabContent6 = useRef(null); const form = useForm(); const { watch } = form; @@ -208,29 +224,50 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => { } const audience = selectedAudience.join(" "); - return await Promise.all([ - adminClient.clients.evaluateGenerateAccessToken({ - id: clientId, - userId: user[0], - scope, - audience, - }), - adminClient.clients.evaluateGenerateUserInfo({ - id: clientId, - userId: user[0], - scope, - }), - adminClient.clients.evaluateGenerateIdToken({ - id: clientId, - userId: user[0], - scope, - }), - ]); + if (isOpenIdConnectProtocol()) { + return await Promise.all([ + adminClient.clients.evaluateGenerateAccessToken({ + id: clientId, + userId: user[0], + scope, + audience, + }), + adminClient.clients.evaluateGenerateUserInfo({ + id: clientId, + userId: user[0], + scope, + }), + adminClient.clients.evaluateGenerateIdToken({ + id: clientId, + userId: user[0], + scope, + }), + ]); + } + + if (isSamlProtocol()) { + return await Promise.all([ + adminClient.clients.evaluateGenerateSamlResponse({ + id: clientId, + userId: user[0], + scope, + }), + ]); + } + + return await Promise.all([]); }, - ([accessToken, userInfo, idToken]) => { - setAccessToken(prettyPrintJSON(accessToken)); - setUserInfo(prettyPrintJSON(userInfo)); - setIdToken(prettyPrintJSON(idToken)); + (responses) => { + if (isOpenIdConnectProtocol()) { + const [generatedAccessToken, generatedUserInfo, generatedIdToken] = + responses; + setAccessToken(prettyPrintJSON(generatedAccessToken)); + setUserInfo(prettyPrintJSON(generatedUserInfo)); + setIdToken(prettyPrintJSON(generatedIdToken)); + } else if (isSamlProtocol()) { + const [generatedSamlResponse] = responses; + setSamlResponse(generatedSamlResponse as any); + } }, [form.getValues("user"), selected, selectedAudience], ); @@ -246,52 +283,56 @@ export const EvaluateScopes = ({ clientId, protocol }: EvaluateScopesProps) => { )}
- - } - > - - - setIsScopeOpen(!isScopeOpen)} - isOpen={isScopeOpen} - selections={selected} - onSelect={(value) => { - const option = value as string; - if (selected.includes(option)) { - if (option !== prefix) { - setSelected(selected.filter((item) => item !== option)); + {supportsScopeSelection(protocol) && ( + + } + > + + + setIsScopeOpen(!isScopeOpen)} + isOpen={isScopeOpen} + selections={selected} + onSelect={(value) => { + const option = value as string; + if (selected.includes(option)) { + if (option !== openidPrefix) { + setSelected( + selected.filter((item) => item !== option), + ); + } + } else { + setSelected([...selected, option]); } - } else { - setSelected([...selected, option]); - } - }} - aria-labelledby={t("scopeParameter")} - placeholderText={t("scopeParameterPlaceholder")} - > - {selectableScopes.map((option, index) => ( - - {option.name} - - ))} - - - - - {selected.join(" ")} - - - - + }} + aria-labelledby={t("scopeParameter")} + placeholderText={t("scopeParameterPlaceholder")} + > + {selectableScopes.map((option, index) => ( + + {option.name} + + ))} + + + + + {selected.join(" ")} + + + + + )} {hasViewUsers && ( { > - - - + {isOpenIdConnectProtocol() && ( + + )} + {isOpenIdConnectProtocol() && ( + + )} + {isOpenIdConnectProtocol() && ( + + )} + + {isSamlProtocol() && ( + + )} + { } tabContentRef={tabContent2} > - - {t("generatedAccessToken")}{" "} - - - } - tabContentRef={tabContent3} - /> - - {t("generatedIdToken")}{" "} - - - } - tabContentRef={tabContent4} - /> - - {t("generatedUserInfo")}{" "} - - - } - tabContentRef={tabContent5} - /> + {isOpenIdConnectProtocol() && ( + + {t("generatedAccessToken")}{" "} + + + } + tabContentRef={tabContent3} + /> + )} + {isOpenIdConnectProtocol() && ( + + {t("generatedIdToken")}{" "} + + + } + tabContentRef={tabContent4} + /> + )} + {isOpenIdConnectProtocol() && ( + + {t("generatedUserInfo")}{" "} + + + } + tabContentRef={tabContent5} + /> + )} + {isSamlProtocol() && ( + + {t("generatedSamlResponse")}{" "} + + + } + tabContentRef={tabContent6} + /> + )} diff --git a/js/libs/keycloak-admin-client/src/resources/clients.ts b/js/libs/keycloak-admin-client/src/resources/clients.ts index ec5ac6449500..b146550b5203 100644 --- a/js/libs/keycloak-admin-client/src/resources/clients.ts +++ b/js/libs/keycloak-admin-client/src/resources/clients.ts @@ -419,6 +419,16 @@ export class Clients extends Resource<{ realm?: string }> { queryParamKeys: ["scope"], }); + public evaluateGenerateSamlResponse = this.makeRequest< + { id: string; scope: string; userId: string }, + Record + >({ + method: "GET", + path: "/{id}/evaluate-scopes/generate-example-saml-response", + urlParamKeys: ["id"], + queryParamKeys: ["scope", "userId"], + }); + public evaluateGenerateAccessToken = this.makeRequest< { id: string; scope: string; userId: string; audience: string }, Record diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 4fd1ec568ec0..bf59999bd719 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -548,7 +548,8 @@ public static ClientSessionContext attachAuthenticationSession(KeycloakSession s String scopeParam = authSession.getClientNote(OAuth2Constants.SCOPE); Set clientScopes; - if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES)) { + // The additional check for the OIDC Login Protocol is necessary, as this logic is also executed for SAML Clients if the DYNAMIC_SCOPES feature is enabled. + if (Profile.isFeatureEnabled(Profile.Feature.DYNAMIC_SCOPES) && OIDCLoginProtocol.LOGIN_PROTOCOL.equals(authSession.getProtocol())) { session.getContext().setClient(client); clientScopes = AuthorizationContextUtil.getClientScopesStreamFromAuthorizationRequestContextWithClient(session, scopeParam) .collect(Collectors.toSet()); 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 52768129ba34..b171324d7c2f 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,6 +19,10 @@ import static org.keycloak.protocol.ProtocolMapperUtils.isEnabled; +import java.io.InputStream; +import java.io.StringWriter; +import java.io.Writer; +import java.net.URI; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -27,6 +31,8 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import com.fasterxml.jackson.annotation.JsonValue; +import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; import org.eclipse.microprofile.openapi.annotations.extensions.Extension; @@ -49,9 +55,11 @@ import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.NoCache; import org.keycloak.authentication.actiontoken.TokenUtils; +import org.keycloak.authentication.authenticators.client.ClientAuthUtil; import org.keycloak.common.ClientConnection; import org.keycloak.common.util.CollectionUtil; import org.keycloak.common.util.TriFunction; +import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ClientSessionContext; @@ -64,16 +72,27 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.protocol.saml.SamlProtocol; import org.keycloak.representations.AccessToken; import org.keycloak.representations.IDToken; +import org.keycloak.saml.common.util.DocumentUtil; +import org.keycloak.saml.common.util.TransformerUtil; +import org.keycloak.saml.processing.web.util.RedirectBindingUtil; import org.keycloak.services.Urls; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.services.managers.UserSessionManager; import org.keycloak.services.resources.KeycloakOpenAPI; import org.keycloak.services.resources.admin.fgap.AdminPermissionEvaluator; +import org.keycloak.services.util.ResolveRelative; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.sessions.RootAuthenticationSessionModel; +import org.w3c.dom.Document; + +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; /** * @author Marek Posolda @@ -192,7 +211,7 @@ public Map generateExampleUserinfo(@QueryParam("scope") String s logger.debugf("generateExampleUserinfo invoked. User: %s", user.getUsername()); - return sessionAware(user, scopeParam, "", (userSession, clientSessionCtx, audienceClients) -> { + return sessionAware(OIDCLoginProtocol.LOGIN_PROTOCOL, user, scopeParam, "", (userSession, clientSessionCtx, audienceClients, authSession) -> { AccessToken userInfo = new AccessToken(); TokenManager tokenManager = new TokenManager(); @@ -224,7 +243,7 @@ public IDToken generateExampleIdToken(@QueryParam("scope") String scopeParam, @Q logger.debugf("generateExampleIdToken invoked. User: %s, Scope param: %s, Target Audience: %s", user.getUsername(), scopeParam); - return sessionAware(user, scopeParam, audience, (userSession, clientSessionCtx, audienceClients) -> + return sessionAware(OIDCLoginProtocol.LOGIN_PROTOCOL, user, scopeParam, audience, (userSession, clientSessionCtx, audienceClients, authSession) -> { TokenManager tokenManager = new TokenManager(); TokenManager.AccessTokenResponseBuilder response = tokenManager.responseBuilder(realm, client, null, session, userSession, clientSessionCtx) @@ -262,7 +281,7 @@ public AccessToken generateExampleAccessToken(@QueryParam("scope") String scopeP logger.debugf("generateExampleAccessToken invoked. User: %s, Scope param: %s, Target Audience: %s", user.getUsername(), scopeParam, audience); - return sessionAware(user, scopeParam, audience, (userSession, clientSessionCtx, audienceClients) -> + return sessionAware(OIDCLoginProtocol.LOGIN_PROTOCOL, user, scopeParam, audience, (userSession, clientSessionCtx, audienceClients, authSession) -> { TokenManager tokenManager = new TokenManager(); AccessToken accessToken = tokenManager.responseBuilder(realm, client, null, session, userSession, clientSessionCtx) @@ -272,7 +291,71 @@ public AccessToken generateExampleAccessToken(@QueryParam("scope") String scopeP }); } - private R sessionAware(UserModel user, String scopeParam, String audienceParam, TriFunction function) { + /** + * Create SAMLResponse for the given user and the current client. + * + * @return + */ + @GET + @Path("generate-example-saml-response") + @NoCache + @Produces(MediaType.APPLICATION_JSON) + @Tag(name = KeycloakOpenAPI.Admin.Tags.CLIENTS) + @Operation( summary = "Create JSON with payload of example access token") + public SamlExampleResponse generateExampleSamlResponse(@QueryParam("scope") String scopeParam, @QueryParam("userId") String userId, @QueryParam("audience") String audience) { + auth.clients().requireView(client); + + UserModel user = getUserModel(userId); + + logger.debugf("generateExampleSamlResponse invoked. User: %s, Scope param: %s", user.getUsername(), scopeParam); + + if (audience == null) { + // if no audience is given, fallback to current client + audience = client.getClientId(); + } + + return sessionAware(SamlProtocol.LOGIN_PROTOCOL, user, scopeParam, audience, (userSession, clientSessionCtx, audienceClients, authSession) -> + { + SamlProtocol samlProtocol = new SamlProtocol(){ + @Override + protected boolean isPostBinding(AuthenticatedClientSessionModel authenticatedClientSession) { + // force redirect binding to read values from response URL header + return false; + } + }; + samlProtocol.setSession(session); + samlProtocol.setRealm(realm); + samlProtocol.setUriInfo(uriInfo); + + String baseUrl = ResolveRelative.resolveRelativeUri(session, client.getRootUrl(), client.getBaseUrl()); + authSession.setRedirectUri(baseUrl); + + String samlResponseString; + try (Response samlResponse = samlProtocol.authenticated(authSession, userSession, clientSessionCtx)) { + URI urlWithSamlResponseParameter = samlResponse.getLocation(); + + String extractedSamlResponseParameter = urlWithSamlResponseParameter.getQuery().substring("SAMLResponse=".length(), urlWithSamlResponseParameter.getQuery().indexOf("&SigAlg=")); + InputStream inputStream = RedirectBindingUtil.base64DeflateDecode(extractedSamlResponseParameter); + Document document = DocumentUtil.getDocument(inputStream); + + // format SAML Response for display + Transformer transformer = TransformerUtil.getTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + + Writer out = new StringWriter(); + transformer.transform(new DOMSource(document), new StreamResult(out)); + + samlResponseString = out.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + + return new SamlExampleResponse(samlResponseString); + }); + } + + private R sessionAware(String protocol, UserModel user, String scopeParam, String audienceParam, ProtocolResponseGenerator function) { AuthenticationSessionModel authSession = null; UserSessionModel userSession = null; AuthenticationSessionManager authSessionManager = new AuthenticationSessionManager(session); @@ -282,7 +365,7 @@ private R sessionAware(UserModel user, String scopeParam, String audiencePara authSession = rootAuthSession.createAuthenticationSession(client); authSession.setAuthenticatedUser(user); - authSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + authSession.setProtocol(protocol); authSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())); authSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, scopeParam); @@ -297,7 +380,7 @@ private R sessionAware(UserModel user, String scopeParam, String audiencePara clientSessionCtx.setAttribute(Constants.REQUESTED_AUDIENCE_CLIENTS, audienceClients); } - return function.apply(userSession, clientSessionCtx, audienceClients); + return function.generateProtocolResponse(userSession, clientSessionCtx, audienceClients, authSession); } finally { if (authSession != null) { @@ -413,4 +496,23 @@ public void setProtocolMapper(String protocolMapper) { this.protocolMapper = protocolMapper; } } + + public static class SamlExampleResponse { + + private final String samlResponse; + + public SamlExampleResponse(String samlResponse) { + this.samlResponse = samlResponse; + } + + @JsonValue + public String getSamlResponse() { + return samlResponse; + } + } + + interface ProtocolResponseGenerator { + + T generateProtocolResponse(UserSessionModel userSessionModel, ClientSessionContext clientSessionContext, ClientModel[] audienceClients, AuthenticationSessionModel authSession); + } }