Skip to content

Commit

Permalink
Improve passkey autofill (#12)
Browse files Browse the repository at this point in the history
* Improve passkey-autofill

* Add password check

* Remove logs

* Move logic to functions

* Make logic backwards compatible to work with standard Keycloak username-password flow

* Bump version
  • Loading branch information
stevenclouston authored Jan 26, 2025
1 parent fe8d514 commit c6ff0f2
Show file tree
Hide file tree
Showing 4 changed files with 175 additions and 85 deletions.
2 changes: 1 addition & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ plugins {
}

group 'com.authsignal'
version '2.1.0'
version '2.1.1'

repositories {
mavenCentral()
Expand Down
247 changes: 164 additions & 83 deletions app/src/main/java/com/authsignal/keycloak/AuthsignalAuthenticator.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,124 +21,205 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.credential.CredentialInput;

/** Authsignal Authenticator. */
public class AuthsignalAuthenticator implements Authenticator {
private static final Logger logger = Logger.getLogger(AuthsignalAuthenticator.class.getName());

public static final AuthsignalAuthenticator SINGLETON = new AuthsignalAuthenticator();

private AuthsignalClient authsignalClient;

private AuthsignalClient getAuthsignalClient(AuthenticationFlowContext context) {
if (authsignalClient == null) {
authsignalClient = new AuthsignalClient(secretKey(context), baseUrl(context));
}
return authsignalClient;
}

@Override
public void authenticate(AuthenticationFlowContext context) {
AuthsignalClient authsignalClient = new AuthsignalClient(secretKey(context), baseUrl(context));
logger.info("authenticate method called");

AuthenticatorConfigModel config = context.getAuthenticatorConfig();
boolean isPasskeyAutofill = false;

if (config != null) {
Object passkeyAutofillObj = config.getConfig().get(AuthsignalAuthenticatorFactory.PROP_PASSKEY_AUTOFILL);
isPasskeyAutofill = passkeyAutofillObj != null && Boolean.parseBoolean(passkeyAutofillObj.toString());
}

if (isPasskeyAutofill) {
Response challenge = context.form()
.setAttribute("message", "Please enter your token")
.createForm("login.ftl");
context.challenge(challenge);
return;
} else {
handleAuthenticationFlow(context);
}
}

@Override
public void action(AuthenticationFlowContext context) {
logger.info("action method called");
handlePasswordAuthentication(context);
handleAuthenticationFlow(context);
}

private void handleAuthenticationFlow(AuthenticationFlowContext context) {
AuthsignalClient client = getAuthsignalClient(context);

MultivaluedMap<String, String> queryParams = context.getUriInfo().getQueryParameters();
MultivaluedMap<String, String> formParams = context.getHttpRequest().getDecodedFormParameters();

String token = queryParams.getFirst("token");
String token = formParams.getFirst("token");

if (token == null) {
token = formParams.getFirst("token");
}
String userId = context.getUser().getId();
if (userId == null) {
userId = formParams.getFirst("userId");
token = queryParams.getFirst("token");
}

if (token != null && !token.isEmpty()) {
ValidateChallengeRequest request = new ValidateChallengeRequest();
request.token = token;
request.userId = userId;
handleTokenValidation(context, client, token);
} else {
handleAuthsignalTrack(context, client);
}
}

try {
private void handleTokenValidation(AuthenticationFlowContext context, AuthsignalClient authsignalClient, String token) {
logger.info("handleTokenValidation method called");
ValidateChallengeRequest request = new ValidateChallengeRequest();
request.token = token;

try {
ValidateChallengeResponse response = authsignalClient.validateChallenge(request).get();
if (response.state == UserActionState.CHALLENGE_SUCCEEDED) {
context.success();

if (response.state == UserActionState.CHALLENGE_SUCCEEDED || response.state == UserActionState.ALLOW) {
String userId = response.userId;
UserModel user = context.getSession().users().getUserById(context.getRealm(), userId);
if (user == null) {
context.failure(AuthenticationFlowError.INVALID_USER);
return;
}
context.setUser(user);
context.success();
} else {
context.failure(AuthenticationFlowError.ACCESS_DENIED);
context.failure(AuthenticationFlowError.ACCESS_DENIED);
}
} catch (Exception e) {
} catch (Exception e) {
e.printStackTrace();
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
}
} else {
String sessionCode = context.generateAccessCode();

URI actionUri = context.getActionUrl(sessionCode);

String redirectUrl =
context.getHttpRequest().getUri().getBaseUri().toString().replaceAll("/+$", "")
+ "/realms/" + URLEncoder.encode(context.getRealm().getName(), StandardCharsets.UTF_8)
+ "/authsignal-authenticator/callback" + "?kc_client_id="
+ URLEncoder.encode(context.getAuthenticationSession().getClient().getClientId(),
StandardCharsets.UTF_8)
+ "&kc_execution="
+ URLEncoder.encode(context.getExecution().getId(), StandardCharsets.UTF_8)
+ "&kc_tab_id="
+ URLEncoder.encode(context.getAuthenticationSession().getTabId(),
StandardCharsets.UTF_8)
+ "&kc_session_code=" + URLEncoder.encode(sessionCode, StandardCharsets.UTF_8)
+ "&kc_action_url=" + URLEncoder.encode(actionUri.toString(), StandardCharsets.UTF_8);

TrackRequest request = new TrackRequest();
request.action = actionCode(context);

request.attributes = new TrackAttributes();
request.attributes.redirectUrl = redirectUrl;
request.attributes.ipAddress = context.getConnection().getRemoteAddr();
request.attributes.userAgent =
context.getHttpRequest().getHttpHeaders().getHeaderString("User-Agent");
request.userId = context.getUser().getId();
request.attributes.username = context.getUser().getUsername();

try {
CompletableFuture<TrackResponse> responseFuture = authsignalClient.track(request);

TrackResponse response = responseFuture.get();

String url = response.url;

Response responseRedirect =
Response.status(Response.Status.FOUND).location(URI.create(url)).build();

boolean isEnrolled = response.isEnrolled;

// If the user is not enrolled (has no authenticators) and enrollment by default
// is enabled,
// display the challenge page to allow the user to enroll.
if (enrolByDefault(context) && !isEnrolled) {
if (response.state == UserActionState.BLOCK) {
}
}

private void handlePasswordAuthentication(AuthenticationFlowContext context) {
AuthsignalClient client = getAuthsignalClient(context);
MultivaluedMap<String, String> formParams = context.getHttpRequest().getDecodedFormParameters();
String username = formParams.getFirst("username");
String password = formParams.getFirst("password");

if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
logger.warning("Username or password is missing");
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, context.form()
.setError("Invalid username or password")
.createForm("login.ftl"));
return;
}

UserModel user = context.getSession().users().getUserByUsername(context.getRealm(), username);

if (user == null) {
logger.warning("User not found for username: " + username);
context.failureChallenge(AuthenticationFlowError.INVALID_USER, context.form()
.setError("Invalid username or password")
.createForm("login.ftl"));
return;
}

context.setUser(user);

if (!validateCredentials(user, password)) {
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, context.form()
.setError("Invalid username or password")
.createForm("login.ftl"));
return;
}
}

private boolean validateCredentials(UserModel user, String password) {
CredentialInput credentialInput = UserCredentialModel.password(password);
return user.credentialManager().isValid(credentialInput);
}

private void handleAuthsignalTrack(AuthenticationFlowContext context, AuthsignalClient authsignalClient) {
String sessionCode = context.generateAccessCode();
URI actionUri = context.getActionUrl(sessionCode);
String redirectUrl = buildRedirectUrl(context, sessionCode, actionUri);

TrackRequest request = createTrackRequest(context, redirectUrl);

try {
TrackResponse response = authsignalClient.track(request).get();
handleTrackResponse(context, response);
} catch (Exception e) {
e.printStackTrace();
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
}
}

private String buildRedirectUrl(AuthenticationFlowContext context, String sessionCode, URI actionUri) {
return context.getHttpRequest().getUri().getBaseUri().toString().replaceAll("/+$", "")
+ "/realms/" + URLEncoder.encode(context.getRealm().getName(), StandardCharsets.UTF_8)
+ "/authsignal-authenticator/callback" + "?kc_client_id="
+ URLEncoder.encode(context.getAuthenticationSession().getClient().getClientId(), StandardCharsets.UTF_8)
+ "&kc_execution=" + URLEncoder.encode(context.getExecution().getId(), StandardCharsets.UTF_8)
+ "&kc_tab_id=" + URLEncoder.encode(context.getAuthenticationSession().getTabId(), StandardCharsets.UTF_8)
+ "&kc_session_code=" + URLEncoder.encode(sessionCode, StandardCharsets.UTF_8)
+ "&kc_action_url=" + URLEncoder.encode(actionUri.toString(), StandardCharsets.UTF_8);
}

private TrackRequest createTrackRequest(AuthenticationFlowContext context, String redirectUrl) {
TrackRequest request = new TrackRequest();
request.action = actionCode(context);
request.attributes = new TrackAttributes();
request.attributes.redirectUrl = redirectUrl;
request.attributes.ipAddress = context.getConnection().getRemoteAddr();
request.attributes.userAgent = context.getHttpRequest().getHttpHeaders().getHeaderString("User-Agent");
request.userId = context.getUser().getId();
request.attributes.username = context.getUser().getUsername();
return request;
}

private void handleTrackResponse(AuthenticationFlowContext context, TrackResponse response) {
String url = response.url;
Response responseRedirect = Response.status(Response.Status.FOUND).location(URI.create(url)).build();
boolean isEnrolled = response.isEnrolled;

if (enrolByDefault(context) && !isEnrolled) {
if (response.state == UserActionState.BLOCK) {
context.failure(AuthenticationFlowError.ACCESS_DENIED);
}
context.challenge(responseRedirect);
} else {
if (response.state == UserActionState.CHALLENGE_REQUIRED) {
return;
}
context.challenge(responseRedirect);
} else {
if (response.state == UserActionState.CHALLENGE_REQUIRED) {
context.challenge(responseRedirect);
} else if (response.state == UserActionState.BLOCK) {
} else if (response.state == UserActionState.BLOCK) {
context.failure(AuthenticationFlowError.ACCESS_DENIED);
} else if (response.state == UserActionState.ALLOW) {
} else if (response.state == UserActionState.ALLOW) {
context.success();
} else {
} else {
context.failure(AuthenticationFlowError.ACCESS_DENIED);
}
}

} catch (Exception e) {
e.printStackTrace();
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
}
}
}

@Override
public void action(AuthenticationFlowContext context) {
logger.info("Action method called");
// No-op
}

@Override
public boolean requiresUser() {
logger.info("requiresUser method called");
return true;
return false;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public class AuthsignalAuthenticatorFactory implements AuthenticatorFactory {
public static final String PROP_API_HOST_BASE_URL = "authsignal.baseUrl";
public static final String PROP_ACTION_CODE = "authsignal.actionCode";
public static final String PROP_ENROL_BY_DEFAULT = "authsignal.enrolByDefault";
public static final String PROP_PASSKEY_AUTOFILL = "passkey-autofill";

private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES =
{AuthenticationExecutionModel.Requirement.REQUIRED,
Expand Down Expand Up @@ -76,6 +77,14 @@ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
enrolByDefault.setHelpText("Optional: Auto enroll user if no authenticators "
+ "are available i.e. the user is not enrolled. Defaults to true.");
configProperties.add(enrolByDefault);

ProviderConfigProperty passkeyAutofill = new ProviderConfigProperty();
passkeyAutofill.setName(PROP_PASSKEY_AUTOFILL);
passkeyAutofill.setLabel("Enable Passkey Autofill");
passkeyAutofill.setType(ProviderConfigProperty.BOOLEAN_TYPE);
passkeyAutofill.setDefaultValue(false);
passkeyAutofill.setHelpText("Optional: Enable passkey autofill functionality. Defaults to false.");
configProperties.add(passkeyAutofill);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public Response get() {
String redirect =
"<html><body onload=\"document.forms[0].submit()\"><form id=\"form1\" action=\""
+ kcActionUrl
+ "\" method=\"post\"><input type=\"hidden\" name=\"authenticationExecution\" value=\""
+ "\" method=\"post\"><input type=\"hidden\" name=\"actionExecution\" value=\""
+ authenticationExecution
+ "\"><noscript><input type=\"submit\" value=\"Continue\"></noscript></form>"
+ "</body></html>";
Expand Down

0 comments on commit c6ff0f2

Please sign in to comment.