Skip to content

Conversation

admin-coderabbit
Copy link
Owner

@admin-coderabbit admin-coderabbit commented Feb 4, 2026

This pull request was automatically created by @coderabbitai/e2e-reviewer.

Batch created pull request.

Summary by CodeRabbit

  • New Features

    • Added Recovery Codes credential creation and management support across authentication workflows.
    • Implemented centralized credential retrieval utilities for improved consistency.
  • Tests

    • Added comprehensive test coverage for Recovery Codes authentication, including backwards-compatible storage implementation and multi-factor authentication login workflows.

closes #38445

Signed-off-by: rtufisi <rtufisi@phasetwo.io>
@coderabbit-eval
Copy link

coderabbit-eval bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

The changes introduce Recovery Codes credential support across multiple authentication components, consolidating credential retrieval and creation logic through new utility methods while extending backwards-compatibility storage to handle recovery code storage and validation.

Changes

Cohort / File(s) Summary
Core Credential Utilities
server-spi-private/src/main/java/org/keycloak/utils/CredentialHelper.java, server-spi/src/main/java/org/keycloak/models/utils/RecoveryAuthnCodesUtils.java
Added createRecoveryCodesCredential() method to create and store recovery codes with JSON serialization. Added getCredential() method in RecoveryAuthnCodesUtils to retrieve recovery codes from both federated and stored credentials with fallback logic.
Authenticators & Login Components
services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java, services/src/main/java/org/keycloak/authentication/requiredactions/RecoveryAuthnCodesAction.java, services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java
Refactored credential retrieval and creation to use new utility methods, replacing direct provider/stream lookups with calls to RecoveryAuthnCodesUtils.getCredential() and CredentialHelper.createRecoveryCodesCredential().
Backwards Compatibility Storage
testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java, testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorageFactory.java
Extended backwards-compatibility user storage with recovery codes support, including credential creation, retrieval, and validation flows. Added getRecoveryCodes() field and getter to internal MyUser model. Added hasRecoveryCodes() method to factory.
Recovery Codes Feature Tests
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java
Enabled RECOVERY\_CODES feature for test scope, added testRecoveryKeysSetupAndLogin() test method with recovery authentication flow configuration and test page integration.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 With whiskers twitching, codes in sight,
Recovery paths now shining bright,
Credentials bundled, neat and clean,
The finest storage we've ever seen!
Hop along, the safeguards bloom,
No user locked outside the room!

🚥 Pre-merge checks | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Description check ⚠️ Warning The pull request description is largely incomplete and does not follow the repository's contribution guidelines or template. Provide a detailed description explaining the feature implementation, changes made across files, and testing approach. Reference the CONTRIBUTING.md guidelines for the expected format.
Title check ❓ Inconclusive The title 'Automated Test: feature-recovery-keys-implementation' is vague and does not clearly describe the actual changes in the pull request. Replace with a more descriptive title that summarizes the main changes, such as 'Add recovery codes credential support and utility methods' or 'Implement recovery authentication codes feature'.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature-recovery-keys-implementation

Comment @coderabbit eval help to get the list of available commands and usage tips.

@coderabbit-eval coderabbit-eval bot closed this Feb 4, 2026
Copy link

@coderabbit-eval coderabbit-eval bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In
`@services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java`:
- Around line 17-21: The code currently calls credentialModelOpt.get() without
checking the Optional returned by RecoveryAuthnCodesUtils.getCredential(user),
which can throw; update the logic in RecoveryAuthnCodeInputLoginBean to guard
the Optional (using isPresent()/ifPresent(), orElseThrow() with a clear message,
or provide a fallback) before calling
RecoveryAuthnCodesCredentialModel.createFromCredentialModel and accessing
getNextRecoveryAuthnCode().get().getNumber(), and ensure codeNumber is set or a
clear exception is raised when no credential exists.

In
`@testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java`:
- Around line 326-341: In BackwardsCompatibilityUserStorage, replace the raw
List used for deserializing recovery codes (generatedKeys) with a typed
List<String> when calling JsonSerialization.readValue to restore type safety,
handle potential null elements by using Objects.equals(key,
input.getChallengeResponse()) or by comparing
input.getChallengeResponse().equals(key) to avoid NPEs, and fix the extra space
before the else; also add import java.util.Objects if you use Objects.equals.
- Around line 236-246: The deserialization uses a raw List.class and swallows
exceptions: change the JsonSerialization.readValue call to use a typed
TypeReference<List<String>> so
RecoveryAuthnCodesCredentialModel.createFromValues receives a List<String>, and
update the catch(IOException e) in the block around
RecoveryAuthnCodesCredentialModel.createFromValues to not silently ignore
failures—either rethrow a runtime exception or propagate the IOException (or at
minimum log the error with e as parameter) so callers know deserialization
failed; also fix the double-space typo in the log message ("deserialize 
credential" -> "deserialize credential"). Reference:
RecoveryAuthnCodesCredentialModel.createFromValues, JsonSerialization.readValue,
and the catch(IOException e) logging line.

Comment on lines +17 to 21
Optional<CredentialModel> credentialModelOpt = RecoveryAuthnCodesUtils.getCredential(user);

RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModel);
RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModelOpt.get());

