Merge branch 'main' into issue-46191

This commit is contained in:
Asish Kumar 2026-03-31 23:52:18 +05:30 committed by GitHub
commit 04cd1a4e21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 3676 additions and 2001 deletions

View file

@ -180,6 +180,17 @@ This makes it easier to process and archive access logs independently for securi
For more information, see https://www.keycloak.org/server/logging#http-access-logging[Configuring HTTP access logging].
== Customizable service fields in JSON log output
{project_name} now provides native options to customize the `service.name` and `service.environment` fields in JSON log output across all log handlers (console, file, and syslog).
Previously, when using the ECS format, `service.name` and `service.environment` could not be overridden through {project_name} configuration.
This made it difficult to align JSON log fields with OpenTelemetry resource attributes.
You can now set these fields using `log-service-name` and `log-service-environment`.
For more information, see the https://www.keycloak.org/server/logging#customize-service-fields[Configuring logging] guide.
== New and updated translations
New translations for Indonesian and Armenian were added. A warm welcome to the new language maintainers for these languages!

View file

@ -44,4 +44,5 @@ https://saml.xml.org*
# To be removed once KC 26.6.0 is released
https://www.keycloak.org/securing-apps/dpop
https://www.keycloak.org/server/reverseproxy#graceful-http-shutdown
https://www.keycloak.org/server/db#_secure_the_database_connection
https://www.keycloak.org/server/db#_secure_the_database_connection
https://www.keycloak.org/server/logging#customize-service-fields

View file

