parseFilter(Attribute, ?> attribute) {
- String[] parts = filter.trim().split(" ");
-
- if (parts.length == 3) {
- String leftOperand = parts[0];
- String operator = parts[1];
- String rightOperand = parts[2];
-
- if ("eq".equals(operator)) {
- return new EqualExpression(attribute, leftOperand, rightOperand);
+ if (rawValue.isArray()) {
+ ArrayNode matches = JsonNodeFactory.instance.arrayNode();
+ for (JsonNode node : rawValue) {
+ if (node.isObject() && predicate.test(node)) {
+ matches.add(node);
+ }
+ }
+ if (!matches.isEmpty()) {
+ return matches.size() == 1 ? matches.get(0) : matches;
}
-
- // for now, we only support equality filter in the path, and we assume the filter is always in the format "attribute eq "value""
- throw new ModelValidationException("Unsupported filter operator: " + operator);
}
- throw new ModelValidationException("Unsupported filter format: " + filter);
+ return NullNode.getInstance();
}
public boolean hasFilter() {
diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/ScimJsonNodeFilterEvaluator.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/ScimJsonNodeFilterEvaluator.java
new file mode 100644
index 00000000000..298c859d8b7
--- /dev/null
+++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/ScimJsonNodeFilterEvaluator.java
@@ -0,0 +1,136 @@
+package org.keycloak.scim.resource.schema.path;
+
+import java.util.function.Predicate;
+
+import org.keycloak.scim.filter.ScimFilterParser;
+import org.keycloak.scim.filter.ScimFilterParserBaseVisitor;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+/**
+ * Visitor that converts a SCIM filter AST into a {@link Predicate} over {@link JsonNode} elements.
+ *
+ * This is used by {@link Path} to evaluate filter expressions (e.g., {@code value eq "some-id"})
+ * against JSON array elements in-memory, supporting all SCIM comparison operators and logical
+ * operators ({@code and}, {@code or}, {@code not}).
+ */
+class ScimJsonNodeFilterEvaluator extends ScimFilterParserBaseVisitor> {
+
+ @Override
+ public Predicate visitFilter(ScimFilterParser.FilterContext ctx) {
+ return visit(ctx.expression());
+ }
+
+ @Override
+ public Predicate visitExpression(ScimFilterParser.ExpressionContext ctx) {
+ if (ctx.OR() != null) {
+ Predicate left = visit(ctx.expression());
+ Predicate right = visit(ctx.andExpression());
+ return left.or(right);
+ }
+ return visit(ctx.andExpression());
+ }
+
+ @Override
+ public Predicate visitAndExpression(ScimFilterParser.AndExpressionContext ctx) {
+ if (ctx.AND() != null) {
+ Predicate left = visit(ctx.andExpression());
+ Predicate right = visit(ctx.notExpression());
+ return left.and(right);
+ }
+ return visit(ctx.notExpression());
+ }
+
+ @Override
+ public Predicate visitNotExpression(ScimFilterParser.NotExpressionContext ctx) {
+ if (ctx.NOT() != null) {
+ Predicate child = visit(ctx.notExpression());
+ return child.negate();
+ }
+ return visit(ctx.atom());
+ }
+
+ @Override
+ public Predicate visitAtom(ScimFilterParser.AtomContext ctx) {
+ if (ctx.valuePath() != null) {
+ return visit(ctx.valuePath());
+ }
+ if (ctx.attributeExpression() != null) {
+ return visit(ctx.attributeExpression());
+ }
+ return visit(ctx.expression());
+ }
+
+ @Override
+ public Predicate visitValuePath(ScimFilterParser.ValuePathContext ctx) {
+ return visit(ctx.expression());
+ }
+
+ @Override
+ public Predicate visitPresentExpression(ScimFilterParser.PresentExpressionContext ctx) {
+ String attrName = ctx.ATTRPATH().getText();
+ return node -> {
+ if (!node.isObject()) return false;
+ JsonNode value = node.get(attrName);
+ return value != null && !value.isNull() && !value.isMissingNode();
+ };
+ }
+
+ @Override
+ public Predicate visitComparisonExpression(ScimFilterParser.ComparisonExpressionContext ctx) {
+ String attrName = ctx.ATTRPATH().getText();
+ String operator = ctx.compareOp().getText().toLowerCase();
+ String compValue = extractValue(ctx.compValue());
+
+ return node -> {
+ if (!node.isObject()) return false;
+ JsonNode attrNode = node.get(attrName);
+ if (attrNode == null || attrNode.isNull()) {
+ return "eq".equals(operator) && compValue == null;
+ }
+ return compare(attrNode.asText(), operator, compValue);
+ };
+ }
+
+ private boolean compare(String nodeValue, String operator, String compValue) {
+ if (compValue == null || nodeValue == null) {
+ return false;
+ }
+
+ return switch (operator) {
+ case "eq" -> nodeValue.equals(compValue);
+ case "ne" -> !nodeValue.equals(compValue);
+ case "co" -> nodeValue.contains(compValue);
+ case "sw" -> nodeValue.startsWith(compValue);
+ case "ew" -> nodeValue.endsWith(compValue);
+ case "gt" -> nodeValue.compareTo(compValue) > 0;
+ case "ge" -> nodeValue.compareTo(compValue) >= 0;
+ case "lt" -> nodeValue.compareTo(compValue) < 0;
+ case "le" -> nodeValue.compareTo(compValue) <= 0;
+ default -> false;
+ };
+ }
+
+ private String extractValue(ScimFilterParser.CompValueContext ctx) {
+ if (ctx.STRING() != null) {
+ String raw = ctx.STRING().getText();
+ return unescapeJsonString(raw.substring(1, raw.length() - 1));
+ }
+ if (ctx.TRUE() != null) return "true";
+ if (ctx.FALSE() != null) return "false";
+ if (ctx.NULL() != null) return null;
+ if (ctx.NUMBER() != null) return ctx.NUMBER().getText();
+ return null;
+ }
+
+ private String unescapeJsonString(String s) {
+ return s.replace("\\\"", "\"")
+ .replace("\\\\", "\\")
+ .replace("\\/", "/")
+ .replace("\\b", "\b")
+ .replace("\\f", "\f")
+ .replace("\\n", "\n")
+ .replace("\\r", "\r")
+ .replace("\\t", "\t");
+ }
+}
diff --git a/scim/model/src/main/java/org/keycloak/scim/model/config/ServiceProviderConfigResourceTypeProvider.java b/scim/model/src/main/java/org/keycloak/scim/model/config/ServiceProviderConfigResourceTypeProvider.java
index 5b3695ae2e6..542ef929e2c 100644
--- a/scim/model/src/main/java/org/keycloak/scim/model/config/ServiceProviderConfigResourceTypeProvider.java
+++ b/scim/model/src/main/java/org/keycloak/scim/model/config/ServiceProviderConfigResourceTypeProvider.java
@@ -10,6 +10,7 @@ import org.keycloak.models.Model;
import org.keycloak.scim.protocol.ForbiddenException;
import org.keycloak.scim.protocol.request.SearchRequest;
import org.keycloak.scim.resource.config.ServiceProviderConfig;
+import org.keycloak.scim.resource.config.ServiceProviderConfig.AuthenticationScheme;
import org.keycloak.scim.resource.config.ServiceProviderConfig.BulkSupport;
import org.keycloak.scim.resource.config.ServiceProviderConfig.FilterSupport;
import org.keycloak.scim.resource.config.ServiceProviderConfig.Supported;
@@ -36,11 +37,31 @@ public class ServiceProviderConfigResourceTypeProvider implements SingletonResou
config.setChangePassword(Supported.FALSE);
config.setCreatedTimestamp(Time.currentTimeMillis());
config.setSort(Supported.FALSE);
- config.setFilter(new FilterSupport());
+ config.setFilter(getFilterSupport());
+ config.setAuthenticationSchemes(getAuthenticationSchemes());
return config;
}
+ private FilterSupport getFilterSupport() {
+ FilterSupport filter = new FilterSupport();
+
+ filter.setSupported(true);
+
+ return filter;
+ }
+
+ private List getAuthenticationSchemes() {
+ AuthenticationScheme scheme = new AuthenticationScheme();
+
+ scheme.setName("OAuth Bearer Token");
+ scheme.setDescription("Authentication scheme using the OAuth Bearer Token standard");
+ scheme.setSpecUri("https://tools.ietf.org/html/rfc6750");
+ scheme.setType("oauthbearertoken");
+
+ return List.of(scheme);
+ }
+
@Override
public Stream getAll(SearchRequest searchRequest) {
if (!session.getContext().getPermissions().hasPermission(AdminPermissionsSchema.REALMS_RESOURCE_TYPE, AdminPermissionsSchema.VIEW)) {
diff --git a/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateProvider.java b/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateProvider.java
index da6265e4a67..b678e2aa9dc 100644
--- a/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateProvider.java
+++ b/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateProvider.java
@@ -133,6 +133,11 @@ public class ScimJPAPredicateProvider {
basePredicate = cb.equal(join.get("name"), modelAttributeName);
}
+ if (value != null && !attrInfo.isCaseExact() && "string".equals(attrInfo.getType())) {
+ value = value.toString().toLowerCase();
+ expression = cb.lower((Expression) expression);
+ }
+
Predicate predicate = operatorMap.get(operation).apply(cb, expression, value);
return (basePredicate != null) ? cb.and(basePredicate, predicate) : predicate;
}
diff --git a/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java b/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java
index 7e475eaa700..5e127ed5242 100644
--- a/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java
+++ b/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java
@@ -35,28 +35,31 @@ public final class GroupCoreModelSchema extends AbstractModelSchema getModelAttributeNames() {
- return Set.of("name");
+ return Set.of("name", "externalId");
}
@Override
protected String getAttributeValue(GroupModel model, String name) {
- if (name.equals("name")) {
- return model.getName();
- }
- return null;
+ return switch (name) {
+ case "name" -> model.getName();
+ case "externalId" -> model.getFirstAttribute("externalId");
+ default -> null;
+ };
}
@Override
protected String getAttributeSchemaName(String name) {
- if (name.equals("name")) {
- return "displayName";
- }
- return null;
+ return switch (name) {
+ case "name" -> "displayName";
+ case "externalId" -> name;
+ default -> null;
+ };
}
@Override
protected Map> doGetAttributes() {
List> attributes = new ArrayList<>(Attribute.simple("displayName")
+ .notCaseExact()
.modelAttributeResolver((attribute) -> {
if (attribute.getName().equals("displayName")) {
return "name";
@@ -65,6 +68,11 @@ public final class GroupCoreModelSchema extends AbstractModelSchemasimple("externalId")
+ .immutable()
+ .string()
+ .withModelSetter(GroupModel::setSingleAttribute)
+ .build());
attributes.addAll(Attribute.simple("meta.created")
.timestamp()
.immutable()
diff --git a/scim/model/src/main/java/org/keycloak/scim/model/schema/SchemaResourceTypeProvider.java b/scim/model/src/main/java/org/keycloak/scim/model/schema/SchemaResourceTypeProvider.java
index 889869ca6b8..499b9edd427 100644
--- a/scim/model/src/main/java/org/keycloak/scim/model/schema/SchemaResourceTypeProvider.java
+++ b/scim/model/src/main/java/org/keycloak/scim/model/schema/SchemaResourceTypeProvider.java
@@ -87,6 +87,10 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider subAttributes = parent.getSubAttributes();
if (subAttributes == null) {
@@ -109,6 +115,10 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider subAttributes = parent.getSubAttributes();
if (subAttributes == null) {
@@ -132,6 +146,10 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider> attributes = new ArrayList<>();
attributes.addAll(Attribute.simple("userName")
+ .required()
+ .notCaseExact()
+ .serverUnique()
.modelAttributeResolver(this::createModelAttributeResolver)
.withModelSetter(UserModel::setSingleAttribute)
.build());
attributes.addAll(Attribute.complex("emails", Email.class)
.modelAttributeResolver(this::createModelAttributeResolver)
+ .notCaseExact()
+ .globalUnique()
.multivalued()
.withModelSetter((TriConsumer>) (model, name, values) -> {
for (Email value : values) {
diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FilterTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FilterTest.java
index a3d76208851..9d384bbbcf4 100644
--- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FilterTest.java
+++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FilterTest.java
@@ -785,10 +785,10 @@ public class FilterTest extends AbstractScimTest {
Instant before = Instant.now();
Group group = new Group();
- group.setDisplayName("groupA");
+ group.setDisplayName(KeycloakModelUtils.generateId());
group = client.groups().create(group);
groupIdsToRemove.add(group.getId());
- final String displayName = group.getDisplayName();
+ String displayName = group.getDisplayName();
Instant after = Instant.now();
diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java
index ea526f548dc..82764827168 100644
--- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java
+++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java
@@ -8,7 +8,9 @@ import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.idm.GroupRepresentation;
+import org.keycloak.scim.client.ResourceFilter;
import org.keycloak.scim.protocol.request.PatchRequest;
+import org.keycloak.scim.protocol.response.ListResponse;
import org.keycloak.scim.resource.group.Group;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.events.AdminEventAssertion;
@@ -17,7 +19,9 @@ import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
+import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -28,6 +32,7 @@ public class GroupTest extends AbstractScimTest {
public void testCreate() {
Group expected = new Group();
expected.setDisplayName(KeycloakModelUtils.generateId());
+ expected.setExternalId(KeycloakModelUtils.generateId());
expected = client.groups().create(expected);
assertNotNull(expected);
@@ -39,6 +44,7 @@ public class GroupTest extends AbstractScimTest {
Group actual = client.groups().get(expected.getId());
assertNotNull(actual);
assertEquals(expected.getDisplayName(), actual.getDisplayName());
+ assertEquals(expected.getExternalId(), actual.getExternalId());
}
@Test
@@ -71,6 +77,7 @@ public class GroupTest extends AbstractScimTest {
expected = client.groups().get(expected.getId());
expected.setDisplayName("Updated " + expected.getDisplayName());
+ expected.setExternalId(KeycloakModelUtils.generateId());
adminEvents.clear();
client.groups().update(expected);
@@ -81,6 +88,7 @@ public class GroupTest extends AbstractScimTest {
Group actual = client.groups().get(expected.getId());
assertEquals(expected.getDisplayName(), actual.getDisplayName());
+ assertEquals(expected.getExternalId(), actual.getExternalId());
}
@Test
@@ -92,9 +100,11 @@ public class GroupTest extends AbstractScimTest {
expected = client.groups().get(expected.getId());
expected.setDisplayName("Updated " + expected.getDisplayName());
+ expected.setExternalId(KeycloakModelUtils.generateId());
adminEvents.clear();
client.groups().patch(expected.getId(), PatchRequest.create()
.replace("displayName", expected.getDisplayName())
+ .replace("externalId", expected.getExternalId())
.build());
AdminEventAssertion.assertSuccess(adminEvents.poll())
@@ -104,6 +114,7 @@ public class GroupTest extends AbstractScimTest {
Group actual = client.groups().get(expected.getId());
assertEquals(expected.getDisplayName(), actual.getDisplayName());
+ assertEquals(expected.getExternalId(), actual.getExternalId());
}
@Test
@@ -177,4 +188,29 @@ public class GroupTest extends AbstractScimTest {
assertNotNull(group);
assertEquals(rep.getName(), group.getDisplayName());
}
+
+ @Test
+ public void testSearchByExternalId() {
+ Group group = new Group();
+ group.setDisplayName(KeycloakModelUtils.generateId());
+ group.setExternalId(KeycloakModelUtils.generateId());
+ group = client.groups().create(group);
+
+ Group group2 = new Group();
+ group2.setDisplayName(KeycloakModelUtils.generateId());
+ group2.setExternalId(KeycloakModelUtils.generateId());
+ group2 = client.groups().create(group2);
+
+ String filter = ResourceFilter.filter().eq("externalId", group.getExternalId()).build();
+ ListResponse response = client.groups().getAll(filter);
+ assertFalse(response.getResources().isEmpty());
+ assertThat(response.getTotalResults(), is(1));
+ assertThat(response.getResources().get(0).getExternalId(), is(group.getExternalId()));
+
+ filter = ResourceFilter.filter().eq("externalId", group2.getExternalId()).build();
+ response = client.groups().getAll(filter);
+ assertFalse(response.getResources().isEmpty());
+ assertThat(response.getTotalResults(), is(1));
+ assertThat(response.getResources().get(0).getExternalId(), is(group2.getExternalId()));
+ }
}
diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java
index 4f8f5b2c35c..d66b2c8f4c7 100644
--- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java
+++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java
@@ -50,27 +50,42 @@ public class SchemaTest extends AbstractScimTest {
assertFalse(schema.getAttributes().isEmpty());
// Verify ALL expected attributes are present (extracted from UserCoreModelSchema)
- // UserCoreModelSchema has: userName, emails[0].value, name.*, externalId, nickName, locale, active
- // These should map to top-level attributes: userName, emails, name, externalId, nickName, locale, active
Set attributeNames = schema.getAttributes().stream()
.map(Schema.Attribute::getName)
.collect(Collectors.toSet());
- assertEquals(14, attributeNames.size(), "User schema should have exactly 7 attributes");
- assertTrue(attributeNames.contains("userName"));
- assertTrue(attributeNames.contains("emails"));
- assertTrue(attributeNames.contains("name"));
- assertTrue(attributeNames.contains("externalId"));
- assertTrue(attributeNames.contains("nickName"));
- assertTrue(attributeNames.contains("locale"));
- assertTrue(attributeNames.contains("active"));
- assertTrue(attributeNames.contains("profileUrl"));
- assertTrue(attributeNames.contains("preferredLanguage"));
- assertTrue(attributeNames.contains("displayName"));
- assertTrue(attributeNames.contains("timezone"));
- assertTrue(attributeNames.contains("groups"));
- assertTrue(attributeNames.contains("title"));
- assertTrue(attributeNames.contains("userType"));
+ assertEquals(14, attributeNames.size(), "User schema should have exactly 14 attributes");
+
+ assertAttribute(findAttribute(schema, "userName"), "string", false, true, false, "readWrite", "server");
+ assertAttribute(findAttribute(schema, "emails"), "complex", true, false, false, "readWrite", "global");
+ assertAttribute(findAttribute(schema, "name"), "complex", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "displayName"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "title"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "externalId"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "userType"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "nickName"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "locale"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "timezone"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "preferredLanguage"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "profileUrl"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "active"), "boolean", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "groups"), "complex", true, false, true, "readWrite", "none");
+
+ // Verify name sub-attributes
+ Schema.Attribute name = findAttribute(schema, "name");
+ assertNotNull(name.getSubAttributes(), "name should have sub-attributes");
+ Set nameSubAttrNames = name.getSubAttributes().stream()
+ .map(Schema.Attribute::getName)
+ .collect(Collectors.toSet());
+ assertTrue(nameSubAttrNames.contains("givenName"));
+ assertTrue(nameSubAttrNames.contains("familyName"));
+ assertTrue(nameSubAttrNames.contains("middleName"));
+ assertTrue(nameSubAttrNames.contains("honorificPrefix"));
+ assertTrue(nameSubAttrNames.contains("honorificSuffix"));
+ assertTrue(nameSubAttrNames.contains("formatted"));
+ for (Schema.Attribute subAttr : name.getSubAttributes()) {
+ assertSubAttribute(subAttr, "string", false, "readWrite");
+ }
}
@Test
@@ -83,15 +98,14 @@ public class SchemaTest extends AbstractScimTest {
assertEquals("Group", schema.getDescription());
assertNotNull(schema.getAttributes());
- // Verify ALL expected attributes are present (extracted from GroupCoreModelSchema)
- // GroupCoreModelSchema currently only has: displayName
- // Note: members is not yet supported in GroupCoreModelSchema attribute mappers
Set attributeNames = schema.getAttributes().stream()
.map(Schema.Attribute::getName)
.collect(Collectors.toSet());
- assertEquals(1, attributeNames.size(), "Group schema should have exactly 1 attribute");
- assertTrue(attributeNames.contains("displayName"));
+ assertEquals(2, attributeNames.size(), "Group schema should have exactly 2 attributes");
+
+ assertAttribute(findAttribute(schema, "displayName"), "string", false, false, false, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "externalId"), "string", false, false, true, "immutable", "none");
}
@Test
@@ -104,145 +118,31 @@ public class SchemaTest extends AbstractScimTest {
assertEquals("Enterprise User", schema.getDescription());
assertNotNull(schema.getAttributes());
- // Verify ALL expected attributes are present (extracted from UserEnterpriseModelSchema)
- // UserEnterpriseModelSchema has: employeeNumber, costCenter, organization, division, department, manager.*
- // These should map to: employeeNumber, costCenter, organization, division, department, manager
Set attributeNames = schema.getAttributes().stream()
.map(Schema.Attribute::getName)
.collect(Collectors.toSet());
assertEquals(6, attributeNames.size(), "Enterprise User schema should have exactly 6 attributes");
- assertTrue(attributeNames.contains("employeeNumber"));
- assertTrue(attributeNames.contains("costCenter"));
- assertTrue(attributeNames.contains("organization"));
- assertTrue(attributeNames.contains("division"));
- assertTrue(attributeNames.contains("department"));
- assertTrue(attributeNames.contains("manager"));
- }
- @Test
- public void testAttributeProperties() {
- Schema schema = client.schemas().get(Scim.USER_CORE_SCHEMA);
+ // Simple string attributes
+ assertAttribute(findAttribute(schema, "employeeNumber"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "costCenter"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "organization"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "division"), "string", false, false, true, "readWrite", "none");
+ assertAttribute(findAttribute(schema, "department"), "string", false, false, true, "readWrite", "none");
- // Test STRING type (userName)
- Schema.Attribute userNameAttr = findAttribute(schema, "userName");
- assertNotNull(userNameAttr, "userName attribute should exist");
- assertEquals("string", userNameAttr.getType());
- assertEquals(false, userNameAttr.getMultiValued());
-
- // Test BOOLEAN type (active)
- Schema.Attribute activeAttr = findAttribute(schema, "active");
- assertNotNull(activeAttr, "active attribute should exist");
- assertEquals("boolean", activeAttr.getType());
- assertEquals(false, activeAttr.getMultiValued());
-
- // Test COMPLEX multi-valued (emails)
- Schema.Attribute emailsAttr = findAttribute(schema, "emails");
- assertNotNull(emailsAttr, "emails attribute should exist");
- assertEquals("complex", emailsAttr.getType());
- assertEquals(true, emailsAttr.getMultiValued());
-
- // Test COMPLEX single-valued (name)
- Schema.Attribute nameAttr = findAttribute(schema, "name");
- assertNotNull(nameAttr, "name attribute should exist");
- assertEquals("complex", nameAttr.getType());
- assertEquals(false, nameAttr.getMultiValued());
-
- // Test STRING attributes (externalId, nickName, locale)
- Schema.Attribute externalIdAttr = findAttribute(schema, "externalId");
- assertNotNull(externalIdAttr, "externalId attribute should exist");
- assertEquals("string", externalIdAttr.getType());
- assertEquals(false, externalIdAttr.getMultiValued());
-
- Schema.Attribute nickNameAttr = findAttribute(schema, "nickName");
- assertNotNull(nickNameAttr, "nickName attribute should exist");
- assertEquals("string", nickNameAttr.getType());
- assertEquals(false, nickNameAttr.getMultiValued());
-
- Schema.Attribute localeAttr = findAttribute(schema, "locale");
- assertNotNull(localeAttr, "locale attribute should exist");
- assertEquals("string", localeAttr.getType());
- assertEquals(false, localeAttr.getMultiValued());
- }
-
- @Test
- public void testReferenceTypes() {
- // Test EnterpriseUser manager is a complex attribute
- Schema enterpriseUserSchema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA);
- Schema.Attribute managerAttr = findAttribute(enterpriseUserSchema, "manager");
- assertNotNull(managerAttr, "manager attribute should exist");
- assertEquals("complex", managerAttr.getType());
- assertEquals(false, managerAttr.getMultiValued());
-
- // TODO: referenceTypes are not yet tracked in the model schema Attribute.
- // Once Attribute supports reference types, add assertions for:
- // managerAttr.getReferenceTypes() containing "User"
-
- // Note: Group.members is not yet supported in GroupCoreModelSchema,
- // so reference type testing for members is omitted
- }
-
- @Test
- public void testEnterpriseUserAttributeTypes() {
- Schema schema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA);
-
- // All enterprise user attributes (except manager) should be string type
- String[] stringAttributes = {"employeeNumber", "costCenter", "organization", "division", "department"};
- for (String attrName : stringAttributes) {
- Schema.Attribute attr = findAttribute(schema, attrName);
- assertNotNull(attr, attrName + " attribute should exist");
- assertEquals("string", attr.getType(), attrName + " should be string type");
- assertEquals(false, attr.getMultiValued(), attrName + " should not be multi-valued");
+ // Manager is a complex attribute with sub-attributes
+ assertAttribute(findAttribute(schema, "manager"), "complex", false, false, false, "readWrite", "none");
+ Schema.Attribute manager = findAttribute(schema, "manager");
+ assertNotNull(manager.getSubAttributes(), "manager should have sub-attributes");
+ Set managerSubAttrNames = manager.getSubAttributes().stream()
+ .map(Schema.Attribute::getName)
+ .collect(Collectors.toSet());
+ assertTrue(managerSubAttrNames.contains("value"));
+ assertTrue(managerSubAttrNames.contains("displayName"));
+ for (Schema.Attribute subAttr : manager.getSubAttributes()) {
+ assertSubAttribute(subAttr, "string", false, "readWrite");
}
-
- // Manager is complex type with User reference
- Schema.Attribute managerAttr = findAttribute(schema, "manager");
- assertNotNull(managerAttr, "manager attribute should exist");
- assertEquals("complex", managerAttr.getType());
- assertEquals(false, managerAttr.getMultiValued());
- }
-
- @Test
- public void testGroupAttributeTypes() {
- Schema schema = client.schemas().get(Scim.GROUP_CORE_SCHEMA);
-
- // displayName should be string
- Schema.Attribute displayNameAttr = findAttribute(schema, "displayName");
- assertNotNull(displayNameAttr, "displayName attribute should exist");
- assertEquals("string", displayNameAttr.getType());
- assertEquals(false, displayNameAttr.getMultiValued());
-
- // Note: members is not yet supported in GroupCoreModelSchema attribute mappers
- }
-
- @Test
- public void testPathExtractionLogic() {
- // This test verifies that the path extraction logic works correctly
- // Multiple SCIM paths should map to the same top-level attribute
-
- Schema userSchema = client.schemas().get(Scim.USER_CORE_SCHEMA);
-
- // UserCoreModelSchema has multiple paths for 'name':
- // - name.givenName, name.familyName, name.middleName, name.honorificPrefix, name.honorificSuffix
- // All should map to a single 'name' attribute
- Schema.Attribute nameAttr = findAttribute(userSchema, "name");
- assertNotNull(nameAttr, "name attribute should exist (from multiple name.* paths)");
- assertEquals("complex", nameAttr.getType());
- assertEquals(false, nameAttr.getMultiValued());
-
- // emails[0].value should map to 'emails' attribute
- Schema.Attribute emailsAttr = findAttribute(userSchema, "emails");
- assertNotNull(emailsAttr, "emails attribute should exist (from emails[0].value path)");
- assertEquals("complex", emailsAttr.getType());
- assertEquals(true, emailsAttr.getMultiValued());
-
- // EnterpriseUser has manager.value and manager.displayName
- // Both should map to a single 'manager' attribute
- Schema enterpriseSchema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA);
- Schema.Attribute managerAttr = findAttribute(enterpriseSchema, "manager");
- assertNotNull(managerAttr, "manager attribute should exist (from manager.* paths)");
- assertEquals("complex", managerAttr.getType());
- assertEquals(false, managerAttr.getMultiValued());
}
@Test
@@ -284,4 +184,22 @@ public class SchemaTest extends AbstractScimTest {
.findFirst()
.orElse(null);
}
+
+ private void assertAttribute(Schema.Attribute attribute, String type, boolean multiValued,
+ boolean required, boolean caseExact, String mutability, String uniqueness) {
+ assertNotNull(attribute, "attribute should not be null");
+ assertEquals(type, attribute.getType(), attribute.getName() + " type");
+ assertEquals(multiValued, attribute.getMultiValued(), attribute.getName() + " multiValued");
+ assertEquals(required, attribute.getRequired(), attribute.getName() + " required");
+ assertEquals(caseExact, attribute.getCaseExact(), attribute.getName() + " caseExact");
+ assertEquals(mutability, attribute.getMutability(), attribute.getName() + " mutability");
+ assertEquals(uniqueness, attribute.getUniqueness(), attribute.getName() + " uniqueness");
+ }
+
+ private void assertSubAttribute(Schema.Attribute subAttribute, String type, boolean multiValued, String mutability) {
+ assertNotNull(subAttribute, "sub-attribute should not be null");
+ assertEquals(type, subAttribute.getType(), subAttribute.getName() + " sub-attribute type");
+ assertEquals(multiValued, subAttribute.getMultiValued(), subAttribute.getName() + " sub-attribute multiValued");
+ assertEquals(mutability, subAttribute.getMutability(), subAttribute.getName() + " sub-attribute mutability");
+ }
}
diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/ServiceProviderConfigTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/ServiceProviderConfigTest.java
index 241f7d4332b..e578248c66e 100644
--- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/ServiceProviderConfigTest.java
+++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/ServiceProviderConfigTest.java
@@ -6,6 +6,7 @@ import java.util.Set;
import org.keycloak.scim.resource.config.ServiceProviderConfig;
import org.keycloak.scim.resource.config.ServiceProviderConfig.AuthenticationScheme;
import org.keycloak.scim.resource.config.ServiceProviderConfig.BulkSupport;
+import org.keycloak.scim.resource.config.ServiceProviderConfig.FilterSupport;
import org.keycloak.scim.resource.config.ServiceProviderConfig.Supported;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
@@ -37,8 +38,15 @@ public class ServiceProviderConfigTest extends AbstractScimTest {
assertTrue(patch.getSupported());
List authenticationSchemes = config.getAuthenticationSchemes();
assertNotNull(authenticationSchemes);
- // TODO: support at least bearer token authentication scheme
- assertTrue(authenticationSchemes.isEmpty());
+ assertEquals(1, authenticationSchemes.size());
+ AuthenticationScheme bearerScheme = authenticationSchemes.get(0);
+ assertEquals("OAuth Bearer Token", bearerScheme.getName());
+ assertEquals("Authentication scheme using the OAuth Bearer Token standard", bearerScheme.getDescription());
+ assertEquals("https://tools.ietf.org/html/rfc6750", bearerScheme.getSpecUri());
+ assertEquals("oauthbearertoken", bearerScheme.getType());
+ FilterSupport filter = config.getFilter();
+ assertNotNull(filter);
+ assertTrue(filter.getSupported());
Set schemas = config.getSchemas();
assertNotNull(schemas);
assertEquals(1, schemas.size());
diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java
index 33a6e9c8fef..0aa1c0f8927 100644
--- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java
+++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java
@@ -625,6 +625,14 @@ public class UserTest extends AbstractScimTest {
expected.setActive(false);
assertRootAttributes(actual, expected);
+ // patch a multivalued attribute using a filter in the path that matches an existing value
+ client.users().patch(expected.getId(), PatchRequest.create()
+ .replace("emails[value ew \"patched4.org\"].value", expected.getEmail().replace("patched4.org", "filtered.org"))
+ .build());
+ actual = client.users().get(expected.getId());
+ expected.setEmail(expected.getEmail().replace("patched4.org", "filtered.org"));
+ assertRootAttributes(actual, expected);
+
// patch a multivalued attribute using a filter in the path that does not resolve to any value, no update should be performed
String expectedEmail = expected.getEmail();
expected.setEmail(expected.getEmail().replace("patched4.org", "patched5.org"));
@@ -765,8 +773,7 @@ public class UserTest extends AbstractScimTest {
assertEquals(5, groups.size());
client.users().patch(expected.getId(), PatchRequest.create()
- .remove("groups[value eq \"" + groupA1.getId() + "\"]")
- .remove("groups[value eq \"" + groupB.getId() + "\"]")
+ .remove("groups[value eq \"" + groupA1.getId() + "\" or value eq \"" + groupB.getId() + "\"]")
.build());
actual = client.users().get(expected.getId());
groups = actual.getGroups();
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java b/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java
index cc0736f6f49..510d94e6060 100755
--- a/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java
@@ -33,9 +33,11 @@ import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelDuplicateException;
import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.provider.ProviderConfigProperty;
+import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.managers.AuthenticationManager;
@@ -102,7 +104,11 @@ public class ValidateUsername extends AbstractDirectGrantAuthenticator {
if (!user.isEnabled()) {
context.getEvent().user(user);
context.getEvent().error(Errors.USER_DISABLED);
- Response challengeResponse = errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_grant", "Account disabled");
+ String password = retrievePassword(context);
+ String errorDescription = user.credentialManager().isValid(UserCredentialModel.password(password))
+ ? "Account disabled"
+ : "Invalid user credentials";
+ Response challengeResponse = errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_grant", errorDescription);
context.forceChallenge(challengeResponse);
return;
}
@@ -174,4 +180,9 @@ public class ValidateUsername extends AbstractDirectGrantAuthenticator {
MultivaluedMap inputData = context.getHttpRequest().getDecodedFormParameters();
return inputData.getFirst(AuthenticationManager.FORM_USERNAME);
}
+
+ protected String retrievePassword(AuthenticationFlowContext context) {
+ MultivaluedMap inputData = context.getHttpRequest().getDecodedFormParameters();
+ return inputData.getFirst(CredentialRepresentation.PASSWORD);
+ }
}
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java
index 20540295247..532374609c9 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java
@@ -111,6 +111,9 @@ public class JwtCredentialBuilder implements CredentialBuilder {
@Override
public void contributeToMetadata(SupportedCredentialConfiguration credentialConfig, CredentialScopeModel credentialScope) {
CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope);
+ // @context must not be included for jwt_vc_json format per OID4VCI spec;
+ // it is only valid for ldp_vc format.
+ credentialDefinition.setContext(null);
credentialConfig.setCredentialDefinition(credentialDefinition);
}
}
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilder.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilder.java
index 75895bdf428..703d223434a 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilder.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilder.java
@@ -18,7 +18,10 @@
package org.keycloak.protocol.oid4vc.issuance.credentialbuilder;
import org.keycloak.VCFormat;
+import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig;
+import org.keycloak.protocol.oid4vc.model.CredentialDefinition;
+import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
@@ -38,6 +41,12 @@ public class LDCredentialBuilder implements CredentialBuilder {
return VCFormat.LDP_VC;
}
+ @Override
+ public void contributeToMetadata(SupportedCredentialConfiguration credentialConfig, CredentialScopeModel credentialScope) {
+ CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope);
+ credentialConfig.setCredentialDefinition(credentialDefinition);
+ }
+
@Override
public LDCredentialBody buildCredentialBody(
VerifiableCredential verifiableCredential,
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java
index b5f5de11340..cc86aed1c39 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java
@@ -86,6 +86,9 @@ public class OID4VCGeneratedIdMapper extends OID4VCMapper {
public void setClaim(Map claims, UserSessionModel userSessionModel) {
// Assign a generated ID
List attributePath = getMetadataAttributePath();
+ if (attributePath.isEmpty()) {
+ return;
+ }
String propertyName = attributePath.get(attributePath.size() - 1);
claims.put(propertyName, String.format("urn:uuid:%s", UUID.randomUUID()));
}
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java
index 41daefdae67..85c574c323a 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java
@@ -113,8 +113,14 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP
public List getMetadataAttributePath() {
final String claimName = mapperModel.getConfig().get(CLAIM_NAME);
final String userAttributeName = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY);
- return ListUtils.union(getAttributePrefix(),
- List.of(Optional.ofNullable(claimName).orElse(userAttributeName)));
+ String attributeName = Optional.ofNullable(claimName)
+ .orElse(userAttributeName);
+
+ if (attributeName == null) {
+ return Collections.emptyList();
+ }
+
+ return ListUtils.union(getAttributePrefix(), List.of(attributeName));
}
protected List getAttributePrefix() {
@@ -168,12 +174,15 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP
*/
public void setClaimWithMetadataPrefix(Map claimsOrig, Map claimsWithPrefix) {
List attributePath = getMetadataAttributePath();
+ if (attributePath.isEmpty()) {
+ return;
+ }
String propertyName = attributePath.get(attributePath.size() - 1);
if (claimsOrig.get(propertyName) != null) {
Object claimValue = claimsOrig.get(propertyName);
Map current = claimsWithPrefix;
- for (int i = 0; i < attributePath.size() ; i++) {
+ for (int i = 0; i < attributePath.size(); i++) {
String currentSnippetName = attributePath.get(i);
if (i < attributePath.size() - 1) {
Map obj = (Map) current.get(currentSnippetName);
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java
index 744617a75a0..6fcf5c1479f 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java
@@ -69,6 +69,9 @@ public class OID4VCStaticClaimMapper extends OID4VCMapper {
@Override
public void setClaim(Map claims, UserSessionModel userSessionModel) {
List attributePath = getMetadataAttributePath();
+ if (attributePath.isEmpty()) {
+ return;
+ }
String propertyName = attributePath.get(attributePath.size() - 1);
String staticValue = mapperModel.getConfig().get(STATIC_CLAIM_KEY);
claims.put(propertyName, staticValue);
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java
index 805f396e5a7..2680a1c40e1 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java
@@ -128,6 +128,9 @@ public class OID4VCSubjectIdMapper extends OID4VCMapper {
public void setClaim(Map claims, UserSessionModel userSessionModel) {
UserModel userModel = userSessionModel.getUser();
List attributePath = getMetadataAttributePath();
+ if (attributePath.isEmpty()) {
+ return;
+ }
String propertyName = attributePath.get(attributePath.size() - 1);
String userAttributeName = mapperModel.getConfig().get(OID4VCMapper.USER_ATTRIBUTE_KEY);
Consumer userIdConsumer = (val) -> claims.put(propertyName, val);
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java
index f92fbad5095..566998b311e 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java
@@ -152,6 +152,9 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
public void setClaim(Map claims,
UserSessionModel userSessionModel) {
List attributePath = getMetadataAttributePath();
+ if (attributePath.isEmpty()) {
+ return;
+ }
String propertyName = attributePath.get(attributePath.size() - 1);
String client = mapperModel.getConfig().get(CLIENT_CONFIG_KEY);
ClientModel clientModel = userSessionModel.getRealm().getClientByClientId(client);
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java
index c9b0c30c983..9cb1c8967fa 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java
@@ -88,6 +88,9 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper {
public void setClaim(Map claims, UserSessionModel userSessionModel) {
String claimName = mapperModel.getConfig().get(CLAIM_NAME);
String userAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY);
+ if (claimName == null && userAttribute == null) {
+ return;
+ }
boolean aggregateAttributes = Optional.ofNullable(mapperModel.getConfig().get(AGGREGATE_ATTRIBUTES_KEY))
.map(Boolean::parseBoolean).orElse(false);
Collection attributes =
@@ -96,7 +99,7 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper {
attributes.removeAll(Collections.singleton(null));
if (!attributes.isEmpty()) {
JsonUtils.mapClaim(
- JsonUtils.splitClaimPath(claimName),
+ JsonUtils.splitClaimPath(Optional.ofNullable(claimName).orElse(userAttribute)),
String.join(",", attributes),
claims,
false
@@ -143,7 +146,14 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper {
String claimName = mapperModel.getConfig().get(CLAIM_NAME);
final String userAttributeName = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY);
// Split claim name into path segments for metadata endpoint.
- final List claimPath = Optional.ofNullable(claimName).map(JsonUtils::splitClaimPath).orElse(List.of(userAttributeName));
+ final List claimPath = Optional.ofNullable(claimName)
+ .map(JsonUtils::splitClaimPath)
+ .orElse(Optional.ofNullable(userAttributeName)
+ .map(List::of)
+ .orElse(Collections.emptyList()));
+ if (claimPath.isEmpty()) {
+ return Collections.emptyList();
+ }
return ListUtils.union(getAttributePrefix(), claimPath);
}
}
diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java
index b7c5bd54f5b..39dc3edddc9 100644
--- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java
+++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java
@@ -77,9 +77,14 @@ public class Claim {
return Optional.empty();
}
- claim.setName(String.join(".", mapper.getMetadataAttributePath()));
+ List attributePath = mapper.getMetadataAttributePath();
+ if (attributePath == null || attributePath.isEmpty()) {
+ return Optional.empty();
+ }
- claim.setPath(mapper.getMetadataAttributePath());
+ claim.setName(String.join(".", attributePath));
+
+ claim.setPath(attributePath);
claim.setMandatory(protocolMapper.isMandatory());
String displayString = protocolMapper.getDisplay();
diff --git a/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java b/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java
index 266331e920d..9e4a916a610 100644
--- a/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java
+++ b/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java
@@ -166,7 +166,6 @@ public abstract class DefaultKeycloakSession implements KeycloakSession {
return getDatastoreProvider().users();
}
- @SuppressWarnings("unchecked")
@Override
public T getProvider(Class clazz) {
List key = List.of(clazz.getName());
@@ -174,6 +173,7 @@ public abstract class DefaultKeycloakSession implements KeycloakSession {
}
private T getOrCreateProvider(List key, Supplier> supplier) {
+ @SuppressWarnings("unchecked")
T provider = (T) providers.get(key);
// KEYCLOAK-11890 - Avoid using HashMap.computeIfAbsent() to implement logic in outer if() block below,
// since per JDK-8071667 the remapping function should not modify the map during computation. While
@@ -188,7 +188,6 @@ public abstract class DefaultKeycloakSession implements KeycloakSession {
return provider;
}
- @SuppressWarnings("unchecked")
@Override
public T getProvider(Class clazz, String id) {
List key = List.of(clazz.getName(), id);
@@ -207,11 +206,10 @@ public abstract class DefaultKeycloakSession implements KeycloakSession {
}
@Override
- @SuppressWarnings("unchecked")
public T getComponentProvider(Class clazz, String componentId, Function modelGetter) {
List key = List.of("component", clazz.getName(), componentId);
final RealmModel realm = getContext().getRealm();
- return getOrCreateProvider(key, () -> factory.getProviderFactory(clazz, Optional.ofNullable(realm.getId()).orElse(null), componentId, modelGetter));
+ return getOrCreateProvider(key, () -> factory.getProviderFactory(clazz, Optional.ofNullable(realm).map(RealmModel::getId).orElse(null), componentId, modelGetter));
}
@Override
diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/OfflineTokenBasicFlowTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/OfflineTokenBasicFlowTest.java
new file mode 100644
index 00000000000..22a395d2687
--- /dev/null
+++ b/tests/base/src/test/java/org/keycloak/tests/oauth/OfflineTokenBasicFlowTest.java
@@ -0,0 +1,1018 @@
+package org.keycloak.tests.oauth;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import jakarta.ws.rs.NotFoundException;
+
+import org.keycloak.OAuth2Constants;
+import org.keycloak.admin.client.Keycloak;
+import org.keycloak.admin.client.resource.ClientResource;
+import org.keycloak.admin.client.resource.RealmResource;
+import org.keycloak.admin.client.resource.RoleResource;
+import org.keycloak.admin.client.resource.UserResource;
+import org.keycloak.common.constants.ServiceAccountConstants;
+import org.keycloak.crypto.Algorithm;
+import org.keycloak.events.Details;
+import org.keycloak.events.Errors;
+import org.keycloak.events.EventType;
+import org.keycloak.jose.jws.JWSHeader;
+import org.keycloak.jose.jws.JWSInput;
+import org.keycloak.models.AdminRoles;
+import org.keycloak.models.Constants;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.models.session.UserSessionPersisterProvider;
+import org.keycloak.models.utils.KeycloakModelUtils;
+import org.keycloak.protocol.oidc.OIDCConfigAttributes;
+import org.keycloak.protocol.oidc.encode.AccessTokenContext;
+import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider;
+import org.keycloak.representations.AccessToken;
+import org.keycloak.representations.RefreshToken;
+import org.keycloak.representations.idm.ClientRepresentation;
+import org.keycloak.representations.idm.ClientScopeRepresentation;
+import org.keycloak.representations.idm.EventRepresentation;
+import org.keycloak.representations.idm.RealmRepresentation;
+import org.keycloak.representations.idm.RoleRepresentation;
+import org.keycloak.testframework.admin.AdminClientFactory;
+import org.keycloak.testframework.annotations.InjectAdminClient;
+import org.keycloak.testframework.annotations.InjectAdminClientFactory;
+import org.keycloak.testframework.annotations.InjectEvents;
+import org.keycloak.testframework.annotations.InjectRealm;
+import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
+import org.keycloak.testframework.events.EventAssertion;
+import org.keycloak.testframework.events.Events;
+import org.keycloak.testframework.oauth.OAuthClient;
+import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
+import org.keycloak.testframework.realm.ClientConfig;
+import org.keycloak.testframework.realm.ClientConfigBuilder;
+import org.keycloak.testframework.realm.ManagedRealm;
+import org.keycloak.testframework.realm.RealmConfig;
+import org.keycloak.testframework.realm.RealmConfigBuilder;
+import org.keycloak.testframework.realm.RoleConfigBuilder;
+import org.keycloak.testframework.remote.runonserver.InjectRunOnServer;
+import org.keycloak.testframework.remote.runonserver.RunOnServerClient;
+import org.keycloak.testframework.remote.timeoffset.InjectTimeOffSet;
+import org.keycloak.testframework.remote.timeoffset.TimeOffSet;
+import org.keycloak.testframework.ui.annotations.InjectWebDriver;
+import org.keycloak.testframework.ui.webdriver.ManagedWebDriver;
+import org.keycloak.tests.utils.admin.AdminApiUtil;
+import org.keycloak.testsuite.util.AccountHelper;
+import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
+import org.keycloak.util.TokenUtil;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.keycloak.tests.utils.admin.AdminApiUtil.findRealmRoleByName;
+import static org.keycloak.tests.utils.admin.AdminApiUtil.findUserByUsername;
+import static org.keycloak.tests.utils.admin.AdminApiUtil.findUserByUsernameId;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@KeycloakIntegrationTest
+public class OfflineTokenBasicFlowTest {
+
+ private static final String OFFLINE_CLIENT_ID = "offline-client";
+ private static final String OFFLINE_CLIENT_APP_URI = "http://localhost:8080/offline-client";
+ private static final String TEST_APP_REDIRECT_URI = "http://localhost:8080/auth/realms/test/app/auth";
+ private String userId;
+ private String serviceAccountUserId;
+
+ @InjectRealm(config = OfflineTokenBasicFlowTest.OfflineTokenRealmConfig.class)
+ ManagedRealm realm;
+
+ @InjectOAuthClient(config = OfflineTokenBasicFlowTest.OfflineAuthClientConfig.class)
+ OAuthClient oauth;
+
+ @InjectEvents
+ Events events;
+
+ @InjectAdminClient
+ Keycloak adminClient;
+
+ @InjectWebDriver
+ ManagedWebDriver driver;
+
+ @InjectRunOnServer
+ RunOnServerClient runOnServer;
+
+ @InjectTimeOffSet
+ TimeOffSet timeOffSet;
+
+ @InjectAdminClientFactory
+ AdminClientFactory adminClientFactory;
+
+ @BeforeEach
+ public void clientConfiguration() {
+
+ timeOffSet.set(0);
+
+ // Reset OAuth client config to defaults
+ oauth.realm("test");
+ oauth.client("test-app"); // Reset to default client
+ oauth.redirectUri(TEST_APP_REDIRECT_URI); // Reset to default redirect
+ oauth.scope(null); // Clear any scope
+ oauth.responseType(OAuth2Constants.CODE); // Reset to default
+
+ // Force server-side logout
+ try {
+ adminClient.realm("test").logoutAll();
+ } catch (NotFoundException e) {
+ // Expected behavior on the first run if the realm/sessions don't exist yet. Safe to ignore.
+ }
+
+ // Fetch the auto-generated service account user
+ serviceAccountUserId = realm.admin().users()
+ .search(ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + OFFLINE_CLIENT_ID, true)
+ .get(0).getId();
+
+ userId = findUserByUsername(adminClient.realm("test"), "test-user@localhost").getId();
+
+ events.clear();
+ }
+
+ @Test
+ public void offlineTokenDisabledForClient() {
+ // Remove offline-access scope from client
+ ClientScopeRepresentation offlineScope = adminClient.realm("test").clientScopes().findAll().stream()
+ .filter((ClientScopeRepresentation clientScope) -> OAuth2Constants.OFFLINE_ACCESS.equals(clientScope.getName()))
+ .findFirst().orElseThrow();
+
+ ClientResource clientResource = realm.admin().clients()
+ .get(realm.admin().clients().findByClientId("offline-client").get(0).getId());
+
+ ClientRepresentation client = clientResource.toRepresentation();
+ client.setFullScopeAllowed(false);
+ clientResource.update(client);
+ clientResource.removeOptionalClientScope(offlineScope.getId());
+
+ try {
+ // Test that offline access is denied
+ oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
+ oauth.client("offline-client", "secret1");
+ oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
+ oauth.openLoginForm();
+
+ EventRepresentation errorEvent = events.poll();
+ EventAssertion.assertError(errorEvent)
+ .type(EventType.LOGIN_ERROR)
+ .clientId("offline-client")
+ .error(Errors.INVALID_REQUEST)
+ .details(Details.REASON, "Invalid scopes: openid offline_access");
+
+ } finally {
+ // Revert changes
+ client.setFullScopeAllowed(true);
+ clientResource.update(client);
+ clientResource.addOptionalClientScope(offlineScope.getId());
+ }
+ }
+
+ @Test
+ public void offlineTokenUserNotAllowed() {
+ String userId = realm.admin().users()
+ .search("keycloak-user@localhost", true)
+ .stream()
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("User 'keycloak-user@localhost' not found!"))
+ .getId();
+
+ oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
+ oauth.client("offline-client", "secret1");
+ oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
+ oauth.doLogin("keycloak-user@localhost", "password");
+
+ EventRepresentation loginEvent = events.poll();
+ EventAssertion.assertSuccess(loginEvent)
+ .type(EventType.LOGIN)
+ .clientId("offline-client")
+ .userId(userId)
+ .details(Details.REDIRECT_URI, OFFLINE_CLIENT_APP_URI);
+
+ String sessionId = loginEvent.getSessionId();
+ String codeId = loginEvent.getDetails().get(Details.CODE_ID);
+
+ String code = oauth.parseLoginResponse().getCode();
+
+ AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code);
+
+ assertEquals(400, tokenResponse.getStatusCode());
+ assertEquals("not_allowed", tokenResponse.getError());
+
+ EventRepresentation tokenEvent = events.poll();
+ EventAssertion.assertError(tokenEvent)
+ .type(EventType.CODE_TO_TOKEN_ERROR)
+ .clientId("offline-client")
+ .userId(userId)
+ .sessionId(sessionId)
+ .error("not_allowed")
+ .details(Details.CODE_ID, codeId);
+ }
+
+ @Test
+ public void offlineTokenBrowserFlow() {
+ setupCustomerUserRoles();
+
+ oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
+ oauth.client("offline-client", "secret1");
+ oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
+ oauth.doLogin("test-user@localhost", "password");
+
+ EventRepresentation loginEvent = events.poll();
+ EventAssertion.assertSuccess(loginEvent)
+ .type(EventType.LOGIN)
+ .clientId("offline-client")
+ .details(Details.REDIRECT_URI, OFFLINE_CLIENT_APP_URI);
+
+ final String sessionId = loginEvent.getSessionId();
+ String codeId = loginEvent.getDetails().get(Details.CODE_ID);
+
+ String code = oauth.parseLoginResponse().getCode();
+
+ AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code);
+ AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken());
+ String offlineTokenString = tokenResponse.getRefreshToken();
+ RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString);
+
+ EventRepresentation codeToTokenEvent = events.poll();
+ EventAssertion.assertSuccess(codeToTokenEvent)
+ .type(EventType.CODE_TO_TOKEN)
+ .clientId("offline-client")
+ .sessionId(sessionId)
+ .details(Details.CODE_ID, codeId)
+ .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE);
+
+ assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType());
+ Assertions.assertNull(offlineToken.getExp());
+
+ AccessTokenContext ctx = runOnServer.fetch(session -> {
+ return session.getProvider(TokenContextEncoderProvider.class)
+ .getTokenContextFromTokenId(token.getId());
+ }, AccessTokenContext.class);
+ Assertions.assertEquals(AccessTokenContext.SessionType.OFFLINE, ctx.getSessionType());
+ Assertions.assertEquals(AccessTokenContext.TokenType.REGULAR, ctx.getTokenType());
+ Assertions.assertEquals(OAuth2Constants.AUTHORIZATION_CODE, ctx.getGrantType());
+
+ assertTrue(tokenResponse.getScope().contains(OAuth2Constants.OFFLINE_ACCESS));
+
+ // check only offline session is created
+ checkNumberOfSessions(userId, "offline-client", offlineToken.getSessionId(), 0, 1);
+
+ String newRefreshTokenString = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, sessionId, userId);
+
+ // Change offset to very big value to ensure offline session expires
+ timeOffSet.set(3000000);
+
+ AccessTokenResponse response = oauth.doRefreshTokenRequest(newRefreshTokenString);
+ RefreshToken newRefreshToken = oauth.parseRefreshToken(newRefreshTokenString);
+ Assertions.assertEquals(400, response.getStatusCode());
+ assertEquals("invalid_grant", response.getError());
+
+ EventRepresentation refreshErrorEvent = events.poll();
+ EventAssertion.assertError(refreshErrorEvent)
+ .type(EventType.REFRESH_TOKEN_ERROR)
+ .sessionId(newRefreshToken.getSessionId())
+ //.userId(loginEvent.getUserId())
+ .clientId("offline-client")
+ .error(Errors.INVALID_TOKEN)
+ .details(Details.REFRESH_TOKEN_SUB, loginEvent.getUserId());
+ timeOffSet.set(0);
+ }
+
+
+ @Test
+ public void onlineOfflineTokenBrowserFlow() {
+ // request an online token for the client
+ oauth.scope(null);
+ oauth.client("offline-client", "secret1");
+ oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
+ oauth.doLogin("test-user@localhost", "password");
+
+ EventRepresentation onlineLoginEvent = events.poll();
+ EventAssertion.assertSuccess(onlineLoginEvent)
+ .type(EventType.LOGIN)
+ .clientId("offline-client")
+ .details(Details.REDIRECT_URI, OFFLINE_CLIENT_APP_URI);
+
+ final String onlineSessionId = onlineLoginEvent.getSessionId();
+ String codeId = onlineLoginEvent.getDetails().get(Details.CODE_ID);
+ AccessTokenResponse onlineTokenResponse = oauth.doAccessTokenRequest(oauth.parseLoginResponse().getCode());
+ RefreshToken onlineRefreshToken = assertRefreshToken(onlineTokenResponse, TokenUtil.TOKEN_TYPE_REFRESH);
+
+ EventRepresentation onlineCodeToTokenEvent = events.poll();
+ EventAssertion.assertSuccess(onlineCodeToTokenEvent)
+ .type(EventType.CODE_TO_TOKEN)
+ .clientId("offline-client")
+ .sessionId(onlineSessionId)
+ .details(Details.CODE_ID, codeId)
+ .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH);
+ assertEquals(TokenUtil.TOKEN_TYPE_REFRESH, onlineRefreshToken.getType());
+ Assertions.assertNotNull(onlineRefreshToken.getExp());
+
+ // request an offline token for the same client
+ oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
+ oauth.openLoginForm();
+ EventRepresentation offlineLoginEvent = events.poll();
+ EventAssertion.assertSuccess(offlineLoginEvent)
+ .type(EventType.LOGIN)
+ .clientId("offline-client")
+ .details(Details.REDIRECT_URI, OFFLINE_CLIENT_APP_URI);
+ AccessTokenResponse offlineTokenResponse = oauth.doAccessTokenRequest(
+ oauth.parseLoginResponse().getCode());
+ RefreshToken offlineRefreshToken = assertRefreshToken(offlineTokenResponse, TokenUtil.TOKEN_TYPE_OFFLINE);
+ final String offlineSessionId = offlineLoginEvent.getSessionId();
+
+ EventRepresentation offlineCodeToTokenEvent = events.poll();
+ EventAssertion.assertSuccess(offlineCodeToTokenEvent)
+ .type(EventType.CODE_TO_TOKEN)
+ .clientId("offline-client")
+ .sessionId(onlineSessionId)
+ .details(Details.CODE_ID, codeId)
+ .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE);
+ assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineRefreshToken.getType());
+ Assertions.assertNull(offlineRefreshToken.getExp());
+ assertTrue(offlineTokenResponse.getScope().contains(OAuth2Constants.OFFLINE_ACCESS));
+
+ // check both sessions are created
+ checkNumberOfSessions(userId, "offline-client", onlineRefreshToken.getSessionId(), 1, 1);
+
+ // check online token can be refreshed
+ onlineTokenResponse = oauth.doRefreshTokenRequest(onlineTokenResponse.getRefreshToken());
+ assertRefreshToken(onlineTokenResponse, TokenUtil.TOKEN_TYPE_REFRESH);
+ AccessToken renewedOnlineAccessToken = oauth.verifyToken(onlineTokenResponse.getAccessToken());
+
+ EventRepresentation onlineRefreshEvent = events.poll();
+ EventAssertion.assertSuccess(onlineRefreshEvent)
+ .type(EventType.REFRESH_TOKEN)
+ .clientId("offline-client")
+ .userId(userId)
+ .sessionId(onlineSessionId)
+ .details(Details.TOKEN_ID, renewedOnlineAccessToken.getId())
+ .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH)
+ .details(Details.REFRESH_TOKEN_ID, onlineRefreshToken.getId());
+
+ // check offline token can be refreshed
+ offlineTokenResponse = oauth.doRefreshTokenRequest(offlineTokenResponse.getRefreshToken());
+ assertRefreshToken(offlineTokenResponse, TokenUtil.TOKEN_TYPE_OFFLINE);
+ AccessToken renewedOfflineAccessToken = oauth.verifyToken(offlineTokenResponse.getAccessToken());
+
+ EventRepresentation offlineRefreshEvent = events.poll();
+ EventAssertion.assertSuccess(offlineRefreshEvent)
+ .type(EventType.REFRESH_TOKEN)
+ .clientId("offline-client")
+ .userId(userId)
+ .sessionId(offlineSessionId)
+ .details(Details.TOKEN_ID, renewedOfflineAccessToken.getId())
+ .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
+ .details(Details.REFRESH_TOKEN_ID, offlineRefreshToken.getId());
+ }
+
+ @Test
+ public void offlineTokenDirectGrantFlow() {
+ setupCustomerUserRoles();
+
+ oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
+ oauth.client("offline-client", "secret1");
+ AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password");
+ Assertions.assertNull(tokenResponse.getErrorDescription());
+ AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken());
+ String offlineTokenString = tokenResponse.getRefreshToken();
+ RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString);
+
+ EventRepresentation loginEvent = events.poll();
+ EventAssertion.assertSuccess(loginEvent)
+ .type(EventType.LOGIN)
+ .clientId("offline-client")
+ .userId(userId)
+ .sessionId(token.getSessionId())
+ .details(Details.GRANT_TYPE, OAuth2Constants.PASSWORD)
+ .details(Details.TOKEN_ID, token.getId())
+ .details(Details.REFRESH_TOKEN_ID, offlineToken.getId())
+ .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
+ .details(Details.USERNAME, "test-user@localhost");
+
+ Assertions.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType());
+ Assertions.assertNull(offlineToken.getExp());
+
+ // check only the offline session is created
+ checkNumberOfSessions(userId, "offline-client", offlineToken.getSessionId(), 0, 1);
+
+ // refresh token
+ testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), userId);
+
+ // Assert same token can be refreshed again
+ testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), userId);
+ }
+
+ @Test
+ public void offlineTokenDirectGrantFlowWithRefreshTokensRevoked() {
+ setupCustomerUserRoles();
+ realm.updateWithCleanup(r -> r.revokeRefreshToken(true));
+
+ oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
+ oauth.client("offline-client", "secret1");
+ AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password");
+
+ AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken());
+ String offlineTokenString = tokenResponse.getRefreshToken();
+ RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString);
+
+ EventRepresentation loginEvent = events.poll();
+ EventAssertion.assertSuccess(loginEvent)
+ .type(EventType.LOGIN)
+ .clientId("offline-client")
+ .userId(userId)
+ .sessionId(token.getSessionId())
+ .details(Details.GRANT_TYPE, OAuth2Constants.PASSWORD)
+ .details(Details.TOKEN_ID, token.getId())
+ .details(Details.REFRESH_TOKEN_ID, offlineToken.getId())
+ .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
+ .details(Details.USERNAME, "test-user@localhost");
+
+ Assertions.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType());
+ Assertions.assertNull(offlineToken.getExp());
+
+ String offlineTokenString2 = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), userId);
+ RefreshToken offlineToken2 = oauth.parseRefreshToken(offlineTokenString2);
+
+ // Clear the events queue to prevent any pollution from time-shifted events
+ // generated inside testRefreshWithOfflineToken
+ events.clear();
+
+ // Assert second refresh with same refresh token will fail
+ AccessTokenResponse response = oauth.doRefreshTokenRequest(offlineTokenString);
+ Assertions.assertEquals(400, response.getStatusCode());
+ EventRepresentation refreshEvent = events.poll();
+ EventAssertion.assertError(refreshEvent)
+ .type(EventType.REFRESH_TOKEN_ERROR)
+ .clientId("offline-client")
+ .userId(null)
+ .error(Errors.INVALID_TOKEN)
+ .sessionId(token.getSessionId())
+ .details(Details.REFRESH_TOKEN_ID, offlineToken.getId());
+
+ // Refresh with new refreshToken fails as well (client session was invalidated because of attempt to refresh with revoked refresh token)
+ AccessTokenResponse response2 = oauth.doRefreshTokenRequest(offlineTokenString2);
+ Assertions.assertEquals(400, response2.getStatusCode());
+ EventRepresentation refreshEvent2 = events.poll();
+ EventAssertion.assertError(refreshEvent2)
+ .type(EventType.REFRESH_TOKEN_ERROR)
+ .clientId("offline-client")
+ .userId(null)
+ .error(Errors.INVALID_TOKEN)
+ .sessionId(offlineToken2.getSessionId())
+ .details(Details.REFRESH_TOKEN_ID, offlineToken2.getId());
+
+ realm.updateWithCleanup(r -> r.revokeRefreshToken(false));
+ }
+
+ @Test
+ public void offlineTokenServiceAccountFlow() {
+ setupCustomerUserRoles();
+
+ oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
+ oauth.client("offline-client", "secret1");
+ AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest();
+
+ AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken());
+ String offlineTokenString = tokenResponse.getRefreshToken();
+ RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString);
+
+ EventRepresentation loginEvent = events.poll();
+ EventAssertion.assertSuccess(loginEvent)
+ .type(EventType.CLIENT_LOGIN)
+ .clientId("offline-client")
+ .userId(serviceAccountUserId)
+ .sessionId(token.getSessionId())
+ .details(Details.TOKEN_ID, token.getId())
+ .details(Details.REFRESH_TOKEN_ID, offlineToken.getId())
+ .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
+ .details(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client");
+
+ Assertions.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType());
+ Assertions.assertNull(offlineToken.getExp());
+
+ // check only the offline session is created
+ checkNumberOfSessions(serviceAccountUserId, "offline-client", offlineToken.getSessionId(), 0, 1);
+
+ testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId);
+
+ // Now retrieve another offline token and verify that previous offline token is still valid
+ tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest();
+
+ AccessToken token2 = oauth.verifyToken(tokenResponse.getAccessToken());
+ String offlineTokenString2 = tokenResponse.getRefreshToken();
+ RefreshToken offlineToken2 = oauth.parseRefreshToken(offlineTokenString2);
+
+ EventRepresentation loginEvent2 = events.poll();
+ EventAssertion.assertSuccess(loginEvent2)
+ .type(EventType.CLIENT_LOGIN)
+ .clientId("offline-client")
+ .userId(serviceAccountUserId)
+ .sessionId(token2.getSessionId())
+ .details(Details.TOKEN_ID, token2.getId())
+ .details(Details.REFRESH_TOKEN_ID, offlineToken2.getId())
+ .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
+ .details(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client");
+
+ // check only the offline session is created
+ checkNumberOfSessions(serviceAccountUserId, "offline-client", offlineToken2.getSessionId(), 0, 1);
+
+ // Refresh with both offline tokens is fine
+ testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId);
+ testRefreshWithOfflineToken(token2, offlineToken2, offlineTokenString2, token2.getSessionId(), serviceAccountUserId);
+ }
+
+
+ @Test
+ public void offlineTokenAllowedWithCompositeRole() {
+ setupCustomerUserRoles();
+ RealmResource appRealm = adminClient.realm("test");
+ UserResource testUser = findUserByUsernameId(appRealm, "test-user@localhost");
+ RoleRepresentation offlineAccess = findRealmRoleByName(adminClient.realm("test"),
+ Constants.OFFLINE_ACCESS_ROLE).toRepresentation();
+
+ // Grant offline_access role indirectly through composite role
+ appRealm.roles().create(RoleConfigBuilder.create().name("composite").build());
+ RoleResource roleResource = appRealm.roles().get("composite");
+ roleResource.addComposites(Collections.singletonList(offlineAccess));
+
+ testUser.roles().realmLevel().remove(Collections.singletonList(offlineAccess));
+ testUser.roles().realmLevel().add(Collections.singletonList(roleResource.toRepresentation()));
+
+ // Integration test
+ offlineTokenDirectGrantFlow();
+
+ // Revert changes
+ testUser.roles().realmLevel().remove(Collections.singletonList(appRealm.roles().get("composite").toRepresentation()));
+ appRealm.roles().get("composite").remove();
+ testUser.roles().realmLevel().add(Collections.singletonList(offlineAccess));
+ }
+
+ /**
+ * KEYCLOAK-4201
+ *
+ */
+ @Test
+ public void offlineTokenAdminRESTAccess() {
+ // Grant "view-realm" role to user
+ RealmResource appRealm = adminClient.realm("test");
+ ClientResource realmMgmt = AdminApiUtil.findClientByClientId(appRealm, Constants.REALM_MANAGEMENT_CLIENT_ID);
+ assert realmMgmt != null;
+ String realmMgmtUuid = realmMgmt.toRepresentation().getId();
+ RoleRepresentation roleRep = realmMgmt.roles().get(AdminRoles.VIEW_REALM).toRepresentation();
+
+ UserResource testUser = findUserByUsernameId(appRealm, "test-user@localhost");
+ testUser.roles().clientLevel(realmMgmtUuid).add(Collections.singletonList(roleRep));
+
+ try {
+ // Login with offline token now
+ oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
+ oauth.client("offline-client", "secret1");
+ AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password");
+
+ events.clear();
+
+ // Set the time offset, so that "normal" userSession expires
+ timeOffSet.set(86400);
+
+ // Remove expired sessions. This will remove "normal" userSession
+ runOnServer.run(session -> {
+ session.getProvider(UserSessionPersisterProvider.class).removeExpired(session.getContext().getRealm());
+ });
+
+ // Refresh with the offline token
+ tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
+ Assertions.assertNull(tokenResponse.getError(), "received error " + tokenResponse.getError() + ", " + tokenResponse.getErrorDescription());
+
+ // Use accessToken to admin REST request
+ try (Keycloak offlineTokenAdmin = adminClientFactory.create()
+ .realm("master")
+ .authorization(tokenResponse.getAccessToken())
+ .clientId(Constants.ADMIN_CLI_CLIENT_ID)
+ .build()) {
+ RealmRepresentation testRealm = offlineTokenAdmin.realm("test").toRepresentation();
+ Assertions.assertNotNull(testRealm);
+ }
+ } finally {
+ // clean up the admin role
+ testUser.roles().clientLevel(realmMgmtUuid).remove(Collections.singletonList(roleRep));
+ }
+ }
+
+ // KEYCLOAK-4525
+ @Test
+ public void offlineTokenRemoveClientWithTokens() {
+ // Create new client
+ RealmResource appRealm = adminClient.realm("test");
+
+ ClientRepresentation clientRep = ClientConfigBuilder.create().clientId("offline-client-2")
+ .id(KeycloakModelUtils.generateId())
+ .directAccessGrantsEnabled(true)
+ .secret("secret1").build();
+
+ appRealm.clients().create(clientRep).close();
+
+ // Direct grant login requesting offline token
+ oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
+ oauth.client("offline-client-2", "secret1");
+ AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password");
+ Assertions.assertNull(tokenResponse.getErrorDescription());
+ AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken());
+ String offlineTokenString = tokenResponse.getRefreshToken();
+ RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString);
+
+ EventRepresentation loginEvent = events.poll();
+ EventAssertion.assertSuccess(loginEvent)
+ .type(EventType.LOGIN)
+ .clientId("offline-client-2")
+ .userId(userId)
+ .sessionId(token.getSessionId())
+ .details(Details.GRANT_TYPE, OAuth2Constants.PASSWORD)
+ .details(Details.TOKEN_ID, token.getId())
+ .details(Details.REFRESH_TOKEN_ID, offlineToken.getId())
+ .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
+ .details(Details.USERNAME, "test-user@localhost");
+
+ // Confirm that offline-client-2 token was granted
+ List