this.codeNumber = recoveryCodeCredentialModel.getNextRecoveryAuthnCode().get().getNumber();

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard the Optional to avoid runtime failures.
If getCredential(user) returns empty, credentialModelOpt.get() will throw and break the login page. Add an explicit guard (or fail fast with a clear message) before accessing the credential.

🛠️ Proposed fix
-        Optional<CredentialModel> credentialModelOpt = RecoveryAuthnCodesUtils.getCredential(user);
-
-        RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModelOpt.get());
-
-        this.codeNumber = recoveryCodeCredentialModel.getNextRecoveryAuthnCode().get().getNumber();
+        Optional<CredentialModel> credentialModelOpt = RecoveryAuthnCodesUtils.getCredential(user);
+        RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = credentialModelOpt
+                .map(RecoveryAuthnCodesCredentialModel::createFromCredentialModel)
+                .orElseThrow(() -> new IllegalStateException("Recovery codes credential missing for user " + user.getUsername()));
+
+        this.codeNumber = recoveryCodeCredentialModel.getNextRecoveryAuthnCode()
+                .orElseThrow(() -> new IllegalStateException("No recovery codes available for user " + user.getUsername()))
+                .getNumber();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Optional<CredentialModel> credentialModelOpt = RecoveryAuthnCodesUtils.getCredential(user);
RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModel);
RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModelOpt.get());
this.codeNumber = recoveryCodeCredentialModel.getNextRecoveryAuthnCode().get().getNumber();
Optional<CredentialModel> credentialModelOpt = RecoveryAuthnCodesUtils.getCredential(user);
RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = credentialModelOpt
.map(RecoveryAuthnCodesCredentialModel::createFromCredentialModel)
.orElseThrow(() -> new IllegalStateException("Recovery codes credential missing for user " + user.getUsername()));
this.codeNumber = recoveryCodeCredentialModel.getNextRecoveryAuthnCode()
.orElseThrow(() -> new IllegalStateException("No recovery codes available for user " + user.getUsername()))
.getNumber();
🤖 Prompt for AI Agents
In
`@services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java`
around lines 17 - 21, The code currently calls credentialModelOpt.get() without
checking the Optional returned by RecoveryAuthnCodesUtils.getCredential(user),
which can throw; update the logic in RecoveryAuthnCodeInputLoginBean to guard
the Optional (using isPresent()/ifPresent(), orElseThrow() with a clear message,
or provide a fallback) before calling
RecoveryAuthnCodesCredentialModel.createFromCredentialModel and accessing
getNextRecoveryAuthnCode().get().getNumber(), and ensure codeNumber is set or a
clear exception is raised when no credential exists.

Comment on lines +236 to +246
try {
model = RecoveryAuthnCodesCredentialModel.createFromValues(
JsonSerialization.readValue(myUser.recoveryCodes.getCredentialData(), List.class),
myUser.recoveryCodes.getCreatedDate(),
myUser.recoveryCodes.getUserLabel()
);
credentialModels.add(model);
} catch (IOException e) {
log.error("Could not deserialize credential of type: recovery-codes");
}
}

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Raw List type and silently swallowed exception.

  1. Line 238 uses raw List.class, causing an unchecked conversion when passed to createFromValues(List<String>, ...). This loses type safety.
  2. The caught IOException only logs and continues, meaning recovery codes will silently be missing from the returned stream. Callers won't know deserialization failed.
  3. Line 244 has a double space typo: "deserialize credential".
