From dd7ad5b86618d6262b1a2bdc9207a983672fb300 Mon Sep 17 00:00:00 2001 From: Marek Posolda Date: Wed, 20 Aug 2025 11:42:24 +0200 Subject: [PATCH] Ability to display 'authenticator provider' of the WebAuthn credential (#41615) closes #41613 Signed-off-by: mposolda Co-authored-by: Jon Koops Signed-off-by: Marek Posolda --- .../CredentialMetadataRepresentation.java | 11 ++ .../account/messages/messages_en.properties | 3 +- .../src/account-security/SigningIn.tsx | 29 ++++ js/apps/account-ui/src/api/representations.ts | 1 + .../models/utils/ModelToRepresentation.java | 3 + .../credential/CredentialMetadata.java | 11 ++ .../credential/CredentialProvider.java | 15 +++ .../credential/WebAuthnCredentialModel.java | 9 ++ .../WebAuthnCredentialPresentationData.java | 31 +++++ .../browser/WebAuthnMetadataService.java | 58 ++++++++ .../WebAuthnCredentialProvider.java | 52 ++++++-- .../WebAuthnCredentialProviderFactory.java | 17 ++- ...ebAuthnPasswordlessCredentialProvider.java | 5 +- ...PasswordlessCredentialProviderFactory.java | 25 +--- .../clientpolicy/ClientPoliciesUtil.java | 12 +- .../resources/admin/UserResource.java | 15 +++ ...DefaultSecurityProfileProviderFactory.java | 12 +- .../java/org/keycloak/utils/FileUtils.java | 35 +++++ .../resources/keycloak-webauthn-metadata.json | 124 ++++++++++++++++++ .../browser/WebAuthnMetadataServiceTest.java | 25 ++++ 20 files changed, 431 insertions(+), 62 deletions(-) create mode 100644 server-spi/src/main/java/org/keycloak/models/credential/dto/WebAuthnCredentialPresentationData.java create mode 100644 services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnMetadataService.java create mode 100644 services/src/main/java/org/keycloak/utils/FileUtils.java create mode 100644 services/src/main/resources/keycloak-webauthn-metadata.json create mode 100644 services/src/test/java/org/keycloak/authentication/authenticators/browser/WebAuthnMetadataServiceTest.java diff --git a/core/src/main/java/org/keycloak/representations/account/CredentialMetadataRepresentation.java b/core/src/main/java/org/keycloak/representations/account/CredentialMetadataRepresentation.java index 594ab0d64a0..3e81a3f7552 100644 --- a/core/src/main/java/org/keycloak/representations/account/CredentialMetadataRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/account/CredentialMetadataRepresentation.java @@ -1,10 +1,13 @@ package org.keycloak.representations.account; +import java.util.List; + import org.keycloak.representations.idm.CredentialRepresentation; public class CredentialMetadataRepresentation { LocalizedMessage infoMessage; + List infoProperties; LocalizedMessage warningMessageTitle; LocalizedMessage warningMessageDescription; @@ -27,6 +30,14 @@ public class CredentialMetadataRepresentation { this.infoMessage = infoMessage; } + public List getInfoProperties() { + return infoProperties; + } + + public void setInfoProperties(List infoProperties) { + this.infoProperties = infoProperties; + } + public LocalizedMessage getWarningMessageTitle() { return warningMessageTitle; } diff --git a/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties b/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties index 4703305c8a6..e76c1ad2066 100644 --- a/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties +++ b/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties @@ -182,6 +182,8 @@ sharedWithMe=Shared with Me username=Username webauthn-display-name=Passkey webauthn-help-text=Use your Passkey to sign in. +webauthn-authenticator-provider=Authenticator provider +webauthn-transports=Transport media webauthn-passwordless-display-name=Passkey webauthn-passwordless-help-text=Use your Passkey for passwordless sign in. passwordless=Passwordless @@ -216,4 +218,3 @@ domains=Domains refresh=Refresh termsAndConditionsDeclined=You need to accept the Terms and Conditions to continue defaultLocale=Choose... -webauthn-info= Transport media: {{0}} \ No newline at end of file diff --git a/js/apps/account-ui/src/account-security/SigningIn.tsx b/js/apps/account-ui/src/account-security/SigningIn.tsx index bc6e82cf399..34dcdae12f1 100644 --- a/js/apps/account-ui/src/account-security/SigningIn.tsx +++ b/js/apps/account-ui/src/account-security/SigningIn.tsx @@ -6,6 +6,10 @@ import { DataListItem, DataListItemCells, DataListItemRow, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, Dropdown, DropdownItem, MenuToggle, @@ -124,6 +128,7 @@ export const SigningIn = () => { } if ( credMetadata.infoMessage || + credMetadata.infoProperties || (credMetadata.warningMessageTitle && credMetadata.warningMessageDescription) ) { @@ -145,6 +150,30 @@ export const SigningIn = () => { )}

)} + {credMetadata.infoProperties && ( + + + + + + + {credMetadata.infoProperties.map((prop) => ( + + {t(prop.key)} + + {prop.parameters ? prop.parameters[0] : ""} + + + ))} + + + + )} {credMetadata.warningMessageTitle && credMetadata.warningMessageDescription && ( <> diff --git a/js/apps/account-ui/src/api/representations.ts b/js/apps/account-ui/src/api/representations.ts index 357100dbc3e..9f439e6f701 100644 --- a/js/apps/account-ui/src/api/representations.ts +++ b/js/apps/account-ui/src/api/representations.ts @@ -41,6 +41,7 @@ export interface CredentialMetadataRepresentationMessage { export interface CredentialMetadataRepresentation { infoMessage: CredentialMetadataRepresentationMessage; + infoProperties: CredentialMetadataRepresentationMessage[]; warningMessageTitle: CredentialMetadataRepresentationMessage; warningMessageDescription: CredentialMetadataRepresentationMessage; credential: CredentialRepresentation; diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 5a9a5d3917a..794e321bc86 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -719,6 +719,9 @@ public class ModelToRepresentation { rep.setCredential(ModelToRepresentation.toRepresentation(credentialMetadata.getCredentialModel())); rep.setInfoMessage(toLocalizedMessage(credentialMetadata.getInfoMessage())); + rep.setInfoProperties(credentialMetadata.getInfoProperties() == null ? null : credentialMetadata.getInfoProperties().stream() + .map(ModelToRepresentation::toLocalizedMessage) + .toList()); rep.setWarningMessageDescription(toLocalizedMessage(credentialMetadata.getWarningMessageDescription())); rep.setWarningMessageTitle(toLocalizedMessage(credentialMetadata.getWarningMessageTitle())); return rep; diff --git a/server-spi/src/main/java/org/keycloak/credential/CredentialMetadata.java b/server-spi/src/main/java/org/keycloak/credential/CredentialMetadata.java index dcc915c809c..e6d999e7386 100644 --- a/server-spi/src/main/java/org/keycloak/credential/CredentialMetadata.java +++ b/server-spi/src/main/java/org/keycloak/credential/CredentialMetadata.java @@ -1,7 +1,10 @@ package org.keycloak.credential; +import java.util.List; + public class CredentialMetadata { LocalizedMessage infoMessage; + List infoProperties; LocalizedMessage warningMessageTitle; LocalizedMessage warningMessageDescription; CredentialModel credentialModel; @@ -18,6 +21,10 @@ public class CredentialMetadata { return infoMessage; } + public List getInfoProperties() { + return infoProperties; + } + public LocalizedMessage getWarningMessageTitle() { return warningMessageTitle; } @@ -41,6 +48,10 @@ public class CredentialMetadata { this.infoMessage = message; } + public void setInfoProperties(List infoProperties) { + this.infoProperties = infoProperties; + } + public static class LocalizedMessage { private final String key; private final Object[] parameters; diff --git a/server-spi/src/main/java/org/keycloak/credential/CredentialProvider.java b/server-spi/src/main/java/org/keycloak/credential/CredentialProvider.java index 1856a788885..31d081ba2c1 100644 --- a/server-spi/src/main/java/org/keycloak/credential/CredentialProvider.java +++ b/server-spi/src/main/java/org/keycloak/credential/CredentialProvider.java @@ -40,6 +40,21 @@ public interface CredentialProvider extends Provider T getCredentialFromModel(CredentialModel model); + /** + * Get the credential (usually stored credential retrieved from the DB) and decorates it with additional metadata + * to be present for example in the admin console. Those additional metadata could be various metadata, which are not saved in the DB, + * but can be retrieved from saved data to be presented to admins/users in the nice way (For example display "authenticator Provider" + * for WebAuthn credential based on the AAGUID of WebAuthn credential) + * + * @param model stored credential retrieved from the DB + * @return credential model useful for the presentation (not necessarily only stored data, but possibly some other metadata added) + */ + default T getCredentialForPresentationFromModel(CredentialModel model) { + T presentationModel = getCredentialFromModel(model); + presentationModel.setFederationLink(model.getFederationLink()); + return presentationModel; + } + default T getDefaultCredential(KeycloakSession session, RealmModel realm, UserModel user) { CredentialModel model = user.credentialManager().getStoredCredentialsByTypeStream(getType()) .findFirst().orElse(null); diff --git a/server-spi/src/main/java/org/keycloak/models/credential/WebAuthnCredentialModel.java b/server-spi/src/main/java/org/keycloak/models/credential/WebAuthnCredentialModel.java index 8a3436ab486..a3f04cb0340 100644 --- a/server-spi/src/main/java/org/keycloak/models/credential/WebAuthnCredentialModel.java +++ b/server-spi/src/main/java/org/keycloak/models/credential/WebAuthnCredentialModel.java @@ -66,6 +66,15 @@ public class WebAuthnCredentialModel extends CredentialModel { return credentialModel; } + public static WebAuthnCredentialModel create(String id, String credentialType, Long createdDate, String userLabel, WebAuthnCredentialData credentialData, WebAuthnSecretData secretData) { + WebAuthnCredentialModel credentialModel = new WebAuthnCredentialModel(credentialType, credentialData, secretData); + credentialModel.fillCredentialModelFields(); + credentialModel.setId(id); + credentialModel.setCreatedDate(createdDate); + credentialModel.setUserLabel(userLabel); + return credentialModel; + } + public static WebAuthnCredentialModel createFromCredentialModel(CredentialModel credentialModel) { try { diff --git a/server-spi/src/main/java/org/keycloak/models/credential/dto/WebAuthnCredentialPresentationData.java b/server-spi/src/main/java/org/keycloak/models/credential/dto/WebAuthnCredentialPresentationData.java new file mode 100644 index 00000000000..ec242b520a5 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/credential/dto/WebAuthnCredentialPresentationData.java @@ -0,0 +1,31 @@ +package org.keycloak.models.credential.dto; + +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author Marek Posolda + */ +public class WebAuthnCredentialPresentationData extends WebAuthnCredentialData { + + private final String authenticatorProvider; + + @JsonCreator + public WebAuthnCredentialPresentationData(@JsonProperty("aaguid") String aaguid, + @JsonProperty("credentialId") String credentialId, + @JsonProperty("counter") long counter, + @JsonProperty("attestationStatement") String attestationStatement, + @JsonProperty("credentialPublicKey") String credentialPublicKey, + @JsonProperty("attestationStatementFormat") String attestationStatementFormat, + @JsonProperty("transports") Set transports, + @JsonProperty("authenticatorProvider") String authenticatorProvider) { + super(aaguid, credentialId, counter, attestationStatement, credentialPublicKey, attestationStatementFormat, transports); + this.authenticatorProvider = authenticatorProvider; + } + + public String getAuthenticatorProvider() { + return authenticatorProvider; + } +} diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnMetadataService.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnMetadataService.java new file mode 100644 index 00000000000..aba3210f8f0 --- /dev/null +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnMetadataService.java @@ -0,0 +1,58 @@ +package org.keycloak.authentication.authenticators.browser; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.keycloak.util.JsonSerialization; +import org.keycloak.utils.FileUtils; + +/** + * Provides metadata for WebAuthn credentials + * + * @author Marek Posolda + */ +public class WebAuthnMetadataService { + + // Based on https://github.com/duo-labs/webauthn.io/blob/master/_app/homepage/services/metadata.py + private static final String FILE_NAME = "keycloak-webauthn-metadata.json"; + + private Map aaguidToProviderNames; + + private Map readMetadata() { + try { + try (InputStream is = FileUtils.getJsonFileFromClasspathOrConfFolder(FILE_NAME)) { + Map> map = JsonSerialization.readValue(is, new TypeReference<>() {}); + return map.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> { + String value = entry.getValue().get("name"); + if (value == null) { + throw new IllegalStateException("Not found 'name' for the AAGUID '" + entry.getKey() + "' in the file '" + FILE_NAME + "'."); + } + return value; + })); + } + } catch (IOException ioe) { + throw new IllegalStateException("Error loading the webauthn metadata from file " + FILE_NAME, ioe); + } + + } + + private Map getAaguidToProviderNames() { + if (aaguidToProviderNames == null) { + synchronized (this) { + if (aaguidToProviderNames == null) { + // Make sure the file is not parsed during server startup, but "lazily" when needed for the 1st time + this.aaguidToProviderNames = readMetadata(); + } + } + } + return aaguidToProviderNames; + } + + public String getAuthenticatorProvider(String aaguid) { + return aaguid == null ? null : getAaguidToProviderNames().get(aaguid); + } +} diff --git a/services/src/main/java/org/keycloak/credential/WebAuthnCredentialProvider.java b/services/src/main/java/org/keycloak/credential/WebAuthnCredentialProvider.java index 4357519f960..fb47bb1193a 100644 --- a/services/src/main/java/org/keycloak/credential/WebAuthnCredentialProvider.java +++ b/services/src/main/java/org/keycloak/credential/WebAuthnCredentialProvider.java @@ -35,6 +35,7 @@ import com.webauthn4j.verifier.OriginVerifierImpl; import com.webauthn4j.verifier.exception.BadOriginException; import jakarta.annotation.Nonnull; import org.jboss.logging.Logger; +import org.keycloak.authentication.authenticators.browser.WebAuthnMetadataService; import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; import org.keycloak.common.util.Base64; import org.keycloak.common.util.Time; @@ -44,10 +45,12 @@ import org.keycloak.models.UserModel; import org.keycloak.models.WebAuthnPolicy; import org.keycloak.models.credential.WebAuthnCredentialModel; import org.keycloak.models.credential.dto.WebAuthnCredentialData; +import org.keycloak.models.credential.dto.WebAuthnCredentialPresentationData; import org.keycloak.util.JsonSerialization; import java.io.IOException; import java.util.Arrays; +import java.util.LinkedList; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -58,19 +61,19 @@ import java.util.stream.Collectors; public class WebAuthnCredentialProvider implements CredentialProvider, CredentialInputValidator { private static final Logger logger = Logger.getLogger(WebAuthnCredentialProvider.class); - private static final String WEBAUTHN_INFO = "webauthn-info"; + private static final String WEBAUTHN_AUTHENTICATOR_PROVIDER = "webauthn-authenticator-provider"; + private static final String WEBAUTHN_TRANSPORTS = "webauthn-transports"; - private KeycloakSession session; + private final KeycloakSession session; + private final WebAuthnMetadataService metadataService; + private final CredentialPublicKeyConverter credentialPublicKeyConverter; + private final AttestationStatementConverter attestationStatementConverter; - private CredentialPublicKeyConverter credentialPublicKeyConverter; - private AttestationStatementConverter attestationStatementConverter; - - public WebAuthnCredentialProvider(KeycloakSession session, ObjectConverter objectConverter) { + public WebAuthnCredentialProvider(KeycloakSession session, WebAuthnMetadataService metadataService, ObjectConverter objectConverter) { this.session = session; - if (credentialPublicKeyConverter == null) - credentialPublicKeyConverter = new CredentialPublicKeyConverter(objectConverter); - if (attestationStatementConverter == null) - attestationStatementConverter = new AttestationStatementConverter(objectConverter); + this.metadataService = metadataService; + this.credentialPublicKeyConverter = new CredentialPublicKeyConverter(objectConverter); + this.attestationStatementConverter = new AttestationStatementConverter(objectConverter); } @Override @@ -93,6 +96,21 @@ public class WebAuthnCredentialProvider implements CredentialProvider properties = new LinkedList<>(); try { - WebAuthnCredentialData credentialData = JsonSerialization.readValue(credentialModel.getCredentialData(), WebAuthnCredentialData.class); + credentialModel = getCredentialForPresentationFromModel(credentialModel); + WebAuthnCredentialPresentationData credentialData = JsonSerialization.readValue(credentialModel.getCredentialData(), WebAuthnCredentialPresentationData.class); Set transports = credentialData.getTransports(); + if (credentialData.getAuthenticatorProvider() != null) { + properties.add(new CredentialMetadata.LocalizedMessage(WEBAUTHN_AUTHENTICATOR_PROVIDER, new String[] { credentialData.getAuthenticatorProvider() })); + } if (transports != null && !transports.isEmpty()) { String joinedTransports = String.join(", ", transports); - credentialMetadata.setInfoMessage(WEBAUTHN_INFO, joinedTransports); - + properties.add(new CredentialMetadata.LocalizedMessage(WEBAUTHN_TRANSPORTS, new String[] { joinedTransports })); + } + if (!properties.isEmpty()) { + credentialMetadata.setInfoProperties(properties); } - } catch ( IOException e) { logger.warn("unable to deserialize model information, skipping messages", e); diff --git a/services/src/main/java/org/keycloak/credential/WebAuthnCredentialProviderFactory.java b/services/src/main/java/org/keycloak/credential/WebAuthnCredentialProviderFactory.java index d8e4224d26c..b1db157dce4 100644 --- a/services/src/main/java/org/keycloak/credential/WebAuthnCredentialProviderFactory.java +++ b/services/src/main/java/org/keycloak/credential/WebAuthnCredentialProviderFactory.java @@ -18,6 +18,7 @@ package org.keycloak.credential; import com.webauthn4j.converter.util.ObjectConverter; import org.keycloak.Config; +import org.keycloak.authentication.authenticators.browser.WebAuthnMetadataService; import org.keycloak.common.Profile; import org.keycloak.models.KeycloakSession; import org.keycloak.provider.EnvironmentDependentProviderFactory; @@ -27,13 +28,14 @@ public class WebAuthnCredentialProviderFactory implements CredentialProviderFact public static final String PROVIDER_ID = "keycloak-webauthn"; private ObjectConverter converter; + private volatile WebAuthnMetadataService metadataService; @Override public CredentialProvider create(KeycloakSession session) { - return new WebAuthnCredentialProvider(session, createOrGetObjectConverter()); + return new WebAuthnCredentialProvider(session, getMetadataService(), createOrGetObjectConverter()); } - private ObjectConverter createOrGetObjectConverter() { + protected ObjectConverter createOrGetObjectConverter() { if (converter == null) { synchronized (this) { if (converter == null) { @@ -53,4 +55,15 @@ public class WebAuthnCredentialProviderFactory implements CredentialProviderFact public boolean isSupported(Config.Scope config) { return Profile.isFeatureEnabled(Profile.Feature.WEB_AUTHN); } + + protected WebAuthnMetadataService getMetadataService() { + if (metadataService == null) { + synchronized (this) { + if (metadataService == null) { + this.metadataService = new WebAuthnMetadataService(); + } + } + } + return this.metadataService; + } } diff --git a/services/src/main/java/org/keycloak/credential/WebAuthnPasswordlessCredentialProvider.java b/services/src/main/java/org/keycloak/credential/WebAuthnPasswordlessCredentialProvider.java index b5cef67626c..30e00f65047 100644 --- a/services/src/main/java/org/keycloak/credential/WebAuthnPasswordlessCredentialProvider.java +++ b/services/src/main/java/org/keycloak/credential/WebAuthnPasswordlessCredentialProvider.java @@ -19,6 +19,7 @@ package org.keycloak.credential; import com.webauthn4j.converter.util.ObjectConverter; +import org.keycloak.authentication.authenticators.browser.WebAuthnMetadataService; import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory; import org.keycloak.models.KeycloakSession; import org.keycloak.models.WebAuthnPolicy; @@ -31,8 +32,8 @@ import org.keycloak.models.credential.WebAuthnCredentialModel; */ public class WebAuthnPasswordlessCredentialProvider extends WebAuthnCredentialProvider { - public WebAuthnPasswordlessCredentialProvider(KeycloakSession session, ObjectConverter objectConverter) { - super(session, objectConverter); + public WebAuthnPasswordlessCredentialProvider(KeycloakSession session, WebAuthnMetadataService metadataService, ObjectConverter objectConverter) { + super(session, metadataService, objectConverter); } @Override diff --git a/services/src/main/java/org/keycloak/credential/WebAuthnPasswordlessCredentialProviderFactory.java b/services/src/main/java/org/keycloak/credential/WebAuthnPasswordlessCredentialProviderFactory.java index 77433239d5f..f57a89307e4 100644 --- a/services/src/main/java/org/keycloak/credential/WebAuthnPasswordlessCredentialProviderFactory.java +++ b/services/src/main/java/org/keycloak/credential/WebAuthnPasswordlessCredentialProviderFactory.java @@ -18,35 +18,18 @@ package org.keycloak.credential; -import com.webauthn4j.converter.util.ObjectConverter; -import org.keycloak.Config; -import org.keycloak.common.Profile; import org.keycloak.models.KeycloakSession; -import org.keycloak.provider.EnvironmentDependentProviderFactory; /** * @author Marek Posolda */ -public class WebAuthnPasswordlessCredentialProviderFactory implements CredentialProviderFactory, EnvironmentDependentProviderFactory { +public class WebAuthnPasswordlessCredentialProviderFactory extends WebAuthnCredentialProviderFactory { public static final String PROVIDER_ID = "keycloak-webauthn-passwordless"; - private ObjectConverter converter; - @Override public CredentialProvider create(KeycloakSession session) { - return new WebAuthnPasswordlessCredentialProvider(session, createOrGetObjectConverter()); - } - - private ObjectConverter createOrGetObjectConverter() { - if (converter == null) { - synchronized (this) { - if (converter == null) { - converter = new ObjectConverter(); - } - } - } - return converter; + return new WebAuthnPasswordlessCredentialProvider(session, getMetadataService(), createOrGetObjectConverter()); } @Override @@ -54,8 +37,4 @@ public class WebAuthnPasswordlessCredentialProviderFactory implements Credential return PROVIDER_ID; } - @Override - public boolean isSupported(Config.Scope config) { - return Profile.isFeatureEnabled(Profile.Feature.WEB_AUTHN); - } } diff --git a/services/src/main/java/org/keycloak/services/clientpolicy/ClientPoliciesUtil.java b/services/src/main/java/org/keycloak/services/clientpolicy/ClientPoliciesUtil.java index 54956d72643..019b6672e7b 100644 --- a/services/src/main/java/org/keycloak/services/clientpolicy/ClientPoliciesUtil.java +++ b/services/src/main/java/org/keycloak/services/clientpolicy/ClientPoliciesUtil.java @@ -54,6 +54,7 @@ import org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProvide import org.keycloak.services.clientpolicy.condition.ClientPolicyConditionProviderFactory; import org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorProvider; import org.keycloak.util.JsonSerialization; +import org.keycloak.utils.FileUtils; /** * Utilities for treating client policies/profiles @@ -66,16 +67,7 @@ public class ClientPoliciesUtil { public static InputStream getJsonFileFromClasspathOrConfFolder(String name) throws IOException { final String fileName = name + ".json"; - // first try to read the json configuration file from classpath - InputStream is = ClientPoliciesUtil.class.getResourceAsStream("/" + fileName); - if (is == null) { - Path path = Paths.get(System.getProperty("jboss.server.config.dir")).resolve(fileName); - if (!Files.isReadable(path)) { - throw new IOException(String.format("File \"%s\" does not exists under the config folder", path)); - } - is = Files.newInputStream(path); - } - return is; + return FileUtils.getJsonFileFromClasspathOrConfFolder(fileName); } public static List readGlobalClientProfilesRepresentation(KeycloakSession session, String name) throws ClientPolicyException { diff --git a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java index ef1b3039e5f..e26b1315cdd 100755 --- a/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java +++ b/services/src/main/java/org/keycloak/services/resources/admin/UserResource.java @@ -27,6 +27,7 @@ import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.NoCache; +import org.keycloak.authentication.AuthenticatorUtil; import org.keycloak.authentication.RequiredActionProvider; import org.keycloak.authentication.actiontoken.execactions.ExecuteActionsActionToken; import org.keycloak.authentication.actiontoken.verifyemail.VerifyEmailActionToken; @@ -36,6 +37,7 @@ import org.keycloak.common.Profile; import org.keycloak.common.util.CollectionUtil; import org.keycloak.common.util.Time; import org.keycloak.credential.CredentialModel; +import org.keycloak.credential.CredentialProvider; import org.keycloak.email.EmailException; import org.keycloak.email.EmailTemplateProvider; import org.keycloak.events.Details; @@ -810,10 +812,23 @@ public class UserResource { auth.users().requireView(user); return user.credentialManager().getCredentials() + .map(this::decorateCredentialForPresentation) .map(ModelToRepresentation::toRepresentation) .peek(credentialRepresentation -> credentialRepresentation.setSecretData(null)); } + private CredentialModel decorateCredentialForPresentation(CredentialModel credential) { + CredentialProvider credentialProvider = AuthenticatorUtil.getCredentialProviders(session) + .filter(p -> p.supportsCredentialType(credential.getType())) + .findFirst().orElse(null); + if (credentialProvider == null) { + logger.warnf("Credential Provider not found for credential of type '%s'", credential.getType()); + return credential; + } + + return credentialProvider.getCredentialForPresentationFromModel(credential); + } + /** * Return credential types, which are provided by the user storage where user is stored. Returned values can contain for example "password", "otp" etc. * This will always return empty list for "local" users, which are not backed by any user storage diff --git a/services/src/main/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProviderFactory.java b/services/src/main/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProviderFactory.java index ad1e3e8c5ed..9760fe9f790 100644 --- a/services/src/main/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProviderFactory.java +++ b/services/src/main/java/org/keycloak/services/securityprofile/DefaultSecurityProfileProviderFactory.java @@ -34,6 +34,7 @@ import org.keycloak.securityprofile.SecurityProfileProviderFactory; import org.keycloak.services.clientpolicy.ClientPoliciesUtil; import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.util.JsonSerialization; +import org.keycloak.utils.FileUtils; /** * The default implementation for the security profile. It reads the configuration @@ -89,16 +90,7 @@ public class DefaultSecurityProfileProviderFactory implements SecurityProfilePro SecurityProfileConfiguration conf; final String file = name + ".json"; try { - // first try to read the json configuration file from classpath - InputStream tmp = getClass().getResourceAsStream("/" + file); - if (tmp == null) { - Path path = Paths.get(System.getProperty("jboss.server.config.dir")).resolve(file); - if (!Files.isReadable(path)) { - throw new IOException(String.format("File %s does not exists in the conf folder", file)); - } - tmp = Files.newInputStream(path); - } - try (InputStream is = tmp) { + try (InputStream is = FileUtils.getJsonFileFromClasspathOrConfFolder(file)) { conf = JsonSerialization.readValue(is, SecurityProfileConfiguration.class); } // read the list of client profiles and policies validated diff --git a/services/src/main/java/org/keycloak/utils/FileUtils.java b/services/src/main/java/org/keycloak/utils/FileUtils.java new file mode 100644 index 00000000000..fdb61793501 --- /dev/null +++ b/services/src/main/java/org/keycloak/utils/FileUtils.java @@ -0,0 +1,35 @@ +package org.keycloak.utils; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.keycloak.services.clientpolicy.ClientPoliciesUtil; + +/** + * @author Marek Posolda + */ +public class FileUtils { + + /** + * Read the input stream from the specified file + * + * @param fileName file name without path + * @return input stream + * @throws IOException + */ + public static InputStream getJsonFileFromClasspathOrConfFolder(String fileName) throws IOException { + // first try to read the json configuration file from classpath + InputStream is = ClientPoliciesUtil.class.getResourceAsStream("/" + fileName); + if (is == null) { + Path path = Paths.get(System.getProperty("jboss.server.config.dir")).resolve(fileName); + if (!Files.isReadable(path)) { + throw new IOException(String.format("File \"%s\" does not exists under the config folder", path)); + } + is = Files.newInputStream(path); + } + return is; + } +} diff --git a/services/src/main/resources/keycloak-webauthn-metadata.json b/services/src/main/resources/keycloak-webauthn-metadata.json new file mode 100644 index 00000000000..aee9dad4d3e --- /dev/null +++ b/services/src/main/resources/keycloak-webauthn-metadata.json @@ -0,0 +1,124 @@ +{ + "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4": { + "name": "Google Password Manager" + }, + "adce0002-35bc-c60a-648b-0b25f1f05503": { + "name": "Chrome on Mac" + }, + "08987058-cadc-4b81-b6e1-30de50dcbe96": { + "name": "Windows Hello" + }, + "9ddd1817-af5a-4672-a2b9-3e3dd95000a9": { + "name": "Windows Hello" + }, + "6028b017-b1d4-4c02-b4b3-afcdafc96bb2": { + "name": "Windows Hello" + }, + "dd4ec289-e01d-41c9-bb89-70fa845d4bf2": { + "name": "iCloud Keychain (Managed)" + }, + "531126d6-e717-415c-9320-3d9aa6981239": { + "name": "Dashlane" + }, + "bada5566-a7aa-401f-bd96-45619a55120d": { + "name": "1Password" + }, + "b84e4048-15dc-4dd0-8640-f4f60813c8af": { + "name": "NordPass" + }, + "0ea242b4-43c4-4a1b-8b17-dd6d0b6baec6": { + "name": "Keeper" + }, + "f3809540-7f14-49c1-a8b3-8f813b225541": { + "name": "Enpass" + }, + "b5397666-4885-aa6b-cebf-e52262a439a2": { + "name": "Chromium Browser" + }, + "771b48fd-d3d4-4f74-9232-fc157ab0507a": { + "name": "Edge on Mac" + }, + "39a5647e-1853-446c-a1f6-a79bae9f5bc7": { + "name": "IDmelon" + }, + "d548826e-79b4-db40-a3d8-11116f7e8349": { + "name": "Bitwarden" + }, + "fbfc3007-154e-4ecc-8c0b-6e020557d7bd": { + "name": "iCloud Keychain" + }, + "53414d53-554e-4700-0000-000000000000": { + "name": "Samsung Pass" + }, + "66a0ccb3-bd6a-191f-ee06-e375c50b9846": { + "name": "Thales Bio iOS SDK" + }, + "8836336a-f590-0921-301d-46427531eee6": { + "name": "Thales Bio Android SDK" + }, + "cd69adb5-3c7a-deb9-3177-6800ea6cb72a": { + "name": "Thales PIN Android SDK" + }, + "17290f1e-c212-34d0-1423-365d729f09d9": { + "name": "Thales PIN iOS SDK" + }, + "0bb43545-fd2c-4185-87dd-feb0b2916ace": { + "name": "Security Key NFC by Yubico - Enterprise Edition (5.4)" + }, + "149a2021-8ef6-4133-96b8-81f8d5b7f1f5": { + "name": "Security Key by Yubico with NFC (5.2, 5.4)" + }, + "2fc0579f-8113-47ea-b116-bb5a8db9202a": { + "name": "YubiKey 5 Series with NFC (5.2, 5.4)" + }, + "6d44ba9b-f6ec-2e49-b930-0c8fe920cb73": { + "name": "Security Key by Yubico with NFC (5.1)" + }, + "73bb0cd4-e502-49b8-9c6f-b59445bf720b": {"name": "YubiKey 5 FIPS Series (5.4)"}, + "85203421-48f9-4355-9bc8-8a53846e5083": {"name": "YubiKey 5Ci FIPS (5.4)"}, + "a4e9fc6d-4cbe-4758-b8ba-37598bb5bbaa": { + "name": "Security Key by Yubico with NFC (5.4)" + }, + "b92c3f9a-c014-4056-887f-140a2501163b": {"name": "Security Key by Yubico (5.2)"}, + "c1f9a0bc-1dd2-404a-b27f-8e29047a43fd": { + "name": "YubiKey 5 FIPS Series with NFC (5.4)" + }, + "c5ef55ff-ad9a-4b9f-b580-adebafe026d0": {"name": "YubiKey 5Ci (5.2, 5.4)"}, + "cb69481e-8ff7-4039-93ec-0a2729a154a8": {"name": "YubiKey 5 Series (5.1)"}, + "d8522d9f-575b-4866-88a9-ba99fa02f35b": {"name": "YubiKey Bio Series (5.5, 5.6)"}, + "ee882879-721c-4913-9775-3dfcce97072a": {"name": "YubiKey 5 Series (5.2, 5.4)"}, + "f8a011f3-8c0a-4d15-8006-17111f9edc7d": {"name": "Security Key by Yubico (5.1)"}, + "fa2b99dc-9e39-4257-8f92-4a30d23c4118": {"name": "YubiKey 5 Series with NFC (5.1)"}, + "e77e3c64-05e3-428b-8824-0cbeb04b829d": { + "name": "Security Key by Yubico with NFC (5.7)" + }, + "47ab2fb4-66ac-4184-9ae1-86be814012d5": { + "name": "Security Key by Yubico with NFC - Enterprise Edition (5.7)" + }, + "a25342c0-3cdc-4414-8e46-f4807fca511c": {"name": "YubiKey 5 Series with NFC (5.7)"}, + "19083c3d-8383-4b18-bc03-8f1c9ab2fd1b": {"name": "YubiKey 5 Series (5.7)"}, + "a02167b9-ae71-4ac7-9a07-06432ebb6f1c": {"name": "YubiKey 5Ci (5.7)"}, + "7d1351a6-e097-4852-b8bf-c9ac5c9ce4a3": { + "name": "YubiKey Bio Series - Multi-protocol Edition (5.7)" + }, + "30b5035e-d297-4ff1-b00b-addc96ba6a98": {"name": "DIGIPASS FX1 Bio"}, + "30b5035e-d297-4ff1-010b-addc96ba6a98": {"name": "DIGIPASS FX1a Bio"}, + "30b5035e-d297-4ff2-b00b-addc96ba6a98": {"name": "DIGIPASS FX2"}, + "30b5035e-d297-4ff3-b00b-addc96ba6a98": {"name": "DIGIPASS FX3"}, + "30b5035e-d297-4ff7-b00b-addc96ba6a98": {"name": "DIGIPASS FX7"}, + "9ff4cc65-6154-4fff-ba09-9e2af7882ad2": { + "name": "Security Key by Yubico with NFC - Enterprise Edition (5.7, enterprise profile)" + }, + "1ac71f64-468d-4fe0-bef1-0e5f2f551f18": { + "name": "YubiKey 5 Series with NFC (5.7, enterprise profile)" + }, + "20ac7a17-c814-4833-93fe-539f0d5e3389": { + "name": "YubiKey 5 Series (5.7, enterprise profile)" + }, + "b90e7dc1-316e-4fee-a25a-56a666a670fe": { + "name": "YubiKey 5Ci (5.7, enterprise profile)" + }, + "68167692-ff5a-4ac8-9fd7-0be292a1af68": { + "name": "YubiKey Bio Series - Multi-protocol Edition (5.7, enterprise profile)" + } +} \ No newline at end of file diff --git a/services/src/test/java/org/keycloak/authentication/authenticators/browser/WebAuthnMetadataServiceTest.java b/services/src/test/java/org/keycloak/authentication/authenticators/browser/WebAuthnMetadataServiceTest.java new file mode 100644 index 00000000000..902076f759d --- /dev/null +++ b/services/src/test/java/org/keycloak/authentication/authenticators/browser/WebAuthnMetadataServiceTest.java @@ -0,0 +1,25 @@ +package org.keycloak.authentication.authenticators.browser; + +import org.junit.Assert; +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; + +/** + * @author Marek Posolda + */ +public class WebAuthnMetadataServiceTest { + + /** + * + */ + @Test + public void testWebAuthnMetadata() { + WebAuthnMetadataService service = new WebAuthnMetadataService(); + Assert.assertThat( service.getAuthenticatorProvider(null), nullValue()); + Assert.assertThat( service.getAuthenticatorProvider("00000000-0000-0000-0000-000000000000"), nullValue()); + Assert.assertThat( service.getAuthenticatorProvider("ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4"), is("Google Password Manager")); + Assert.assertThat( service.getAuthenticatorProvider("d548826e-79b4-db40-a3d8-11116f7e8349"), is("Bitwarden")); + } +}