Configure Extension with UiPageProvider #1

Open
kritzl wants to merge 6 commits from addUiPageProvider into main
11 changed files with 5593 additions and 128 deletions

86
README.md Normal file
View file

@ -0,0 +1,86 @@
# Attribute Endpoints Provider
This is a Keycloak Provider that exports an anonymized list of user profile attribute values.
For this it will provide API endpoints for every configured attribute-group.
The configuration of the provider is possible via an admin page.
Every endpoint responds with a list of all attribute values, that:
- is in the attribute group matching `attribute-group`
- matches an optional RegEx Pattern `attribute-regex`
- belongs to a user with a role matching `match-role`
- is non-empty

Should use plural, like:

Every endpoint responds with a list of all attribute values, that: 
- are in the attribute group matching `attribute-group`
- match an optional RegEx Pattern `attribute-regex`
- belong to a user with a role matching `match-role`
- are non-empty
Should use plural, like: ``` Every endpoint responds with a list of all attribute values, that: - are in the attribute group matching `attribute-group` - match an optional RegEx Pattern `attribute-regex` - belong to a user with a role matching `match-role` - are non-empty ```
Multivalue attributes are flattened in the response.
## Example Setup
We assume an unconfigured, fresh Keycloak installation running under `http://localhost:8080`.
1. Add a new realm
e.g. "TestRealm"
2. Under `Realm Settings > User profile > Attributes Group`, add a new attribute Group
Example:
- `Name` = `"my-attributes-group"`
- `Display name` = `"Endpoint Attributes"`
- `Display description` = `"Attributes exported by the provider."`
3. Under `Realm Settings > User profile > Attributes`, add a new attribute
Example:
- `Attribute [Name]` = `"ssh-keys"`
- `Display name ` = `"SSH Keys"`
- `Multivalued` = `On`
- `Attribute group` = `"my-attributes-group"`
- `Who can edit?` = `user, admin`
- `Validators`
You can add validators, which will limit what values the user can enter. These validators are ignored by the provider.
4. Under `Realm roles`, add two new roles
Example:
1. `Role name` = `"myattribute-match"`
2. `Role name` = `"myattribute-export"`
5. Under `Users`, add a new user
Example:
- `Username` = `"user"`
- `Email` = `"user@example.com"`
- `First name` = `"User"`
- `Last name` = `"User"`
- `SSH Keys` = `"example-value-1", "example-value-2"`
6. In the Settings of the newly created user, go to `Role mapping > Assing role > Realm roles` and check the role `myattribute-match`
7. create a second user to use the provider
- `Username` = `"bot-user"`
- `Email` = `"bot@example.com"`
- `First name` = `"Bot"`
- `Last name` = `"Bot"`
- After creating:
- give it the role `myattribute-export`
- set a password in the users settings `Creadentials > Set password`. For Example `"password"`
8. Under `🪪 Attribute Endpoints 🚀 > Create item`, add a new endpoint to the provider
Example:
- `Slug` = `"ssh_keys"`
- `Attribute Group` = `"my-attributes-group"`
- `Match Role` = `"myattribute-match"`
- `Auth Role` = `"myattribute-export"`
- `Attribute RegEx` = `".*"`
9. Aquire an OIDC Access Token:
```shell
curl --request POST \
--url http://localhost:8080/realms/TestRealm/protocol/openid-connect/token \
--header 'content-type: application/x-www-form-urlencoded' \
--data scope=openid \
--data username=bot-user \
--data password=password \
--data grant_type=password \
--data client_id=admin-cli
```
10. copy the value of the response key `access_token` and use it in a second request:
```shell
curl --request GET \
--url http://localhost:8080/realms/TestRealm/attribute-endpoints-provider/export/ssh_keys \
--header 'authorization: Bearer ey...' \
--header 'content-type: application/json'
```
11. You should get a response like this:
```json
["example-value-1","example-value-2"]
```
Although this example uses a simple bot account to authenticate to Keycloak, we recommend using a client with service account, when using this provider programmatically.

View file

@ -5,11 +5,11 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.ccc.hamburg.keycloak.ssh_key</groupId>
<artifactId>ssh-key-provider</artifactId>
<groupId>de.ccc.hamburg.keycloak.attribute_endpoints</groupId>
<artifactId>attribute-endpoints-provider</artifactId>
<version>1.0-SNAPSHOT</version>
<name>ssh-key-provider</name>
<name>attribute-endpoints-provider</name>
<!-- FIXME change it to the project's website -->
<url>http://www.example.com</url>

View file

