Skip to content

Implement recovery key support for user storage providers#9

Open
everettbu wants to merge 1 commit into
feature-recovery-keys-foundationfrom
feature-recovery-keys-implementation
Open

Implement recovery key support for user storage providers#9
everettbu wants to merge 1 commit into
feature-recovery-keys-foundationfrom
feature-recovery-keys-implementation

Conversation

@everettbu

@everettbu everettbu commented Jul 26, 2025

Copy link
Copy Markdown

Test 9

Summary by CodeRabbit

  • New Features

    • Added support for recovery authentication codes as a credential type in user storage, enabling their storage, retrieval, and validation.
    • Introduced new methods and utilities for handling recovery codes credentials across the user interface and backend.
  • Bug Fixes

    • Improved handling and retrieval of recovery codes credentials to ensure consistent behavior across federated and local user storage.
  • Tests

    • Expanded test coverage to include setup and login flows using recovery authentication codes, verifying backward compatibility in user storage.

closes #38445

Signed-off-by: rtufisi <rtufisi@phasetwo.io>
@coderabbitai

coderabbitai Bot commented Jul 26, 2025

Copy link
Copy Markdown

Walkthrough

These changes introduce support for recovery authentication codes as a credential type in both the main application and a legacy user storage provider. New utility and helper methods are added for creating, retrieving, and managing recovery codes. The test suite is expanded to verify recovery code setup and login scenarios, ensuring backward compatibility.

Changes

Cohort / File(s) Change Summary
Credential Helper and Utilities
server-spi-private/src/main/java/org/keycloak/utils/CredentialHelper.java, server-spi/src/main/java/org/keycloak/models/utils/RecoveryAuthnCodesUtils.java
Added static methods for creating and retrieving recovery codes credentials, handling both local and federated storage, and serializing code lists.
Authenticator and Login Form Refactoring
services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java, services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java
Refactored credential retrieval to use the new utility method, replacing direct credential manager access.
Required Action Refactoring
services/src/main/java/org/keycloak/authentication/requiredactions/RecoveryAuthnCodesAction.java
Replaced provider-based credential creation with new helper method, updating imports and static calls accordingly.
Legacy User Storage Provider Enhancements
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
Added support for recovery codes: credential type support, storage, retrieval, update, validation, and new helper method to check for recovery codes.
Test Suite Extensions
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java
Introduced tests for recovery codes: setup, login, and verification in user storage, along with new helper methods and flow configuration.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant UI
    participant Authenticator
    participant CredentialHelper
    participant UserStorage
    participant LocalDB

    User->>UI: Initiate recovery code setup
    UI->>Authenticator: Request to generate recovery codes
    Authenticator->>CredentialHelper: createRecoveryCodesCredential(session, realm, user, model, codes)
    CredentialHelper->>UserStorage: Try updateCredential with recovery codes
    alt Success
        UserStorage-->>CredentialHelper: Update OK
        CredentialHelper->>Authenticator: Return success
    else Failure
        CredentialHelper->>LocalDB: Create credential in local DB
        LocalDB-->>CredentialHelper: Store credential
        CredentialHelper->>Authenticator: Return success
    end
    Authenticator->>UI: Show recovery codes to user
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

In the warren of code, recovery hops anew,
With helpers and tests, old storage grew.
Credentials now safe, from local or afar,
Rabbits rejoice—no login bizarre!
Legacy and modern, together they play,
Recovery codes ready for a bright, secure day.
🐇✨

Note

⚡️ Unit Test Generation is now available in beta!

Learn more here, or try it out under "Finishing Touches" below.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature-recovery-keys-implementation

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Explain this complex logic.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai explain this code block.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and explain its main purpose.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai generate unit tests to generate unit tests for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@everettbu

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2025

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java (1)

135-135: Fix typo in authenticator alias

There's a typo in the authenticator alias name.

-                                            config.setAlias("delayed-suthenticator-config");
+                                            config.setAlias("delayed-authenticator-config");
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a0852ea and 134437a.

