DEV Community

Jihed Ben Arfa
Jihed Ben Arfa

Posted on

Keycloak lets you pick a login option that's guaranteed to fail — here's the fix

Project: keycloak/keycloak — the identity and access management server. PR: #51021, fixing #46481.

The bug: if you write a custom Keycloak Authenticator that isn't a CredentialValidator — anything that isn't a password, OTP, WebAuthn, that kind of thing — and it requires a user to already be identified, Keycloak's "Try another way" screen will offer it as an option even when it isn't actually usable for that user. Pick it, and you get an immediate CREDENTIAL_SETUP_REQUIRED error. The option shouldn't have been on the screen in the first place.

Root cause, traced through two files in the services module:

AuthenticationSelectionResolver#addSimpleAuthenticationExecution decides what goes on the selection list. It only checks one thing:

Authenticator localAuthenticator = processor.getSession().getProvider(Authenticator.class, execution.getAuthenticator());
if (!(localAuthenticator instanceof CredentialValidator)) {
    nonCredentialExecutions.add(execution);
} else {
    ...
}
Enter fullscreen mode Exit fullscreen mode

Not a CredentialValidator? Straight onto the list, no further questions. Nothing here checks authenticator.configuredFor(...) or the factory's isUserSetupAllowed().

There's a second, separate instance of the same gap. DefaultAuthenticationFlow.processSingleFlowExecutionModel has its own configuredFor/isUserSetupAllowed guard meant to catch exactly this — but only for requiresUser() == false authenticators, and only if they're a CredentialValidator:

if ((authUser != null) &&
        !authenticator.configuredFor(processor.getSession(), processor.getRealm(), authUser) &&
        !factory.isUserSetupAllowed() &&
        (authenticator instanceof CredentialValidator)) {
    throw new AuthenticationFlowException(...);
}
Enter fullscreen mode Exit fullscreen mode

That instanceof CredentialValidator at the end means a plain authenticator in that exact shape runs authenticate() completely unguarded, even as a REQUIRED step — a code path that never goes through the selection list at all, so fixing the resolver alone wouldn't have caught it.

The fix: added isSelectableForCurrentUser to the resolver — authenticators that don't require a user pass straight through, the rest only get listed if they're configured for the current user or the factory allows self-setup on the spot. And dropped the instanceof CredentialValidator restriction in the flow's own guard, so it applies the same way the requiresUser() == true branch right above it already does.

Test: AuthenticationSelectionResolverTest, five cases covering the full matrix — no user yet, configured, not configured but self-setup allowed, not configured and not allowed, and authenticators that don't require a user at all (always selectable, regardless of the rest).

This one didn't need any special tooling — just reading the two files the original report pointed at and following the logic all the way to where it actually breaks, which is most of what real bug fixing is.

Top comments (0)