fix: guard against NPE when hashIterations/OTP fields are null in deprecated credentials import#51008
Conversation
…d credentials import When importing users via the deprecated 'credentials' JSON format, CredentialRepresentation fields (hashIterations, digits, counter, period) are nullable Integer types. Auto-unboxing a null Integer to primitive int throws NullPointerException when any field is absent from the payload. Fix: null-safe extraction with sensible defaults before constructing PasswordCredentialData (-1 for hashIterations — Keycloak's existing "use provider default" sentinel) and OTPCredentialData (6 digits, 0 counter, 30s period — standard TOTP/HOTP defaults). Fixes keycloak#41640 Signed-off-by: Nandini Sharma <46066660+nandinisharma3120@users.noreply.github.com>
|
|
||
| if (PasswordCredentialModel.TYPE.equals(cred.getType()) || PasswordCredentialModel.PASSWORD_HISTORY.equals(cred.getType())) { | ||
| PasswordCredentialData credentialData = new PasswordCredentialData(cred.getHashIterations(), cred.getAlgorithm()); | ||
| int hashIterations = cred.getHashIterations() != null ? cred.getHashIterations() : -1; |
| int hashIterations = cred.getHashIterations() != null ? cred.getHashIterations() : -1; | ||
| PasswordCredentialData credentialData = new PasswordCredentialData(hashIterations, cred.getAlgorithm()); |
| int digits = cred.getDigits() != null ? cred.getDigits() : 6; | ||
| int counter = cred.getCounter() != null ? cred.getCounter() : 0; | ||
| int period = cred.getPeriod() != null ? cred.getPeriod() : 30; |
There was a problem hiding this comment.
Fixed. Added createUserWithDeprecatedOtpCredentialsMissingFields() in UserCreateTest.java which imports a deprecated TOTP credential with all optional fields omitted (digits, counter, period, algorithm) and asserts the stored values match the defaults: digits=6, counter=0, period=30, algorithm=HmacOTP.DEFAULT_ALGORITHM. The RepresentationToModel change now defaults the algorithm to HmacOTP.DEFAULT_ALGORITHM when cred.getAlgorithm() is null, so Mac.getInstance() will never receive null.
… hashIterations Addresses Copilot review comment (line 261) on PR keycloak#51008: - Changed method signature to accept RealmModel realm - When hashIterations is null, fall back to realm.getPasswordPolicy().getHashIterations() - If realm policy returns -1, skip the credential explicitly with a warning - Updated call site in createCredentials() to pass the realm parameter Signed-off-by: Nandini Sharma <46066660+nandinisharma3120@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java:265
hashIterationsdescribes how the existing hash was produced, so substituting the destination realm's current policy silently makes imported passwords unverifiable whenever the source used a different iteration count. Missing hash metadata should be rejected/skipped rather than guessed, unless the deprecated format defines a source-algorithm default that can be derived reliably.
int policyIterations = realm.getPasswordPolicy().getHashIterations();
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java:270
continueonly exits this conversion loop;createCredentials()subsequently iterates the unchanged representation and stores it with null credential/secret data, so the warning's claim that it is skipped is false and authentication later encounters a malformed credential. Remove the rejected representation from the credentials passed to the creation loop, or return an explicit conversion result that the caller filters.
logger.warnf("Cannot convert deprecated password credential for user '%s': " +
"'hashIterations' is absent and no realm password policy provides a default. " +
"The credential will be skipped.", user.getUsername());
continue;
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java:266
- Add a regression test beside
UserCreateTest.createUserWithDeprecatedCredentialsFormat()for an omittedhashIterations, including a realm without an explicit iteration policy, and assert the resulting credential behavior. The reported NPE path and this new fallback/skip branch are currently untested.
if (cred.getHashIterations() != null) {
hashIterations = cred.getHashIterations();
} else {
int policyIterations = realm.getPasswordPolicy().getHashIterations();
if (policyIterations < 0) {
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java:282
- The new null defaults for deprecated OTP credentials have no regression coverage. Add an import test with omitted
digits,counter, andperiodthat verifies the persisted OTP data and successful validation for both TOTP/HOTP as applicable.
int digits = cred.getDigits() != null ? cred.getDigits() : 6;
int counter = cred.getCounter() != null ? cred.getCounter() : 0;
int period = cred.getPeriod() != null ? cred.getPeriod() : 30;
…ed credentials import Addresses Copilot review comments 2 and 3 on PR keycloak#51008: - Test for null hashIterations: sets realm policy to 30000 iterations, imports without hashIterations, verifies 30000 stored - Test for null OTP fields: imports TOTP without digits/counter/period, verifies standard defaults (6/0/30) Signed-off-by: Nandini Sharma <46066660+nandinisharma3120@users.noreply.github.com>
|
Thanks for the thorough review! I've pushed two follow-up commits addressing all three points: Comment 1 (line 261 — invalid Comments 2 & 3 (missing tests)
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java:270
- This
continueonly exits the conversion iteration; the unchanged representation remains inuser.getCredentials()and is subsequently passed tocreateCredentialThroughProvider()with null credential/secret data. The warning therefore claims the credential is skipped, but a malformed password credential is stored; actually remove it from the collection (or fail the import) beforecreateCredentials()processes it.
continue;
| int digits = cred.getDigits() != null ? cred.getDigits() : 6; | ||
| int counter = cred.getCounter() != null ? cred.getCounter() : 0; | ||
| int period = cred.getPeriod() != null ? cred.getPeriod() : 30; | ||
| OTPCredentialData credentialData = new OTPCredentialData(cred.getType(), digits, counter, period, cred.getAlgorithm(), null); |
When converting deprecated OTP credentials, cred.getAlgorithm() can be null, causing Mac.getInstance(null) to throw at OTP validation time. Default the algorithm to HmacOTP.DEFAULT_ALGORITHM when the field is absent, consistent with the digits/counter/period defaulting already in place. Also add import for org.keycloak.models.utils.HmacOTP. Signed-off-by: Nandini Sharma <46066660+nandinisharma3120@users.noreply.github.com>
| OTPCredentialData credentialData = new OTPString algorithm = cred.getAlgorithm() != null ? cred.getAlgorithm() : HmacOTP.DEFAULT_ALGORITHM; | ||
| OTPCredentialData credentialData = new OTPCredentialData(cred.getType(), digits, counter, period, algorithm, null); secretData = new OTPSecretData(cred.getHashedSaltedValue()); |
| int policyIterations = realm.getPasswordPolicy().getHashIterations(); | ||
| if (policyIterations < 0) { | ||
| logger.warnf("Cannot convert deprecated password credential for user '%s': " + | ||
| "'hashIterations' is absent and no realm password policy provides a default. " + | ||
| "The credential will be skipped.", user.getUsername()); |
There was a problem hiding this comment.
Thanks for the detailed feedback. We chose the skip-with-warning approach here because storing -1 in PasswordCredentialData.hashIterations would cause PBEKeySpec to throw at verification time (iteration count must be >= 1). Skipping the credential cleanly avoids importing data that cannot be used.
That said, if the preference is to keep the credential importable in the no-policy case, we could instead use a hardcoded safe default (e.g. 27500 for pbkdf2-sha256). We're happy to adjust the approach based on maintainer preference — please let us know which direction to take and we'll update accordingly.
Addresses Copilot review comment: assert that a null algorithm in a deprecated OTP credential is defaulted to HmacOTP.DEFAULT_ALGORITHM, confirming that Mac.getInstance() will not receive null. Also adds import for org.keycloak.models.utils.HmacOTP. Signed-off-by: Nandini Sharma <46066660+nandinisharma3120@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java:271
- This does not implement the documented fallback for the reported payload: when the realm has no explicit
hashIterationspolicy,getHashIterations()returns-1, so the credential is silently skipped and the new user has no password. Resolve the algorithm/provider default (and cover the unconfigured-policy case) rather than dropping the credential.
if (policyIterations < 0) {
logger.warnf("Cannot convert deprecated password credential for user '%s': " +
"'hashIterations' is absent and no realm password policy provides a default. " +
"The credential will be skipped.", user.getUsername());
continue;
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java:285
- These declarations are syntactically merged and duplicated:
OTPStringdoes not exist,credentialDatais declared twice, andsecretDatais undeclared, so this module cannot compile.
OTPCredentialData credentialData = new OTPString algorithm = cred.getAlgorithm() != null ? cred.getAlgorithm() : HmacOTP.DEFAULT_ALGORITHM;
OTPCredentialData credentialData = new OTPCredentialData(cred.getType(), digits, counter, period, algorithm, null); secretData = new OTPSecretData(cred.getHashedSaltedValue());
…ntialData constructor The previous commit incorrectly merged the new "String algorithm" line with the OTPCredentialData constructor call, producing "new OTPString algorithm = ..." which does not compile. This commit removes the spurious OTPCredentialData prefix so that line 284 is a clean standalone String declaration and line 285 remains the correct OTPCredentialData constructor using the algorithm variable. Signed-off-by: Nandini Sharma <46066660+nandinisharma3120@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java:285
secretDatais assigned without being declared, so this module will not compile. Restore theOTPSecretDatalocal declaration as a separate statement.
OTPCredentialData credentialData = new OTPCredentialData(cred.getType(), digits, counter, period, algorithm, null); secretData = new OTPSecretData(cred.getHashedSaltedValue());
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java:271
- This
continueonly exits the conversion iteration;createCredentials()subsequently iterates the unchanged representation and passes its nullcredentialData/secretDatato the password provider. Thus a realm without an explicithashIterationspolicy does not actually skip this credential as logged and can persist an unusable password credential; remove it from the credentials collection (using an iterator) or otherwise exclude it from the later creation loop.
continue;
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Nandini Sharma <46066660+nandinisharma3120@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java:285
secretDatais assigned without being declared, so this module will not compile. Split the statement and restore theOTPSecretDatalocal declaration.
OTPCredentialData credentialData = new OTPCredentialData(cred.getType(), digits, counter, period, algorithm, null); secretData = new OTPSecretData(cred.getHashedSaltedValue());
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java:271
- This
continueonly exits the conversion loop iteration;createCredentials()subsequently submits the same unconverted representation, and the password provider stores it with null credential/secret data. The credential therefore is not actually skipped as the warning claims; remove rejected credentials from the later creation path or return an explicit rejection result.
if (policyIterations < 0) {
logger.warnf("Cannot convert deprecated password credential for user '%s': " +
"'hashIterations' is absent and no realm password policy provides a default. " +
"The credential will be skipped.", user.getUsername());
continue;
tests/base/src/test/java/org/keycloak/tests/admin/user/UserCreateTest.java:286
- The cleanup lambda captures
realmRep, which is mutated on the next line, so cleanup reapplies the 30000-iteration policy instead of restoring the original realm and leaks state into later tests. Use the managed realm's cloning cleanup helper for this update.
managedRealm.admin().update(realmRep);
UserRepresentation user = new UserRepresentation();
user.setUsername("user_creds_no_iter");
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java:285
- This does not compile because
secretDatais assigned without being declared. Declare theOTPSecretDatalocal separately before serializing it.
OTPCredentialData credentialData = new OTPCredentialData(cred.getType(), digits, counter, period, algorithm, null); secretData = new OTPSecretData(cred.getHashedSaltedValue());
|
Hi @nandinisharma3120, thank you for the effort and the thorough investigation of the root cause - your PR description explaining the auto-unboxing issue is well written. However, we're going to go with the validation approach from PR #49316 instead. This is a deprecated credential import path - the password was already hashed by an external source, and the OTP authenticator was already configured externally. Defaulting missing fields creates credentials that import successfully but fail silently at verification time:
Rejecting with a clear Closing this as a duplicate - thank you for the contribution, and we hope you'll pick up another issue! |
Summary
Fixes #41640
When importing users via the deprecated
credentialsJSON format,convertDeprecatedCredentialsFormat()passesCredentialRepresentationfields directly to constructors that expect primitiveintparameters. Since these fields are declared asInteger(nullable boxed type) inCredentialRepresentation, Java auto-unboxing throwsNullPointerExceptionwhen any field is absent from the import payload.Root Cause
CredentialRepresentation.getHashIterations()(andgetDigits(),getCounter(),getPeriod()) returnsInteger, which is nullable. ThePasswordCredentialDataandOTPCredentialDataconstructors both take primitiveint— auto-unboxing a nullIntegertointcauses the NPE reported in #41640.Fix
Null-safe extraction with sensible defaults before constructing the credential data objects:
hashIterationsdefaults to-1, which is Keycloak's existing sentinel value meaning "use provider default" (consistent withPasswordCredentialProviderandPasswordPolicy).digitsdefaults to6,counterto0,periodto30— standard TOTP/HOTP defaults. This proactively fixes the same NPE pattern in the OTP path.How to reproduce (before fix)
POST to
/admin/realms/{realm}/userswith:{ "username": "testuser", "credentials": [{ "type": "password", "hashedSaltedValue": "someHash", "salt": "someSalt" }] }Previously throws NPE at
RepresentationToModel.java:261, now succeeds.