📒 Files selected for processing (8)
  • server-spi-private/src/main/java/org/keycloak/utils/CredentialHelper.java (3 hunks)
  • server-spi/src/main/java/org/keycloak/models/utils/RecoveryAuthnCodesUtils.java (2 hunks)
  • services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java (1 hunks)
  • services/src/main/java/org/keycloak/authentication/requiredactions/RecoveryAuthnCodesAction.java (3 hunks)
  • services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java (1 hunks)
  • testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java (12 hunks)
  • testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorageFactory.java (1 hunks)
  • testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java (8 hunks)
🔇 Additional comments (6)
services/src/main/java/org/keycloak/authentication/authenticators/browser/RecoveryAuthnCodesFormAuthenticator.java (1)

80-80: LGTM: Good refactoring to use unified credential retrieval.

The change to use RecoveryAuthnCodesUtils.getCredential(authenticatedUser) properly centralizes credential retrieval logic and supports both federated and local storage scenarios.

testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorageFactory.java (1)

53-57: LGTM: Consistent implementation following established pattern.

The hasRecoveryCodes method correctly mirrors the hasUserOTP implementation pattern and provides the necessary functionality for testing recovery codes in the backward compatibility user storage provider.

server-spi/src/main/java/org/keycloak/models/utils/RecoveryAuthnCodesUtils.java (1)

50-62: LGTM: Well-implemented utility method with proper fallback logic.

The getCredential method correctly implements the federated-first, local-fallback pattern for credential retrieval. The use of Optional.or() provides clean fallback semantics, and the Javadoc clearly explains the method's behavior.

services/src/main/java/org/keycloak/authentication/requiredactions/RecoveryAuthnCodesAction.java (1)

28-28: LGTM: Good refactoring to centralize credential creation logic.

The changes properly delegate credential creation to CredentialHelper.createRecoveryCodesCredential, which centralizes the logic for handling both federated and local storage scenarios. The static import usage is appropriate here.

Also applies to: 116-116

server-spi-private/src/main/java/org/keycloak/utils/CredentialHelper.java (1)

119-119: Ensure Recovery Codes Are Secured at Rest
I didn’t find any code-level encryption or hashing in the RecoveryAuthnCodesCredentialProvider/model. The recovery codes are serialized to plaintext JSON via

recoveryCodesJson = JsonSerialization.writeValueAsString(generatedCodes);

and persisted in RecoveryAuthnCodesCredentialModel.secretData unencrypted—just like OTP secrets. Please manually verify that:
• The credential provider or underlying storage layer (DB, vault, etc.) encrypts or otherwise protects these codes at rest.
• If not, consider hashing (e.g. HMAC or salted hash) the codes before persisting to avoid storing them in plaintext.

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/federation/storage/BackwardsCompatibilityUserStorageTest.java (1)

240-269: Well-structured test implementation

The test properly covers the recovery codes setup and login flow with appropriate cleanup in the finally block.