Suggested improvements
         if (myUser.recoveryCodes != null) {
             try {
+                `@SuppressWarnings`("unchecked")
+                List<String> codes = JsonSerialization.readValue(myUser.recoveryCodes.getCredentialData(), List.class);
                 model = RecoveryAuthnCodesCredentialModel.createFromValues(
-                        JsonSerialization.readValue(myUser.recoveryCodes.getCredentialData(), List.class),
+                        codes,
                         myUser.recoveryCodes.getCreatedDate(),
                         myUser.recoveryCodes.getUserLabel()
                 );
                 credentialModels.add(model);
             } catch (IOException e) {
-                log.error("Could not deserialize  credential of type: recovery-codes");
+                log.error("Could not deserialize credential of type: recovery-codes", e);
+                throw new RuntimeException("Failed to deserialize recovery codes credential", e);
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
model = RecoveryAuthnCodesCredentialModel.createFromValues(
JsonSerialization.readValue(myUser.recoveryCodes.getCredentialData(), List.class),
myUser.recoveryCodes.getCreatedDate(),
myUser.recoveryCodes.getUserLabel()
);
credentialModels.add(model);
} catch (IOException e) {
log.error("Could not deserialize credential of type: recovery-codes");
}
}
try {
`@SuppressWarnings`("unchecked")
List<String> codes = JsonSerialization.readValue(myUser.recoveryCodes.getCredentialData(), List.class);
model = RecoveryAuthnCodesCredentialModel.createFromValues(
codes,
myUser.recoveryCodes.getCreatedDate(),
myUser.recoveryCodes.getUserLabel()
);
credentialModels.add(model);
} catch (IOException e) {
log.error("Could not deserialize credential of type: recovery-codes", e);
throw new RuntimeException("Failed to deserialize recovery codes credential", e);
}
}
🤖 Prompt for AI Agents
In
`@testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java`
around lines 236 - 246, The deserialization uses a raw List.class and swallows
exceptions: change the JsonSerialization.readValue call to use a typed
TypeReference<List<String>> so
RecoveryAuthnCodesCredentialModel.createFromValues receives a List<String>, and
update the catch(IOException e) in the block around
RecoveryAuthnCodesCredentialModel.createFromValues to not silently ignore
failures—either rethrow a runtime exception or propagate the IOException (or at
minimum log the error with e as parameter) so callers know deserialization
failed; also fix the double-space typo in the log message ("deserialize 
credential" -> "deserialize credential"). Reference:
RecoveryAuthnCodesCredentialModel.createFromValues, JsonSerialization.readValue,
and the catch(IOException e) logging line.

Comment on lines +326 to +341
} else if (input.getType().equals(RecoveryAuthnCodesCredentialModel.TYPE)) {
CredentialModel storedRecoveryKeys = myUser.recoveryCodes;
if (storedRecoveryKeys == null) {
log.warnf("Not found credential for the user %s", user.getUsername());
return false;
}
List generatedKeys;
try {
generatedKeys = JsonSerialization.readValue(storedRecoveryKeys.getCredentialData(), List.class);
} catch (IOException e) {
log.warnf("Cannot deserialize recovery keys credential for the user %s", user.getUsername());
return false;
}

return generatedKeys.stream().anyMatch(key -> key.equals(input.getChallengeResponse()));
} else {

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Raw type usage and potential NPE in recovery code validation.

  1. Line 332/334: Raw List type loses type safety; same issue as in getCredentials.
  2. Line 340: If the deserialized list contains null elements (malformed data), key.equals(...) will throw NullPointerException. Consider using Objects.equals() or reversing the comparison.
  3. Line 341: Minor formatting issue - double space before else.
Suggested fix
-            List generatedKeys;
+            `@SuppressWarnings`("unchecked")
+            List<String> generatedKeys;
             try {
                 generatedKeys = JsonSerialization.readValue(storedRecoveryKeys.getCredentialData(), List.class);
             } catch (IOException e) {
                 log.warnf("Cannot deserialize recovery keys credential for the user %s", user.getUsername());
                 return false;
             }

-            return generatedKeys.stream().anyMatch(key -> key.equals(input.getChallengeResponse()));
-        }  else {
+            String challengeResponse = input.getChallengeResponse();
+            return generatedKeys.stream().anyMatch(key -> Objects.equals(key, challengeResponse));
+        } else {

Note: Add import java.util.Objects; at the top of the file.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} else if (input.getType().equals(RecoveryAuthnCodesCredentialModel.TYPE)) {
CredentialModel storedRecoveryKeys = myUser.recoveryCodes;
if (storedRecoveryKeys == null) {
log.warnf("Not found credential for the user %s", user.getUsername());
return false;
}
List generatedKeys;
try {
generatedKeys = JsonSerialization.readValue(storedRecoveryKeys.getCredentialData(), List.class);
} catch (IOException e) {
log.warnf("Cannot deserialize recovery keys credential for the user %s", user.getUsername());
return false;
}
return generatedKeys.stream().anyMatch(key -> key.equals(input.getChallengeResponse()));
} else {
} else if (input.getType().equals(RecoveryAuthnCodesCredentialModel.TYPE)) {
CredentialModel storedRecoveryKeys = myUser.recoveryCodes;
if (storedRecoveryKeys == null) {
log.warnf("Not found credential for the user %s", user.getUsername());
return false;
}
`@SuppressWarnings`("unchecked")
List<String> generatedKeys;
try {
generatedKeys = JsonSerialization.readValue(storedRecoveryKeys.getCredentialData(), List.class);
} catch (IOException e) {
log.warnf("Cannot deserialize recovery keys credential for the user %s", user.getUsername());
return false;
}
String challengeResponse = input.getChallengeResponse();
return generatedKeys.stream().anyMatch(key -> Objects.equals(key, challengeResponse));
} else {
🤖 Prompt for AI Agents
In
`@testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java`
around lines 326 - 341, In BackwardsCompatibilityUserStorage, replace the raw
List used for deserializing recovery codes (generatedKeys) with a typed
List<String> when calling JsonSerialization.readValue to restore type safety,
handle potential null elements by using Objects.equals(key,
input.getChallengeResponse()) or by comparing
input.getChallengeResponse().equals(key) to avoid NPEs, and fix the extra space
before the else; also add import java.util.Objects if you use Objects.equals.

Sign in to join this conversation on GitHub.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

1 participant