mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-28 04:13:22 -04:00
Merge branch 'main' into issue-46191
This commit is contained in:
commit
04cd1a4e21
50 changed files with 3676 additions and 2001 deletions
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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*.
|
||||
|
||||
|
|
|
|||
|
|
@ -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}.
|
||||
====
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,6 @@ KcOidcUserSessionLimitsBrokerTest
|
|||
KcSamlUserSessionLimitsBrokerTest
|
||||
AbstractUserSessionLimitsBrokerTest
|
||||
UserSessionLimitsTest
|
||||
OfflineTokenTest
|
||||
AccessTokenTest
|
||||
LogoutTest
|
||||
ClientStorageTest
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ KcOidcUserSessionLimitsBrokerTest
|
|||
KcSamlUserSessionLimitsBrokerTest
|
||||
AbstractUserSessionLimitsBrokerTest
|
||||
UserSessionLimitsTest
|
||||
OfflineTokenTest
|
||||
AccessTokenTest
|
||||
LogoutTest
|
||||
ClientStorageTest
|
||||
|
|
|
|||
Loading…
Reference in a new issue