Comment on lines +115 to +131
public static void createRecoveryCodesCredential(KeycloakSession session, RealmModel realm, UserModel user, RecoveryAuthnCodesCredentialModel credentialModel, List<String> generatedCodes) {
var recoveryCodeCredentialProvider = session.getProvider(CredentialProvider.class, "keycloak-recovery-authn-codes");
String recoveryCodesJson;
try {
recoveryCodesJson = JsonSerialization.writeValueAsString(generatedCodes);
} catch (IOException e) {
throw new RuntimeException(e);
}
UserCredentialModel recoveryCodesCredential = new UserCredentialModel("", credentialModel.getType(), recoveryCodesJson);

boolean userStorageCreated = user.credentialManager().updateCredential(recoveryCodesCredential);
if (userStorageCreated) {
logger.debugf("Created RecoveryCodes credential for user '%s' in the user storage", user.getUsername());
} else {
recoveryCodeCredentialProvider.createCredential(realm, user, credentialModel);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Critical: Improve exception handling and add input validation

Several issues need to be addressed in this method:

  1. The caught IOException loses context when rethrowing
  2. Missing null checks for input parameters
  3. No logging when creating credentials locally (inconsistent with OTP creation)

Apply this diff to fix these issues:

 public static void createRecoveryCodesCredential(KeycloakSession session, RealmModel realm, UserModel user, RecoveryAuthnCodesCredentialModel credentialModel, List<String> generatedCodes) {
+    if (session == null || realm == null || user == null || credentialModel == null || generatedCodes == null || generatedCodes.isEmpty()) {
+        throw new IllegalArgumentException("Invalid parameters for creating recovery codes credential");
+    }
     var recoveryCodeCredentialProvider = session.getProvider(CredentialProvider.class, "keycloak-recovery-authn-codes");
     String recoveryCodesJson;
     try {
         recoveryCodesJson =  JsonSerialization.writeValueAsString(generatedCodes);
     } catch (IOException e) {
-        throw new RuntimeException(e);
+        throw new RuntimeException("Failed to serialize recovery codes", e);
     }
     UserCredentialModel recoveryCodesCredential = new UserCredentialModel("", credentialModel.getType(), recoveryCodesJson);

     boolean userStorageCreated = user.credentialManager().updateCredential(recoveryCodesCredential);
     if (userStorageCreated) {
         logger.debugf("Created RecoveryCodes credential for user '%s' in the user storage", user.getUsername());
     } else {
-        recoveryCodeCredentialProvider.createCredential(realm, user, credentialModel);
+        CredentialModel createdCredential = recoveryCodeCredentialProvider.createCredential(realm, user, credentialModel);
+        logger.debugf("Created RecoveryCodes credential for user '%s' in local storage with id '%s'", user.getUsername(), createdCredential.getId());
     }
 }
🤖 Prompt for AI Agents
In server-spi-private/src/main/java/org/keycloak/utils/CredentialHelper.java
lines 115 to 131, improve exception handling by catching IOException and
rethrowing it with a descriptive message to preserve context, add null checks
for all input parameters at the start of the method to prevent
NullPointerExceptions, and add appropriate logging when creating credentials
locally to maintain consistency with OTP creation logging. Implement these
changes to enhance robustness and traceability.

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Potential NoSuchElementException on Optional.get().

Line 19 calls .get() on the Optional without checking if it's present first. This will throw a NoSuchElementException if no recovery codes credential is found.

Consider adding a safety check:

-Optional<CredentialModel> credentialModelOpt = RecoveryAuthnCodesUtils.getCredential(user);
-
-RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(credentialModelOpt.get());
+Optional<CredentialModel> credentialModelOpt = RecoveryAuthnCodesUtils.getCredential(user);
+
+RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(
+    credentialModelOpt.orElseThrow(() -> new IllegalStateException("Recovery codes credential not found for user")));
📝 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());
Optional<CredentialModel> credentialModelOpt = RecoveryAuthnCodesUtils.getCredential(user);
RecoveryAuthnCodesCredentialModel recoveryCodeCredentialModel = RecoveryAuthnCodesCredentialModel.createFromCredentialModel(
credentialModelOpt.orElseThrow(() -> new IllegalStateException("Recovery codes credential not found for user")));
🤖 Prompt for AI Agents
In
services/src/main/java/org/keycloak/forms/login/freemarker/model/RecoveryAuthnCodeInputLoginBean.java
around lines 17 to 19, the code calls .get() on an Optional without checking if
a value is present, risking a NoSuchElementException. To fix this, add a check
to verify if the Optional contains a value before calling .get(), such as using
isPresent() or orElseThrow() with a clear exception, and handle the case where
the credential is absent appropriately.

Comment on lines +193 to 201
} else if (input.getType().equals(RecoveryAuthnCodesCredentialModel.TYPE)) {
CredentialModel recoveryCodesModel = new CredentialModel();
recoveryCodesModel.setId(KeycloakModelUtils.generateId());
recoveryCodesModel.setType(input.getType());
recoveryCodesModel.setCredentialData(input.getChallengeResponse());
long createdDate = Time.currentTimeMillis();
recoveryCodesModel.setCreatedDate(createdDate);
users.get(translateUserName(user.getUsername())).recoveryCodes = recoveryCodesModel;
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Add validation for recovery codes input

The method stores recovery codes without validating the input format or content.

 } else if (input.getType().equals(RecoveryAuthnCodesCredentialModel.TYPE)) {
+    String credentialData = input.getChallengeResponse();
+    if (credentialData == null || credentialData.trim().isEmpty()) {
+        log.warnf("Empty recovery codes data for user %s", user.getUsername());
+        return false;
+    }
+    // Validate that the data is valid JSON array
+    try {
+        JsonSerialization.readValue(credentialData, List.class);
+    } catch (IOException e) {
+        log.warnf("Invalid recovery codes format for user %s: %s", user.getUsername(), e.getMessage());
+        return false;
+    }
     CredentialModel recoveryCodesModel = new CredentialModel();
     recoveryCodesModel.setId(KeycloakModelUtils.generateId());
     recoveryCodesModel.setType(input.getType());
-    recoveryCodesModel.setCredentialData(input.getChallengeResponse());
+    recoveryCodesModel.setCredentialData(credentialData);
     long createdDate = Time.currentTimeMillis();
     recoveryCodesModel.setCreatedDate(createdDate);
     users.get(translateUserName(user.getUsername())).recoveryCodes = recoveryCodesModel;
     return true;
📝 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 recoveryCodesModel = new CredentialModel();
recoveryCodesModel.setId(KeycloakModelUtils.generateId());
recoveryCodesModel.setType(input.getType());
recoveryCodesModel.setCredentialData(input.getChallengeResponse());
long createdDate = Time.currentTimeMillis();
recoveryCodesModel.setCreatedDate(createdDate);
users.get(translateUserName(user.getUsername())).recoveryCodes = recoveryCodesModel;
return true;
} else if (input.getType().equals(RecoveryAuthnCodesCredentialModel.TYPE)) {
String credentialData = input.getChallengeResponse();
if (credentialData == null || credentialData.trim().isEmpty()) {
log.warnf("Empty recovery codes data for user %s", user.getUsername());
return false;
}
// Validate that the data is valid JSON array
try {
JsonSerialization.readValue(credentialData, List.class);
} catch (IOException e) {
log.warnf("Invalid recovery codes format for user %s: %s", user.getUsername(), e.getMessage());
return false;
}
CredentialModel recoveryCodesModel = new CredentialModel();
recoveryCodesModel.setId(KeycloakModelUtils.generateId());
recoveryCodesModel.setType(input.getType());
recoveryCodesModel.setCredentialData(credentialData);
long createdDate = Time.currentTimeMillis();
recoveryCodesModel.setCreatedDate(createdDate);
users.get(translateUserName(user.getUsername())).recoveryCodes = recoveryCodesModel;
return true;
🤖 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 193 to 201, the code stores recovery codes without validating the
input. Add validation logic to check the format and content of
input.getChallengeResponse() before creating and setting the recoveryCodesModel.
If the input is invalid, handle it appropriately by returning false or throwing
an exception to prevent storing malformed recovery codes.

Comment on lines +231 to +252
public Stream<CredentialModel> getCredentials(RealmModel realm, UserModel user) {
var myUser = getMyUser(user);
RecoveryAuthnCodesCredentialModel model;
List<CredentialModel> credentialModels = new ArrayList<>();
if (myUser.recoveryCodes != null) {
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");
}
}
if (myUser.otp != null) {
credentialModels.add(myUser.getOtp());
}

return credentialModels.stream();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Improve error handling in getCredentials method

The method silently continues after deserialization failure and uses var keyword which might not be compatible with the target Java version.

 @Override
 public Stream<CredentialModel> getCredentials(RealmModel realm, UserModel user) {
-    var myUser = getMyUser(user);
+    MyUser myUser = getMyUser(user);
     RecoveryAuthnCodesCredentialModel model;
     List<CredentialModel> credentialModels = new ArrayList<>();
     if (myUser.recoveryCodes != null) {
         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");
+            log.error("Could not deserialize credential of type: recovery-codes", e);
+            // Consider whether to throw an exception or return partial results
         }
     }
     if (myUser.otp != null) {
         credentialModels.add(myUser.getOtp());
     }

     return credentialModels.stream();
 }
