Skip to content

Commit c6ff0f2

Browse files
Improve passkey autofill (#12)
* 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
1 parent fe8d514 commit c6ff0f2

File tree

4 files changed

+175
-85
lines changed

4 files changed

+175
-85
lines changed

app/build.gradle

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ plugins {
1010
}
1111

1212
group 'com.authsignal'
13-
version '2.1.0'
13+
version '2.1.1'
1414

1515
repositories {
1616
mavenCentral()

app/src/main/java/com/authsignal/keycloak/AuthsignalAuthenticator.java

+164-83
Original file line numberDiff line numberDiff line change
@@ -21,124 +21,205 @@
2121
import org.keycloak.models.KeycloakSession;
2222
import org.keycloak.models.RealmModel;
2323
import org.keycloak.models.UserModel;
24+
import org.keycloak.models.UserCredentialModel;
25+
import org.keycloak.credential.CredentialInput;
2426

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

2931
public static final AuthsignalAuthenticator SINGLETON = new AuthsignalAuthenticator();
3032

33+
private AuthsignalClient authsignalClient;
34+
35+
private AuthsignalClient getAuthsignalClient(AuthenticationFlowContext context) {
36+
if (authsignalClient == null) {
37+
authsignalClient = new AuthsignalClient(secretKey(context), baseUrl(context));
38+
}
39+
return authsignalClient;
40+
}
41+
3142
@Override
3243
public void authenticate(AuthenticationFlowContext context) {
33-
AuthsignalClient authsignalClient = new AuthsignalClient(secretKey(context), baseUrl(context));
44+
logger.info("authenticate method called");
45+
46+
AuthenticatorConfigModel config = context.getAuthenticatorConfig();
47+
boolean isPasskeyAutofill = false;
48+
49+
if (config != null) {
50+
Object passkeyAutofillObj = config.getConfig().get(AuthsignalAuthenticatorFactory.PROP_PASSKEY_AUTOFILL);
51+
isPasskeyAutofill = passkeyAutofillObj != null && Boolean.parseBoolean(passkeyAutofillObj.toString());
52+
}
53+
54+
if (isPasskeyAutofill) {
55+
Response challenge = context.form()
56+
.setAttribute("message", "Please enter your token")
57+
.createForm("login.ftl");
58+
context.challenge(challenge);
59+
return;
60+
} else {
61+
handleAuthenticationFlow(context);
62+
}
63+
}
64+
65+
@Override
66+
public void action(AuthenticationFlowContext context) {
67+
logger.info("action method called");
68+
handlePasswordAuthentication(context);
69+
handleAuthenticationFlow(context);
70+
}
71+
72+
private void handleAuthenticationFlow(AuthenticationFlowContext context) {
73+
AuthsignalClient client = getAuthsignalClient(context);
3474

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

38-
String token = queryParams.getFirst("token");
78+
String token = formParams.getFirst("token");
79+
3980
if (token == null) {
40-
token = formParams.getFirst("token");
41-
}
42-
String userId = context.getUser().getId();
43-
if (userId == null) {
44-
userId = formParams.getFirst("userId");
81+
token = queryParams.getFirst("token");
4582
}
4683

4784
if (token != null && !token.isEmpty()) {
48-
ValidateChallengeRequest request = new ValidateChallengeRequest();
49-
request.token = token;
50-
request.userId = userId;
85+
handleTokenValidation(context, client, token);
86+
} else {
87+
handleAuthsignalTrack(context, client);
88+
}
89+
}
5190

52-
try {
91+
private void handleTokenValidation(AuthenticationFlowContext context, AuthsignalClient authsignalClient, String token) {
92+
logger.info("handleTokenValidation method called");
93+
ValidateChallengeRequest request = new ValidateChallengeRequest();
94+
request.token = token;
95+
96+
try {
5397
ValidateChallengeResponse response = authsignalClient.validateChallenge(request).get();
54-
if (response.state == UserActionState.CHALLENGE_SUCCEEDED) {
55-
context.success();
98+
99+
if (response.state == UserActionState.CHALLENGE_SUCCEEDED || response.state == UserActionState.ALLOW) {
100+
String userId = response.userId;
101+
UserModel user = context.getSession().users().getUserById(context.getRealm(), userId);
102+
if (user == null) {
103+
context.failure(AuthenticationFlowError.INVALID_USER);
104+
return;
105+
}
106+
context.setUser(user);
107+
context.success();
56108
} else {
57-
context.failure(AuthenticationFlowError.ACCESS_DENIED);
109+
context.failure(AuthenticationFlowError.ACCESS_DENIED);
58110
}
59-
} catch (Exception e) {
111+
} catch (Exception e) {
60112
e.printStackTrace();
61113
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
62-
}
63-
} else {
64-
String sessionCode = context.generateAccessCode();
65-
66-
URI actionUri = context.getActionUrl(sessionCode);
67-
68-
String redirectUrl =
69-
context.getHttpRequest().getUri().getBaseUri().toString().replaceAll("/+$", "")
70-
+ "/realms/" + URLEncoder.encode(context.getRealm().getName(), StandardCharsets.UTF_8)
71-
+ "/authsignal-authenticator/callback" + "?kc_client_id="
72-
+ URLEncoder.encode(context.getAuthenticationSession().getClient().getClientId(),
73-
StandardCharsets.UTF_8)
74-
+ "&kc_execution="
75-
+ URLEncoder.encode(context.getExecution().getId(), StandardCharsets.UTF_8)
76-
+ "&kc_tab_id="
77-
+ URLEncoder.encode(context.getAuthenticationSession().getTabId(),
78-
StandardCharsets.UTF_8)
79-
+ "&kc_session_code=" + URLEncoder.encode(sessionCode, StandardCharsets.UTF_8)
80-
+ "&kc_action_url=" + URLEncoder.encode(actionUri.toString(), StandardCharsets.UTF_8);
81-
82-
TrackRequest request = new TrackRequest();
83-
request.action = actionCode(context);
84-
85-
request.attributes = new TrackAttributes();
86-
request.attributes.redirectUrl = redirectUrl;
87-
request.attributes.ipAddress = context.getConnection().getRemoteAddr();
88-
request.attributes.userAgent =
89-
context.getHttpRequest().getHttpHeaders().getHeaderString("User-Agent");
90-
request.userId = context.getUser().getId();
91-
request.attributes.username = context.getUser().getUsername();
92-
93-
try {
94-
CompletableFuture<TrackResponse> responseFuture = authsignalClient.track(request);
95-
96-
TrackResponse response = responseFuture.get();
97-
98-
String url = response.url;
99-
100-
Response responseRedirect =
101-
Response.status(Response.Status.FOUND).location(URI.create(url)).build();
102-
103-
boolean isEnrolled = response.isEnrolled;
104-
105-
// If the user is not enrolled (has no authenticators) and enrollment by default
106-
// is enabled,
107-
// display the challenge page to allow the user to enroll.
108-
if (enrolByDefault(context) && !isEnrolled) {
109-
if (response.state == UserActionState.BLOCK) {
114+
}
115+
}
116+
117+
private void handlePasswordAuthentication(AuthenticationFlowContext context) {
118+
AuthsignalClient client = getAuthsignalClient(context);
119+
MultivaluedMap<String, String> formParams = context.getHttpRequest().getDecodedFormParameters();
120+
String username = formParams.getFirst("username");
121+
String password = formParams.getFirst("password");
122+
123+
if (username == null || username.isEmpty() || password == null || password.isEmpty()) {
124+
logger.warning("Username or password is missing");
125+
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, context.form()
126+
.setError("Invalid username or password")
127+
.createForm("login.ftl"));
128+
return;
129+
}
130+
131+
UserModel user = context.getSession().users().getUserByUsername(context.getRealm(), username);
132+
133+
if (user == null) {
134+
logger.warning("User not found for username: " + username);
135+
context.failureChallenge(AuthenticationFlowError.INVALID_USER, context.form()
136+
.setError("Invalid username or password")
137+
.createForm("login.ftl"));
138+
return;
139+
}
140+
141+
context.setUser(user);
142+
143+
if (!validateCredentials(user, password)) {
144+
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, context.form()
145+
.setError("Invalid username or password")
146+
.createForm("login.ftl"));
147+
return;
148+
}
149+
}
150+
151+
private boolean validateCredentials(UserModel user, String password) {
152+
CredentialInput credentialInput = UserCredentialModel.password(password);
153+
return user.credentialManager().isValid(credentialInput);
154+
}
155+
156+
private void handleAuthsignalTrack(AuthenticationFlowContext context, AuthsignalClient authsignalClient) {
157+
String sessionCode = context.generateAccessCode();
158+
URI actionUri = context.getActionUrl(sessionCode);
159+
String redirectUrl = buildRedirectUrl(context, sessionCode, actionUri);
160+
161+
TrackRequest request = createTrackRequest(context, redirectUrl);
162+
163+
try {
164+
TrackResponse response = authsignalClient.track(request).get();
165+
handleTrackResponse(context, response);
166+
} catch (Exception e) {
167+
e.printStackTrace();
168+
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
169+
}
170+
}
171+
172+
private String buildRedirectUrl(AuthenticationFlowContext context, String sessionCode, URI actionUri) {
173+
return context.getHttpRequest().getUri().getBaseUri().toString().replaceAll("/+$", "")
174+
+ "/realms/" + URLEncoder.encode(context.getRealm().getName(), StandardCharsets.UTF_8)
175+
+ "/authsignal-authenticator/callback" + "?kc_client_id="
176+
+ URLEncoder.encode(context.getAuthenticationSession().getClient().getClientId(), StandardCharsets.UTF_8)
177+
+ "&kc_execution=" + URLEncoder.encode(context.getExecution().getId(), StandardCharsets.UTF_8)
178+
+ "&kc_tab_id=" + URLEncoder.encode(context.getAuthenticationSession().getTabId(), StandardCharsets.UTF_8)
179+
+ "&kc_session_code=" + URLEncoder.encode(sessionCode, StandardCharsets.UTF_8)
180+
+ "&kc_action_url=" + URLEncoder.encode(actionUri.toString(), StandardCharsets.UTF_8);
181+
}
182+
183+
private TrackRequest createTrackRequest(AuthenticationFlowContext context, String redirectUrl) {
184+
TrackRequest request = new TrackRequest();
185+
request.action = actionCode(context);
186+
request.attributes = new TrackAttributes();
187+
request.attributes.redirectUrl = redirectUrl;
188+
request.attributes.ipAddress = context.getConnection().getRemoteAddr();
189+
request.attributes.userAgent = context.getHttpRequest().getHttpHeaders().getHeaderString("User-Agent");
190+
request.userId = context.getUser().getId();
191+
request.attributes.username = context.getUser().getUsername();
192+
return request;
193+
}
194+
195+
private void handleTrackResponse(AuthenticationFlowContext context, TrackResponse response) {
196+
String url = response.url;
197+
Response responseRedirect = Response.status(Response.Status.FOUND).location(URI.create(url)).build();
198+
boolean isEnrolled = response.isEnrolled;
199+
200+
if (enrolByDefault(context) && !isEnrolled) {
201+
if (response.state == UserActionState.BLOCK) {
110202
context.failure(AuthenticationFlowError.ACCESS_DENIED);
111-
}
112-
context.challenge(responseRedirect);
113-
} else {
114-
if (response.state == UserActionState.CHALLENGE_REQUIRED) {
203+
return;
204+
}
205+
context.challenge(responseRedirect);
206+
} else {
207+
if (response.state == UserActionState.CHALLENGE_REQUIRED) {
115208
context.challenge(responseRedirect);
116-
} else if (response.state == UserActionState.BLOCK) {
209+
} else if (response.state == UserActionState.BLOCK) {
117210
context.failure(AuthenticationFlowError.ACCESS_DENIED);
118-
} else if (response.state == UserActionState.ALLOW) {
211+
} else if (response.state == UserActionState.ALLOW) {
119212
context.success();
120-
} else {
213+
} else {
121214
context.failure(AuthenticationFlowError.ACCESS_DENIED);
122-
}
123215
}
124-
125-
} catch (Exception e) {
126-
e.printStackTrace();
127-
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
128-
}
129216
}
130217
}
131218

132-
@Override
133-
public void action(AuthenticationFlowContext context) {
134-
logger.info("Action method called");
135-
// No-op
136-
}
137-
138219
@Override
139220
public boolean requiresUser() {
140221
logger.info("requiresUser method called");
141-
return true;
222+
return false;
142223
}
143224

144225
@Override

app/src/main/java/com/authsignal/keycloak/AuthsignalAuthenticatorFactory.java

+9
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public class AuthsignalAuthenticatorFactory implements AuthenticatorFactory {
2424
public static final String PROP_API_HOST_BASE_URL = "authsignal.baseUrl";
2525
public static final String PROP_ACTION_CODE = "authsignal.actionCode";
2626
public static final String PROP_ENROL_BY_DEFAULT = "authsignal.enrolByDefault";
27+
public static final String PROP_PASSKEY_AUTOFILL = "passkey-autofill";
2728

2829
private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES =
2930
{AuthenticationExecutionModel.Requirement.REQUIRED,
@@ -76,6 +77,14 @@ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
7677
enrolByDefault.setHelpText("Optional: Auto enroll user if no authenticators "
7778
+ "are available i.e. the user is not enrolled. Defaults to true.");
7879
configProperties.add(enrolByDefault);
80+
81+
ProviderConfigProperty passkeyAutofill = new ProviderConfigProperty();
82+
passkeyAutofill.setName(PROP_PASSKEY_AUTOFILL);
83+
passkeyAutofill.setLabel("Enable Passkey Autofill");
84+
passkeyAutofill.setType(ProviderConfigProperty.BOOLEAN_TYPE);
85+
passkeyAutofill.setDefaultValue(false);
86+
passkeyAutofill.setHelpText("Optional: Enable passkey autofill functionality. Defaults to false.");
87+
configProperties.add(passkeyAutofill);
7988
}
8089

8190
@Override

app/src/main/java/com/authsignal/keycloak/GetShimResource.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public Response get() {
7575
String redirect =
7676
"<html><body onload=\"document.forms[0].submit()\"><form id=\"form1\" action=\""
7777
+ kcActionUrl
78-
+ "\" method=\"post\"><input type=\"hidden\" name=\"authenticationExecution\" value=\""
78+
+ "\" method=\"post\"><input type=\"hidden\" name=\"actionExecution\" value=\""
7979
+ authenticationExecution
8080
+ "\"><noscript><input type=\"submit\" value=\"Continue\"></noscript></form>"
8181
+ "</body></html>";

0 commit comments

Comments
 (0)