KEYCLOAK-18276 client content screen enhancement

This commit is contained in:
Konstantinos Georgilakis 2021-05-26 09:29:07 +03:00 committed by Marek Posolda
parent 4fd29759ad
commit 63c9845cb9
22 changed files with 536 additions and 3 deletions

View file

@ -14,6 +14,10 @@ public class ClientRepresentation {
private String baseUrl; private String baseUrl;
private String effectiveUrl; private String effectiveUrl;
private ConsentRepresentation consent; private ConsentRepresentation consent;
private String logoUri;
private String policyUri;
private String tosUri;
public String getClientId() { public String getClientId() {
return clientId; return clientId;
@ -94,4 +98,28 @@ public class ClientRepresentation {
public void setConsent(ConsentRepresentation consent) { public void setConsent(ConsentRepresentation consent) {
this.consent = consent; this.consent = consent;
} }
public String getLogoUri() {
return logoUri;
}
public void setLogoUri(String logoUri) {
this.logoUri = logoUri;
}
public String getPolicyUri() {
return policyUri;
}
public void setPolicyUri(String policyUri) {
this.policyUri = policyUri;
}
public String getTosUri() {
return tosUri;
}
public void setTosUri(String tosUri) {
this.tosUri = tosUri;
}
} }

View file

@ -0,0 +1,48 @@
package org.keycloak.dom.saml.v2.mdui;
import java.util.List;
/**
* <p>
* Java class for localizedURIType complex type.
*
* <p>
* The following schema fragment specifies the expected content contained within this class.
*
* <pre>
* &lt;complexType name="KeywordsType">
* &lt;simpleContent>
* &lt;extension base="mdui:listOfStrings">
* &lt;attribute ref="{http://www.w3.org/XML/1998/namespace}lang use="required""/>
* &lt;/extension>
* &lt;/simpleContent>
* &lt;/complexType>
* &lt;simpleType name="listOfStrings">
* &lt;list itemType="string"/>
* &lt;/simpleType>
* </pre>
*/
public class KeywordsType {
protected List<String> values;
protected String lang;
public KeywordsType(String lang) {
this.lang = lang;
}
public List<String> getValues() {
return values;
}
public void setValues(List<String> values) {
this.values = values;
}
public String getLang() {
return lang;
}
}

View file

@ -0,0 +1,70 @@
package org.keycloak.dom.saml.v2.mdui;
import java.net.URI;
/**
* <p>
* Java class for localizedURIType complex type.
*
* <p>
* The following schema fragment specifies the expected content contained within this class.
*
* <pre>
* &lt;complexType name="LogoType">
* &lt;simpleContent>
* &lt;extension base="&lt;http://www.w3.org/2001/XMLSchema>anyURI">
* &lt;attribute name="height" type="positiveInteger" use="required""/>
* &lt;attribute name="width" type="positiveInteger" use="required""/>
* &lt;attribute ref="{http://www.w3.org/XML/1998/namespace}lang "/>
* &lt;/extension>
* &lt;/simpleContent>
* &lt;/complexType>
* </pre>
*/
public class LogoType {
protected URI value;
protected int height;
protected int width;
protected String lang;
public LogoType(int height, int width) {
this.height = height;
this.width = width;
}
/**
* Gets the value of the value property.
*
* @return possible object is {@link String }
*/
public URI getValue() {
return value;
}
/**
* Sets the value of the value property.
*
* @param value allowed object is {@link String }
*/
public void setValue(URI value) {
this.value = value;
}
public String getLang() {
return lang;
}
public void setLang(String lang) {
this.lang = lang;
}
public int getHeight() {
return height;
}
public int getWidth() {
return width;
}
}

View file

@ -0,0 +1,92 @@
package org.keycloak.dom.saml.v2.mdui;
import org.keycloak.dom.saml.v2.metadata.LocalizedNameType;
import org.keycloak.dom.saml.v2.metadata.LocalizedURIType;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* *
* <p>
* Java class for UIInfoType complex type.
*
* <p>
* The following schema fragment specifies the expected content contained within this class.
*
* <pre>
* &lt;element name="UIInfo" type="mdui:UIInfoType"/>
* &lt;complexType name="UIInfoType">
* &lt;choice minOccurs="0" maxOccurs="unbounded">
* &lt;element ref="mdui:DisplayName"/>
* &lt;element ref="mdui:Description"/>
* &lt;element ref="mdui:Keywords"/>
* &lt;element ref="mdui:Logo"/>
* &lt;element ref="mdui:InformationURL"/>
* &lt;element ref="mdui:PrivacyStatementURL"/>
* &lt;any namespace="##other" processContents="lax"/>
* &lt;/choice>
* &lt;/complexType>
*
* </pre>
*/
public class UIInfoType implements Serializable {
protected List<LocalizedNameType> displayName = new ArrayList<>();
protected List<LocalizedNameType> description = new ArrayList<>();
protected List<KeywordsType> keywords = new ArrayList<>();
protected List<LocalizedURIType> informationURL = new ArrayList<>();
protected List<LocalizedURIType> privacyStatementURL = new ArrayList<>();
protected List<LogoType> logo = new ArrayList<>();
public void addDisplayName(LocalizedNameType displayName) {
this.displayName.add(displayName);
}
public void addDescription(LocalizedNameType description) {
this.description.add(description);
}
public void addKeywords(KeywordsType keywords) {
this.keywords.add(keywords);
}
public void addInformationURL(LocalizedURIType informationURL) {
this.informationURL.add(informationURL);
}
public void addPrivacyStatementURL(LocalizedURIType privacyStatementURL) {
this.privacyStatementURL.add(privacyStatementURL);
}
public void addLogo(LogoType logo) {
this.logo.add(logo);
}
public List<LocalizedNameType> getDisplayName() {
return displayName;
}
public List<LocalizedNameType> getDescription() {
return description;
}
public List<KeywordsType> getKeywords() {
return keywords;
}
public List<LocalizedURIType> getInformationURL() {
return informationURL;
}
public List<LocalizedURIType> getPrivacyStatementURL() {
return privacyStatementURL;
}
public List<LogoType> getLogo() {
return logo;
}
}

View file

@ -21,6 +21,7 @@ import java.util.Collections;
import java.util.List; import java.util.List;
import org.keycloak.dom.saml.v2.mdattr.EntityAttributes; import org.keycloak.dom.saml.v2.mdattr.EntityAttributes;
import org.keycloak.dom.saml.v2.mdui.UIInfoType;
import org.w3c.dom.Element; import org.w3c.dom.Element;
/** /**
@ -111,4 +112,13 @@ public class ExtensionsType {
return null; return null;
} }
public UIInfoType getUIInfo() {
for (Object o : this.any) {
if (o instanceof UIInfoType) {
return (UIInfoType) o;
}
}
return null;
}
} }

View file

@ -67,6 +67,8 @@ public enum JBossSAMLURIConstants {
METADATA_NSURI("urn:oasis:names:tc:SAML:2.0:metadata"), METADATA_NSURI("urn:oasis:names:tc:SAML:2.0:metadata"),
// http://docs.oasis-open.org/security/saml/Post2.0/sstc-metadata-attr-cd-01.pdf // http://docs.oasis-open.org/security/saml/Post2.0/sstc-metadata-attr-cd-01.pdf
METADATA_ENTITY_ATTRIBUTES_NSURI("urn:oasis:names:tc:SAML:metadata:attribute"), METADATA_ENTITY_ATTRIBUTES_NSURI("urn:oasis:names:tc:SAML:metadata:attribute"),
//http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-metadata-ui/v1.0/os/sstc-saml-metadata-ui-v1.0-os.pdf
METADATA_UI("urn:oasis:names:tc:SAML:metadata:ui"),
NAMEID_FORMAT_TRANSIENT("urn:oasis:names:tc:SAML:2.0:nameid-format:transient"), NAMEID_FORMAT_TRANSIENT("urn:oasis:names:tc:SAML:2.0:nameid-format:transient"),
NAMEID_FORMAT_PERSISTENT("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"), NAMEID_FORMAT_PERSISTENT("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"),

View file

@ -0,0 +1,95 @@
package org.keycloak.saml.processing.core.parsers.saml.mdui;
import static org.keycloak.saml.processing.core.parsers.saml.metadata.SAMLMetadataQNames.ATTR_LANG;
import static org.keycloak.saml.processing.core.parsers.saml.metadata.SAMLMetadataQNames.ATTR_WIDTH;
import static org.keycloak.saml.processing.core.parsers.saml.metadata.SAMLMetadataQNames.ATTR_HEIGHT;
import org.keycloak.dom.saml.v2.mdui.KeywordsType;
import org.keycloak.dom.saml.v2.mdui.LogoType;
import org.keycloak.dom.saml.v2.mdui.UIInfoType;
import org.keycloak.dom.saml.v2.metadata.LocalizedNameType;
import org.keycloak.dom.saml.v2.metadata.LocalizedURIType;
import org.keycloak.saml.common.exceptions.ParsingException;
import org.keycloak.saml.common.util.StaxParserUtil;
import org.keycloak.saml.processing.core.parsers.saml.metadata.AbstractStaxSamlMetadataParser;
import org.keycloak.saml.processing.core.parsers.saml.metadata.SAMLMetadataQNames;
import javax.xml.stream.XMLEventReader;
import javax.xml.stream.events.StartElement;
import java.net.URI;
import java.net.URISyntaxException;
public class SAMLUIInfoParser extends AbstractStaxSamlMetadataParser<UIInfoType> {
private static final SAMLUIInfoParser INSTANCE = new SAMLUIInfoParser();
private SAMLUIInfoParser() {
super(SAMLMetadataQNames.UIINFO);
}
public static SAMLUIInfoParser getInstance() {
return INSTANCE;
}
@Override
protected UIInfoType instantiateElement(XMLEventReader xmlEventReader, StartElement element) throws ParsingException {
return new UIInfoType();
}
@Override
protected void processSubElement(XMLEventReader xmlEventReader, UIInfoType target, SAMLMetadataQNames element,
StartElement elementDetail) throws ParsingException {
switch (element) {
case DISPLAY_NAME:
LocalizedNameType displayName = new LocalizedNameType(
StaxParserUtil.getRequiredAttributeValue(elementDetail, ATTR_LANG));
StaxParserUtil.advance(xmlEventReader);
displayName.setValue(StaxParserUtil.getElementText(xmlEventReader));
target.addDisplayName(displayName);
break;
case DESCRIPTION:
LocalizedNameType description = new LocalizedNameType(
StaxParserUtil.getRequiredAttributeValue(elementDetail, ATTR_LANG));
StaxParserUtil.advance(xmlEventReader);
description.setValue(StaxParserUtil.getElementText(xmlEventReader));
target.addDescription(description);
break;
case KEYWORDS:
KeywordsType keywords = new KeywordsType(StaxParserUtil.getRequiredAttributeValue(elementDetail, ATTR_LANG));
target.addKeywords(keywords);
break;
case INFORMATION_URL:
LocalizedURIType informationURL = new LocalizedURIType(
StaxParserUtil.getRequiredAttributeValue(elementDetail, ATTR_LANG));
StaxParserUtil.advance(xmlEventReader);
informationURL.setValue(URI.create(StaxParserUtil.getElementText(xmlEventReader)));
target.addInformationURL(informationURL);
break;
case PRIVACY_STATEMENT_URL:
LocalizedURIType privacyStatementURL = new LocalizedURIType(
StaxParserUtil.getRequiredAttributeValue(elementDetail, ATTR_LANG));
StaxParserUtil.advance(xmlEventReader);
privacyStatementURL.setValue(URI.create(StaxParserUtil.getElementText(xmlEventReader)));
target.addPrivacyStatementURL(privacyStatementURL);
break;
case LOGO:
LogoType logo = new LogoType(
Integer.parseInt(StaxParserUtil.getRequiredAttributeValue(elementDetail, ATTR_HEIGHT )),
Integer.parseInt(StaxParserUtil.getRequiredAttributeValue(elementDetail, ATTR_WIDTH)));
String lang = StaxParserUtil.getAttributeValue(elementDetail, ATTR_LANG);
if (lang != null)
logo.setLang(lang);
StaxParserUtil.advance(xmlEventReader);
try {
String logoValue =StaxParserUtil.getElementText(xmlEventReader).replaceAll("\\s+", "");
logo.setValue(new URI(logoValue));
} catch (URISyntaxException ex) {
throw new ParsingException(ex);
}
target.addLogo(logo);
break;
default:
throw LOGGER.parserUnknownTag(StaxParserUtil.getElementName(elementDetail), elementDetail.getLocation());
}
}
}

View file

@ -23,6 +23,7 @@ import org.keycloak.dom.saml.v2.metadata.ExtensionsType;
import org.keycloak.saml.common.exceptions.ParsingException; import org.keycloak.saml.common.exceptions.ParsingException;
import org.keycloak.saml.common.util.StaxParserUtil; import org.keycloak.saml.common.util.StaxParserUtil;
import org.keycloak.saml.processing.core.parsers.saml.mdattr.SAMLEntityAttributesParser; import org.keycloak.saml.processing.core.parsers.saml.mdattr.SAMLEntityAttributesParser;
import org.keycloak.saml.processing.core.parsers.saml.mdui.SAMLUIInfoParser;
/** /**
* Parses &lt;samlp:Extensions&gt; SAML2 element into series of DOM nodes. * Parses &lt;samlp:Extensions&gt; SAML2 element into series of DOM nodes.
@ -51,6 +52,9 @@ public class SAMLExtensionsParser extends AbstractStaxSamlMetadataParser<Extensi
StartElement elementDetail) throws ParsingException { StartElement elementDetail) throws ParsingException {
switch (element) { switch (element) {
case UIINFO:
target.addExtension(SAMLUIInfoParser.getInstance().parse(xmlEventReader));
break;
case ENTITY_ATTRIBUTES: case ENTITY_ATTRIBUTES:
target.addExtension(SAMLEntityAttributesParser.getInstance().parse(xmlEventReader)); target.addExtension(SAMLEntityAttributesParser.getInstance().parse(xmlEventReader));
break; break;

View file

@ -55,6 +55,15 @@ public enum SAMLMetadataQNames implements HasQName {
SURNAME("SurName"), SURNAME("SurName"),
TELEPHONE_NUMBER("TelephoneNumber"), TELEPHONE_NUMBER("TelephoneNumber"),
//mdui elements
DESCRIPTION(JBossSAMLURIConstants.METADATA_UI, "Description"),
DISPLAY_NAME(JBossSAMLURIConstants.METADATA_UI, "DisplayName"),
INFORMATION_URL(JBossSAMLURIConstants.METADATA_UI, "InformationURL"),
KEYWORDS(JBossSAMLURIConstants.METADATA_UI, "Keywords"),
LOGO(JBossSAMLURIConstants.METADATA_UI, "Logo"),
PRIVACY_STATEMENT_URL(JBossSAMLURIConstants.METADATA_UI, "PrivacyStatementURL"),
UIINFO(JBossSAMLURIConstants.METADATA_UI, "UIInfo"),
// Attribute names // Attribute names
ATTR_ENTITY_ID(null, "entityID"), ATTR_ENTITY_ID(null, "entityID"),
ATTR_ID(null, "ID"), ATTR_ID(null, "ID"),
@ -77,6 +86,8 @@ public enum SAMLMetadataQNames implements HasQName {
ATTR_IS_REQUIRED(null, "isRequired"), ATTR_IS_REQUIRED(null, "isRequired"),
ATTR_NAME(null, "Name"), ATTR_NAME(null, "Name"),
ATTR_NAME_FORMAT(null, "NameFormat"), ATTR_NAME_FORMAT(null, "NameFormat"),
ATTR_WIDTH(null, "width"),
ATTR_HEIGHT(null, "height"),
// Elements from other namespaces that can be direct subelements of this namespace's elements // Elements from other namespaces that can be direct subelements of this namespace's elements
SIGNATURE(XmlDSigQNames.SIGNATURE), SIGNATURE(XmlDSigQNames.SIGNATURE),
KEY_INFO(XmlDSigQNames.KEY_INFO), KEY_INFO(XmlDSigQNames.KEY_INFO),

View file

@ -37,6 +37,9 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
String PRIVATE_KEY = "privateKey"; String PRIVATE_KEY = "privateKey";
String PUBLIC_KEY = "publicKey"; String PUBLIC_KEY = "publicKey";
String X509CERTIFICATE = "X509Certificate"; String X509CERTIFICATE = "X509Certificate";
String LOGO_URI ="logoUri";
String POLICY_URI ="policyUri";
String TOS_URI ="tosUri";
public static class SearchableFields { public static class SearchableFields {
public static final SearchableModelField<ClientModel> ID = new SearchableModelField<>("id", String.class); public static final SearchableModelField<ClientModel> ID = new SearchableModelField<>("id", String.class);

View file

@ -20,6 +20,7 @@ package org.keycloak.protocol.oidc;
import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator; import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
import org.keycloak.jose.jws.Algorithm; import org.keycloak.jose.jws.Algorithm;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.utils.StringUtil; import org.keycloak.utils.StringUtil;
@ -316,6 +317,18 @@ public class OIDCAdvancedConfigWrapper {
return getAttribute(OIDCConfigAttributes.FRONT_CHANNEL_LOGOUT_URI); return getAttribute(OIDCConfigAttributes.FRONT_CHANNEL_LOGOUT_URI);
} }
public void setLogoUri(String logoUri) {
setAttribute(ClientModel.LOGO_URI, logoUri);
}
public void setPolicyUri(String policyUri) {
setAttribute(ClientModel.POLICY_URI, policyUri);
}
public void setTosUri(String tosUri) {
setAttribute(ClientModel.TOS_URI, tosUri);
}
private String getAttribute(String attrKey) { private String getAttribute(String attrKey) {
if (clientModel != null) { if (clientModel != null) {
return clientModel.getAttribute(attrKey); return clientModel.getAttribute(attrKey);

View file

@ -28,6 +28,7 @@ import org.keycloak.dom.saml.v2.metadata.KeyTypes;
import org.keycloak.dom.saml.v2.metadata.SPSSODescriptorType; import org.keycloak.dom.saml.v2.metadata.SPSSODescriptorType;
import org.keycloak.exportimport.ClientDescriptionConverter; import org.keycloak.exportimport.ClientDescriptionConverter;
import org.keycloak.exportimport.ClientDescriptionConverterFactory; import org.keycloak.exportimport.ClientDescriptionConverterFactory;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
@ -232,6 +233,15 @@ public class EntityDescriptorDescriptionConverter implements ClientDescriptionCo
} }
} }
if (spDescriptorType.getExtensions() != null && spDescriptorType.getExtensions().getUIInfo() != null) {
if (!spDescriptorType.getExtensions().getUIInfo().getLogo().isEmpty()) {
attributes.put(ClientModel.LOGO_URI, spDescriptorType.getExtensions().getUIInfo().getLogo().get(0).getValue().toString());
}
if (!spDescriptorType.getExtensions().getUIInfo().getPrivacyStatementURL().isEmpty()) {
attributes.put(ClientModel.POLICY_URI, spDescriptorType.getExtensions().getUIInfo().getPrivacyStatementURL().stream().filter(dn -> "en".equals(dn.getLang())).findFirst().orElse(spDescriptorType.getExtensions().getUIInfo().getPrivacyStatementURL().get(0)).getValue().toString());
}
}
app.setProtocolMappers(spDescriptorType.getAttributeConsumingService().stream().flatMap(att -> att.getRequestedAttribute().stream()) app.setProtocolMappers(spDescriptorType.getAttributeConsumingService().stream().flatMap(att -> att.getRequestedAttribute().stream())
.map(attr -> { .map(attr -> {
ProtocolMapperRepresentation mapper = new ProtocolMapperRepresentation(); ProtocolMapperRepresentation mapper = new ProtocolMapperRepresentation();

View file

@ -192,6 +192,18 @@ public class DescriptionConverter {
configWrapper.setBackchannelLogoutRevokeOfflineTokens(clientOIDC.getBackchannelLogoutRevokeOfflineTokens()); configWrapper.setBackchannelLogoutRevokeOfflineTokens(clientOIDC.getBackchannelLogoutRevokeOfflineTokens());
} }
if (clientOIDC.getLogoUri() != null) {
configWrapper.setLogoUri(clientOIDC.getLogoUri());
}
if (clientOIDC.getPolicyUri() != null) {
configWrapper.setPolicyUri(clientOIDC.getPolicyUri());
}
if (clientOIDC.getTosUri() != null) {
configWrapper.setTosUri(clientOIDC.getTosUri());
}
// CIBA // CIBA
String backchannelTokenDeliveryMode = clientOIDC.getBackchannelTokenDeliveryMode(); String backchannelTokenDeliveryMode = clientOIDC.getBackchannelTokenDeliveryMode();
if (backchannelTokenDeliveryMode != null) { if (backchannelTokenDeliveryMode != null) {

View file

@ -287,6 +287,9 @@ public class AccountRestService {
UserConsentModel consentModel = consents.get(model.getClientId()); UserConsentModel consentModel = consents.get(model.getClientId());
if(consentModel != null) { if(consentModel != null) {
representation.setConsent(modelToRepresentation(consentModel)); representation.setConsent(modelToRepresentation(consentModel));
representation.setLogoUri(model.getAttribute(ClientModel.LOGO_URI));
representation.setPolicyUri(model.getAttribute(ClientModel.POLICY_URI));
representation.setTosUri(model.getAttribute(ClientModel.TOS_URI));
} }
return representation; return representation;
} }

View file

@ -17,10 +17,11 @@
package org.keycloak.validation; package org.keycloak.validation;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.protocol.ProtocolMapperConfigException; import org.keycloak.protocol.ProtocolMapperConfigException;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.grants.ciba.CibaClientValidation;
import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.grants.ciba.CibaClientValidation;
import org.keycloak.protocol.oidc.mappers.PairwiseSubMapperHelper; import org.keycloak.protocol.oidc.mappers.PairwiseSubMapperHelper;
import org.keycloak.protocol.oidc.utils.PairwiseSubMapperUtils; import org.keycloak.protocol.oidc.utils.PairwiseSubMapperUtils;
import org.keycloak.protocol.oidc.utils.PairwiseSubMapperValidator; import org.keycloak.protocol.oidc.utils.PairwiseSubMapperValidator;
@ -58,7 +59,22 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
BACKCHANNEL_LOGOUT_URL("backchannelLogoutUrl", BACKCHANNEL_LOGOUT_URL("backchannelLogoutUrl",
"Backchannel logout URL is not a valid URL", "backchannelLogoutUrlIsInvalid", "Backchannel logout URL is not a valid URL", "backchannelLogoutUrlIsInvalid",
null, null, null, null,
"Backchannel logout URL uses an illegal scheme", "backchannelLogoutUrlIllegalSchemeError"); "Backchannel logout URL uses an illegal scheme", "backchannelLogoutUrlIllegalSchemeError"),
LOGO_URI(ClientModel.LOGO_URI,
"Logo URL is not a valid URL", "logoURLInvalid",
null, null,
"Logo URL uses an illegal scheme", "logoURLIllegalSchemeError"),
POLICY_URI(ClientModel.POLICY_URI,
"Policy URL is not a valid URL", "policyURLInvalid",
null, null,
"Policy URL uses an illegal scheme", "policyURLIllegalSchemeError"),
TOS_URI(ClientModel.TOS_URI,
"Terms of service URL is not a valid URL", "tosURLInvalid",
null, null,
"Terms of service URL uses an illegal scheme", "tosURLIllegalSchemeError");
private String fieldId; private String fieldId;
@ -151,6 +167,9 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
client.getRedirectUris().stream() client.getRedirectUris().stream()
.map(u -> ResolveRelative.resolveRelativeUri(authServerUrl, authServerUrl, rootUrl, u)) .map(u -> ResolveRelative.resolveRelativeUri(authServerUrl, authServerUrl, rootUrl, u))
.forEach(u -> checkUri(FieldMessages.REDIRECT_URIS, u, context, false, true)); .forEach(u -> checkUri(FieldMessages.REDIRECT_URIS, u, context, false, true));
checkUriLogo(FieldMessages.LOGO_URI, client.getAttribute(ClientModel.LOGO_URI), context);
checkUri(FieldMessages.POLICY_URI, client.getAttribute(ClientModel.POLICY_URI), context, true, false);
checkUri(FieldMessages.TOS_URI, client.getAttribute(ClientModel.TOS_URI), context, true, false);
} }
private void checkUri(FieldMessages field, String url, ValidationContext<ClientModel> context, boolean checkValidUrl, boolean checkFragment) { private void checkUri(FieldMessages field, String url, ValidationContext<ClientModel> context, boolean checkValidUrl, boolean checkFragment) {
@ -185,6 +204,24 @@ public class DefaultClientValidationProvider implements ClientValidationProvider
} }
} }
private void checkUriLogo(FieldMessages field, String url, ValidationContext<ClientModel> context) {
if (url == null || url.isEmpty()) {
return;
}
try {
URI uri = new URI(url);
if (uri.getScheme() != null && uri.getScheme().equals("javascript")) {
context.addError(field.getFieldId(), field.getScheme(), field.getSchemeKey());
}
}
catch (URISyntaxException e) {
context.addError(field.getFieldId(), field.getInvalid(), field.getInvalidKey());
}
}
private void validatePairwiseInClientModel(ValidationContext<ClientModel> context) { private void validatePairwiseInClientModel(ValidationContext<ClientModel> context) {
List<ProtocolMapperRepresentation> foundPairwiseMappers = PairwiseSubMapperUtils.getPairwiseSubMappers(toRepresentation(context.getObjectToValidate(), context.getSession())); List<ProtocolMapperRepresentation> foundPairwiseMappers = PairwiseSubMapperUtils.getPairwiseSubMappers(toRepresentation(context.getObjectToValidate(), context.getSession()));

View file

@ -31,6 +31,7 @@ import org.keycloak.admin.client.resource.UsersResource;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.models.ClientModel;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ClientScopeRepresentation; import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.EventRepresentation;
@ -65,6 +66,7 @@ import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.A
import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse; import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse;
import org.keycloak.testsuite.util.OAuthClient.AuthorizationEndpointResponse; import org.keycloak.testsuite.util.OAuthClient.AuthorizationEndpointResponse;
import org.openqa.selenium.By;
/** /**
* @author <a href="mailto:mstrukel@redhat.com">Marko Strukelj</a> * @author <a href="mailto:mstrukel@redhat.com">Marko Strukelj</a>
@ -468,6 +470,37 @@ public class ConsentsTest extends AbstractKeycloakTest {
} }
} }
@Test
public void testConsentWithAdditionalClientAttributes() {
// setup account client to require consent
RealmResource providerRealm = adminClient.realm(providerRealmName());
ClientResource accountClient = findClientByClientId(providerRealm, "account");
ClientRepresentation clientRepresentation = accountClient.toRepresentation();
clientRepresentation.setConsentRequired(true);
clientRepresentation.getAttributes().put(ClientModel.LOGO_URI,"https://www.keycloak.org/resources/images/keycloak_logo_480x108.png");
clientRepresentation.getAttributes().put(ClientModel.POLICY_URI,"https://www.keycloak.org/policy");
clientRepresentation.getAttributes().put(ClientModel.TOS_URI,"https://www.keycloak.org/tos");
accountClient.update(clientRepresentation);
// setup correct realm
accountPage.setAuthRealm(providerRealmName());
// navigate to account console and login
accountPage.navigateTo();
loginPage.form().login(getUserLogin(), getUserPassword());
consentPage.assertCurrent();
assertTrue("logoUri must be presented", driver.findElement(By.xpath("//img[@src='https://www.keycloak.org/resources/images/keycloak_logo_480x108.png']")).isDisplayed());
assertTrue("policyUri must be presented", driver.findElement(By.xpath("//a[@href='https://www.keycloak.org/policy']")).isDisplayed());
assertTrue("tosUri must be presented", driver.findElement(By.xpath("//a[@href='https://www.keycloak.org/tos']")).isDisplayed());
consentPage.confirm();
// successful login
accountPage.assertCurrent();
}
private String getAccountUrl(String realmName) { private String getAccountUrl(String realmName) {
return getAuthRoot() + "/auth/realms/" + realmName + "/account"; return getAuthRoot() + "/auth/realms/" + realmName + "/account";
} }

View file

@ -450,6 +450,13 @@ authorization-encrypted-response-alg=Authorization Response Encryption Key Manag
authorization-encrypted-response-alg.tooltip=JWA Algorithm used for key management in encrypting the authorization response when the response mode is jwt. This option is needed if you want encrypted authorization response. If left empty, the authorization response is just signed, but not encrypted. authorization-encrypted-response-alg.tooltip=JWA Algorithm used for key management in encrypting the authorization response when the response mode is jwt. This option is needed if you want encrypted authorization response. If left empty, the authorization response is just signed, but not encrypted.
authorization-encrypted-response-enc=Authorization Response Encryption Content Encryption Algorithm authorization-encrypted-response-enc=Authorization Response Encryption Content Encryption Algorithm
authorization-encrypted-response-enc.tooltip=JWA Algorithm used for content encryption in encrypting the authorization response when the response mode is jwt. This option is needed if you want encrypted authorization response. If left empty, the authorization response is just signed, but not encrypted. authorization-encrypted-response-enc.tooltip=JWA Algorithm used for content encryption in encrypting the authorization response when the response mode is jwt. This option is needed if you want encrypted authorization response. If left empty, the authorization response is just signed, but not encrypted.
logo-uri=Logo URL
logo-uri.tooltip=URL that references a logo for the Client application
policy-uri=Policy URL
policy-uri.tooltip=URL that the Relying Party Client provides to the End-User to read about the how the profile data will be used
tos-uri=Terms of service URL
tos-uri.tooltip=URL that the Relying Party Client provides to the End-User to read about the Relying Party's terms of service
# client import # client import
import-client=Import Client import-client=Import Client

View file

@ -369,6 +369,27 @@
</div> </div>
<kc-tooltip>{{:: 'idp-sso-url-ref.tooltip' | translate}}</kc-tooltip> <kc-tooltip>{{:: 'idp-sso-url-ref.tooltip' | translate}}</kc-tooltip>
</div> </div>
<div class="form-group" data-ng-show="protocol == 'saml' || protocol == 'openid-connect'">
<label class="col-md-2 control-label" for="logoUri">{{:: 'logo-uri' | translate}}</label>
<div class="col-sm-6">
<input class="form-control" type="text" name="logoUri" id="logoUri" data-ng-model="clientEdit.attributes.logoUri">
</div>
<kc-tooltip>{{:: 'logo-uri.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group" data-ng-show="protocol == 'saml' || protocol == 'openid-connect'">
<label class="col-md-2 control-label" for="policyUri">{{:: 'policy-uri' | translate}}</label>
<div class="col-sm-6">
<input class="form-control" type="text" name="policyUri" id="policyUri" data-ng-model="clientEdit.attributes.policyUri">
</div>
<kc-tooltip>{{:: 'policy-uri.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group" data-ng-show="protocol == 'saml' || protocol == 'openid-connect'">
<label class="col-md-2 control-label" for="tosUri">{{:: 'tos-uri' | translate}}</label>
<div class="col-sm-6">
<input class="form-control" type="text" name="tosUri" id="tosUri" data-ng-model="clientEdit.attributes.tosUri">
</div>
<kc-tooltip>{{:: 'tos-uri.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix block" data-ng-show="protocol == 'saml'"> <div class="form-group clearfix block" data-ng-show="protocol == 'saml'">
<label class="col-md-2 control-label" for="idpInitiatedRelayState">{{:: 'idp-sso-relay-state' | translate}}</label> <label class="col-md-2 control-label" for="idpInitiatedRelayState">{{:: 'idp-sso-relay-state' | translate}}</label>
<div class="col-sm-6"> <div class="col-sm-6">

View file

@ -1,11 +1,16 @@
<#import "template.ftl" as layout> <#import "template.ftl" as layout>
<@layout.registrationLayout bodyClass="oauth"; section> <@layout.registrationLayout bodyClass="oauth"; section>
<#if section = "header"> <#if section = "header">
<#if client.attributes.logoUri??>
<img src="${client.attributes.logoUri}"/>
</#if>
<p>
<#if client.name?has_content> <#if client.name?has_content>
${msg("oauthGrantTitle",advancedMsg(client.name))} ${msg("oauthGrantTitle",advancedMsg(client.name))}
<#else> <#else>
${msg("oauthGrantTitle",client.clientId)} ${msg("oauthGrantTitle",client.clientId)}
</#if> </#if>
</p>
<#elseif section = "form"> <#elseif section = "form">
<div id="kc-oauth" class="content-area"> <div id="kc-oauth" class="content-area">
<h3>${msg("oauthGrantRequest")}</h3> <h3>${msg("oauthGrantRequest")}</h3>
@ -18,6 +23,23 @@
</#list> </#list>
</#if> </#if>
</ul> </ul>
<#if client.attributes.policyUri?? || client.attributes.tosUri??>
<h3>
<#if client.name?has_content>
${msg("oauthGrantInformation",advancedMsg(client.name))}
<#else>
${msg("oauthGrantInformation",client.clientId)}
</#if>
<#if client.attributes.tosUri??>
${msg("oauthGrantReview")}
<a href="${client.attributes.tosUri}" target="_blank">${msg("oauthGrantTos")}</a>
</#if>
<#if client.attributes.policyUri??>
${msg("oauthGrantReview")}
<a href="${client.attributes.policyUri}" target="_blank">${msg("oauthGrantPolicy")}</a>
</#if>
</h3>
</#if>
<form class="form-actions" action="${url.oauthAction}" method="POST"> <form class="form-actions" action="${url.oauthAction}" method="POST">
<input type="hidden" name="code" value="${oauth.code}"> <input type="hidden" name="code" value="${oauth.code}">

View file

@ -35,6 +35,10 @@ loginIdpReviewProfileTitle=Update Account Information
loginTimeout=Your login attempt timed out. Login will start from the beginning. loginTimeout=Your login attempt timed out. Login will start from the beginning.
oauthGrantTitle=Grant Access to {0} oauthGrantTitle=Grant Access to {0}
oauthGrantTitleHtml={0} oauthGrantTitleHtml={0}
oauthGrantInformation=Make sure you trust {0} by learning how {0} will handle your data.
oauthGrantReview=You could review the
oauthGrantTos=terms of service.
oauthGrantPolicy=privacy policy.
errorTitle=We are sorry... errorTitle=We are sorry...
errorTitleHtml=We are <strong>sorry</strong> ... errorTitleHtml=We are <strong>sorry</strong> ...
emailVerifyTitle=Email verification emailVerifyTitle=Email verification

View file

@ -113,6 +113,8 @@ removeModalTitle=Remove Access
removeModalMessage=This will remove the currently granted access permission for {0}. You will need to grant access again if you want to use this app. removeModalMessage=This will remove the currently granted access permission for {0}. You will need to grant access again if you want to use this app.
confirmButton=Confirm confirmButton=Confirm
infoMessage=By clicking 'Remove Access', you will remove granted permissions of this application. This application will no longer use your information. infoMessage=By clicking 'Remove Access', you will remove granted permissions of this application. This application will no longer use your information.
termsOfService=Terms of service
policy=Privacy policy
#Delete Account page #Delete Account page
doDelete=Delete doDelete=Delete

View file

@ -68,6 +68,9 @@ interface Application {
offlineAccess: boolean; offlineAccess: boolean;
userConsentRequired: boolean; userConsentRequired: boolean;
scope: string[]; scope: string[];
logoUri: string;
policyUri: string;
tosUri: string;
} }
export class ApplicationsPage extends React.Component<ApplicationsPageProps, ApplicationsPageState> { export class ApplicationsPage extends React.Component<ApplicationsPageProps, ApplicationsPageState> {
@ -175,7 +178,7 @@ export class ApplicationsPage extends React.Component<ApplicationsPageProps, App
id={this.elementId("expandable", application)} id={this.elementId("expandable", application)}
isHidden={!this.state.isRowOpen[appIndex]} isHidden={!this.state.isRowOpen[appIndex]}
> >
<Grid sm={12} md={12} lg={12}> <Grid sm={6} md={6} lg={6}>
<div className='pf-c-content'> <div className='pf-c-content'>
<GridItem><strong>{Msg.localize('client') + ': '}</strong> {application.clientId}</GridItem> <GridItem><strong>{Msg.localize('client') + ': '}</strong> {application.clientId}</GridItem>
{application.description && {application.description &&
@ -196,6 +199,8 @@ export class ApplicationsPage extends React.Component<ApplicationsPageProps, App
</React.Fragment> </React.Fragment>
) )
})} })}
{application.tosUri && <GridItem><strong>{Msg.localize('termsOfService') + ': '}</strong>{application.tosUri}</GridItem>}
{application.policyUri && <GridItem><strong>{Msg.localize('policy') + ': '}</strong>{application.policyUri}</GridItem>}
<GridItem><strong>{Msg.localize('accessGrantedOn') + ': '}</strong> <GridItem><strong>{Msg.localize('accessGrantedOn') + ': '}</strong>
{new Intl.DateTimeFormat(locale, { {new Intl.DateTimeFormat(locale, {
year: 'numeric', year: 'numeric',
@ -209,6 +214,7 @@ export class ApplicationsPage extends React.Component<ApplicationsPageProps, App
</React.Fragment> </React.Fragment>
} }
</div> </div>
{application.logoUri && <div className='pf-c-content'><img src={application.logoUri} /></div> }
</Grid> </Grid>
{(application.consent || application.offlineAccess) && {(application.consent || application.offlineAccess) &&
<Grid gutter='sm'> <Grid gutter='sm'>