📝 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
public Stream<CredentialModel> getCredentials(RealmModel realm, UserModel user) {
var myUser = getMyUser(user);
RecoveryAuthnCodesCredentialModel model;
List<CredentialModel> credentialModels = new ArrayList<>();
if (myUser.recoveryCodes != null) {
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");
}
}
if (myUser.otp != null) {
credentialModels.add(myUser.getOtp());
}
return credentialModels.stream();
}
@Override
public Stream<CredentialModel> getCredentials(RealmModel realm, UserModel user) {
MyUser myUser = getMyUser(user);
RecoveryAuthnCodesCredentialModel model;
List<CredentialModel> credentialModels = new ArrayList<>();
if (myUser.recoveryCodes != null) {
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", e);
// Consider whether to throw an exception or return partial results
}
}
if (myUser.otp != null) {
credentialModels.add(myUser.getOtp());
}
return credentialModels.stream();
}
🤖 Prompt for AI Agents
In
testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java
between lines 231 and 252, improve error handling in the getCredentials method
by explicitly declaring the type of the RecoveryAuthnCodesCredentialModel
variable instead of using 'var' for compatibility, and modify the catch block to
handle the IOException more robustly, such as rethrowing it or returning an
empty stream to avoid silent failures after deserialization errors.

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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

Critical: Recovery codes should be single-use