@ -0,0 +1,144 @@
package de.ccc.hamburg.keycloak.attribute_endpoints;
import java.util.List;
import java.util.regex.Pattern;
import org.keycloak.Config;
import org.keycloak.component.ComponentModel;
import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.services.ui.extend.UiPageProvider;
import org.keycloak.services.ui.extend.UiPageProviderFactory;
import org.keycloak.userprofile.UserProfileProvider;

I think getting rid of the emojis, while they are fun, might be a good idea.

I think getting rid of the emojis, while they are fun, might be a good idea.
import com.google.auto.service.AutoService;
/**
* Implements UiPageProvider to show a config page in the admin
*/
@AutoService(UiPageProviderFactory.class)
public class AdminUiPage implements UiPageProvider, UiPageProviderFactory<ComponentModel> {
public static final String PROVIDER_ID = "🪪 Attribute Endpoints 🚀";
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return PROVIDER_ID;
}
public String getHelpText() {
return "Configure endpoints of the Attribute Endpoint Provider.";
}
@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) {
String errorString = "\n";
Boolean hasError = false;
Pattern slugPattern = Pattern.compile("^[a-zA-Z0-9_-]*$");
String configAttributeSlug = model.getConfig().getFirst("slug");
if (!slugPattern.matcher(configAttributeSlug).matches()) {

Currently this gives us an ugly exception in the Keycloak logs for an empty slug, as configAttributeSlug is then null. Not critical as it still just errors, but we should probably check, if configAttributeSlug is null. I guess then adding a null check to all the other config$things is probably a good idea as well.

Currently this gives us an ugly exception in the Keycloak logs for an empty slug, as `configAttributeSlug` is then `null`. Not critical as it still just errors, but we should probably check, if `configAttributeSlug` is `null`. I guess then adding a null check to all the other `config$thing`s is probably a good idea as well.
hasError = true;
errorString += " • [Slug] can only contain anlphanumeric characters, dash and underscore (a-z A-Z 0-9 _ - )\n";
}
String configAuthRole = model.getConfig().getFirst("auth-role");
RoleModel authRole = realm.getRole(configAuthRole);
if (authRole == null) {
hasError = true;
errorString += " • [Auth Role] does not exist\n";
}
String configMatchRole = model.getConfig().getFirst("match-role");
RoleModel matchRole = realm.getRole(configMatchRole);
if (matchRole == null) {
hasError = true;
errorString += " • [Match Role] does not exist\n";
}
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
UPConfig upconfig = profileProvider.getConfiguration();
String configAttributeGroup = model.getConfig().getFirst("attribute-group");
if (!upconfig.getGroups().stream().anyMatch(g -> g.getName().equals(configAttributeGroup))) {
hasError = true;
errorString += " • [Attribute Group] does not exist\n";
}
String configAttributeRegex = model.getConfig().getFirst("attribute-regex");
Boolean regexIsBlank = configAttributeRegex == null;
if (!regexIsBlank) {
try {
Pattern.compile(configAttributeRegex);
} catch (Exception e) {
hasError = true;
errorString += " • [Attribute RegEx] is not a valid regex pattern\n";
}
}
if (hasError) {
throw new ComponentValidationException(errorString);
}
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return ProviderConfigurationBuilder.create()
.property()
.name("slug")
.label("Slug")
.helpText(
"The slug in the path of the API endpoint (e.g. /realms/:realm/attribute-endpoint-provider/export/:slug)")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property()
.name("attribute-group")
.label("Attribute Group")
.helpText("The attribute group to export.")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property()
.name("match-role")
.label("Match Role")
.helpText("Export only attributes of users with this role.")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property()
.name("auth-role")
.label("Auth Role")
.helpText("Role needeed by the authenticated account to be able to use this endpoint.")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.property()
.name("attribute-regex")
.label("Attribute RegEx")
.helpText("A RegEx Rule used to verify each attribute value. Only matching values are returned.")
.type(ProviderConfigProperty.STRING_TYPE)
.add()
.build();
}
}

View file

