Skip to content

Commit 56f10af

Browse files
committed
✨ Prefill stock Keycloak registration forms
1 parent 12363fa commit 56f10af

4 files changed

Lines changed: 365 additions & 0 deletions

File tree

packages/keycloak-extensions/voter-enrollment/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ SPDX-License-Identifier: AGPL-3.0-only
3131
<version>6.0.3</version>
3232
<scope>test</scope>
3333
</dependency>
34+
<dependency>
35+
<groupId>org.mockito</groupId>
36+
<artifactId>mockito-core</artifactId>
37+
<version>5.19.0</version>
38+
<scope>test</scope>
39+
</dependency>
3440
</dependencies>
3541

3642
<build>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// SPDX-FileCopyrightText: 2026 Sequent Tech <legal@sequentech.io>
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
package sequent.keycloak.voter_enrollment;
5+
6+
import jakarta.ws.rs.core.MultivaluedHashMap;
7+
import jakarta.ws.rs.core.MultivaluedMap;
8+
import java.util.LinkedHashMap;
9+
import java.util.Map;
10+
import java.util.Set;
11+
import java.util.regex.Pattern;
12+
import org.keycloak.authentication.forms.RegistrationPage;
13+
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
14+
import org.keycloak.userprofile.Attributes;
15+
16+
final class LoginHintPrefill {
17+
18+
static final String HINT_PREFIX = "login_hint__";
19+
static final int MAX_HINT_COUNT = 5;
20+
static final int MAX_HINT_NAME_LENGTH = 128;
21+
static final int MAX_HINT_VALUE_LENGTH = 255;
22+
23+
private static final String CLIENT_NOTE_PREFIX =
24+
AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + HINT_PREFIX;
25+
private static final Pattern HINT_NAME_PATTERN = Pattern.compile("[A-Za-z0-9._-]+");
26+
private static final Set<String> CREDENTIAL_FIELDS =
27+
Set.of(RegistrationPage.FIELD_PASSWORD, RegistrationPage.FIELD_PASSWORD_CONFIRM);
28+
29+
private LoginHintPrefill() {}
30+
31+
static Map<String, String> extractHints(Map<String, String> clientNotes) {
32+
Map<String, String> hints = new LinkedHashMap<>();
33+
34+
for (Map.Entry<String, String> clientNote : clientNotes.entrySet()) {
35+
if (!clientNote.getKey().startsWith(CLIENT_NOTE_PREFIX)) {
36+
continue;
37+
}
38+
39+
String attributeName = clientNote.getKey().substring(CLIENT_NOTE_PREFIX.length());
40+
String value = clientNote.getValue();
41+
if (hints.size() == MAX_HINT_COUNT
42+
|| attributeName.isEmpty()
43+
|| attributeName.length() > MAX_HINT_NAME_LENGTH
44+
|| !HINT_NAME_PATTERN.matcher(attributeName).matches()
45+
|| value == null
46+
|| value.isBlank()
47+
|| value.length() > MAX_HINT_VALUE_LENGTH) {
48+
throw new IllegalArgumentException("Invalid login hint parameters");
49+
}
50+
51+
hints.put(attributeName, value);
52+
}
53+
54+
return Map.copyOf(hints);
55+
}
56+
57+
static MultivaluedMap<String, String> filterWritableHints(
58+
Map<String, String> hints, Attributes attributes, Set<String> excludedAttributes) {
59+
MultivaluedMap<String, String> filteredHints = new MultivaluedHashMap<>();
60+
Map<String, ?> writableAttributes = attributes.getWritable();
61+
Map<String, ?> unmanagedAttributes = attributes.getUnmanagedAttributes();
62+
63+
hints.forEach(
64+
(attributeName, value) -> {
65+
if (!CREDENTIAL_FIELDS.contains(attributeName)
66+
&& !excludedAttributes.contains(attributeName)
67+
&& writableAttributes.containsKey(attributeName)
68+
&& !unmanagedAttributes.containsKey(attributeName)
69+
&& attributes.getMetadata(attributeName) != null) {
70+
filteredHints.putSingle(attributeName, value);
71+
}
72+
});
73+
74+
return filteredHints;
75+
}
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// SPDX-FileCopyrightText: 2026 Sequent Tech <legal@sequentech.io>
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
package sequent.keycloak.voter_enrollment;
5+
6+
import com.google.auto.service.AutoService;
7+
import jakarta.ws.rs.core.MultivaluedHashMap;
8+
import jakarta.ws.rs.core.MultivaluedMap;
9+
import java.util.List;
10+
import java.util.Map;
11+
import java.util.Set;
12+
import org.keycloak.Config;
13+
import org.keycloak.authentication.FormAction;
14+
import org.keycloak.authentication.FormActionFactory;
15+
import org.keycloak.authentication.FormContext;
16+
import org.keycloak.authentication.ValidationContext;
17+
import org.keycloak.forms.login.LoginFormsProvider;
18+
import org.keycloak.models.AuthenticationExecutionModel;
19+
import org.keycloak.models.KeycloakSession;
20+
import org.keycloak.models.KeycloakSessionFactory;
21+
import org.keycloak.models.RealmModel;
22+
import org.keycloak.models.UserModel;
23+
import org.keycloak.provider.ProviderConfigProperty;
24+
import org.keycloak.userprofile.UserProfile;
25+
import org.keycloak.userprofile.UserProfileContext;
26+
import org.keycloak.userprofile.UserProfileProvider;
27+
28+
@AutoService(FormActionFactory.class)
29+
public class LoginHintRegistrationPrefill implements FormAction, FormActionFactory {
30+
31+
public static final String PROVIDER_ID = "login-hint-registration-prefill";
32+
33+
@Override
34+
public void buildPage(FormContext context, LoginFormsProvider form) {
35+
if (!"GET".equals(context.getHttpRequest().getHttpMethod())) {
36+
return;
37+
}
38+
39+
Map<String, String> hints;
40+
try {
41+
hints = LoginHintPrefill.extractHints(context.getAuthenticationSession().getClientNotes());
42+
} catch (IllegalArgumentException invalidHints) {
43+
return;
44+
}
45+
46+
if (hints.isEmpty()) {
47+
return;
48+
}
49+
50+
MultivaluedMap<String, String> candidateFormData = new MultivaluedHashMap<>();
51+
hints.forEach(candidateFormData::putSingle);
52+
UserProfile profile =
53+
context
54+
.getSession()
55+
.getProvider(UserProfileProvider.class)
56+
.create(UserProfileContext.REGISTRATION, candidateFormData);
57+
MultivaluedMap<String, String> writableHints =
58+
LoginHintPrefill.filterWritableHints(hints, profile.getAttributes(), Set.of());
59+
60+
if (!writableHints.isEmpty()) {
61+
form.setFormData(writableHints);
62+
}
63+
}
64+
65+
@Override
66+
public void validate(ValidationContext context) {
67+
context.success();
68+
}
69+
70+
@Override
71+
public void success(FormContext context) {}
72+
73+
@Override
74+
public boolean requiresUser() {
75+
return false;
76+
}
77+
78+
@Override
79+
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
80+
return true;
81+
}
82+
83+
@Override
84+
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
85+
86+
@Override
87+
public FormAction create(KeycloakSession session) {
88+
return this;
89+
}
90+
91+
@Override
92+
public String getDisplayType() {
93+
return "Sequent: Login hint registration prefill";
94+
}
95+
96+
@Override
97+
public String getReferenceCategory() {
98+
return null;
99+
}
100+
101+
@Override
102+
public boolean isConfigurable() {
103+
return false;
104+
}
105+
106+
@Override
107+
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
108+
return new AuthenticationExecutionModel.Requirement[] {
109+
AuthenticationExecutionModel.Requirement.REQUIRED,
110+
AuthenticationExecutionModel.Requirement.DISABLED
111+
};
112+
}
113+
114+
@Override
115+
public boolean isUserSetupAllowed() {
116+
return false;
117+
}
118+
119+
@Override
120+
public String getHelpText() {
121+
return "Prefills managed writable registration attributes from validated login hint parameters. Place this action before registration user creation.";
122+
}
123+
124+
@Override
125+
public List<ProviderConfigProperty> getConfigProperties() {
126+
return List.of();
127+
}
128+
129+
@Override
130+
public String getId() {
131+
return PROVIDER_ID;
132+
}
133+
134+
@Override
135+
public void init(Config.Scope config) {}
136+
137+
@Override
138+
public void postInit(KeycloakSessionFactory factory) {}
139+
140+
@Override
141+
public void close() {}
142+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// SPDX-FileCopyrightText: 2026 Sequent Tech <legal@sequentech.io>
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
package sequent.keycloak.voter_enrollment;
5+
6+
import static org.junit.jupiter.api.Assertions.assertEquals;
7+
import static org.junit.jupiter.api.Assertions.assertThrows;
8+
import static org.mockito.ArgumentMatchers.eq;
9+
import static org.mockito.Mockito.mock;
10+
import static org.mockito.Mockito.never;
11+
import static org.mockito.Mockito.verify;
12+
import static org.mockito.Mockito.when;
13+
14+
import jakarta.ws.rs.core.MultivaluedMap;
15+
import java.util.HashMap;
16+
import java.util.List;
17+
import java.util.Map;
18+
import java.util.Set;
19+
import org.junit.jupiter.api.Test;
20+
import org.keycloak.authentication.FormContext;
21+
import org.keycloak.forms.login.LoginFormsProvider;
22+
import org.keycloak.http.HttpRequest;
23+
import org.keycloak.models.KeycloakSession;
24+
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
25+
import org.keycloak.sessions.AuthenticationSessionModel;
26+
import org.keycloak.userprofile.AttributeMetadata;
27+
import org.keycloak.userprofile.Attributes;
28+
import org.keycloak.userprofile.UserProfile;
29+
import org.keycloak.userprofile.UserProfileContext;
30+
import org.keycloak.userprofile.UserProfileProvider;
31+
import org.mockito.ArgumentMatchers;
32+
33+
class LoginHintPrefillTest {
34+
35+
@Test
36+
void extractsOnlyBoundedNamespacedClientNotes() {
37+
Map<String, String> clientNotes =
38+
Map.of(
39+
clientNote("username"),
40+
"voter@example.com",
41+
clientNote("dateOfBirth"),
42+
"2000-01-01",
43+
"state",
44+
"oidc-state");
45+
46+
assertEquals(
47+
Map.of("username", "voter@example.com", "dateOfBirth", "2000-01-01"),
48+
LoginHintPrefill.extractHints(clientNotes));
49+
}
50+
51+
@Test
52+
void rejectsAnInvalidHintSetWithoutReturningPartialData() {
53+
Map<String, String> tooManyHints = new HashMap<>();
54+
for (int index = 0; index <= LoginHintPrefill.MAX_HINT_COUNT; index++) {
55+
tooManyHints.put(clientNote("field" + index), "value" + index);
56+
}
57+
58+
assertThrows(IllegalArgumentException.class, () -> LoginHintPrefill.extractHints(tooManyHints));
59+
assertThrows(
60+
IllegalArgumentException.class,
61+
() -> LoginHintPrefill.extractHints(Map.of(clientNote("first name"), "value")));
62+
assertThrows(
63+
IllegalArgumentException.class,
64+
() -> LoginHintPrefill.extractHints(Map.of(clientNote("username"), " ")));
65+
}
66+
67+
@Test
68+
void keepsOnlyExplicitWritableManagedAttributes() {
69+
Attributes attributes = mock(Attributes.class);
70+
AttributeMetadata metadata = mock(AttributeMetadata.class);
71+
when(attributes.getWritable())
72+
.thenReturn(
73+
Map.of(
74+
"username", List.of("voter@example.com"),
75+
"dateOfBirth", List.of("2000-01-01"),
76+
"unmanaged", List.of("value")));
77+
when(attributes.getUnmanagedAttributes()).thenReturn(Map.of("unmanaged", List.of("value")));
78+
when(attributes.getMetadata("username")).thenReturn(metadata);
79+
when(attributes.getMetadata("dateOfBirth")).thenReturn(metadata);
80+
81+
MultivaluedMap<String, String> result =
82+
LoginHintPrefill.filterWritableHints(
83+
Map.of(
84+
"username", "voter@example.com",
85+
"dateOfBirth", "2000-01-01",
86+
"verificationStatus", "VERIFIED",
87+
"unmanaged", "value",
88+
"password", "secret"),
89+
attributes,
90+
Set.of("verificationStatus"));
91+
92+
assertEquals(
93+
Map.of("username", List.of("voter@example.com"), "dateOfBirth", List.of("2000-01-01")),
94+
result);
95+
}
96+
97+
@Test
98+
void stockActionPrefillsOnlyInitialGetRender() {
99+
FormContext context = mock(FormContext.class);
100+
LoginFormsProvider form = mock(LoginFormsProvider.class);
101+
HttpRequest request = mock(HttpRequest.class);
102+
AuthenticationSessionModel authenticationSession = mock(AuthenticationSessionModel.class);
103+
KeycloakSession session = mock(KeycloakSession.class);
104+
UserProfileProvider profileProvider = mock(UserProfileProvider.class);
105+
UserProfile profile = mock(UserProfile.class);
106+
Attributes attributes = mock(Attributes.class);
107+
AttributeMetadata metadata = mock(AttributeMetadata.class);
108+
109+
when(context.getHttpRequest()).thenReturn(request);
110+
when(request.getHttpMethod()).thenReturn("GET");
111+
when(context.getAuthenticationSession()).thenReturn(authenticationSession);
112+
when(authenticationSession.getClientNotes())
113+
.thenReturn(Map.of(clientNote("username"), "voter@example.com"));
114+
when(context.getSession()).thenReturn(session);
115+
when(session.getProvider(UserProfileProvider.class)).thenReturn(profileProvider);
116+
when(profileProvider.create(
117+
eq(UserProfileContext.REGISTRATION), ArgumentMatchers.<Map<String, ?>>any()))
118+
.thenReturn(profile);
119+
when(profile.getAttributes()).thenReturn(attributes);
120+
when(attributes.getWritable()).thenReturn(Map.of("username", List.of("voter@example.com")));
121+
when(attributes.getUnmanagedAttributes()).thenReturn(Map.of());
122+
when(attributes.getMetadata("username")).thenReturn(metadata);
123+
124+
new LoginHintRegistrationPrefill().buildPage(context, form);
125+
126+
verify(form).setFormData(ArgumentMatchers.<MultivaluedMap<String, String>>any());
127+
128+
when(request.getHttpMethod()).thenReturn("POST");
129+
new LoginHintRegistrationPrefill().buildPage(context, form);
130+
131+
verify(form).setFormData(ArgumentMatchers.<MultivaluedMap<String, String>>any());
132+
verify(profileProvider, never())
133+
.create(eq(UserProfileContext.ACCOUNT), ArgumentMatchers.<Map<String, ?>>any());
134+
}
135+
136+
private static String clientNote(String attributeName) {
137+
return AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX
138+
+ LoginHintPrefill.HINT_PREFIX
139+
+ attributeName;
140+
}
141+
}

0 commit comments

Comments
 (0)