Recovery codes are typically single-use for security reasons, but this implementation doesn't remove used codes after successful validation. Additionally, the validation logic needs improvement.

 } 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;
     }
+    String inputCode = input.getChallengeResponse();
+    if (inputCode == null || inputCode.trim().isEmpty()) {
+        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()));
+    // Check if the code matches and remove it for single-use
+    boolean found = generatedKeys.removeIf(key -> key.toString().trim().equals(inputCode.trim()));
+    if (found) {
+        // Update the stored credential with remaining codes
+        try {
+            storedRecoveryKeys.setCredentialData(JsonSerialization.writeValueAsString(generatedKeys));
+        } catch (IOException e) {
+            log.errorf(e, "Failed to update recovery codes for user %s", user.getUsername());
+            return false;
+        }
+    }
+    return found;
 }

Note: This is a backwards compatibility test provider, so the single-use behavior might be intentionally omitted. Please verify if this matches the expected behavior.

📝 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;
}
String inputCode = input.getChallengeResponse();
if (inputCode == null || inputCode.trim().isEmpty()) {
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;
}
// Check if the code matches and remove it for single-use
boolean found = generatedKeys.removeIf(key -> key.toString().trim().equals(inputCode.trim()));
if (found) {
// Update the stored credential with remaining codes
try {
storedRecoveryKeys.setCredentialData(JsonSerialization.writeValueAsString(generatedKeys));
} catch (IOException e) {
log.errorf(e, "Failed to update recovery codes for user %s", user.getUsername());
return false;
}
}
return found;
} else {
🤖 Prompt for AI Agents
In
testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/BackwardsCompatibilityUserStorage.java
between lines 326 and 341, the recovery codes validation currently does not
remove a code after it is used, which is a security risk since recovery codes
should be single-use. To fix this, after confirming a code matches the input
challenge response, remove that code from the stored list, update the stored
credential data with the modified list, and persist the change to ensure the
code cannot be reused. Verify if this single-use behavior aligns with the
backwards compatibility requirements before implementing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants