8000 Emit credential_id on UPDATE_CREDENTIAL events from built-in required actions by nunofaria11 · Pull Request #50787 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/documentation/release_notes/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ include::topics/templates/document-attributes.adoc[]
:release_header_latest_link: {releasenotes_link_latest}
include::topics/templates/release-header.adoc[]

== {project_name_full} 27.0.0
include::topics/27_0_0.adoc[leveloffset=2]

== {project_name_full} 26.8.0
include::topics/26_8_0.adoc[leveloffset=2]

Expand Down
1 change: 0 additions & 1 deletion docs/documentation/release_notes/topics/26_8_0.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -277,4 +277,3 @@ For setup instructions and common configuration patterns, see link:{adminguide_l
The social identity provider section on the login page has been refreshed with updated icons, a new divider layout, and improved button labels.

If you have a custom login theme, see the link:{upgradingguide_link}[{upgradingguide_name}] for details on what changed.

5 changes: 5 additions & 0 deletions docs/documentation/release_notes/topics/27_0_0.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
= Security and Standards

== Credential ID in credential update events

The `UPDATE_CREDENTIAL` events emitted by built-in required actions now carry the `credential_id` detail when a credential was created or updated in the {project_name} credential store, as the `REMOVE_CREDENTIAL` event already did. The detail is omitted when an external user-storage provider handles the update because there is no {project_name} credential ID to report.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

package org.keycloak.models.cache.infinispan;

import java.util.Optional;

import org.keycloak.credential.CredentialInput;
import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.UserCredentialManager;
Expand All @@ -41,6 +43,12 @@ public boolean updateCredential(CredentialInput input) {
return super.updateCredential(input);
}

@Override
public Optional<CredentialModel> updateCredentialAndGet(CredentialInput input) {
invalidateCacheForEntity();
return super.updateCredentialAndGet(input);
}

@Override
public void updateStoredCredential(CredentialModel cred) {
invalidateCacheForEntity();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;

import org.keycloak.common.util.reflections.Types;
Expand Down Expand Up @@ -93,21 +94,37 @@ public boolean isValid(List<CredentialInput> inputs) {

@Override
public boolean updateCredential(CredentialInput input) {
return update(input).handled();
}

@Override
public Optional<CredentialModel> updateCredentialAndGet(CredentialInput input) {
return Optional.ofNullable(update(input).storedCredential());
}

/**
* Updates a credential of the user with the given input on the updater handling that credential type.
*/
private CredentialUpdate update(CredentialInput input) {
if (!StorageId.isLocalStorage(user.getId())) throwExceptionIfInvalidUser(user);

if (user.isFederated()) {
UserStorageProviderModel model = getStorageProviderModel(realm, user.getFederationLink());
if (model == null || !model.isEnabled()) return false;
if (model == null || !model.isEnabled()) return CredentialUpdate.NOT_HANDLED;

CredentialInputUpdater updater = getStorageProviderInstance(model, CredentialInputUpdater.class);
if (updater != null && updater.supportsCredentialType(input.getType())) {
if (updater.updateCredential(realm, user, input)) return true;
CredentialUpdate update = updater.updateCredentialAndGet(realm, user, input);
if (update.handled()) return update;
}
}

return getCredentialProviders(session, CredentialInputUpdater.class)
.filter(updater -> updater.supportsCredentialType(input.getType()))
.anyMatch(updater -> updater.updateCredential(realm, user, input));
.map(updater -> updater.updateCredentialAndGet(realm, user, input))
.filter(CredentialUpdate::handled)
.findFirst()
.orElse(CredentialUpdate.NOT_HANDLED);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,11 @@ public static boolean createOTPCredential(KeycloakSession session, RealmModel re

/**
* Create RecoveryCodes credential either in userStorage or local storage (Keycloak DB)
*
* @return the credential created in the local storage, or {@code null} when it was created in the userStorage
* and hence there is no Keycloak credential ID available
*/
public static void createRecoveryCodesCredential(KeycloakSession session, RealmModel realm, UserModel user, RecoveryAuthnCodesCredentialModel credentialModel, List<String> generatedCodes) {
public static CredentialModel createRecoveryCodesCredential(KeycloakSession session, RealmModel realm, UserModel user, RecoveryAuthnCodesCredentialModel credentialModel, List<String> generatedCodes) {
Comment thread
mabartos marked this conversation as resolved.
Resolved
Comment thread
nunofaria11 marked this conversation as resolved.
Resolved
var recoveryCodeCredentialProvider = session.getProvider(CredentialProvider.class, "keycloak-recovery-authn-codes");
String recoveryCodesJson;
try {
Expand All @@ -126,8 +129,9 @@ public static void createRecoveryCodesCredential(KeycloakSession session, RealmM
boolean userStorageCreated = user.credentialManager().updateCredential(recoveryCodesCredential);
if (userStorageCreated) {
logger.debugf("Created RecoveryCodes credential for user '%s' in the user storage", user.getUsername());
return null;
} else {
recoveryCodeCredentialProvider.createCredential(realm, user, credentialModel);
return recoveryCodeCredentialProvider.createCredential(realm, user, credentialModel);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,13 @@ public interface CredentialInputUpdater {
default Stream<CredentialModel> getCredentials(RealmModel realm, UserModel user) {
return Stream.empty();
}

/**
* Updates a credential of the user with the given input, reporting the credential written to the Keycloak
* credential store. The default implementation delegates to
* {@link #updateCredential(RealmModel, UserModel, CredentialInput)} and reports no credential.
*/
default CredentialUpdate updateCredentialAndGet(RealmModel realm, UserModel user, CredentialInput input) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use also the Optional<CredentialModel> and not introduce the CredentialUpdate? Do we need to distinguish anywhere whether it's empty due to some error, or comes from the external user storage?

@nunofaria11 nunofaria11 Sep 17, 2026 •
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the CredentialUpdate record is useful here. We need to distinguish whether an update took the input and whether this update actually produced a credential.

The scenarios are

  • LDAP in WRITABLE mode: writes the password to LDAP (no KC credential, so no id in the event); handled = true
  • LDAP in UNSYNCED mode: rejects the update and the next PasswordCredentialProvider takes it; handled = false

If an Optional is used, both are Optional.empty(), and the UserCredentialManager cannot decide whether to move to the next provider for the update.

return new CredentialUpdate(updateCredential(realm, user, input), null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.keycloak.credential;

/**
* The outcome of a credential update performed by a {@link CredentialInputUpdater}.
*
* @param handled whether the updater handled the input
* @param storedCredential the credential written to the Keycloak credential store, or {@code null} if the updater
* wrote it elsewhere or has no data about it
*/
public record CredentialUpdate(boolean handled, CredentialModel storedCredential) {

public static final CredentialUpdate NOT_HANDLED = new CredentialUpdate(false, null);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;

import org.keycloak.credential.CredentialInput;
Expand Down Expand Up @@ -54,6 +55,22 @@ default boolean isValid(CredentialInput... inputs) {
*/
boolean updateCredential(CredentialInput input);

/**
* Updates a credential of the entity with the inputs provided by the entity, and returns the credential as
* written to the Keycloak credential store.
* <p>
* The default implementation performs the update and reports no stored credential. Implementations writing
* credentials to the Keycloak credential store are expected to override it; leaving the default in place only
* means callers cannot identify the credential that was written.
*
* @return the credential written to the Keycloak credential store, or an empty {@link Optional} when nothing
* has been written there, for example because a user-storage provider handled the update
*/
default Optional<CredentialModel> updateCredentialAndGet(CredentialInput input) {
updateCredential(input);
return Optional.empty();
}

/**
* Updates a credential of the entity with an updated {@link CredentialModel}.
* Usually called by a {@link org.keycloak.credential.CredentialProvider}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.authentication.authenticators.browser.RecoveryAuthnCodesFormAuthenticator;
import org.keycloak.common.Profile;
import org.keycloak.credential.CredentialModel;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
Expand Down Expand Up @@ -135,7 +136,10 @@ public void processAction(RequiredActionContext reqActionContext) {
AuthenticatorUtil.logoutOtherSessions(reqActionContext);
}

createRecoveryCodesCredential(reqActionContext.getSession(), reqActionContext.getRealm(), reqActionContext.getUser(), credentialModel, generatedCodes);
CredentialModel createdCredential = createRecoveryCodesCredential(reqActionContext.getSession(), reqActionContext.getRealm(), reqActionContext.getUser(), credentialModel, generatedCodes);
if (createdCredential != null) {
event.detail(Details.CREDENTIAL_ID, createdCredential.getId());
}

reqActionContext.success();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ public void processAction(RequiredActionContext context) {
}

try {
user.credentialManager().updateCredential(UserCredentialModel.password(passwordNew, false));
user.credentialManager()
.updateCredentialAndGet(UserCredentialModel.password(passwordNew, false))
.ifPresent(credential -> event.detail(Details.CREDENTIAL_ID, credential.getId()));

context.success();
deprecatedEvent.success();
} catch (ModelException me) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ public void processAction(RequiredActionContext context) {
}

context.getAuthenticationSession().removeAuthNote(Constants.TOTP_SECRET_KEY);
if (credentialModel.getId() != null) {
event.detail(Details.CREDENTIAL_ID, credentialModel.getId());
}
Comment thread
nunofaria11 marked this conversation as resolved.
Resolved
context.success();
deprecatedEvent.success();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.CollectionUtil;
import org.keycloak.common.util.UriUtils;
import org.keycloak.credential.CredentialModel;
import org.keycloak.credential.CredentialProvider;
import org.keycloak.credential.WebAuthnCredentialModelInput;
import org.keycloak.credential.WebAuthnCredentialProvider;
Expand Down Expand Up @@ -337,7 +338,7 @@ public void processAction(RequiredActionContext context) {
WebAuthnCredentialProvider webAuthnCredProvider = (WebAuthnCredentialProvider) this.session.getProvider(CredentialProvider.class, getCredentialProviderId());
WebAuthnCredentialModel newCredentialModel = webAuthnCredProvider.getCredentialModelFromCredentialInput(credential, label);

webAuthnCredProvider.createCredential(context.getRealm(), context.getUser(), newCredentialModel);
CredentialModel createdCredential = webAuthnCredProvider.createCredential(context.getRealm(), context.getUser(), newCredentialModel);

String aaguid = newCredentialModel.getWebAuthnCredentialData().getAaguid();
logger.debugv("WebAuthn credential registration success for user {0}. credentialType = {1}, publicKeyCredentialId = {2}, publicKeyCredentialLabel = {3}, publicKeyCredentialAAGUID = {4}",
Expand All @@ -349,6 +350,10 @@ public void processAction(RequiredActionContext context) {
.detail(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, label)
.detail(WebAuthnConstants.PUBKEY_CRED_AAGUID_ATTR, aaguid);
context.getEvent().clone().event(originalEventType).success();
// the deprecated event is emitted first, so that it keeps carrying the same details as before
if (createdCredential != null) {
context.getEvent().detail(Details.CREDENTIAL_ID, createdCredential.getId());
}
context.success();
} catch (WebAuthnPolicyException wpe) {
logger.debug("WebAuthn policy violation during registration.", wpe);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ public CredentialModel createCredential(RealmModel realm, UserModel user, OTPCre
if (credentialModel.getCreatedDate() == null) {
credentialModel.setCreatedDate(Time.currentTimeMillis());
}
return user.credentialManager().createStoredCredential(credentialModel);
CredentialModel createdCredential = user.credentialManager().createStoredCredential(credentialModel);
if (createdCredential != null) {
credentialModel.setId(createdCredential.getId());
}
return createdCredential;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,28 @@ public PasswordCredentialModel getPassword(RealmModel realm, UserModel user) {
return PasswordCredentialModel.createFromCredentialModel(passwords.get(0));
}

public boolean createCredential(RealmModel realm, UserModel user, String password) {
public CredentialModel createCredential(RealmModel realm, UserModel user, String password) {
return createCredentialAndGet(realm, user, password).storedCredential();
}

private CredentialUpdate createCredentialAndGet(RealmModel realm, UserModel user, String password) {
PasswordPolicy policy = realm.getPasswordPolicy();

PolicyError error = session.getProvider(PasswordPolicyManagerProvider.class).validate(realm, user, password);
if (error != null) throw new ModelException(error.getMessage(), error.getParameters());

PasswordHashProvider hash = getHashProvider(policy);
if (hash == null) {
return false;
return CredentialUpdate.NOT_HANDLED;
}
try {
PasswordCredentialModel credentialModel = hash.encodedCredential(password, policy.getHashIterations());
credentialModel.setCreatedDate(Time.currentTimeMillis());
createCredential(realm, user, credentialModel);
CredentialModel storedCredential = createCredential(realm, user, credentialModel);
return new CredentialUpdate(true, storedCredential);
} catch (Throwable t) {
throw new ModelException(t.getMessage(), t);
}
return true;
}

@Override
Expand Down Expand Up @@ -187,7 +191,12 @@ public boolean supportsCredentialType(String credentialType) {

@Override
public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
return createCredential(realm, user, input.getChallengeResponse());
return updateCredentialAndGet(realm, user, input).handled();
}

@Override
public CredentialUpdate updateCredentialAndGet(RealmModel realm, UserModel user, CredentialInput input) {
return createCredentialAndGet(realm, user, input.getChallengeResponse());
}

@Override
Expand Down
Loading
Loading
0