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"));
+ }
+}