@ -0,0 +1,160 @@
package de.ccc.hamburg.keycloak.attribute_endpoints;
import java.util.Collection;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.jboss.logging.Logger;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.Auth;
import org.keycloak.services.managers.AuthenticationManager.AuthResult;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.userprofile.UserProfileProvider;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.ServerErrorException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
public class AttributeEndpointsResourceProvider implements RealmResourceProvider {
private static final Logger LOG = Logger.getLogger(AttributeEndpointsResourceProvider.class);
private final KeycloakSession session;
public AttributeEndpointsResourceProvider(KeycloakSession keycloakSession) {
this.session = keycloakSession;
}
@Override
public Object getResource() {
return this;
}
@Override
public void close() {
}
@GET
@Path("export/{slug}")
@Produces(MediaType.APPLICATION_JSON)
public Response exportKeys(@PathParam("slug") String slug) {
KeycloakContext context = session.getContext();

I guess this should be called something like exportAttributeValues now that this is not just about SSH Keys anymore.

I guess this should be called something like `exportAttributeValues` now that this is not just about SSH Keys anymore.
RealmModel realm = context.getRealm();
List<ComponentModel> componentList = realm.getComponentsStream()
.filter(c -> c.getProviderId().equals(AdminUiPage.PROVIDER_ID))
.filter(c -> c.getConfig().getFirst("slug").equals(slug))
.toList();
if (componentList.isEmpty()) {
throw new NotFoundException("Endpoint not found.");
}
Auth auth = AttributeEndpointsResourceProvider.getAuth(session);
ComponentModel component = componentList.get(0);
String configAuthRole = component.getConfig().getFirst("auth-role");
RoleModel authRole = realm.getRole(configAuthRole);
if (authRole == null) {
throw new ServerErrorException("Endpoint Configuration Error - auth-role does not exist.", 500);
}
String configMatchRole = component.getConfig().getFirst("match-role");
RoleModel matchRole = realm.getRole(configMatchRole);
if (matchRole == null) {
throw new ServerErrorException("Endpoint Configuration Error - match-role does not exist.", 500);
}
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
UPConfig upconfig = profileProvider.getConfiguration();
String configAttributeGroup = component.getConfig().getFirst("attribute-group");
if (!upconfig.getGroups().stream().anyMatch(g -> g.getName().equals(configAttributeGroup))) {
throw new ServerErrorException("Endpoint Configuration Error - attribute-group does not exist.", 500);
}
String configAttributeRegex = component.getConfig().getFirst("attribute-regex");
Boolean regexIsBlank = configAttributeRegex == null;
if (!regexIsBlank) {
try {
Pattern.compile(configAttributeRegex);
} catch (Exception e) {
throw new ServerErrorException(
"Endpoint Configuration Error - attribute-regex is not a valid regex pattern.", 500);
}
}
UserModel authUser = auth.getUser();
if (!authUser.hasRole(authRole)) {
throw new ForbiddenException("User does not have required auth role.");
}
if (componentList.size() > 1) {
throw new NotFoundException(
"Endpoint Configuration Error - Multiple configurations exist for this endpoint.");
}

Moving this before line 71, where we get the component to work with might make a bit more sense flow-wise.

Moving this before line 71, where we get the component to work with might make a bit more sense flow-wise.
List<String> attributeNames = upconfig.getAttributes()
.stream()
.filter(a -> a.getGroup() != null && a.getGroup().equals(configAttributeGroup))
.map(a -> a.getName())
.toList();
UserProvider userProvider = session.users();
Stream<UserModel> users = userProvider.getRoleMembersStream(realm, matchRole);
List<String> attribute_list = users
.map(user -> {
Stream<String> attributeStream = attributeNames.stream()
.map(attributeName -> user.getAttributeStream(attributeName).toList())
.flatMap(Collection::stream);
return attributeStream
.filter(attribute -> !attribute.isEmpty())
.toList();
})
.flatMap(List::stream)
.filter(attribute -> {
if (regexIsBlank) {
return true;
}
final Pattern pattern = Pattern.compile(configAttributeRegex);
final Matcher matcher = pattern.matcher(attribute);
return matcher.find();
})
.toList();
return Response.ok(attribute_list).build();
}
private static Auth getAuth(KeycloakSession session) {
AuthResult auth = new AppAuthManager.BearerTokenAuthenticator(session).authenticate();
if (auth == null) {
throw new NotAuthorizedException("Bearer");
}
RealmModel realm = session.getContext().getRealm();
ClientModel client = auth.getClient();
return new Auth(realm, auth.getToken(), auth.getUser(), client, auth.getSession(), false);
}
}

View file