@ -11,7 +11,8 @@ includes a single realm, called `master`. Use this realm only for managing {proj
Use these steps to create the first realm.
. Open the {links-admin-console}.
. Click *Create Realm* next to *Current realm*.
. Click *Manage realms* in the left column.
. Click *Create realm*.
. Enter `myrealm` in the *Realm name* field.
. Click *Create*.

View file

@ -24,7 +24,7 @@ backwards compatible with future {project_name} versions, and it will be finally
provided by V2 together with other use cases that are supported by V1. For more details, see this <<_standard-token-exchange-comparison,token exchange comparison>>.
NOTE: If you still need legacy token exchange feature, you also need link:{adminguide_link}#fine-grained-admin-permissions-v1[Fine-grained admin permissions version 1] (FGAP:v1) enabled because
link:{adminguide_link}#_fine_grained_permissions[version 2 (FGAP:v2)] does not have support for token exchange permissions. This is on purpose because
link:{adminguide_link}#_fine_grained_permissions[Fine-grained admin permissions version 2] (FGAP:v2) does not have support for token exchange permissions. This is on purpose because
token-exchange is conceptually not really an "admin" permission and hence there is no plan to add token exchange permissions to FGAP:v2.
[[_standard-token-exchange]]
@ -338,7 +338,10 @@ s|Subject impersonation (including direct naked impersonation) | Not implement
[NOTE]
====
To use more than the <<_internal-token-to-internal-token-exchange,Internal Token to Internal Token Exchange>> flow, also enable the `admin-fine-grained-authz` feature.
If you still need legacy token exchange feature, you also need link:{adminguide_link}#fine-grained-admin-permissions-v1[Fine-grained admin permissions version 1] (FGAP:v1) enabled because
link:{adminguide_link}#_fine_grained_permissions[Fine-grained admin permissions version 2] (FGAP:v2) does not have support for token exchange permissions. This is on purpose because
token-exchange is conceptually not really an "admin" permission and hence there is no plan to add token exchange permissions to FGAP:v2.
For details, see the https://www.keycloak.org/server/features[Enabling and disabling features] {section}.
====

View file

@ -193,6 +193,23 @@ In order to change the JSON output format, properties in the format `log-<handle
* `log-file-json-format` - File log handler
* `log-syslog-json-format` - Syslog log handler
[[customize-service-fields]]
=== Customize the service fields
When using JSON log output, {project_name} includes `service.name` and potentially `service.environment` fields in the log entries.
By default, `service.name` is set to `keycloak`, and `service.environment` is not set for the default JSON format.
For the ECS format, the `service.environment` is set to the Quarkus profile (e.g. `prod`).
You can customize these fields across all log handlers using the following options:
* `log-service-name` - Set the `service.name` field (default: `keycloak`)
* `log-service-environment` - Set the `service.environment` field
These options apply to all log handlers (`console`, `file`, and `syslog`) that have JSON output enabled.
.Example
<@kc.start parameters="--log-console-output=json --log-service-name=my-keycloak --log-service-environment=production"/>
=== Example
If you want to have JSON logs in *ECS* (Elastic Common Schema) format for the console log handler, you can enter the following command:

View file

@ -111,7 +111,7 @@
"react-router-dom": "^6.30.2",
"reactflow": "^11.11.4",
"use-react-router-breadcrumbs": "^4.0.1",
"yaml": "^2.8.2"
"yaml": "^2.8.3"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.0",

View file

@ -18,7 +18,7 @@
"commander": "^14.0.2",
"fs-extra": "^11.3.3",
"mustache": "^4.2.0",
"simple-git": "^3.30.0"
"simple-git": "^3.32.3"
},
"author": {
"name": "Red Hat, Inc.",

View file

@ -144,6 +144,12 @@ components:
type: string
allOf:
- $ref: "#/components/schemas/BaseClientRepresentation"
securitySchemes:
bearer-auth:
type: http
scheme: bearer
bearerFormat: JWT
description: Bearer token authentication using a Keycloak access token
tags:
- name: Clients (v2)
x-smallrye-profile-admin: ""
@ -177,11 +183,12 @@ paths:
$ref: "#/components/schemas/BaseClientRepresentation"
required: true
responses:
"200":
description: OK
"201":
description: Created
content:
application/json:
schema: {}
schema:
$ref: "#/components/schemas/BaseClientRepresentation"
parameters:
- name: realmName
in: path
@ -196,6 +203,8 @@ paths:
pattern: v\d+
/admin/api/{realmName}/clients/{version}/{id}:
get:
summary: Get a client
description: Returns a single client by its clientId
operationId: getClient
tags:
- Clients (v2)
@ -206,7 +215,11 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/BaseClientRepresentation"
"404":
description: Not Found
put:
summary: Create or update a client
description: Creates or updates a client in the realm
operationId: createOrUpdateClient
tags:
- Clients (v2)
@ -221,8 +234,17 @@ paths:
description: OK
content:
application/json:
schema: {}
schema:
$ref: "#/components/schemas/BaseClientRepresentation"
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/BaseClientRepresentation"
patch:
summary: Patch a client
description: Partially updates a client using JSON Merge Patch
operationId: patchClient
tags:
- Clients (v2)
@ -237,13 +259,19 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/BaseClientRepresentation"
"404":
description: Not Found
delete:
summary: Delete a client
description: Deletes a client from the realm
operationId: deleteClient
tags:
- Clients (v2)
responses:
"204":
description: No Content
description: Client successfully deleted
"404":
description: Not Found
parameters:
- name: realmName
in: path
@ -261,6 +289,8 @@ paths:
required: true
schema:
type: string
security:
- bearer-auth: []
info:
title: Keycloak API
version: 999.0.0-SNAPSHOT

File diff suppressed because it is too large Load diff

View file

@ -35,7 +35,7 @@
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-replace": "^6.0.3",
"@rollup/plugin-terser": "^0.4.4",
"rollup": "^4.55.1"
"rollup": "^4.59.0"
},
"author": {
"name": "Red Hat, Inc.",

View file

@ -14,29 +14,48 @@ import org.keycloak.representations.admin.v2.BaseClientRepresentation;
import org.keycloak.services.PatchTypeNames;
import com.fasterxml.jackson.databind.JsonNode;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
public interface ClientApi {
@GET
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get a client", description = "Returns a single client by its clientId")
@APIResponses(value = {
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = BaseClientRepresentation.class))),
@APIResponse(responseCode = "404", description = "Not Found")
})
BaseClientRepresentation getClient();
/**
* @return {@link BaseClientRepresentation} of created/updated client
*/
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create or update a client", description = "Creates or updates a client in the realm")
@APIResponses(value = {
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = BaseClientRepresentation.class))),
@APIResponse(responseCode = "201", description = "Created", content = @Content(schema = @Schema(implementation = BaseClientRepresentation.class)))
})
Response createOrUpdateClient(@Valid BaseClientRepresentation client);
@PATCH
@Consumes(PatchTypeNames.JSON_MERGE)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Patch a client", description = "Partially updates a client using JSON Merge Patch")
@APIResponses(value = {
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = BaseClientRepresentation.class))),
@APIResponse(responseCode = "404", description = "Not Found")
})
BaseClientRepresentation patchClient(JsonNode patch);
// TODO marked as producing json, but does not return anything
@DELETE
@Produces(MediaType.APPLICATION_JSON)
void deleteClient();
@Operation(summary = "Delete a client", description = "Deletes a client from the realm")
@APIResponses(value = {
@APIResponse(responseCode = "204", description = "Client successfully deleted"),
@APIResponse(responseCode = "404", description = "Not Found")
})
Response deleteClient();
}

View file

@ -16,7 +16,12 @@ import org.keycloak.representations.admin.v2.BaseClientRepresentation;
import org.keycloak.services.resources.KeycloakOpenAPI;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.media.Content;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@Tag(name = KeycloakOpenAPI.Admin.Tags.CLIENTS_V2)
@ -27,15 +32,18 @@ public interface ClientsApi {
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Get all clients", description = "Returns a list of all clients in the realm")
@APIResponses(value = {
@APIResponse(responseCode = "200", content = @Content(schema = @Schema(type = SchemaType.ARRAY, implementation = BaseClientRepresentation.class)))
})
Stream<BaseClientRepresentation> getClients();
/**
* @return {@link BaseClientRepresentation} of created client
*/
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a new client", description = "Creates a new client in the realm")
@APIResponses(value = {
@APIResponse(responseCode = "201", description = "Created", content = @Content(schema = @Schema(implementation = BaseClientRepresentation.class)))
})
Response createClient(@Valid BaseClientRepresentation client);
@Path("{id}")

View file

@ -70,7 +70,8 @@ public class DefaultClientApi implements ClientApi {
@DELETE
@Override
public void deleteClient() {
public Response deleteClient() {
clientService.deleteClient(realm, clientId);
return Response.noContent().build();
}
}

View file

@ -11,6 +11,8 @@ import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import org.keycloak.OAuth2Constants;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@ -21,6 +23,8 @@ import org.eclipse.microprofile.openapi.models.OpenAPI;
import org.eclipse.microprofile.openapi.models.PathItem;
import org.eclipse.microprofile.openapi.models.media.Discriminator;
import org.eclipse.microprofile.openapi.models.media.Schema;
import org.eclipse.microprofile.openapi.models.security.SecurityRequirement;
import org.eclipse.microprofile.openapi.models.security.SecurityScheme;
import org.jboss.jandex.AnnotationInstance;
import org.jboss.jandex.AnnotationValue;
import org.jboss.jandex.ClassInfo;
@ -85,6 +89,7 @@ public class OASModelFilter implements OASFilter {
});
addDescriptionsToSchemasProperties(openAPI);
addSecurityScheme(openAPI);
}
/**
@ -269,6 +274,30 @@ public class OASModelFilter implements OASFilter {
}
}
/**
* Adds a Bearer token security scheme and applies it globally to all operations.
* This documents the 401 (Unauthorized) and 403 (Forbidden) responses at the API level
* rather than repeating them on every endpoint.
*/
private void addSecurityScheme(OpenAPI openAPI) {
String schemeName = "bearer-auth";
SecurityScheme bearerScheme = OASFactory.createSecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat(OAuth2Constants.JWT)
.description("Bearer token authentication using a Keycloak access token");
if (openAPI.getComponents() == null) {
openAPI.setComponents(OASFactory.createComponents());
}
openAPI.getComponents().addSecurityScheme(schemeName, bearerScheme);
SecurityRequirement securityRequirement = OASFactory.createSecurityRequirement()
.addScheme(schemeName);
openAPI.addSecurityRequirement(securityRequirement);
}
private PathItem sortOperationsByMethod(PathItem pathItem) {
PathItem sortedPathItem = OASFactory.createPathItem();

View file

@ -221,8 +221,9 @@ public class ClientApiV2Test extends AbstractClientApiV2Test{
var baseClientRepresentation = adminClient.clients(testRealm.getName()).v2().client(clientIdToDelete).getClient();
assertEquals(clientIdToDelete, baseClientRepresentation.getClientId());
// TODO will change when TODO on RestAPI has been fixed. Right now deleteClient() is void, instead of Response
adminClient.clients(testRealm.getName()).v2().client(clientIdToDelete).deleteClient();
try (var response = adminClient.clients(testRealm.getName()).v2().client(clientIdToDelete).deleteClient()) {
assertEquals(204, response.getStatus());
}
NotFoundException exception = assertThrows(
NotFoundException.class,
@ -244,6 +245,9 @@ public class ClientApiV2Test extends AbstractClientApiV2Test{
try (var response = adminClient.clients(testRealm.getName()).v2().createClient(oidcRep)) {
assertEquals(201, response.getStatus());
OIDCClientRepresentation created = response.readEntity(OIDCClientRepresentation.class);
assertThat(created, notNullValue());
masterRealm.cleanup().add(realm -> realm.clients().delete(created.getUuid()));
}
// Create a SAML client with SAML-specific fields
@ -260,6 +264,9 @@ public class ClientApiV2Test extends AbstractClientApiV2Test{
try (var response = adminClient.clients(testRealm.getName()).v2().createClient(samlRep)) {
assertEquals(201, response.getStatus());
SAMLClientRepresentation created = response.readEntity(SAMLClientRepresentation.class);
assertThat(created, notNullValue());
masterRealm.cleanup().add(realm -> realm.clients().delete(created.getUuid()));
}
// Get all clients - this should work with mixed protocols
@ -308,10 +315,6 @@ public class ClientApiV2Test extends AbstractClientApiV2Test{
assertThat(samlClient.getSignAssertions(), is(true));
assertThat(samlClient.getForcePostBinding(), is(true));
assertThat(samlClient.getFrontChannelLogout(), is(false));
// Cleanup
adminClient.clients(testRealm.getName()).v2().client(oidcRep.getClientId()).deleteClient();
adminClient.clients(testRealm.getName()).v2().client(samlRep.getClientId()).deleteClient();
}
@Test
@ -943,6 +946,9 @@ public class ClientApiV2Test extends AbstractClientApiV2Test{
try (var response = adminClient.clients(testRealm.getName()).v2().createClient(validRep)) {
assertThat(response.getStatus(), is(201));
BaseClientRepresentation created = response.readEntity(BaseClientRepresentation.class);
assertThat(created, notNullValue());
masterRealm.cleanup().add(realm -> realm.clients().delete(created.getUuid()));
}
// Now try to update with invalid data
@ -951,9 +957,6 @@ public class ClientApiV2Test extends AbstractClientApiV2Test{
String body = response.readEntity(String.class);
assertThat(body, containsString(expectedErrorMessage));
}
// Cleanup: delete the created client
adminClient.clients(testRealm.getName()).v2().client(clientId).deleteClient();
}
private void assertClientUuid(BaseClientRepresentation client) {

View file

@ -115,7 +115,7 @@ public abstract class AbstractModelSchema<M extends Model, R extends ResourceTyp
}
for (Entry<Attribute<M, R>, JsonNode> entry : resolveAttributes(path.getPath(), value).entrySet()) {
JsonNode attrValue = path.getValue(entry.getKey(), entry.getValue());
JsonNode attrValue = path.getValue(entry.getValue());
setValue(model, entry.getKey(), attrValue, REMOVE);
}
}

View file

@ -64,6 +64,9 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
private String returned = RETURNED_DEFAULT;
private boolean multivalued;
private Class<?> complexType;
private boolean required;
private boolean caseExact;
private String uniqueness;
private Attribute(String name, AttributeMapper<M, R> mapper, String parentName, String alias) {
this.name = name;
@ -159,6 +162,30 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
return complexType;
}
public void setRequired(boolean required) {
this.required = required;
}
public boolean isRequired() {
return required;
}
public void setCaseExact(boolean caseExact) {
this.caseExact = caseExact;
}
public boolean isCaseExact() {
return caseExact;
}
public void setUniqueness(String uniqueness) {
this.uniqueness = uniqueness;
}
public String getUniqueness() {
return uniqueness;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Attribute<?, ?> attribute)) return false;
@ -244,13 +271,17 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
private TriConsumer<M, String, ?> modelSetter;
private BiConsumer<R, ?> representationSetter;
List<Attribute<M, R>> attributes = new ArrayList<>();
private Function<Attribute<M, R>, String> modelAttributeResolver;
// by default, resolve model attribute name as the same as the scim attribute name
private Function<Attribute<M, R>, String> modelAttributeResolver = Attribute::getName;
private String type;
private String mutability;
private String returned;
private boolean multivalued;
private TriConsumer<M, String, Set<?>> modelRemover;
private TriConsumer<M, String, Set<?>> modelAdder;
private boolean required;
private boolean caseExact = true;
private String uniqueness = "none";
private Builder(String name, Class<?> complexType) {
Objects.requireNonNull(name, "name cannot be null");
@ -284,7 +315,7 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
String subName = this.name + "." + name;
Attribute<M, R> attribute = assembleAttribute(subName, this.name, alias,
new AttributeMapper<>(modelSetter, new ComplexAttributeSetter<>(this.name, name, complexType)),
modelAttributeResolver, "string", null, returned, false, null);
modelAttributeResolver, "string", null, returned, false, false, true, null, null);
attributes.add(attribute);
return this;
}
@ -322,7 +353,7 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
public List<Attribute<M, R>> build() {
Attribute<M, R> attribute = assembleAttribute(name, null, null,
new AttributeMapper<>(modelSetter, representationSetter, modelRemover, modelAdder),
modelAttributeResolver, type, mutability, returned, multivalued, complexType);
modelAttributeResolver, type, mutability, returned, multivalued, required, caseExact, uniqueness, complexType);
if (attributes.isEmpty()) {
// do not add the root attribute if there are subattributes
attributes.add(attribute);
@ -334,7 +365,11 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
AttributeMapper<M, R> mapper,
Function<Attribute<M, R>, String> modelAttributeResolver,
String type, String mutability, String returned,
boolean multivalued, Class<?> complexType) {
boolean multivalued,
boolean required,
boolean caseExact,
String uniqueness,
Class<?> complexType) {
Attribute<M, R> attribute = new Attribute<>(name, mapper, parentName, alias);
attribute.setModelAttributeResolver(modelAttributeResolver);
attribute.setType(type);
@ -344,6 +379,9 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
}
attribute.setMultivalued(multivalued);
attribute.setComplexType(complexType);
attribute.setRequired(required);
attribute.setCaseExact(caseExact);
attribute.setUniqueness(uniqueness == null ? "none" : uniqueness);
return attribute;
}
@ -361,5 +399,25 @@ public class Attribute<M extends Model, R extends ResourceTypeRepresentation> {
this.modelAdder = (m, s, objects) -> adder.accept(m, s, (Set<C>) objects);
return this;
}
public Builder<M, R> required() {
this.required = true;
return this;
}
public Builder<M, R> notCaseExact() {
this.caseExact = false;
return this;
}
public Builder<M, R> serverUnique() {
this.uniqueness = "server";
return this;
}
public Builder<M, R> globalUnique() {
this.uniqueness = "global";
return this;
}
}
}

View file

@ -1,39 +0,0 @@
package org.keycloak.scim.resource.schema.path;
import java.util.function.Function;
import org.keycloak.scim.resource.common.MultiValuedAttribute;
import org.keycloak.scim.resource.schema.attribute.Attribute;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.NullNode;
record EqualExpression(Attribute<?, ?> attribute, String attributeName,
String value) implements Function<JsonNode, JsonNode> {
@Override
public JsonNode apply(JsonNode rawValue) {
Class<?> complexType = attribute.getComplexType();
if (complexType != null) {
if (MultiValuedAttribute.class.isAssignableFrom(complexType)) {
if (rawValue.isArray()) {
for (JsonNode node : rawValue) {
if (node.isObject()) {
if ("value".equals(attributeName)) {
JsonNode value = node.get(attributeName);
if (value != null && value.asText().equals(this.value.replaceAll("\"", ""))) {
return node;
}
}
}
}
}
}
}
return NullNode.getInstance();
}
}

View file

@ -1,13 +1,16 @@
package org.keycloak.scim.resource.schema.path;
import java.util.function.Function;
import java.util.function.Predicate;
import org.keycloak.models.ModelValidationException;
import org.keycloak.scim.filter.FilterUtils;
import org.keycloak.scim.filter.ScimFilterParser;
import org.keycloak.scim.resource.ResourceTypeRepresentation;
import org.keycloak.scim.resource.schema.ModelSchema;
import org.keycloak.scim.resource.schema.attribute.Attribute;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.NullNode;
public final class Path {
@ -49,31 +52,27 @@ public final class Path {
return path;
}
public JsonNode getValue(Attribute<?, ?> attribute, JsonNode rawValue) {
public JsonNode getValue(JsonNode rawValue) {
if (filter == null) {
return rawValue;
}
return parseFilter(attribute).apply(rawValue);
}
ScimFilterParser.FilterContext filterContext = FilterUtils.parseFilter(filter);
Predicate<JsonNode> predicate = new ScimJsonNodeFilterEvaluator().visit(filterContext);
private Function<JsonNode, JsonNode> 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() {

View file

@ -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.
* <p>
* 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<Predicate<JsonNode>> {
@Override
public Predicate<JsonNode> visitFilter(ScimFilterParser.FilterContext ctx) {
return visit(ctx.expression());
}
@Override
public Predicate<JsonNode> visitExpression(ScimFilterParser.ExpressionContext ctx) {
if (ctx.OR() != null) {
Predicate<JsonNode> left = visit(ctx.expression());
Predicate<JsonNode> right = visit(ctx.andExpression());
return left.or(right);
}
return visit(ctx.andExpression());
}
@Override
public Predicate<JsonNode> visitAndExpression(ScimFilterParser.AndExpressionContext ctx) {
if (ctx.AND() != null) {
Predicate<JsonNode> left = visit(ctx.andExpression());
Predicate<JsonNode> right = visit(ctx.notExpression());
return left.and(right);
}
return visit(ctx.notExpression());
}
@Override
public Predicate<JsonNode> visitNotExpression(ScimFilterParser.NotExpressionContext ctx) {
if (ctx.NOT() != null) {
Predicate<JsonNode> child = visit(ctx.notExpression());
return child.negate();
}
return visit(ctx.atom());
}
@Override
public Predicate<JsonNode> 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<JsonNode> visitValuePath(ScimFilterParser.ValuePathContext ctx) {
return visit(ctx.expression());
}
@Override
public Predicate<JsonNode> 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<JsonNode> 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");
}
}

View file

@ -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<AuthenticationScheme> 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<ServiceProviderConfig> getAll(SearchRequest searchRequest) {
if (!session.getContext().getPermissions().hasPermission(AdminPermissionsSchema.REALMS_RESOURCE_TYPE, AdminPermissionsSchema.VIEW)) {

View file

@ -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<String>) expression);
}
Predicate predicate = operatorMap.get(operation).apply(cb, expression, value);
return (basePredicate != null) ? cb.and(basePredicate, predicate) : predicate;
}

View file

@ -35,28 +35,31 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
@Override
protected Set<String> 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<String, Attribute<GroupModel, Group>> doGetAttributes() {
List<Attribute<GroupModel, Group>> attributes = new ArrayList<>(Attribute.<GroupModel, Group>simple("displayName")
.notCaseExact()
.modelAttributeResolver((attribute) -> {
if (attribute.getName().equals("displayName")) {
return "name";
@ -65,6 +68,11 @@ public final class GroupCoreModelSchema extends AbstractModelSchema<GroupModel,
})
.withModelSetter(GroupModel::setName)
.build());
attributes.addAll(Attribute.<GroupModel, Group>simple("externalId")
.immutable()
.string()
.withModelSetter(GroupModel::setSingleAttribute)
.build());
attributes.addAll(Attribute.<GroupModel, Group>simple("meta.created")
.timestamp()
.immutable()

View file

@ -87,6 +87,10 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider<Sche
p.setName(k);
p.setType("complex");
p.setMultiValued(false);
p.setMutability("readWrite");
p.setCaseExact(false);
p.setRequired(false);
p.setUniqueness("none");
return p;
});
@ -95,6 +99,8 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider<Sche
subAttr.setType(attribute.getType());
subAttr.setMultiValued(false);
subAttr.setReturned(attribute.getReturned());
subAttr.setMutability(attribute.isImmutable() ? "immutable" : "readWrite");
subAttr.setUniqueness(attribute.getUniqueness());
List<Attribute> subAttributes = parent.getSubAttributes();
if (subAttributes == null) {
@ -109,6 +115,10 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider<Sche
p.setName(k);
p.setType("complex");
p.setMultiValued(attribute.isMultivalued());
p.setMutability(attribute.isImmutable() ? "immutable" : "readWrite");
p.setRequired(attribute.isRequired());
p.setCaseExact(attribute.isCaseExact());
p.setUniqueness(attribute.getUniqueness());
return p;
});
@ -117,6 +127,10 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider<Sche
subAttr.setType(attribute.getType());
subAttr.setMultiValued(false);
subAttr.setReturned(attribute.getReturned());
subAttr.setMutability(attribute.isImmutable() ? "immutable" : "readWrite");
subAttr.setRequired(attribute.isRequired());
subAttr.setCaseExact(attribute.isCaseExact());
subAttr.setUniqueness(attribute.getUniqueness());
List<Attribute> subAttributes = parent.getSubAttributes();
if (subAttributes == null) {
@ -132,6 +146,10 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider<Sche
attr.setType(attribute.getType());
attr.setMultiValued(attribute.isMultivalued());
attr.setReturned(attribute.getReturned());
attr.setMutability(attribute.isImmutable() ? "immutable" : "readWrite");
attr.setRequired(attribute.isRequired());
attr.setCaseExact(attribute.isCaseExact());
attr.setUniqueness(attribute.getUniqueness());
return attr;
});
}
@ -143,6 +161,10 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider<Sche
attr.setType(attribute.getType());
attr.setMultiValued(attribute.isMultivalued());
attr.setReturned(attribute.getReturned());
attr.setMutability(attribute.isImmutable() ? "immutable" : "readWrite");
attr.setRequired(attribute.isRequired());
attr.setCaseExact(attribute.isCaseExact());
attr.setUniqueness(attribute.getUniqueness());
return attr;
});
}

View file

@ -46,11 +46,16 @@ public final class UserCoreModelSchema extends AbstractUserModelSchema {
List<Attribute<UserModel, User>> attributes = new ArrayList<>();
attributes.addAll(Attribute.<UserModel, User>simple("userName")
.required()
.notCaseExact()
.serverUnique()
.modelAttributeResolver(this::createModelAttributeResolver)
.withModelSetter(UserModel::setSingleAttribute)
.build());
attributes.addAll(Attribute.<UserModel, User>complex("emails", Email.class)
.modelAttributeResolver(this::createModelAttributeResolver)
.notCaseExact()
.globalUnique()
.multivalued()
.withModelSetter((TriConsumer<UserModel, String, Set<Email>>) (model, name, values) -> {
for (Email value : values) {

View file

@ -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();

View file

@ -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<Group> 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()));
}
}

View file

@ -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<String> 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<String> 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<String> 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<String> 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<String> 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");
}
}

View file

@ -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<AuthenticationScheme> 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<String> schemas = config.getSchemas();
assertNotNull(schemas);
assertEquals(1, schemas.size());

View file

@ -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();

View file

@ -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<String, String> inputData = context.getHttpRequest().getDecodedFormParameters();
return inputData.getFirst(AuthenticationManager.FORM_USERNAME);
}
protected String retrievePassword(AuthenticationFlowContext context) {
MultivaluedMap<String, String> inputData = context.getHttpRequest().getDecodedFormParameters();
return inputData.getFirst(CredentialRepresentation.PASSWORD);
}
}

View file

@ -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);
}
}

View file

@ -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,

View file

@ -86,6 +86,9 @@ public class OID4VCGeneratedIdMapper extends OID4VCMapper {
public void setClaim(Map<String, Object> claims, UserSessionModel userSessionModel) {
// Assign a generated ID
List<String> attributePath = getMetadataAttributePath();
if (attributePath.isEmpty()) {
return;
}
String propertyName = attributePath.get(attributePath.size() - 1);
claims.put(propertyName, String.format("urn:uuid:%s", UUID.randomUUID()));
}

View file

@ -113,8 +113,14 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP
public List<String> 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<String> getAttributePrefix() {
@ -168,12 +174,15 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP
*/
public void setClaimWithMetadataPrefix(Map<String, Object> claimsOrig, Map<String, Object> claimsWithPrefix) {
List<String> attributePath = getMetadataAttributePath();
if (attributePath.isEmpty()) {
return;
}
String propertyName = attributePath.get(attributePath.size() - 1);
if (claimsOrig.get(propertyName) != null) {
Object claimValue = claimsOrig.get(propertyName);
Map<String, Object> 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<String, Object> obj = (Map<String, Object>) current.get(currentSnippetName);

View file

@ -69,6 +69,9 @@ public class OID4VCStaticClaimMapper extends OID4VCMapper {
@Override
public void setClaim(Map<String, Object> claims, UserSessionModel userSessionModel) {
List<String> 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);

View file

@ -128,6 +128,9 @@ public class OID4VCSubjectIdMapper extends OID4VCMapper {
public void setClaim(Map<String, Object> claims, UserSessionModel userSessionModel) {
UserModel userModel = userSessionModel.getUser();
List<String> attributePath = getMetadataAttributePath();
if (attributePath.isEmpty()) {
return;
}
String propertyName = attributePath.get(attributePath.size() - 1);
String userAttributeName = mapperModel.getConfig().get(OID4VCMapper.USER_ATTRIBUTE_KEY);
Consumer<String> userIdConsumer = (val) -> claims.put(propertyName, val);

View file

@ -152,6 +152,9 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper {
public void setClaim(Map<String, Object> claims,
UserSessionModel userSessionModel) {
List<String> 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);

View file

@ -88,6 +88,9 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper {
public void setClaim(Map<String, Object> 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<String> 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<String> claimPath = Optional.ofNullable(claimName).map(JsonUtils::splitClaimPath).orElse(List.of(userAttributeName));
final List<String> 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);
}
}

View file

@ -77,9 +77,14 @@ public class Claim {
return Optional.empty();
}
claim.setName(String.join(".", mapper.getMetadataAttributePath()));
List<String> 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();

View file

@ -166,7 +166,6 @@ public abstract class DefaultKeycloakSession implements KeycloakSession {
return getDatastoreProvider().users();
}
@SuppressWarnings("unchecked")
@Override
public <T extends Provider> T getProvider(Class<T> clazz) {
List<String> key = List.of(clazz.getName());
@ -174,6 +173,7 @@ public abstract class DefaultKeycloakSession implements KeycloakSession {
}
private <T extends Provider> T getOrCreateProvider(List<String> key, Supplier<ProviderFactory<T>> 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 extends Provider> T getProvider(Class<T> clazz, String id) {
List<String> key = List.of(clazz.getName(), id);
@ -207,11 +206,10 @@ public abstract class DefaultKeycloakSession implements KeycloakSession {
}
@Override
@SuppressWarnings("unchecked")
public <T extends Provider> T getComponentProvider(Class<T> clazz, String componentId, Function<KeycloakSessionFactory, ComponentModel> modelGetter) {
List<String> 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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,601 @@
package org.keycloak.tests.oauth;
import java.io.IOException;
import jakarta.ws.rs.NotFoundException;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
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.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.remote.providers.timeoffset.InfinispanTimeUtil;
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.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.IntrospectionResponse;
import org.keycloak.util.TokenUtil;
import org.junit.jupiter.api.AfterEach;
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.findUserByUsername;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@KeycloakIntegrationTest
public class OfflineTokenRefreshTest {
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;
@InjectRealm(config = OfflineTokenRefreshTest.OfflineTokenRealmConfig.class)
ManagedRealm realm;
@InjectOAuthClient(config = OfflineTokenRefreshTest.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.
}
userId = findUserByUsername(adminClient.realm("test"), "test-user@localhost").getId();
events.clear();
}
@AfterEach
public void cleanup() {
// Reset time offset
timeOffSet.set(0);
// Clear events
events.clear();
}
@Test
public void refreshTokenUserClientMaxLifespanSmallerThanSession() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
int[] prev = changeOfflineSessionSettings(true, 3600, 7200, 1000, 7200);
runOnServer.run(InfinispanTimeUtil.enableTestingTimeService());
try {
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);
String sessionId = loginEvent.getSessionId();
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code);
assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getType());
assertTrue(0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 1000, "Invalid ExpiresIn");
String clientSessionId = getOfflineClientSessionUuid(sessionId, loginEvent.getClientId());
assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
events.poll();
timeOffSet.set(600);
String refreshId = oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getId();
tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getType());
assertTrue(0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 400, "Invalid ExpiresIn");
assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
EventRepresentation refreshEvent = events.poll();
EventAssertion.assertSuccess(refreshEvent)
.type(EventType.REFRESH_TOKEN)
.clientId("offline-client")
.sessionId(sessionId)
.details(Details.REFRESH_TOKEN_ID, refreshId)
.details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE);
timeOffSet.set(1100);
tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
assertEquals(400, tokenResponse.getStatusCode());
assertNull(tokenResponse.getAccessToken());
assertNull(tokenResponse.getRefreshToken());
EventRepresentation errorEvent = events.poll();
EventAssertion.assertError(errorEvent)
.type(EventType.REFRESH_TOKEN_ERROR)
.clientId("offline-client")
.error(Errors.INVALID_TOKEN);
assertEquals(1, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
} finally {
changeOfflineSessionSettings(false, prev[0], prev[1], prev[2], prev[3]);
runOnServer.run(InfinispanTimeUtil.disableTestingTimeService());
events.clear();
timeOffSet.set(0);
}
}
@Test
public void refreshTokenUserClientMaxLifespanGreaterThanSession() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
int[] prev = changeOfflineSessionSettings(true, 3600, 7200, 5000, 7200);
runOnServer.run(InfinispanTimeUtil.enableTestingTimeService());
try {
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);
String sessionId = loginEvent.getSessionId();
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code);
assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getType());
assertTrue(0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 3600, "Invalid ExpiresIn");
String clientSessionId = getOfflineClientSessionUuid(sessionId, loginEvent.getClientId());
assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
events.poll();
timeOffSet.set(1800);
String refreshId = oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getId();
tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getType());
assertTrue(0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 1800, "Invalid ExpiresIn");
assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
EventRepresentation refreshEvent2 = events.poll();
EventAssertion.assertSuccess(refreshEvent2)
.type(EventType.REFRESH_TOKEN)
.clientId("offline-client")
.sessionId(sessionId)
.details(Details.REFRESH_TOKEN_ID, refreshId)
.details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE);
timeOffSet.set(3700);
tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
assertEquals(400, tokenResponse.getStatusCode());
assertNull(tokenResponse.getAccessToken());
assertNull(tokenResponse.getRefreshToken());
EventRepresentation errorEvent2 = events.poll();
EventAssertion.assertError(errorEvent2)
.type(EventType.REFRESH_TOKEN_ERROR)
.clientId("offline-client")
.error(Errors.INVALID_TOKEN);
assertEquals(0, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
} finally {
changeOfflineSessionSettings(false, prev[0], prev[1], prev[2], prev[3]);
runOnServer.run(InfinispanTimeUtil.disableTestingTimeService());
events.clear();
timeOffSet.set(0);
}
}
@Test
public void refreshTokenUserSessionMaxLifespanModifiedAfterTokenRefresh() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
RealmResource realmResource = adminClient.realm("test");
runOnServer.run(InfinispanTimeUtil.enableTestingTimeService());
int[] prev = changeOfflineSessionSettings(true, 7200, 7200, 7200, 7200);
try {
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);
String sessionId = loginEvent.getSessionId();
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code);
assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getType());
assertTrue(0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 7200, "Invalid ExpiresIn");
String clientSessionId = getOfflineClientSessionUuid(sessionId, loginEvent.getClientId());
assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
events.poll();
RealmRepresentation rep = realmResource.toRepresentation();
rep.setOfflineSessionMaxLifespan(3600);
realmResource.update(rep);
timeOffSet.set(3700);
tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
assertEquals(400, tokenResponse.getStatusCode());
assertNull(tokenResponse.getAccessToken());
assertNull(tokenResponse.getRefreshToken());
EventRepresentation errorEvent3 = events.poll();
EventAssertion.assertError(errorEvent3)
.type(EventType.REFRESH_TOKEN_ERROR)
.clientId("offline-client")
.error(Errors.INVALID_TOKEN)
.sessionId(sessionId)
.details(Details.REFRESH_TOKEN_SUB, loginEvent.getUserId());
assertEquals(0, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
} finally {
changeOfflineSessionSettings(false, prev[0], prev[1], prev[2], prev[3]);
runOnServer.run(InfinispanTimeUtil.disableTestingTimeService());
events.clear();
timeOffSet.set(0);
}
}
@Test
public void refreshTokenClientSessionMaxLifespanModifiedAfterTokenRefresh() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
RealmResource realmResource = adminClient.realm("test");
runOnServer.run(InfinispanTimeUtil.enableTestingTimeService());
int[] prev = changeOfflineSessionSettings(true, 7200, 7200, 7200, 7200);
try {
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);
String sessionId = loginEvent.getSessionId();
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code);
assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getType());
assertTrue(0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 7200, "Invalid ExpiresIn");
String clientSessionId = getOfflineClientSessionUuid(sessionId, loginEvent.getClientId());
assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
events.poll();
RealmRepresentation rep = realmResource.toRepresentation();
rep.setClientOfflineSessionMaxLifespan(3600);
realmResource.update(rep);
timeOffSet.set(3700);
tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken());
assertEquals(400, tokenResponse.getStatusCode());
assertNull(tokenResponse.getAccessToken());
assertNull(tokenResponse.getRefreshToken());
EventRepresentation errorEvent4 = events.poll();
EventAssertion.assertError(errorEvent4)
.type(EventType.REFRESH_TOKEN_ERROR)
.clientId("offline-client")
.error(Errors.INVALID_TOKEN)
.sessionId(sessionId);
assertEquals(1, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
} finally {
changeOfflineSessionSettings(false, prev[0], prev[1], prev[2], prev[3]);
runOnServer.run(InfinispanTimeUtil.disableTestingTimeService());
events.clear();
timeOffSet.set(0);
}
}
@Test
public void offlineTokenRefreshWithoutOfflineAccessScope() {
ClientResource offlineClientResource = AdminApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client");
ClientRepresentation clientRep = offlineClientResource.toRepresentation();
clientRep.setFullScopeAllowed(false);
offlineClientResource.update(clientRep);
try {
oauth.scope("openid " + OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
oauth.doLogin("test-user@localhost", "password");
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse response = oauth.doAccessTokenRequest(code);
oauth.scope("openid");
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
assertEquals(200, response.getStatusCode());
AccessToken token = oauth.verifyToken(response.getAccessToken());
// access token scope does not contain offline_access due to luck of it in scope request parameter
assertFalse(token.getScope().contains(OAuth2Constants.OFFLINE_ACCESS));
RefreshToken offlineToken = oauth.parseRefreshToken(response.getRefreshToken());
// refresh token scope are always equal to original refresh token scope
Assertions.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType());
assertTrue(offlineToken.getScope().contains(OAuth2Constants.OFFLINE_ACCESS));
}
finally {
ClientResource offlineClientResource1 = AdminApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client");
ClientRepresentation clientRep1 = offlineClientResource1.toRepresentation();
clientRep1.setFullScopeAllowed(true);
offlineClientResource.update(clientRep1);
}
}
@Test
public void offlineRefreshWhenNoOfflineScope() throws Exception {
// login to obtain a refresh token
oauth.scope("openid " + OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
oauth.doLogin("test-user@localhost", "password");
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse response = oauth.doAccessTokenRequest(code);
EventRepresentation loginEvent = events.poll();
EventAssertion.assertSuccess(loginEvent)
.type(EventType.LOGIN)
.clientId("offline-client")
.details(Details.REDIRECT_URI, OFFLINE_CLIENT_APP_URI);
EventRepresentation codeToTokenEvent = events.poll();
EventAssertion.assertSuccess(codeToTokenEvent)
.type(EventType.CODE_TO_TOKEN)
.clientId("offline-client")
.sessionId(loginEvent.getSessionId())
.details(Details.CODE_ID, loginEvent.getDetails().get(Details.CODE_ID))
.details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE);
// check refresh is successful
RefreshToken offlineToken = oauth.parseRefreshToken(response.getRefreshToken());
oauth.scope(null);
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
assertEquals(200, response.getStatusCode());
Assertions.assertEquals(0, response.getRefreshExpiresIn());
EventRepresentation refreshEvent = events.poll();
EventAssertion.assertSuccess(refreshEvent)
.type(EventType.REFRESH_TOKEN)
.clientId("offline-client")
.userId(userId)
.sessionId(loginEvent.getSessionId())
.details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
.details(Details.REFRESH_TOKEN_ID, offlineToken.getId());
offlineToken = oauth.parseRefreshToken(response.getRefreshToken());
IntrospectionResponse introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getAccessToken());
assertTrue(introspectionResponse.asJsonNode().get("active").asBoolean());
EventRepresentation introspectEvent = events.poll();
EventAssertion.assertSuccess(introspectEvent)
.type(EventType.INTROSPECT_TOKEN)
.clientId("offline-client")
.sessionId(loginEvent.getSessionId());
introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getRefreshToken());
assertTrue(introspectionResponse.asJsonNode().get("active").asBoolean());
EventRepresentation introspectEvent2 = events.poll();
EventAssertion.assertSuccess(introspectEvent2)
.type(EventType.INTROSPECT_TOKEN)
.clientId("offline-client")
.sessionId(loginEvent.getSessionId());
// remove offline scope from the client and perform a second refresh
ClientResource offlineClientResource = AdminApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client");
ClientScopeRepresentation offlineAccessScope = adminClient.realm("test").clientScopes().findAll().stream()
.filter(scope -> "offline_access".equals(scope.getName()))
.findFirst()
.orElseThrow();
// Remove the offline_access scope
offlineClientResource.removeOptionalClientScope(offlineAccessScope.getId());
try {
introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getAccessToken());
assertFalse(introspectionResponse.asJsonNode().get("active").asBoolean());
EventRepresentation introspectErrorEvent = events.poll();
EventAssertion.assertError(introspectErrorEvent)
.type(EventType.INTROSPECT_TOKEN_ERROR)
.clientId("offline-client")
.sessionId(loginEvent.getSessionId())
.error(Errors.SESSION_EXPIRED)
.details(Details.REASON, "Offline session invalid because offline access not granted anymore");
introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getRefreshToken());
assertFalse(introspectionResponse.asJsonNode().get("active").asBoolean());
EventRepresentation introspectErrorEvent2 = events.poll();
EventAssertion.assertError(introspectErrorEvent2)
.type(EventType.INTROSPECT_TOKEN_ERROR)
.clientId("offline-client")
.sessionId(loginEvent.getSessionId())
.error(Errors.SESSION_EXPIRED)
.details(Details.REASON, "Offline session invalid because offline access not granted anymore");
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
assertEquals(400, response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_GRANT, response.getError());
assertEquals("Offline session invalid because offline access not granted anymore", response.getErrorDescription());
EventRepresentation refreshErrorEvent = events.poll();
EventAssertion.assertError(refreshErrorEvent)
.type(EventType.REFRESH_TOKEN_ERROR)
.clientId("offline-client")
.sessionId(loginEvent.getSessionId())
.error(Errors.INVALID_TOKEN)
.details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE)
.details(Details.REFRESH_TOKEN_ID, offlineToken.getId())
.details(Details.REASON, "Offline session invalid because offline access not granted anymore");
} catch (IOException e) {
throw new RuntimeException("Failed to perform offline token introspection", e);
} finally {
// put the offline_access scope back
offlineClientResource.addOptionalClientScope(offlineAccessScope.getId());
}
}
// KEYCLOAK-7688 Offline Session Max for Offline Token
private int[] changeOfflineSessionSettings(boolean isEnabled, int sessionMax, int sessionIdle, int clientSessionMax, int clientSessionIdle) {
int[] prev = new int[5];
RealmRepresentation rep = adminClient.realm("test").toRepresentation();
prev[0] = rep.getOfflineSessionMaxLifespan();
prev[1] = rep.getOfflineSessionIdleTimeout();
prev[2] = rep.getClientOfflineSessionMaxLifespan();
prev[3] = rep.getClientOfflineSessionIdleTimeout();
RealmConfigBuilder realmBuilder = RealmConfigBuilder.create();
realmBuilder.update(r -> {
r.setOfflineSessionMaxLifespanEnabled(isEnabled);
r.setOfflineSessionMaxLifespan(sessionMax);
r.setOfflineSessionIdleTimeout(sessionIdle);
r.setClientOfflineSessionMaxLifespan(clientSessionMax);
r.setClientOfflineSessionIdleTimeout(clientSessionIdle);
});
adminClient.realm("test").update(realmBuilder.build());
return prev;
}
private int checkIfUserAndClientSessionExist(final String userSessionId, final String clientId, final String clientSessionId) {
return runOnServer.fetch(session -> {
RealmModel realmModel = session.realms().getRealmByName("test");
session.getContext().setRealm(realmModel);
ClientModel clientModel = realmModel.getClientByClientId(clientId);
UserSessionModel userSession = session.sessions().getOfflineUserSession(realmModel, userSessionId);
if (userSession != null) {
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(clientModel.getId());
return clientSession != null && clientSessionId.equals(clientSession.getId())? 2 : 1;
}
return 0;
}, Integer.class);
}
private String getOfflineClientSessionUuid(final String userSessionId, final String clientId) {
return runOnServer.fetch(session -> {
RealmModel realmModel = session.realms().getRealmByName("test");
ClientModel clientModel = realmModel.getClientByClientId(clientId);
UserSessionModel userSession = session.sessions().getOfflineUserSession(realmModel, userSessionId);
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(clientModel.getId());
return clientSession.getId();
}, String.class);
}
public static class OfflineTokenRealmConfig implements RealmConfig {
@Override
public RealmConfigBuilder configure(RealmConfigBuilder builder) {
builder.name("test")
.eventsEnabled(true)
.ssoSessionIdleTimeout(30)
.update(r -> r.setAccessTokenLifespan(10));
// Enable all event types
builder.update(r -> {
r.setEnabledEventTypes(java.util.Arrays.asList(
"LOGIN",
"LOGIN_ERROR",
"LOGOUT",
"CODE_TO_TOKEN",
"REFRESH_TOKEN",
"REFRESH_TOKEN_ERROR",
"INTROSPECT_TOKEN",
"INTROSPECT_TOKEN_ERROR"
));
});
// Only create offline-client - test-app is created by @InjectOAuthClient
builder.addClient(OFFLINE_CLIENT_ID)
.secret("secret1")
.redirectUris(OFFLINE_CLIENT_APP_URI)
.adminUrl(OFFLINE_CLIENT_APP_URI)
.directAccessGrantsEnabled(true)
.serviceAccountsEnabled(true)
.attribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, "true");
// Users WITHOUT test-app client roles
builder.addUser("test-user@localhost")
.name("Tom", "Brady")
.email("test-user@localhost")
.emailVerified(true)
.password("password")
.roles("user", "offline_access");
return builder;
}
}
public static class OfflineAuthClientConfig implements ClientConfig {
@Override
public ClientConfigBuilder configure(ClientConfigBuilder client) {
return client.clientId("test-app")
.secret("password")
.serviceAccountsEnabled(true)
.directAccessGrantsEnabled(true)
.redirectUris(
"http://localhost:8080/test-app", // Default
TEST_APP_REDIRECT_URI // Custom URI
);
}
}
}

View file

@ -0,0 +1,754 @@
package org.keycloak.tests.oauth;
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.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.session.UserSessionPersisterProvider;
import org.keycloak.models.utils.SessionTimeoutHelper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
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.remote.providers.timeoffset.InfinispanTimeUtil;
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.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.LogoutResponse;
import org.keycloak.util.TokenUtil;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.keycloak.tests.utils.Assert.assertExpiration;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.lessThanOrEqualTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
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 OfflineTokenSessionManagementTest {
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";
@InjectRealm(config = OfflineTokenSessionManagementTest.OfflineTokenRealmConfig.class)
ManagedRealm realm;
@InjectOAuthClient(config = OfflineTokenSessionManagementTest.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.
}
// Clear browser state
driver.driver().manage().deleteAllCookies();
events.clear();
}
@AfterEach
public void cleanup() {
// Reset time offset
timeOffSet.set(0);
// Clear events
events.clear();
// Clear browser state
try {
driver.driver().manage().deleteAllCookies();
} catch (Exception e) {
// Ignore if driver is already closed
}
}
@Test
public void offlineTokenLogout() {
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
AccessTokenResponse response = oauth.doPasswordGrantRequest("test-user@localhost", "password");
assertEquals(200, response.getStatusCode());
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
assertEquals(200, response.getStatusCode());
LogoutResponse logoutResponse = oauth.doLogout(response.getRefreshToken());
assertTrue(logoutResponse.isSuccess());
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
assertEquals(400, response.getStatusCode());
}
@Test
public void onlineOfflineTokenLogout() {
oauth.client("offline-client", "secret1");
// create online session
AccessTokenResponse response = oauth.doPasswordGrantRequest("test-user@localhost", "password");
assertEquals(200, response.getStatusCode());
// assert refresh token
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
assertEquals(200, response.getStatusCode());
// create offline session
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
AccessTokenResponse offlineResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password");
assertEquals(200, offlineResponse.getStatusCode());
// assert refresh offline token
AccessTokenResponse offlineRefresh = oauth.doRefreshTokenRequest(offlineResponse.getRefreshToken());
assertEquals(200, offlineRefresh.getStatusCode());
// logout online session
LogoutResponse logoutResponse = oauth.scope(null).doLogout(response.getRefreshToken());
assertTrue(logoutResponse.isSuccess());
// assert the online session is gone
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
assertEquals(400, response.getStatusCode());
// assert the offline token refresh still works
offlineRefresh = oauth.doRefreshTokenRequest(offlineResponse.getRefreshToken());
assertEquals(200, offlineRefresh.getStatusCode());
}
@Test
public void browserOfflineTokenLogoutFollowedByLoginSameSession() {
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);
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());
assertNull(offlineToken.getExp());
String offlineUserSessionId = runOnServer.fetch(session -> {
return session.sessions().getOfflineUserSession(session.getContext().getRealm(), offlineToken.getSessionId()).getId();
}, String.class);
// logout offline session
LogoutResponse logoutResponse = oauth.doLogout(offlineTokenString);
assertTrue(logoutResponse.isSuccess());
EventRepresentation logoutEvent = events.poll();
EventAssertion.assertSuccess(logoutEvent)
.type(EventType.LOGOUT)
.clientId("offline-client")
.sessionId(offlineUserSessionId);
// Need to login again now
oauth.doLogin("test-user@localhost", "password");
String code2 = oauth.parseLoginResponse().getCode();
AccessTokenResponse tokenResponse2 = oauth.doAccessTokenRequest(code2);
assertEquals(200, tokenResponse2.getStatusCode());
oauth.verifyToken(tokenResponse2.getAccessToken());
String offlineTokenString2 = tokenResponse2.getRefreshToken();
RefreshToken offlineToken2 = oauth.parseRefreshToken(offlineTokenString2);
loginEvent = events.poll();
EventAssertion.assertSuccess(loginEvent)
.type(EventType.LOGIN)
.clientId("offline-client")
.details(Details.REDIRECT_URI, OFFLINE_CLIENT_APP_URI);
codeId = loginEvent.getDetails().get(Details.CODE_ID);
EventRepresentation codeToTokenEvent2 = events.poll();
EventAssertion.assertSuccess(codeToTokenEvent2)
.type(EventType.CODE_TO_TOKEN)
.clientId("offline-client")
.sessionId(offlineToken2.getSessionId())
.details(Details.CODE_ID, codeId)
.details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE);
assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken2.getType());
Assertions.assertNull(offlineToken.getExp());
// Assert session changed
assertNotEquals(offlineToken.getSessionId(), offlineToken2.getSessionId());
}
@Test
public void offlineTokenBrowserFlowMaxLifespanExpired() {
// expect that offline session expired by max lifespan
final int MAX_LIFESPAN = 3600;
final int IDLE_LIFESPAN = 6000;
testOfflineSessionExpiration(IDLE_LIFESPAN, MAX_LIFESPAN, MAX_LIFESPAN / 2, MAX_LIFESPAN + 60);
}
@Test
public void offlineTokenBrowserFlowIdleTimeExpired() {
// expect that offline session expired by idle time
final int MAX_LIFESPAN = 3000;
final int IDLE_LIFESPAN = 600;
// Additional time window is added for the case when session was updated in different DC and the update to current DC was postponed
//testOfflineSessionExpiration(IDLE_LIFESPAN, MAX_LIFESPAN, 0, IDLE_LIFESPAN + (Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS) ? 0 : SessionTimeoutHelper.IDLE_TIMEOUT_WINDOW_SECONDS) + 60);
// Check feature on server side
boolean isPersistentUserSessionsEnabled = runOnServer.fetch(session -> {
return Profile.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS);
}, Boolean.class);
int additionalTimeout = isPersistentUserSessionsEnabled ? 0 : SessionTimeoutHelper.IDLE_TIMEOUT_WINDOW_SECONDS;
testOfflineSessionExpiration(IDLE_LIFESPAN, MAX_LIFESPAN, 0, IDLE_LIFESPAN + additionalTimeout + 60);
}
// Issue 13706
@Test
public void offlineTokenReauthenticationWhenOfflineClientSessionExpired() throws Exception {
// expect that offline session expired by idle timeout
final int MAX_LIFESPAN = 360000;
final int IDLE_LIFESPAN = 900;
runOnServer.run(InfinispanTimeUtil.enableTestingTimeService());
int[] prev = null;
realm.updateWithCleanup(r -> r.ssoSessionIdleTimeout(900));
try {
prev = changeOfflineSessionSettings(true, MAX_LIFESPAN, IDLE_LIFESPAN, 0, 0);
// Step 1 - online login with "tets-app"
oauth.scope(null);
oauth.client("test-app", "password");
oauth.redirectUri(TEST_APP_REDIRECT_URI);
oauth.doLogin("test-user@localhost", "password");
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code);
assertRefreshToken(tokenResponse, TokenUtil.TOKEN_TYPE_REFRESH);
// Clear browser state between logins.
driver.driver().manage().deleteAllCookies();
// Step 2 - offline login with "offline-client"
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
oauth.openLoginForm();
code = oauth.parseLoginResponse().getCode();
tokenResponse = oauth.doAccessTokenRequest(code);
assertOfflineToken(tokenResponse);
// Step 3 - set some offset to refresh SSO session and offline user session. But use different client, so that we don't refresh offlineClientSession of client "offline-client"
timeOffSet.set(800);
oauth.client("test-app", "password");
oauth.redirectUri(TEST_APP_REDIRECT_URI);
oauth.openLoginForm();
code = oauth.parseLoginResponse().getCode();
tokenResponse = oauth.doAccessTokenRequest(code);
assertOfflineToken(tokenResponse);
// Step 4 - set bigger time offset and login with the original client "offline-token". Login should be successful and offline client session for "offline-client" should be re-created now
timeOffSet.set(900 + SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 20);
oauth.client("offline-client", "secret1");
oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
oauth.openLoginForm();
code = oauth.parseLoginResponse().getCode();
tokenResponse = oauth.doAccessTokenRequest(code);
assertOfflineToken(tokenResponse);
} finally {
runOnServer.run(InfinispanTimeUtil.disableTestingTimeService());
changeOfflineSessionSettings(false, prev[0], prev[1], 0, 0);
}
}
@Test
public void testShortOfflineSessionMax() throws Exception {
int prevOfflineSession[] = null;
int prevSession[] = null;
try {
prevOfflineSession = changeOfflineSessionSettings(true, 60, 30, 0, 0);
prevSession = changeSessionSettings(1800, 300);
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);
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code);
String offlineTokenString = tokenResponse.getRefreshToken();
RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString);
assertThat(tokenResponse.getExpiresIn(), allOf(greaterThanOrEqualTo(59), lessThanOrEqualTo(60)));
assertThat(tokenResponse.getRefreshExpiresIn(), allOf(greaterThanOrEqualTo(29), lessThanOrEqualTo(30)));
assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType());
JsonNode jsonNode = oauth.doIntrospectionAccessTokenRequest(tokenResponse.getAccessToken()).asJsonNode();
assertTrue(jsonNode.get("active").asBoolean());
Assertions.assertEquals("test-user@localhost", jsonNode.get("email").asText());
assertThat(jsonNode.get("exp").asInt() - Time.currentTime(),
allOf(greaterThanOrEqualTo(59), lessThanOrEqualTo(60)));
} finally {
changeOfflineSessionSettings(false, prevOfflineSession[0], prevOfflineSession[1], prevOfflineSession[2], prevOfflineSession[3]);
changeSessionSettings(prevSession[0], prevSession[1]);
}
}
@Test
public void testClientOfflineSessionMaxLifespan() {
ClientResource client = AdminApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client");
ClientRepresentation clientRepresentation = client.toRepresentation();
RealmResource realm = adminClient.realm("test");
RealmRepresentation rep = realm.toRepresentation();
Boolean originalOfflineSessionMaxLifespanEnabled = rep.getOfflineSessionMaxLifespanEnabled();
Integer originalOfflineSessionMaxLifespan = rep.getOfflineSessionMaxLifespan();
int offlineSessionMaxLifespan = rep.getOfflineSessionIdleTimeout() - 100;
Integer originalClientOfflineSessionMaxLifespan = rep.getClientOfflineSessionMaxLifespan();
try {
rep.setOfflineSessionMaxLifespanEnabled(true);
rep.setOfflineSessionMaxLifespan(offlineSessionMaxLifespan);
realm.update(rep);
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
oauth.doLogin("test-user@localhost", "password");
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse response = oauth.doAccessTokenRequest(code);
assertEquals(200, response.getStatusCode());
assertExpiration(response.getRefreshExpiresIn(), offlineSessionMaxLifespan);
rep.setClientOfflineSessionMaxLifespan(offlineSessionMaxLifespan - 100);
realm.update(rep);
String refreshToken = response.getRefreshToken();
response = oauth.doRefreshTokenRequest(refreshToken);
assertEquals(200, response.getStatusCode());
assertExpiration(response.getRefreshExpiresIn(), offlineSessionMaxLifespan - 100);
clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_OFFLINE_SESSION_MAX_LIFESPAN,
Integer.toString(offlineSessionMaxLifespan - 200));
client.update(clientRepresentation);
refreshToken = response.getRefreshToken();
response = oauth.doRefreshTokenRequest(refreshToken);
assertEquals(200, response.getStatusCode());
assertExpiration(response.getRefreshExpiresIn(), offlineSessionMaxLifespan - 200);
} finally {
rep.setOfflineSessionMaxLifespanEnabled(originalOfflineSessionMaxLifespanEnabled);
rep.setOfflineSessionMaxLifespan(originalOfflineSessionMaxLifespan);
rep.setClientOfflineSessionMaxLifespan(originalClientOfflineSessionMaxLifespan);
realm.update(rep);
clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_OFFLINE_SESSION_MAX_LIFESPAN, "");
client.update(clientRepresentation);
}
}
@Test
public void testClientOfflineSessionIdleTimeout() {
ClientResource client = AdminApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client");
ClientRepresentation clientRepresentation = client.toRepresentation();
RealmResource realm = adminClient.realm("test");
RealmRepresentation rep = realm.toRepresentation();
Boolean originalOfflineSessionMaxLifespanEnabled = rep.getOfflineSessionMaxLifespanEnabled();
int offlineSessionIdleTimeout = rep.getOfflineSessionIdleTimeout();
Integer originalClientOfflineSessionIdleTimeout = rep.getClientOfflineSessionIdleTimeout();
try {
rep.setOfflineSessionMaxLifespanEnabled(true);
realm.update(rep);
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
oauth.doLogin("test-user@localhost", "password");
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse response = oauth.doAccessTokenRequest(code);
assertEquals(200, response.getStatusCode());
assertExpiration(response.getRefreshExpiresIn(), offlineSessionIdleTimeout);
rep.setClientOfflineSessionIdleTimeout(offlineSessionIdleTimeout - 100);
realm.update(rep);
String refreshToken = response.getRefreshToken();
response = oauth.doRefreshTokenRequest(refreshToken);
assertEquals(200, response.getStatusCode());
assertExpiration(response.getRefreshExpiresIn(), offlineSessionIdleTimeout - 100);
clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT,
Integer.toString(offlineSessionIdleTimeout - 200));
client.update(clientRepresentation);
refreshToken = response.getRefreshToken();
response = oauth.doRefreshTokenRequest(refreshToken);
assertEquals(200, response.getStatusCode());
assertExpiration(response.getRefreshExpiresIn(), offlineSessionIdleTimeout - 200);
} finally {
rep.setOfflineSessionMaxLifespanEnabled(originalOfflineSessionMaxLifespanEnabled);
rep.setClientOfflineSessionIdleTimeout(originalClientOfflineSessionIdleTimeout);
realm.update(rep);
clientRepresentation.getAttributes().put(OIDCConfigAttributes.CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT, "");
client.update(clientRepresentation);
}
}
@Test
public void offlineRefreshWhenNoStartedAtClientNote() {
int[] prevOfflineSession = null;
try {
prevOfflineSession = changeOfflineSessionSettings(true, 3600, 3600, 0, 0);
// login to obtain a refresh token
oauth.scope("openid " + OAuth2Constants.OFFLINE_ACCESS);
oauth.client("offline-client", "secret1");
oauth.redirectUri(OFFLINE_CLIENT_APP_URI);
oauth.doLogin("test-user@localhost", "password");
String code = oauth.parseLoginResponse().getCode();
AccessTokenResponse response = oauth.doAccessTokenRequest(code);
EventRepresentation loginEvent = events.poll();
EventAssertion.assertSuccess(loginEvent)
.type(EventType.LOGIN)
.clientId("offline-client")
.details(Details.REDIRECT_URI, OFFLINE_CLIENT_APP_URI);
// remove the started notes that can be missed in previous versions
removeClientSessionStartedAtNote(loginEvent.getSessionId(), loginEvent.getClientId());
// check refresh is successful
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
assertEquals(200, response.getStatusCode());
assertTrue(0 < response.getRefreshExpiresIn() && response.getRefreshExpiresIn() <= 3600, "Invalid ExpiresIn");
// check refresh a second time
response = oauth.doRefreshTokenRequest(response.getRefreshToken());
assertEquals(200, response.getStatusCode());
assertTrue(0 < response.getRefreshExpiresIn() && response.getRefreshExpiresIn() <= 3600, "Invalid ExpiresIn");
} finally {
changeOfflineSessionSettings(false, prevOfflineSession[0], prevOfflineSession[1], prevOfflineSession[2], prevOfflineSession[3]);
}
}
private void testOfflineSessionExpiration(int idleTime, int maxLifespan, int offsetHalf, int offset) {
int[] prev = null;
runOnServer.run(InfinispanTimeUtil.enableTestingTimeService());
try {
prev = changeOfflineSessionSettings(true, maxLifespan, idleTime, 0, 0);
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 code = oauth.parseLoginResponse().getCode();
AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code);
String offlineTokenString = tokenResponse.getRefreshToken();
RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString);
assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType());
// obtain the client session ID
final String clientSessionId = getOfflineClientSessionUuid(sessionId, loginEvent.getClientId());
assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
// perform a refresh in the half-time
timeOffSet.set(offsetHalf);
tokenResponse = oauth.doRefreshTokenRequest(offlineTokenString);
oauth.verifyToken(tokenResponse.getAccessToken());
offlineTokenString = tokenResponse.getRefreshToken();
oauth.parseRefreshToken(offlineTokenString);
Assertions.assertEquals(200, tokenResponse.getStatusCode());
assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
// wait to expire
timeOffSet.set(offset);
tokenResponse = oauth.doRefreshTokenRequest(offlineTokenString);
Assertions.assertEquals(400, tokenResponse.getStatusCode());
assertEquals("invalid_grant", tokenResponse.getError());
// Assert userSession expired
assertEquals(0, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId));
runOnServer.run(session -> {
session.getProvider(UserSessionPersisterProvider.class).removeExpired(session.getContext().getRealm());
});
try {
runOnServer.run(session -> {
UserSessionModel userSession = session.sessions().getUserSession(session.getContext().getRealm(), sessionId);
if (userSession != null) {
session.sessions().removeUserSession(session.getContext().getRealm(), userSession);
}
});
} catch (NotFoundException nfe) {
// Ignore
}
timeOffSet.set(0);
} finally {
runOnServer.run(InfinispanTimeUtil.disableTestingTimeService());
changeOfflineSessionSettings(false, prev[0], prev[1], prev[2], prev[3]);
}
}
private int checkIfUserAndClientSessionExist(final String userSessionId, final String clientId, final String clientSessionId) {
return runOnServer.fetch(session -> {
RealmModel realmModel = session.realms().getRealmByName("test");
session.getContext().setRealm(realmModel);
ClientModel clientModel = realmModel.getClientByClientId(clientId);
UserSessionModel userSession = session.sessions().getOfflineUserSession(realmModel, userSessionId);
if (userSession != null) {
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(clientModel.getId());
return clientSession != null && clientSessionId.equals(clientSession.getId())? 2 : 1;
}
return 0;
}, Integer.class);
}
// KEYCLOAK-7688 Offline Session Max for Offline Token
private int[] changeOfflineSessionSettings(boolean isEnabled, int sessionMax, int sessionIdle, int clientSessionMax, int clientSessionIdle) {
int[] prev = new int[5];
RealmRepresentation rep = adminClient.realm("test").toRepresentation();
prev[0] = rep.getOfflineSessionMaxLifespan();
prev[1] = rep.getOfflineSessionIdleTimeout();
prev[2] = rep.getClientOfflineSessionMaxLifespan();
prev[3] = rep.getClientOfflineSessionIdleTimeout();
RealmConfigBuilder realmBuilder = RealmConfigBuilder.create();
realmBuilder.update(r -> {
r.setOfflineSessionMaxLifespanEnabled(isEnabled);
r.setOfflineSessionMaxLifespan(sessionMax);
r.setOfflineSessionIdleTimeout(sessionIdle);
r.setClientOfflineSessionMaxLifespan(clientSessionMax);
r.setClientOfflineSessionIdleTimeout(clientSessionIdle);
});
adminClient.realm("test").update(realmBuilder.build());
return prev;
}
private String getOfflineClientSessionUuid(final String userSessionId, final String clientId) {
return runOnServer.fetch(session -> {
RealmModel realmModel = session.realms().getRealmByName("test");
ClientModel clientModel = realmModel.getClientByClientId(clientId);
UserSessionModel userSession = session.sessions().getOfflineUserSession(realmModel, userSessionId);
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(clientModel.getId());
return clientSession.getId();
}, String.class);
}
private void assertOfflineToken(AccessTokenResponse tokenResponse) {
assertRefreshToken(tokenResponse, TokenUtil.TOKEN_TYPE_OFFLINE);
}
// Asserts that refresh token in the tokenResponse is of the given type. Return parsed token
private RefreshToken assertRefreshToken(AccessTokenResponse tokenResponse, String tokenType) {
Assertions.assertEquals(200, tokenResponse.getStatusCode());
String offlineTokenString = tokenResponse.getRefreshToken();
RefreshToken refreshToken = oauth.parseRefreshToken(offlineTokenString);
assertEquals(tokenType, refreshToken.getType());
return refreshToken;
}
private int[] changeSessionSettings(int ssoSessionIdle, int accessTokenLifespan) {
int[] prev = new int[2];
RealmRepresentation rep = adminClient.realm("test").toRepresentation();
prev[0] = rep.getOfflineSessionMaxLifespan();
prev[1] = rep.getOfflineSessionIdleTimeout();
RealmConfigBuilder realmBuilder = RealmConfigBuilder.create();
realmBuilder.update(r -> {
r.setSsoSessionIdleTimeout(ssoSessionIdle);
r.setAccessTokenLifespan(accessTokenLifespan);
});
adminClient.realm("test").update(realmBuilder.build());
return prev;
}
private void removeClientSessionStartedAtNote(final String userSessionId, final String clientId) {
runOnServer.run(session -> {
RealmModel realmModel = session.realms().getRealmByName("test");
session.getContext().setRealm(realmModel);
ClientModel clientModel = realmModel.getClientByClientId(clientId);
UserSessionModel userSession = session.sessions().getOfflineUserSession(realmModel, userSessionId);
if (userSession != null) {
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(clientModel.getId());
if (clientSession != null) {
clientSession.removeNote(AuthenticatedClientSessionModel.STARTED_AT_NOTE);
clientSession.removeNote(AuthenticatedClientSessionModel.USER_SESSION_STARTED_AT_NOTE);
}
}
});
}
public static class OfflineTokenRealmConfig implements RealmConfig {
@Override
public RealmConfigBuilder configure(RealmConfigBuilder builder) {
builder.name("test")
.eventsEnabled(true)
.ssoSessionIdleTimeout(30)
.update(r -> r.setAccessTokenLifespan(10));
// Enable all event types
builder.update(r -> {
r.setEnabledEventTypes(java.util.Arrays.asList(
"LOGIN",
"LOGOUT",
"CODE_TO_TOKEN",
"REFRESH_TOKEN"
));
});
// Only create offline-client - test-app is created by @InjectOAuthClient
builder.addClient(OFFLINE_CLIENT_ID)
.secret("secret1")
.redirectUris(OFFLINE_CLIENT_APP_URI)
.adminUrl(OFFLINE_CLIENT_APP_URI)
.directAccessGrantsEnabled(true)
.serviceAccountsEnabled(true)
.attribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, "true");
// Users WITHOUT test-app client roles
builder.addUser("test-user@localhost")
.name("Tom", "Brady")
.email("test-user@localhost")
.emailVerified(true)
.password("password")
.roles("user", "offline_access");
return builder;
}
}
public static class OfflineAuthClientConfig implements ClientConfig {
@Override
public ClientConfigBuilder configure(ClientConfigBuilder client) {
return client.clientId("test-app")
.secret("password")
.serviceAccountsEnabled(true)
.directAccessGrantsEnabled(true)
.redirectUris(
"http://localhost:8080/test-app", // Default
TEST_APP_REDIRECT_URI // Custom URI
);
}
}
}

View file

@ -0,0 +1,70 @@
package org.keycloak.tests.oid4vc;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class)
public class OID4VCIMapperEmptyConfigTest extends OID4VCIssuerTestBase {
@Test
public void testEmptyMapperConfigDoesNotCauseNPE() {
assertMapperIsIgnored("oid4vc-subject-id-mapper", "empty-mapper");
}
@Test
public void testUserAttributeMapperEmptyConfig() {
assertMapperIsIgnored("oid4vc-user-attribute-mapper", "user-attr-empty-mapper");
}
@Test
public void testStaticClaimMapperEmptyConfig() {
assertMapperIsIgnored("oid4vc-static-claim-mapper", "static-claim-empty-mapper");
}
@Test
public void testIssuedAtTimeClaimMapperEmptyConfig() {
assertMapperIsIgnored("oid4vc-issued-at-time-claim-mapper", "iat-claim-empty-mapper");
}
private void assertMapperIsIgnored(String mapperType, String mapperName) {
String scopeName = mapperName + "-scope-" + UUID.randomUUID();
ClientScopeRepresentation scope = new ClientScopeRepresentation();
scope.setName(scopeName);
scope.setProtocol("oid4vc");
ProtocolMapperRepresentation emptyMapper = new ProtocolMapperRepresentation();
emptyMapper.setName(mapperName);
emptyMapper.setProtocol("oid4vc");
emptyMapper.setProtocolMapper(mapperType);
emptyMapper.setConfig(Map.of()); // Empty config
scope.setProtocolMappers(List.of(emptyMapper));
testRealm.admin().clientScopes().create(scope).close();
CredentialIssuer credentialIssuer = oauth.oid4vc().doIssuerMetadataRequest().getMetadata();
assertNotNull(credentialIssuer, "Credential Issuer metadata should be available");
// The empty mapper should be ignored and not present in the claims
boolean foundEmptyMapper = credentialIssuer.getCredentialsSupported().values().stream()
.map(SupportedCredentialConfiguration::getCredentialMetadata)
.filter(metadata -> metadata != null && metadata.getClaims() != null)
.flatMap(metadata -> metadata.getClaims().stream())
.anyMatch(claim -> mapperName.equals(claim.getName()) || (claim.getName() != null && claim.getName().isEmpty()));
assertFalse(foundEmptyMapper, "Mapper " + mapperName + " of type " + mapperType + " with empty config should not be included in metadata");
}
}

View file

@ -568,12 +568,9 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerTestBase {
assertEquals(credentialDefinitionTypes.size(), supportedConfig.getCredentialDefinition().getType().size());
}
List<String> credentialDefinitionContexts = credScope.getVcContexts();
if (!credentialDefinitionContexts.isEmpty()) {
assertEquals(credentialDefinitionContexts.size(), supportedConfig.getCredentialDefinition().getContext().size());
MatcherAssert.assertThat(supportedConfig.getCredentialDefinition().getContext(),
Matchers.containsInAnyOrder(credentialDefinitionTypes.toArray()));
}
// @context must not be present for jwt_vc_json format per OID4VCI spec
assertNull(supportedConfig.getCredentialDefinition().getContext(),
"jwt_vc_json credentials should not have @context in credential_definition");
}
List<String> signingAlgsSupported = supportedConfig.getCredentialSigningAlgValuesSupported();

View file

@ -445,9 +445,20 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest {
user.setEnabled(false);
updateUser(user);
// Wrong password on disabled user should return "Invalid user credentials" (not reveal disabled status)
AccessTokenResponse response = getTestToken("invalid", "invalid");
Assert.assertNull(response.getAccessToken());
Assert.assertEquals(response.getError(), "invalid_grant");
Assert.assertEquals(response.getErrorDescription(), "Invalid user credentials");
events.clear();
assertUserNumberOfFailures(user.getId(), 0);
// Correct password on disabled user should return "Account disabled"
String totpSecret = totp.generateTOTP("totpSecret");
response = getTestToken(getPassword("test-user@localhost"), totpSecret);
Assert.assertNull(response.getAccessToken());
Assert.assertEquals(response.getError(), "invalid_grant");
Assert.assertEquals(response.getErrorDescription(), "Account disabled");
events.clear();

View file

@ -10,7 +10,6 @@ KcOidcUserSessionLimitsBrokerTest
KcSamlUserSessionLimitsBrokerTest
AbstractUserSessionLimitsBrokerTest
UserSessionLimitsTest
OfflineTokenTest
AccessTokenTest
LogoutTest
ClientStorageTest

View file

@ -11,7 +11,6 @@ KcOidcUserSessionLimitsBrokerTest
KcSamlUserSessionLimitsBrokerTest
AbstractUserSessionLimitsBrokerTest
UserSessionLimitsTest
OfflineTokenTest
AccessTokenTest
LogoutTest
ClientStorageTest