@ -1,4 +1,4 @@
package de.ccc.hamburg.keycloak.ssh_key;
package de.ccc.hamburg.keycloak.attribute_endpoints;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
@ -9,16 +9,16 @@ import org.keycloak.services.resource.RealmResourceProviderFactory;
import com.google.auto.service.AutoService;
@AutoService(RealmResourceProviderFactory.class)
public class SSHKeyResourceProviderFactory implements RealmResourceProviderFactory {
static final String PROVIDER_ID = "ssh-key-provider";
public class AttributeEndpointsResourceProviderFactory implements RealmResourceProviderFactory {
static final String PROVIDER_ID = "attribute-endpoints-provider";
@Override
public RealmResourceProvider create(KeycloakSession keycloakSession) {
return new SSHKeyResourceProvider(keycloakSession);
return new AttributeEndpointsResourceProvider(keycloakSession);
}
@Override
public void init(Config.Scope scope) {
public void init(Config.Scope config) {
}
@Override

View file

@ -1,8 +1,8 @@
services:
keycloak:
image: quay.io/keycloak/keycloak:26.4.2
image: quay.io/keycloak/keycloak:26.5.3
pull_policy: always
command: start-dev
command: "start-dev --features=declarative-ui"
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
@ -10,4 +10,4 @@ services:
ports:
- "8080:8080"
volumes:
- ./ssh-key-provider/target/ssh-key-provider-1.0-SNAPSHOT.jar:/opt/keycloak/providers/ssh-key-provider.jar
- ./attribute-endpoints-provider/target/attribute-endpoints-provider-1.0-SNAPSHOT.jar:/opt/keycloak/providers/attribute-endpoints-provider.jar

2667
realm-export.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,117 +0,0 @@
package de.ccc.hamburg.keycloak.ssh_key;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.jboss.logging.Logger;
import org.keycloak.models.ClientModel;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserProvider;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AppAuthManager.BearerTokenAuthenticator;
import org.keycloak.services.managers.Auth;
import org.keycloak.services.managers.AuthenticationManager.AuthResult;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.userprofile.UserProfileProvider;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
public class SSHKeyResourceProvider implements RealmResourceProvider {
private static final Logger LOG = Logger.getLogger(SSHKeyResourceProvider.class);
private final KeycloakSession session;
// taken from: https://github.com/nemchik/ssh-key-regex
private static final Pattern SSH_PUBLIC_KEY = Pattern.compile(
"^(?<key>(ssh-dss AAAAB3NzaC1kc3|ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNT|ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzOD|ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1Mj|sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb2|ssh-ed25519 AAAAC3NzaC1lZDI1NTE5|sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29t|ssh-rsa AAAAB3NzaC1yc2)[0-9A-Za-z+/]+[=]{0,3})(\\s.*)?$");
public SSHKeyResourceProvider(KeycloakSession keycloakSession) {
this.session = keycloakSession;
}
@Override
public Object getResource() {
return this;
}
@Override
public void close() {
}
@GET
@Path("export/{group_id}")
@Produces(MediaType.APPLICATION_JSON)
public Response exportKeys(@PathParam("group_id") String groupId) {
try {
SSHKeyResourceProvider.getAuth(session);
} catch (Exception e) {
System.err.println(e);
return Response.status(401, e.getMessage()).build();
}
UserProvider userProvider = session.users();
UserProfileProvider profileProvider = session.getProvider(UserProfileProvider.class);
UPConfig upconfig = profileProvider.getConfiguration();
List<String> attributeNames = upconfig.getAttributes()
.stream()
.filter(a -> a.getGroup() != null && a.getGroup().equals("de.ccc.hamburg.keycloak.ssh_key.keys"))
.map(a -> a.getName())
.toList();
RealmModel realm = session.getContext().getRealm();
// TODO: add allowlist check
GroupModel group = realm.getGroupById(groupId);
Stream<UserModel> users = userProvider.getGroupMembersStream(realm, group);
List<String> keys = users
.map(user -> {
return attributeNames
.stream()
.map(attributeName -> user.getAttributeStream(attributeName).findFirst())
.filter(attribute -> attribute.isPresent())
.map(attribute -> attribute.get())
.toList();
})
.flatMap(List::stream)
.map(key -> {
final Matcher matcher = SSH_PUBLIC_KEY.matcher(key);
return matcher.find() ? matcher.group("key") : null;
})
.filter(Objects::nonNull)
.toList();
return Response.ok(Map.of("keys", keys)).build();
}
private static Auth getAuth(KeycloakSession session) {
AuthResult auth = new AppAuthManager.BearerTokenAuthenticator(session).authenticate();
if (auth == null) {
throw new NotAuthorizedException("Bearer");
} else if (!auth.getToken().getIssuedFor().equals("admin-cli")) {
throw new ForbiddenException();
}
RealmModel realm = session.getContext().getRealm();
ClientModel client = auth.getClient();
return new Auth(realm, auth.getToken(), auth.getUser(), client, auth.getSession(), false);
}
}

2525
test.json Normal file

File diff suppressed because it is too large Load diff