diff --git a/docs/documentation/release_notes/topics/26_6_0.adoc b/docs/documentation/release_notes/topics/26_6_0.adoc index 7873044d65d..efb3c8b8f34 100644 --- a/docs/documentation/release_notes/topics/26_6_0.adoc +++ b/docs/documentation/release_notes/topics/26_6_0.adoc @@ -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! diff --git a/docs/documentation/tests/src/test/resources/ignored-links b/docs/documentation/tests/src/test/resources/ignored-links index 956d507aefc..905e8f7ff70 100644 --- a/docs/documentation/tests/src/test/resources/ignored-links +++ b/docs/documentation/tests/src/test/resources/ignored-links @@ -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 \ No newline at end of file +https://www.keycloak.org/server/db#_secure_the_database_connection +https://www.keycloak.org/server/logging#customize-service-fields \ No newline at end of file diff --git a/docs/guides/getting-started/templates/realm-config.adoc b/docs/guides/getting-started/templates/realm-config.adoc index fd0b5cc6520..13c9e049e75 100644 --- a/docs/guides/getting-started/templates/realm-config.adoc +++ b/docs/guides/getting-started/templates/realm-config.adoc @@ -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*. diff --git a/docs/guides/securing-apps/token-exchange.adoc b/docs/guides/securing-apps/token-exchange.adoc index 4fa6c1cc67b..9894d0a54f2 100644 --- a/docs/guides/securing-apps/token-exchange.adoc +++ b/docs/guides/securing-apps/token-exchange.adoc @@ -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}. ==== diff --git a/docs/guides/server/logging.adoc b/docs/guides/server/logging.adoc index 488b08692f6..55c42ef3759 100644 --- a/docs/guides/server/logging.adoc +++ b/docs/guides/server/logging.adoc @@ -193,6 +193,23 @@ In order to change the JSON output format, properties in the format `log- + === 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: diff --git a/js/apps/admin-ui/package.json b/js/apps/admin-ui/package.json index b610f06981f..beb5da5e971 100644 --- a/js/apps/admin-ui/package.json +++ b/js/apps/admin-ui/package.json @@ -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", diff --git a/js/apps/create-keycloak-theme/package.json b/js/apps/create-keycloak-theme/package.json index 48411aea44b..2cba8fd1ba3 100644 --- a/js/apps/create-keycloak-theme/package.json +++ b/js/apps/create-keycloak-theme/package.json @@ -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.", diff --git a/js/libs/keycloak-admin-client/openapi.yaml b/js/libs/keycloak-admin-client/openapi.yaml index 3af210bbcd1..55053809ed2 100644 --- a/js/libs/keycloak-admin-client/openapi.yaml +++ b/js/libs/keycloak-admin-client/openapi.yaml @@ -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 diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 059e577baee..b164f793776 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -128,7 +128,7 @@ importers: version: 18.3.7(@types/react@18.3.23) '@vitejs/plugin-react-swc': specifier: ^4.2.2 - version: 4.2.2(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.2.2(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)) cross-env: specifier: ^10.1.0 version: 10.1.0 @@ -137,13 +137,13 @@ importers: version: 1.30.2 vite: specifier: ^7.3.0 - version: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-checker: specifier: ^0.12.0 - version: 0.12.0(eslint@9.39.2)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.12.0(eslint@9.39.2)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@25.0.8)(rollup@4.55.1)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.5.4(@types/node@25.0.8)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)) apps/admin-ui: dependencies: @@ -223,8 +223,8 @@ importers: specifier: ^4.0.1 version: 4.0.1(react-router-dom@6.30.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) yaml: - specifier: ^2.8.2 - version: 2.8.2 + specifier: ^2.8.3 + version: 2.8.3 devDependencies: '@axe-core/playwright': specifier: ^4.11.0 @@ -258,7 +258,7 @@ importers: version: 18.3.7(@types/react@18.3.23) '@vitejs/plugin-react-swc': specifier: ^4.2.2 - version: 4.2.2(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.2.2(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)) cross-env: specifier: ^10.1.0 version: 10.1.0 @@ -279,16 +279,16 @@ importers: version: 13.0.0 vite: specifier: ^7.3.0 - version: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-checker: specifier: ^0.12.0 - version: 0.12.0(eslint@9.39.2)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.12.0(eslint@9.39.2)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@25.0.8)(rollup@4.55.1)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.5.4(@types/node@25.0.8)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: ^4.0.16 - version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.8)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.8)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3) apps/create-keycloak-theme: dependencies: @@ -305,8 +305,8 @@ importers: specifier: ^4.2.0 version: 4.2.0 simple-git: - specifier: ^3.30.0 - version: 3.30.0 + specifier: ^3.32.3 + version: 3.32.3 apps/keycloak-server: dependencies: @@ -434,25 +434,25 @@ importers: version: 18.3.7(@types/react@18.3.23) '@vitejs/plugin-react-swc': specifier: ^4.2.2 - version: 4.2.2(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.2.2(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)) rollup-plugin-peer-deps-external: specifier: ^2.2.4 - version: 2.2.4(rollup@4.55.1) + version: 2.2.4(rollup@4.59.0) vite: specifier: ^7.3.0 - version: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3) vite-plugin-checker: specifier: ^0.12.0 - version: 0.12.0(eslint@9.39.2)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 0.12.0(eslint@9.39.2)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)) vite-plugin-dts: specifier: ^4.5.4 - version: 4.5.4(@types/node@25.0.8)(rollup@4.55.1)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.5.4(@types/node@25.0.8)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)) vite-plugin-lib-inject-css: specifier: ^2.2.2 - version: 2.2.2(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 2.2.2(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: ^4.0.16 - version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.8)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.8)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3) themes-vendor: dependencies: @@ -483,19 +483,19 @@ importers: devDependencies: '@rollup/plugin-commonjs': specifier: ^29.0.0 - version: 29.0.0(rollup@4.55.1) + version: 29.0.0(rollup@4.59.0) '@rollup/plugin-node-resolve': specifier: ^16.0.3 - version: 16.0.3(rollup@4.55.1) + version: 16.0.3(rollup@4.59.0) '@rollup/plugin-replace': specifier: ^6.0.3 - version: 6.0.3(rollup@4.55.1) + version: 6.0.3(rollup@4.59.0) '@rollup/plugin-terser': specifier: ^0.4.4 - version: 0.4.4(rollup@4.55.1) + version: 0.4.4(rollup@4.59.0) rollup: - specifier: ^4.55.1 - version: 4.55.1 + specifier: ^4.59.0 + version: 4.59.0 packages: @@ -778,156 +778,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.27.2': resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.27.2': resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.27.2': resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.27.2': resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.27.2': resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.27.2': resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.27.2': resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.27.2': resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.27.2': resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.27.2': resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.27.2': resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.27.2': resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.27.2': resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.27.2': resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.27.2': resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.2': resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.2': resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.2': resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.27.2': resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.27.2': resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.27.2': resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.27.2': resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1312,126 +1468,251 @@ packages: cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.55.1': resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==} cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.55.1': resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.55.1': resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-freebsd-arm64@4.55.1': resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==} cpu: [arm64] os: [freebsd] + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + '@rollup/rollup-freebsd-x64@4.55.1': resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==} cpu: [x64] os: [freebsd] + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.55.1': resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.55.1': resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.55.1': resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-loong64-gnu@4.55.1': resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} cpu: [loong64] os: [linux] + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-loong64-musl@4.55.1': resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} cpu: [loong64] os: [linux] + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-ppc64-gnu@4.55.1': resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-ppc64-musl@4.55.1': resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.55.1': resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-riscv64-musl@4.55.1': resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.55.1': resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} cpu: [s390x] os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.55.1': resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.55.1': resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + '@rollup/rollup-openbsd-x64@4.55.1': resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} cpu: [x64] os: [openbsd] + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + '@rollup/rollup-openharmony-arm64@4.55.1': resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==} cpu: [arm64] os: [openharmony] + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + '@rollup/rollup-win32-arm64-msvc@4.55.1': resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.55.1': resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-x64-gnu@4.55.1': resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.55.1': resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + '@rushstack/node-core-library@5.14.0': resolution: {integrity: sha512-eRong84/rwQUlATGFW3TMTYVyqL1vfW9Lf10PH+mVGfIb9HzU3h5AASNIw+axnBLjnD0n3rT5uQBwu9fvzATrg==} peerDependencies: @@ -2515,6 +2796,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2800,8 +3086,8 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -2813,6 +3099,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: @@ -3868,6 +4155,11 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3971,8 +4263,8 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simple-git@3.30.0: - resolution: {integrity: sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg==} + simple-git@3.32.3: + resolution: {integrity: sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw==} slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} @@ -4567,8 +4859,8 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} hasBin: true @@ -5007,81 +5299,159 @@ snapshots: '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.27.4': + optional: true + '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.27.4': + optional: true + '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.27.4': + optional: true + '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.27.4': + optional: true + '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.27.4': + optional: true + '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.27.4': + optional: true + '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.27.4': + optional: true + '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.27.4': + optional: true + '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.27.4': + optional: true + '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.27.4': + optional: true + '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.27.4': + optional: true + '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.27.4': + optional: true + '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.27.4': + optional: true + '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.27.4': + optional: true + '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.27.4': + optional: true + '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.27.4': + optional: true + '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.27.4': + optional: true + '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.27.4': + optional: true + '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.27.4': + optional: true + '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.27.4': + optional: true + '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.27.4': + optional: true + '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.27.4': + optional: true + '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.27.4': + optional: true + '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.27.4': + optional: true + '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.27.4': + optional: true + '@esbuild/win32-x64@0.27.2': optional: true + '@esbuild/win32-x64@0.27.4': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2)': dependencies: eslint: 9.39.2 @@ -5235,7 +5605,7 @@ snapshots: react-router-dom: 6.30.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) reactflow: 11.11.4(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) use-react-router-breadcrumbs: 4.0.1(react-router-dom@6.30.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) - yaml: 2.8.2 + yaml: 2.8.3 transitivePeerDependencies: - '@babel/runtime' - '@types/react' @@ -5531,9 +5901,9 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.47': {} - '@rollup/plugin-commonjs@29.0.0(rollup@4.55.1)': + '@rollup/plugin-commonjs@29.0.0(rollup@4.59.0)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.55.1) + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) commondir: 1.0.1 estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.3) @@ -5541,124 +5911,199 @@ snapshots: magic-string: 0.30.21 picomatch: 4.0.3 optionalDependencies: - rollup: 4.55.1 + rollup: 4.59.0 - '@rollup/plugin-node-resolve@16.0.3(rollup@4.55.1)': + '@rollup/plugin-node-resolve@16.0.3(rollup@4.59.0)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.55.1) + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.10 optionalDependencies: - rollup: 4.55.1 + rollup: 4.59.0 - '@rollup/plugin-replace@6.0.3(rollup@4.55.1)': + '@rollup/plugin-replace@6.0.3(rollup@4.59.0)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.55.1) + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) magic-string: 0.30.21 optionalDependencies: - rollup: 4.55.1 + rollup: 4.59.0 - '@rollup/plugin-terser@0.4.4(rollup@4.55.1)': + '@rollup/plugin-terser@0.4.4(rollup@4.59.0)': dependencies: serialize-javascript: 6.0.2 smob: 1.5.0 terser: 5.43.1 optionalDependencies: - rollup: 4.55.1 + rollup: 4.59.0 - '@rollup/pluginutils@5.2.0(rollup@4.55.1)': + '@rollup/pluginutils@5.2.0(rollup@4.59.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.55.1 + rollup: 4.59.0 - '@rollup/pluginutils@5.3.0(rollup@4.55.1)': + '@rollup/pluginutils@5.3.0(rollup@4.59.0)': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 4.55.1 + rollup: 4.59.0 '@rollup/rollup-android-arm-eabi@4.55.1': optional: true + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + '@rollup/rollup-android-arm64@4.55.1': optional: true + '@rollup/rollup-android-arm64@4.59.0': + optional: true + '@rollup/rollup-darwin-arm64@4.55.1': optional: true + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + '@rollup/rollup-darwin-x64@4.55.1': optional: true + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + '@rollup/rollup-freebsd-arm64@4.55.1': optional: true + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + '@rollup/rollup-freebsd-x64@4.55.1': optional: true + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.55.1': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.55.1': optional: true + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + '@rollup/rollup-linux-arm64-musl@4.55.1': optional: true + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + '@rollup/rollup-linux-loong64-gnu@4.55.1': optional: true + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + '@rollup/rollup-linux-loong64-musl@4.55.1': optional: true + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + '@rollup/rollup-linux-ppc64-gnu@4.55.1': optional: true + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + '@rollup/rollup-linux-ppc64-musl@4.55.1': optional: true + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.55.1': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + '@rollup/rollup-linux-riscv64-musl@4.55.1': optional: true + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.55.1': optional: true + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + '@rollup/rollup-linux-x64-gnu@4.55.1': optional: true + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + '@rollup/rollup-linux-x64-musl@4.55.1': optional: true + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + '@rollup/rollup-openbsd-x64@4.55.1': optional: true + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + '@rollup/rollup-openharmony-arm64@4.55.1': optional: true + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.55.1': optional: true + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.55.1': optional: true + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + '@rollup/rollup-win32-x64-gnu@4.55.1': optional: true + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + '@rushstack/node-core-library@5.14.0(@types/node@25.0.8)': dependencies: ajv: 8.13.0 @@ -6100,11 +6545,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react-swc@4.2.2(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react-swc@4.2.2(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.47 '@swc/core': 1.15.8 - vite: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@swc/helpers' @@ -6117,13 +6562,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.0.16 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@4.0.16': dependencies: @@ -6903,6 +7348,36 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + optional: true + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -7205,7 +7680,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.0: + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 optional: true @@ -7745,7 +8220,7 @@ snapshots: nano-spawn: 2.0.0 pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.8.2 + yaml: 2.8.3 listr2@9.0.5: dependencies: @@ -8343,9 +8818,9 @@ snapshots: rfdc@1.4.1: {} - rollup-plugin-peer-deps-external@2.2.4(rollup@4.55.1): + rollup-plugin-peer-deps-external@2.2.4(rollup@4.59.0): dependencies: - rollup: 4.55.1 + rollup: 4.59.0 rollup@4.55.1: dependencies: @@ -8378,6 +8853,37 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.55.1 fsevents: 2.3.3 + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -8499,7 +9005,7 @@ snapshots: signal-exit@4.1.0: {} - simple-git@3.30.0: + simple-git@3.32.3: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 @@ -8801,8 +9307,8 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.2 - get-tsconfig: 4.13.0 + esbuild: 0.27.4 + get-tsconfig: 4.13.7 optionalDependencies: fsevents: 2.3.3 optional: true @@ -8964,7 +9470,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-checker@0.12.0(eslint@9.39.2)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-plugin-checker@0.12.0(eslint@9.39.2)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@babel/code-frame': 7.27.1 chokidar: 4.0.3 @@ -8973,17 +9479,17 @@ snapshots: picomatch: 4.0.3 tiny-invariant: 1.3.3 tinyglobby: 0.2.15 - vite: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3) vscode-uri: 3.1.0 optionalDependencies: eslint: 9.39.2 optionator: 0.9.4 typescript: 5.9.3 - vite-plugin-dts@4.5.4(@types/node@25.0.8)(rollup@4.55.1)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-plugin-dts@4.5.4(@types/node@25.0.8)(rollup@4.59.0)(typescript@5.9.3)(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@microsoft/api-extractor': 7.52.10(@types/node@25.0.8) - '@rollup/pluginutils': 5.2.0(rollup@4.55.1) + '@rollup/pluginutils': 5.2.0(rollup@4.59.0) '@volar/typescript': 2.4.22 '@vue/language-core': 2.2.0(typescript@5.9.3) compare-versions: 6.1.1 @@ -8993,20 +9499,20 @@ snapshots: magic-string: 0.30.17 typescript: 5.9.3 optionalDependencies: - vite: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite-plugin-lib-inject-css@2.2.2(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)): + vite-plugin-lib-inject-css@2.2.2(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@ast-grep/napi': 0.36.3 magic-string: 0.30.17 picocolors: 1.1.1 - vite: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3) - vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -9020,12 +9526,12 @@ snapshots: lightningcss: 1.30.2 terser: 5.43.1 tsx: 4.21.0 - yaml: 2.8.2 + yaml: 2.8.3 - vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.8)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.8)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 4.0.16 '@vitest/runner': 4.0.16 '@vitest/snapshot': 4.0.16 @@ -9042,7 +9548,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.0(@types/node@25.0.8)(lightningcss@1.30.2)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -9176,7 +9682,7 @@ snapshots: yallist@4.0.0: {} - yaml@2.8.2: {} + yaml@2.8.3: {} yargs-parser@21.1.1: {} diff --git a/js/themes-vendor/package.json b/js/themes-vendor/package.json index 337d62ba982..8e7a1ba95fc 100644 --- a/js/themes-vendor/package.json +++ b/js/themes-vendor/package.json @@ -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.", diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java index 23386d23a42..2e66209f818 100644 --- a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java +++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java @@ -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(); } diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApi.java index 8cc8d2234bf..b1793f8e5d3 100644 --- a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApi.java +++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApi.java @@ -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 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}") diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java index fd5db76a9c1..b0414f7e494 100644 --- a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java +++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java @@ -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(); } } diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/internal/openapi/OASModelFilter.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/internal/openapi/OASModelFilter.java index f47803ddcd8..353ca276883 100644 --- a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/internal/openapi/OASModelFilter.java +++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/internal/openapi/OASModelFilter.java @@ -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(); diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java index 4296e774fa5..a99753221d4 100644 --- a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java +++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java @@ -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) { diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java index 7637a70ac69..729559815a8 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/AbstractModelSchema.java @@ -115,7 +115,7 @@ public abstract class AbstractModelSchema, 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); } } diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java index c5c88b5064f..8ddb6c51a26 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/attribute/Attribute.java @@ -64,6 +64,9 @@ public class Attribute { 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 mapper, String parentName, String alias) { this.name = name; @@ -159,6 +162,30 @@ public class Attribute { 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 { private TriConsumer modelSetter; private BiConsumer representationSetter; List> attributes = new ArrayList<>(); - private Function, String> modelAttributeResolver; + // by default, resolve model attribute name as the same as the scim attribute name + private Function, String> modelAttributeResolver = Attribute::getName; private String type; private String mutability; private String returned; private boolean multivalued; private TriConsumer> modelRemover; private TriConsumer> 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 { String subName = this.name + "." + name; Attribute 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 { public List> build() { Attribute 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 { AttributeMapper mapper, Function, String> modelAttributeResolver, String type, String mutability, String returned, - boolean multivalued, Class complexType) { + boolean multivalued, + boolean required, + boolean caseExact, + String uniqueness, + Class complexType) { Attribute attribute = new Attribute<>(name, mapper, parentName, alias); attribute.setModelAttributeResolver(modelAttributeResolver); attribute.setType(type); @@ -344,6 +379,9 @@ public class Attribute { } 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 { this.modelAdder = (m, s, objects) -> adder.accept(m, s, (Set) objects); return this; } + + public Builder required() { + this.required = true; + return this; + } + + public Builder notCaseExact() { + this.caseExact = false; + return this; + } + + public Builder serverUnique() { + this.uniqueness = "server"; + return this; + } + + public Builder globalUnique() { + this.uniqueness = "global"; + return this; + } } } diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/EqualExpression.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/EqualExpression.java deleted file mode 100644 index e218df837b4..00000000000 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/EqualExpression.java +++ /dev/null @@ -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 { - - @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(); - } -} diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/Path.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/Path.java index 94b7194e74c..6f5d255a40d 100644 --- a/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/Path.java +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/Path.java @@ -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 predicate = new ScimJsonNodeFilterEvaluator().visit(filterContext); - private Function parseFilter(Attribute attribute) { - String[] parts = filter.trim().split(" "); - - if (parts.length == 3) { - String leftOperand = parts[0]; - String operator = parts[1]; - String rightOperand = parts[2]; - - if ("eq".equals(operator)) { - return new EqualExpression(attribute, leftOperand, rightOperand); + if (rawValue.isArray()) { + ArrayNode matches = JsonNodeFactory.instance.arrayNode(); + for (JsonNode node : rawValue) { + if (node.isObject() && predicate.test(node)) { + matches.add(node); + } + } + if (!matches.isEmpty()) { + return matches.size() == 1 ? matches.get(0) : matches; } - - // for now, we only support equality filter in the path, and we assume the filter is always in the format "attribute eq "value"" - throw new ModelValidationException("Unsupported filter operator: " + operator); } - throw new ModelValidationException("Unsupported filter format: " + filter); + return NullNode.getInstance(); } public boolean hasFilter() { diff --git a/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/ScimJsonNodeFilterEvaluator.java b/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/ScimJsonNodeFilterEvaluator.java new file mode 100644 index 00000000000..298c859d8b7 --- /dev/null +++ b/scim/core/src/main/java/org/keycloak/scim/resource/schema/path/ScimJsonNodeFilterEvaluator.java @@ -0,0 +1,136 @@ +package org.keycloak.scim.resource.schema.path; + +import java.util.function.Predicate; + +import org.keycloak.scim.filter.ScimFilterParser; +import org.keycloak.scim.filter.ScimFilterParserBaseVisitor; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Visitor that converts a SCIM filter AST into a {@link Predicate} over {@link JsonNode} elements. + *

+ * This is used by {@link Path} to evaluate filter expressions (e.g., {@code value eq "some-id"}) + * against JSON array elements in-memory, supporting all SCIM comparison operators and logical + * operators ({@code and}, {@code or}, {@code not}). + */ +class ScimJsonNodeFilterEvaluator extends ScimFilterParserBaseVisitor> { + + @Override + public Predicate visitFilter(ScimFilterParser.FilterContext ctx) { + return visit(ctx.expression()); + } + + @Override + public Predicate visitExpression(ScimFilterParser.ExpressionContext ctx) { + if (ctx.OR() != null) { + Predicate left = visit(ctx.expression()); + Predicate right = visit(ctx.andExpression()); + return left.or(right); + } + return visit(ctx.andExpression()); + } + + @Override + public Predicate visitAndExpression(ScimFilterParser.AndExpressionContext ctx) { + if (ctx.AND() != null) { + Predicate left = visit(ctx.andExpression()); + Predicate right = visit(ctx.notExpression()); + return left.and(right); + } + return visit(ctx.notExpression()); + } + + @Override + public Predicate visitNotExpression(ScimFilterParser.NotExpressionContext ctx) { + if (ctx.NOT() != null) { + Predicate child = visit(ctx.notExpression()); + return child.negate(); + } + return visit(ctx.atom()); + } + + @Override + public Predicate visitAtom(ScimFilterParser.AtomContext ctx) { + if (ctx.valuePath() != null) { + return visit(ctx.valuePath()); + } + if (ctx.attributeExpression() != null) { + return visit(ctx.attributeExpression()); + } + return visit(ctx.expression()); + } + + @Override + public Predicate visitValuePath(ScimFilterParser.ValuePathContext ctx) { + return visit(ctx.expression()); + } + + @Override + public Predicate visitPresentExpression(ScimFilterParser.PresentExpressionContext ctx) { + String attrName = ctx.ATTRPATH().getText(); + return node -> { + if (!node.isObject()) return false; + JsonNode value = node.get(attrName); + return value != null && !value.isNull() && !value.isMissingNode(); + }; + } + + @Override + public Predicate visitComparisonExpression(ScimFilterParser.ComparisonExpressionContext ctx) { + String attrName = ctx.ATTRPATH().getText(); + String operator = ctx.compareOp().getText().toLowerCase(); + String compValue = extractValue(ctx.compValue()); + + return node -> { + if (!node.isObject()) return false; + JsonNode attrNode = node.get(attrName); + if (attrNode == null || attrNode.isNull()) { + return "eq".equals(operator) && compValue == null; + } + return compare(attrNode.asText(), operator, compValue); + }; + } + + private boolean compare(String nodeValue, String operator, String compValue) { + if (compValue == null || nodeValue == null) { + return false; + } + + return switch (operator) { + case "eq" -> nodeValue.equals(compValue); + case "ne" -> !nodeValue.equals(compValue); + case "co" -> nodeValue.contains(compValue); + case "sw" -> nodeValue.startsWith(compValue); + case "ew" -> nodeValue.endsWith(compValue); + case "gt" -> nodeValue.compareTo(compValue) > 0; + case "ge" -> nodeValue.compareTo(compValue) >= 0; + case "lt" -> nodeValue.compareTo(compValue) < 0; + case "le" -> nodeValue.compareTo(compValue) <= 0; + default -> false; + }; + } + + private String extractValue(ScimFilterParser.CompValueContext ctx) { + if (ctx.STRING() != null) { + String raw = ctx.STRING().getText(); + return unescapeJsonString(raw.substring(1, raw.length() - 1)); + } + if (ctx.TRUE() != null) return "true"; + if (ctx.FALSE() != null) return "false"; + if (ctx.NULL() != null) return null; + if (ctx.NUMBER() != null) return ctx.NUMBER().getText(); + return null; + } + + private String unescapeJsonString(String s) { + return s.replace("\\\"", "\"") + .replace("\\\\", "\\") + .replace("\\/", "/") + .replace("\\b", "\b") + .replace("\\f", "\f") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t"); + } +} diff --git a/scim/model/src/main/java/org/keycloak/scim/model/config/ServiceProviderConfigResourceTypeProvider.java b/scim/model/src/main/java/org/keycloak/scim/model/config/ServiceProviderConfigResourceTypeProvider.java index 5b3695ae2e6..542ef929e2c 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/config/ServiceProviderConfigResourceTypeProvider.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/config/ServiceProviderConfigResourceTypeProvider.java @@ -10,6 +10,7 @@ import org.keycloak.models.Model; import org.keycloak.scim.protocol.ForbiddenException; import org.keycloak.scim.protocol.request.SearchRequest; import org.keycloak.scim.resource.config.ServiceProviderConfig; +import org.keycloak.scim.resource.config.ServiceProviderConfig.AuthenticationScheme; import org.keycloak.scim.resource.config.ServiceProviderConfig.BulkSupport; import org.keycloak.scim.resource.config.ServiceProviderConfig.FilterSupport; import org.keycloak.scim.resource.config.ServiceProviderConfig.Supported; @@ -36,11 +37,31 @@ public class ServiceProviderConfigResourceTypeProvider implements SingletonResou config.setChangePassword(Supported.FALSE); config.setCreatedTimestamp(Time.currentTimeMillis()); config.setSort(Supported.FALSE); - config.setFilter(new FilterSupport()); + config.setFilter(getFilterSupport()); + config.setAuthenticationSchemes(getAuthenticationSchemes()); return config; } + private FilterSupport getFilterSupport() { + FilterSupport filter = new FilterSupport(); + + filter.setSupported(true); + + return filter; + } + + private List getAuthenticationSchemes() { + AuthenticationScheme scheme = new AuthenticationScheme(); + + scheme.setName("OAuth Bearer Token"); + scheme.setDescription("Authentication scheme using the OAuth Bearer Token standard"); + scheme.setSpecUri("https://tools.ietf.org/html/rfc6750"); + scheme.setType("oauthbearertoken"); + + return List.of(scheme); + } + @Override public Stream getAll(SearchRequest searchRequest) { if (!session.getContext().getPermissions().hasPermission(AdminPermissionsSchema.REALMS_RESOURCE_TYPE, AdminPermissionsSchema.VIEW)) { diff --git a/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateProvider.java b/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateProvider.java index da6265e4a67..b678e2aa9dc 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateProvider.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/filter/ScimJPAPredicateProvider.java @@ -133,6 +133,11 @@ public class ScimJPAPredicateProvider { basePredicate = cb.equal(join.get("name"), modelAttributeName); } + if (value != null && !attrInfo.isCaseExact() && "string".equals(attrInfo.getType())) { + value = value.toString().toLowerCase(); + expression = cb.lower((Expression) expression); + } + Predicate predicate = operatorMap.get(operation).apply(cb, expression, value); return (basePredicate != null) ? cb.and(basePredicate, predicate) : predicate; } diff --git a/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java b/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java index 7e475eaa700..5e127ed5242 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/group/GroupCoreModelSchema.java @@ -35,28 +35,31 @@ public final class GroupCoreModelSchema extends AbstractModelSchema getModelAttributeNames() { - return Set.of("name"); + return Set.of("name", "externalId"); } @Override protected String getAttributeValue(GroupModel model, String name) { - if (name.equals("name")) { - return model.getName(); - } - return null; + return switch (name) { + case "name" -> model.getName(); + case "externalId" -> model.getFirstAttribute("externalId"); + default -> null; + }; } @Override protected String getAttributeSchemaName(String name) { - if (name.equals("name")) { - return "displayName"; - } - return null; + return switch (name) { + case "name" -> "displayName"; + case "externalId" -> name; + default -> null; + }; } @Override protected Map> doGetAttributes() { List> attributes = new ArrayList<>(Attribute.simple("displayName") + .notCaseExact() .modelAttributeResolver((attribute) -> { if (attribute.getName().equals("displayName")) { return "name"; @@ -65,6 +68,11 @@ public final class GroupCoreModelSchema extends AbstractModelSchemasimple("externalId") + .immutable() + .string() + .withModelSetter(GroupModel::setSingleAttribute) + .build()); attributes.addAll(Attribute.simple("meta.created") .timestamp() .immutable() diff --git a/scim/model/src/main/java/org/keycloak/scim/model/schema/SchemaResourceTypeProvider.java b/scim/model/src/main/java/org/keycloak/scim/model/schema/SchemaResourceTypeProvider.java index 889869ca6b8..499b9edd427 100644 --- a/scim/model/src/main/java/org/keycloak/scim/model/schema/SchemaResourceTypeProvider.java +++ b/scim/model/src/main/java/org/keycloak/scim/model/schema/SchemaResourceTypeProvider.java @@ -87,6 +87,10 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider subAttributes = parent.getSubAttributes(); if (subAttributes == null) { @@ -109,6 +115,10 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider subAttributes = parent.getSubAttributes(); if (subAttributes == null) { @@ -132,6 +146,10 @@ public class SchemaResourceTypeProvider implements ScimResourceTypeProvider> attributes = new ArrayList<>(); attributes.addAll(Attribute.simple("userName") + .required() + .notCaseExact() + .serverUnique() .modelAttributeResolver(this::createModelAttributeResolver) .withModelSetter(UserModel::setSingleAttribute) .build()); attributes.addAll(Attribute.complex("emails", Email.class) .modelAttributeResolver(this::createModelAttributeResolver) + .notCaseExact() + .globalUnique() .multivalued() .withModelSetter((TriConsumer>) (model, name, values) -> { for (Email value : values) { diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FilterTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FilterTest.java index a3d76208851..9d384bbbcf4 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FilterTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/FilterTest.java @@ -785,10 +785,10 @@ public class FilterTest extends AbstractScimTest { Instant before = Instant.now(); Group group = new Group(); - group.setDisplayName("groupA"); + group.setDisplayName(KeycloakModelUtils.generateId()); group = client.groups().create(group); groupIdsToRemove.add(group.getId()); - final String displayName = group.getDisplayName(); + String displayName = group.getDisplayName(); Instant after = Instant.now(); diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java index ea526f548dc..82764827168 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/GroupTest.java @@ -8,7 +8,9 @@ import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.scim.client.ResourceFilter; import org.keycloak.scim.protocol.request.PatchRequest; +import org.keycloak.scim.protocol.response.ListResponse; import org.keycloak.scim.resource.group.Group; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.events.AdminEventAssertion; @@ -17,7 +19,9 @@ import org.junit.jupiter.api.Test; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -28,6 +32,7 @@ public class GroupTest extends AbstractScimTest { public void testCreate() { Group expected = new Group(); expected.setDisplayName(KeycloakModelUtils.generateId()); + expected.setExternalId(KeycloakModelUtils.generateId()); expected = client.groups().create(expected); assertNotNull(expected); @@ -39,6 +44,7 @@ public class GroupTest extends AbstractScimTest { Group actual = client.groups().get(expected.getId()); assertNotNull(actual); assertEquals(expected.getDisplayName(), actual.getDisplayName()); + assertEquals(expected.getExternalId(), actual.getExternalId()); } @Test @@ -71,6 +77,7 @@ public class GroupTest extends AbstractScimTest { expected = client.groups().get(expected.getId()); expected.setDisplayName("Updated " + expected.getDisplayName()); + expected.setExternalId(KeycloakModelUtils.generateId()); adminEvents.clear(); client.groups().update(expected); @@ -81,6 +88,7 @@ public class GroupTest extends AbstractScimTest { Group actual = client.groups().get(expected.getId()); assertEquals(expected.getDisplayName(), actual.getDisplayName()); + assertEquals(expected.getExternalId(), actual.getExternalId()); } @Test @@ -92,9 +100,11 @@ public class GroupTest extends AbstractScimTest { expected = client.groups().get(expected.getId()); expected.setDisplayName("Updated " + expected.getDisplayName()); + expected.setExternalId(KeycloakModelUtils.generateId()); adminEvents.clear(); client.groups().patch(expected.getId(), PatchRequest.create() .replace("displayName", expected.getDisplayName()) + .replace("externalId", expected.getExternalId()) .build()); AdminEventAssertion.assertSuccess(adminEvents.poll()) @@ -104,6 +114,7 @@ public class GroupTest extends AbstractScimTest { Group actual = client.groups().get(expected.getId()); assertEquals(expected.getDisplayName(), actual.getDisplayName()); + assertEquals(expected.getExternalId(), actual.getExternalId()); } @Test @@ -177,4 +188,29 @@ public class GroupTest extends AbstractScimTest { assertNotNull(group); assertEquals(rep.getName(), group.getDisplayName()); } + + @Test + public void testSearchByExternalId() { + Group group = new Group(); + group.setDisplayName(KeycloakModelUtils.generateId()); + group.setExternalId(KeycloakModelUtils.generateId()); + group = client.groups().create(group); + + Group group2 = new Group(); + group2.setDisplayName(KeycloakModelUtils.generateId()); + group2.setExternalId(KeycloakModelUtils.generateId()); + group2 = client.groups().create(group2); + + String filter = ResourceFilter.filter().eq("externalId", group.getExternalId()).build(); + ListResponse response = client.groups().getAll(filter); + assertFalse(response.getResources().isEmpty()); + assertThat(response.getTotalResults(), is(1)); + assertThat(response.getResources().get(0).getExternalId(), is(group.getExternalId())); + + filter = ResourceFilter.filter().eq("externalId", group2.getExternalId()).build(); + response = client.groups().getAll(filter); + assertFalse(response.getResources().isEmpty()); + assertThat(response.getTotalResults(), is(1)); + assertThat(response.getResources().get(0).getExternalId(), is(group2.getExternalId())); + } } diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java index 4f8f5b2c35c..d66b2c8f4c7 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/SchemaTest.java @@ -50,27 +50,42 @@ public class SchemaTest extends AbstractScimTest { assertFalse(schema.getAttributes().isEmpty()); // Verify ALL expected attributes are present (extracted from UserCoreModelSchema) - // UserCoreModelSchema has: userName, emails[0].value, name.*, externalId, nickName, locale, active - // These should map to top-level attributes: userName, emails, name, externalId, nickName, locale, active Set attributeNames = schema.getAttributes().stream() .map(Schema.Attribute::getName) .collect(Collectors.toSet()); - assertEquals(14, attributeNames.size(), "User schema should have exactly 7 attributes"); - assertTrue(attributeNames.contains("userName")); - assertTrue(attributeNames.contains("emails")); - assertTrue(attributeNames.contains("name")); - assertTrue(attributeNames.contains("externalId")); - assertTrue(attributeNames.contains("nickName")); - assertTrue(attributeNames.contains("locale")); - assertTrue(attributeNames.contains("active")); - assertTrue(attributeNames.contains("profileUrl")); - assertTrue(attributeNames.contains("preferredLanguage")); - assertTrue(attributeNames.contains("displayName")); - assertTrue(attributeNames.contains("timezone")); - assertTrue(attributeNames.contains("groups")); - assertTrue(attributeNames.contains("title")); - assertTrue(attributeNames.contains("userType")); + assertEquals(14, attributeNames.size(), "User schema should have exactly 14 attributes"); + + assertAttribute(findAttribute(schema, "userName"), "string", false, true, false, "readWrite", "server"); + assertAttribute(findAttribute(schema, "emails"), "complex", true, false, false, "readWrite", "global"); + assertAttribute(findAttribute(schema, "name"), "complex", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "displayName"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "title"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "externalId"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "userType"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "nickName"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "locale"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "timezone"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "preferredLanguage"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "profileUrl"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "active"), "boolean", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "groups"), "complex", true, false, true, "readWrite", "none"); + + // Verify name sub-attributes + Schema.Attribute name = findAttribute(schema, "name"); + assertNotNull(name.getSubAttributes(), "name should have sub-attributes"); + Set nameSubAttrNames = name.getSubAttributes().stream() + .map(Schema.Attribute::getName) + .collect(Collectors.toSet()); + assertTrue(nameSubAttrNames.contains("givenName")); + assertTrue(nameSubAttrNames.contains("familyName")); + assertTrue(nameSubAttrNames.contains("middleName")); + assertTrue(nameSubAttrNames.contains("honorificPrefix")); + assertTrue(nameSubAttrNames.contains("honorificSuffix")); + assertTrue(nameSubAttrNames.contains("formatted")); + for (Schema.Attribute subAttr : name.getSubAttributes()) { + assertSubAttribute(subAttr, "string", false, "readWrite"); + } } @Test @@ -83,15 +98,14 @@ public class SchemaTest extends AbstractScimTest { assertEquals("Group", schema.getDescription()); assertNotNull(schema.getAttributes()); - // Verify ALL expected attributes are present (extracted from GroupCoreModelSchema) - // GroupCoreModelSchema currently only has: displayName - // Note: members is not yet supported in GroupCoreModelSchema attribute mappers Set attributeNames = schema.getAttributes().stream() .map(Schema.Attribute::getName) .collect(Collectors.toSet()); - assertEquals(1, attributeNames.size(), "Group schema should have exactly 1 attribute"); - assertTrue(attributeNames.contains("displayName")); + assertEquals(2, attributeNames.size(), "Group schema should have exactly 2 attributes"); + + assertAttribute(findAttribute(schema, "displayName"), "string", false, false, false, "readWrite", "none"); + assertAttribute(findAttribute(schema, "externalId"), "string", false, false, true, "immutable", "none"); } @Test @@ -104,145 +118,31 @@ public class SchemaTest extends AbstractScimTest { assertEquals("Enterprise User", schema.getDescription()); assertNotNull(schema.getAttributes()); - // Verify ALL expected attributes are present (extracted from UserEnterpriseModelSchema) - // UserEnterpriseModelSchema has: employeeNumber, costCenter, organization, division, department, manager.* - // These should map to: employeeNumber, costCenter, organization, division, department, manager Set attributeNames = schema.getAttributes().stream() .map(Schema.Attribute::getName) .collect(Collectors.toSet()); assertEquals(6, attributeNames.size(), "Enterprise User schema should have exactly 6 attributes"); - assertTrue(attributeNames.contains("employeeNumber")); - assertTrue(attributeNames.contains("costCenter")); - assertTrue(attributeNames.contains("organization")); - assertTrue(attributeNames.contains("division")); - assertTrue(attributeNames.contains("department")); - assertTrue(attributeNames.contains("manager")); - } - @Test - public void testAttributeProperties() { - Schema schema = client.schemas().get(Scim.USER_CORE_SCHEMA); + // Simple string attributes + assertAttribute(findAttribute(schema, "employeeNumber"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "costCenter"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "organization"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "division"), "string", false, false, true, "readWrite", "none"); + assertAttribute(findAttribute(schema, "department"), "string", false, false, true, "readWrite", "none"); - // Test STRING type (userName) - Schema.Attribute userNameAttr = findAttribute(schema, "userName"); - assertNotNull(userNameAttr, "userName attribute should exist"); - assertEquals("string", userNameAttr.getType()); - assertEquals(false, userNameAttr.getMultiValued()); - - // Test BOOLEAN type (active) - Schema.Attribute activeAttr = findAttribute(schema, "active"); - assertNotNull(activeAttr, "active attribute should exist"); - assertEquals("boolean", activeAttr.getType()); - assertEquals(false, activeAttr.getMultiValued()); - - // Test COMPLEX multi-valued (emails) - Schema.Attribute emailsAttr = findAttribute(schema, "emails"); - assertNotNull(emailsAttr, "emails attribute should exist"); - assertEquals("complex", emailsAttr.getType()); - assertEquals(true, emailsAttr.getMultiValued()); - - // Test COMPLEX single-valued (name) - Schema.Attribute nameAttr = findAttribute(schema, "name"); - assertNotNull(nameAttr, "name attribute should exist"); - assertEquals("complex", nameAttr.getType()); - assertEquals(false, nameAttr.getMultiValued()); - - // Test STRING attributes (externalId, nickName, locale) - Schema.Attribute externalIdAttr = findAttribute(schema, "externalId"); - assertNotNull(externalIdAttr, "externalId attribute should exist"); - assertEquals("string", externalIdAttr.getType()); - assertEquals(false, externalIdAttr.getMultiValued()); - - Schema.Attribute nickNameAttr = findAttribute(schema, "nickName"); - assertNotNull(nickNameAttr, "nickName attribute should exist"); - assertEquals("string", nickNameAttr.getType()); - assertEquals(false, nickNameAttr.getMultiValued()); - - Schema.Attribute localeAttr = findAttribute(schema, "locale"); - assertNotNull(localeAttr, "locale attribute should exist"); - assertEquals("string", localeAttr.getType()); - assertEquals(false, localeAttr.getMultiValued()); - } - - @Test - public void testReferenceTypes() { - // Test EnterpriseUser manager is a complex attribute - Schema enterpriseUserSchema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA); - Schema.Attribute managerAttr = findAttribute(enterpriseUserSchema, "manager"); - assertNotNull(managerAttr, "manager attribute should exist"); - assertEquals("complex", managerAttr.getType()); - assertEquals(false, managerAttr.getMultiValued()); - - // TODO: referenceTypes are not yet tracked in the model schema Attribute. - // Once Attribute supports reference types, add assertions for: - // managerAttr.getReferenceTypes() containing "User" - - // Note: Group.members is not yet supported in GroupCoreModelSchema, - // so reference type testing for members is omitted - } - - @Test - public void testEnterpriseUserAttributeTypes() { - Schema schema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA); - - // All enterprise user attributes (except manager) should be string type - String[] stringAttributes = {"employeeNumber", "costCenter", "organization", "division", "department"}; - for (String attrName : stringAttributes) { - Schema.Attribute attr = findAttribute(schema, attrName); - assertNotNull(attr, attrName + " attribute should exist"); - assertEquals("string", attr.getType(), attrName + " should be string type"); - assertEquals(false, attr.getMultiValued(), attrName + " should not be multi-valued"); + // Manager is a complex attribute with sub-attributes + assertAttribute(findAttribute(schema, "manager"), "complex", false, false, false, "readWrite", "none"); + Schema.Attribute manager = findAttribute(schema, "manager"); + assertNotNull(manager.getSubAttributes(), "manager should have sub-attributes"); + Set managerSubAttrNames = manager.getSubAttributes().stream() + .map(Schema.Attribute::getName) + .collect(Collectors.toSet()); + assertTrue(managerSubAttrNames.contains("value")); + assertTrue(managerSubAttrNames.contains("displayName")); + for (Schema.Attribute subAttr : manager.getSubAttributes()) { + assertSubAttribute(subAttr, "string", false, "readWrite"); } - - // Manager is complex type with User reference - Schema.Attribute managerAttr = findAttribute(schema, "manager"); - assertNotNull(managerAttr, "manager attribute should exist"); - assertEquals("complex", managerAttr.getType()); - assertEquals(false, managerAttr.getMultiValued()); - } - - @Test - public void testGroupAttributeTypes() { - Schema schema = client.schemas().get(Scim.GROUP_CORE_SCHEMA); - - // displayName should be string - Schema.Attribute displayNameAttr = findAttribute(schema, "displayName"); - assertNotNull(displayNameAttr, "displayName attribute should exist"); - assertEquals("string", displayNameAttr.getType()); - assertEquals(false, displayNameAttr.getMultiValued()); - - // Note: members is not yet supported in GroupCoreModelSchema attribute mappers - } - - @Test - public void testPathExtractionLogic() { - // This test verifies that the path extraction logic works correctly - // Multiple SCIM paths should map to the same top-level attribute - - Schema userSchema = client.schemas().get(Scim.USER_CORE_SCHEMA); - - // UserCoreModelSchema has multiple paths for 'name': - // - name.givenName, name.familyName, name.middleName, name.honorificPrefix, name.honorificSuffix - // All should map to a single 'name' attribute - Schema.Attribute nameAttr = findAttribute(userSchema, "name"); - assertNotNull(nameAttr, "name attribute should exist (from multiple name.* paths)"); - assertEquals("complex", nameAttr.getType()); - assertEquals(false, nameAttr.getMultiValued()); - - // emails[0].value should map to 'emails' attribute - Schema.Attribute emailsAttr = findAttribute(userSchema, "emails"); - assertNotNull(emailsAttr, "emails attribute should exist (from emails[0].value path)"); - assertEquals("complex", emailsAttr.getType()); - assertEquals(true, emailsAttr.getMultiValued()); - - // EnterpriseUser has manager.value and manager.displayName - // Both should map to a single 'manager' attribute - Schema enterpriseSchema = client.schemas().get(Scim.ENTERPRISE_USER_SCHEMA); - Schema.Attribute managerAttr = findAttribute(enterpriseSchema, "manager"); - assertNotNull(managerAttr, "manager attribute should exist (from manager.* paths)"); - assertEquals("complex", managerAttr.getType()); - assertEquals(false, managerAttr.getMultiValued()); } @Test @@ -284,4 +184,22 @@ public class SchemaTest extends AbstractScimTest { .findFirst() .orElse(null); } + + private void assertAttribute(Schema.Attribute attribute, String type, boolean multiValued, + boolean required, boolean caseExact, String mutability, String uniqueness) { + assertNotNull(attribute, "attribute should not be null"); + assertEquals(type, attribute.getType(), attribute.getName() + " type"); + assertEquals(multiValued, attribute.getMultiValued(), attribute.getName() + " multiValued"); + assertEquals(required, attribute.getRequired(), attribute.getName() + " required"); + assertEquals(caseExact, attribute.getCaseExact(), attribute.getName() + " caseExact"); + assertEquals(mutability, attribute.getMutability(), attribute.getName() + " mutability"); + assertEquals(uniqueness, attribute.getUniqueness(), attribute.getName() + " uniqueness"); + } + + private void assertSubAttribute(Schema.Attribute subAttribute, String type, boolean multiValued, String mutability) { + assertNotNull(subAttribute, "sub-attribute should not be null"); + assertEquals(type, subAttribute.getType(), subAttribute.getName() + " sub-attribute type"); + assertEquals(multiValued, subAttribute.getMultiValued(), subAttribute.getName() + " sub-attribute multiValued"); + assertEquals(mutability, subAttribute.getMutability(), subAttribute.getName() + " sub-attribute mutability"); + } } diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/ServiceProviderConfigTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/ServiceProviderConfigTest.java index 241f7d4332b..e578248c66e 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/ServiceProviderConfigTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/ServiceProviderConfigTest.java @@ -6,6 +6,7 @@ import java.util.Set; import org.keycloak.scim.resource.config.ServiceProviderConfig; import org.keycloak.scim.resource.config.ServiceProviderConfig.AuthenticationScheme; import org.keycloak.scim.resource.config.ServiceProviderConfig.BulkSupport; +import org.keycloak.scim.resource.config.ServiceProviderConfig.FilterSupport; import org.keycloak.scim.resource.config.ServiceProviderConfig.Supported; import org.keycloak.testframework.annotations.KeycloakIntegrationTest; @@ -37,8 +38,15 @@ public class ServiceProviderConfigTest extends AbstractScimTest { assertTrue(patch.getSupported()); List authenticationSchemes = config.getAuthenticationSchemes(); assertNotNull(authenticationSchemes); - // TODO: support at least bearer token authentication scheme - assertTrue(authenticationSchemes.isEmpty()); + assertEquals(1, authenticationSchemes.size()); + AuthenticationScheme bearerScheme = authenticationSchemes.get(0); + assertEquals("OAuth Bearer Token", bearerScheme.getName()); + assertEquals("Authentication scheme using the OAuth Bearer Token standard", bearerScheme.getDescription()); + assertEquals("https://tools.ietf.org/html/rfc6750", bearerScheme.getSpecUri()); + assertEquals("oauthbearertoken", bearerScheme.getType()); + FilterSupport filter = config.getFilter(); + assertNotNull(filter); + assertTrue(filter.getSupported()); Set schemas = config.getSchemas(); assertNotNull(schemas); assertEquals(1, schemas.size()); diff --git a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java index 33a6e9c8fef..0aa1c0f8927 100644 --- a/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java +++ b/scim/tests/base/src/test/java/org/keycloak/tests/scim/tck/UserTest.java @@ -625,6 +625,14 @@ public class UserTest extends AbstractScimTest { expected.setActive(false); assertRootAttributes(actual, expected); + // patch a multivalued attribute using a filter in the path that matches an existing value + client.users().patch(expected.getId(), PatchRequest.create() + .replace("emails[value ew \"patched4.org\"].value", expected.getEmail().replace("patched4.org", "filtered.org")) + .build()); + actual = client.users().get(expected.getId()); + expected.setEmail(expected.getEmail().replace("patched4.org", "filtered.org")); + assertRootAttributes(actual, expected); + // patch a multivalued attribute using a filter in the path that does not resolve to any value, no update should be performed String expectedEmail = expected.getEmail(); expected.setEmail(expected.getEmail().replace("patched4.org", "patched5.org")); @@ -765,8 +773,7 @@ public class UserTest extends AbstractScimTest { assertEquals(5, groups.size()); client.users().patch(expected.getId(), PatchRequest.create() - .remove("groups[value eq \"" + groupA1.getId() + "\"]") - .remove("groups[value eq \"" + groupB.getId() + "\"]") + .remove("groups[value eq \"" + groupA1.getId() + "\" or value eq \"" + groupB.getId() + "\"]") .build()); actual = client.users().get(expected.getId()); groups = actual.getGroups(); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java b/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java index cc0736f6f49..510d94e6060 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/directgrant/ValidateUsername.java @@ -33,9 +33,11 @@ import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.ModelDuplicateException; import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.services.ServicesLogger; import org.keycloak.services.managers.AuthenticationManager; @@ -102,7 +104,11 @@ public class ValidateUsername extends AbstractDirectGrantAuthenticator { if (!user.isEnabled()) { context.getEvent().user(user); context.getEvent().error(Errors.USER_DISABLED); - Response challengeResponse = errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_grant", "Account disabled"); + String password = retrievePassword(context); + String errorDescription = user.credentialManager().isValid(UserCredentialModel.password(password)) + ? "Account disabled" + : "Invalid user credentials"; + Response challengeResponse = errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_grant", errorDescription); context.forceChallenge(challengeResponse); return; } @@ -174,4 +180,9 @@ public class ValidateUsername extends AbstractDirectGrantAuthenticator { MultivaluedMap inputData = context.getHttpRequest().getDecodedFormParameters(); return inputData.getFirst(AuthenticationManager.FORM_USERNAME); } + + protected String retrievePassword(AuthenticationFlowContext context) { + MultivaluedMap inputData = context.getHttpRequest().getDecodedFormParameters(); + return inputData.getFirst(CredentialRepresentation.PASSWORD); + } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java index 20540295247..532374609c9 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/JwtCredentialBuilder.java @@ -111,6 +111,9 @@ public class JwtCredentialBuilder implements CredentialBuilder { @Override public void contributeToMetadata(SupportedCredentialConfiguration credentialConfig, CredentialScopeModel credentialScope) { CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope); + // @context must not be included for jwt_vc_json format per OID4VCI spec; + // it is only valid for ldp_vc format. + credentialDefinition.setContext(null); credentialConfig.setCredentialDefinition(credentialDefinition); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilder.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilder.java index 75895bdf428..703d223434a 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilder.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/credentialbuilder/LDCredentialBuilder.java @@ -18,7 +18,10 @@ package org.keycloak.protocol.oid4vc.issuance.credentialbuilder; import org.keycloak.VCFormat; +import org.keycloak.models.oid4vci.CredentialScopeModel; import org.keycloak.protocol.oid4vc.model.CredentialBuildConfig; +import org.keycloak.protocol.oid4vc.model.CredentialDefinition; +import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration; import org.keycloak.protocol.oid4vc.model.VerifiableCredential; @@ -38,6 +41,12 @@ public class LDCredentialBuilder implements CredentialBuilder { return VCFormat.LDP_VC; } + @Override + public void contributeToMetadata(SupportedCredentialConfiguration credentialConfig, CredentialScopeModel credentialScope) { + CredentialDefinition credentialDefinition = CredentialDefinition.parse(credentialScope); + credentialConfig.setCredentialDefinition(credentialDefinition); + } + @Override public LDCredentialBody buildCredentialBody( VerifiableCredential verifiableCredential, diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java index b5f5de11340..cc86aed1c39 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCGeneratedIdMapper.java @@ -86,6 +86,9 @@ public class OID4VCGeneratedIdMapper extends OID4VCMapper { public void setClaim(Map claims, UserSessionModel userSessionModel) { // Assign a generated ID List attributePath = getMetadataAttributePath(); + if (attributePath.isEmpty()) { + return; + } String propertyName = attributePath.get(attributePath.size() - 1); claims.put(propertyName, String.format("urn:uuid:%s", UUID.randomUUID())); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java index 41daefdae67..85c574c323a 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCMapper.java @@ -113,8 +113,14 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP public List getMetadataAttributePath() { final String claimName = mapperModel.getConfig().get(CLAIM_NAME); final String userAttributeName = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY); - return ListUtils.union(getAttributePrefix(), - List.of(Optional.ofNullable(claimName).orElse(userAttributeName))); + String attributeName = Optional.ofNullable(claimName) + .orElse(userAttributeName); + + if (attributeName == null) { + return Collections.emptyList(); + } + + return ListUtils.union(getAttributePrefix(), List.of(attributeName)); } protected List getAttributePrefix() { @@ -168,12 +174,15 @@ public abstract class OID4VCMapper implements ProtocolMapper, OID4VCEnvironmentP */ public void setClaimWithMetadataPrefix(Map claimsOrig, Map claimsWithPrefix) { List attributePath = getMetadataAttributePath(); + if (attributePath.isEmpty()) { + return; + } String propertyName = attributePath.get(attributePath.size() - 1); if (claimsOrig.get(propertyName) != null) { Object claimValue = claimsOrig.get(propertyName); Map current = claimsWithPrefix; - for (int i = 0; i < attributePath.size() ; i++) { + for (int i = 0; i < attributePath.size(); i++) { String currentSnippetName = attributePath.get(i); if (i < attributePath.size() - 1) { Map obj = (Map) current.get(currentSnippetName); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java index 744617a75a0..6fcf5c1479f 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCStaticClaimMapper.java @@ -69,6 +69,9 @@ public class OID4VCStaticClaimMapper extends OID4VCMapper { @Override public void setClaim(Map claims, UserSessionModel userSessionModel) { List attributePath = getMetadataAttributePath(); + if (attributePath.isEmpty()) { + return; + } String propertyName = attributePath.get(attributePath.size() - 1); String staticValue = mapperModel.getConfig().get(STATIC_CLAIM_KEY); claims.put(propertyName, staticValue); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java index 805f396e5a7..2680a1c40e1 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCSubjectIdMapper.java @@ -128,6 +128,9 @@ public class OID4VCSubjectIdMapper extends OID4VCMapper { public void setClaim(Map claims, UserSessionModel userSessionModel) { UserModel userModel = userSessionModel.getUser(); List attributePath = getMetadataAttributePath(); + if (attributePath.isEmpty()) { + return; + } String propertyName = attributePath.get(attributePath.size() - 1); String userAttributeName = mapperModel.getConfig().get(OID4VCMapper.USER_ATTRIBUTE_KEY); Consumer userIdConsumer = (val) -> claims.put(propertyName, val); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java index f92fbad5095..566998b311e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCTargetRoleMapper.java @@ -152,6 +152,9 @@ public class OID4VCTargetRoleMapper extends OID4VCMapper { public void setClaim(Map claims, UserSessionModel userSessionModel) { List attributePath = getMetadataAttributePath(); + if (attributePath.isEmpty()) { + return; + } String propertyName = attributePath.get(attributePath.size() - 1); String client = mapperModel.getConfig().get(CLIENT_CONFIG_KEY); ClientModel clientModel = userSessionModel.getRealm().getClientByClientId(client); diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java index c9b0c30c983..9cb1c8967fa 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/mappers/OID4VCUserAttributeMapper.java @@ -88,6 +88,9 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper { public void setClaim(Map claims, UserSessionModel userSessionModel) { String claimName = mapperModel.getConfig().get(CLAIM_NAME); String userAttribute = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY); + if (claimName == null && userAttribute == null) { + return; + } boolean aggregateAttributes = Optional.ofNullable(mapperModel.getConfig().get(AGGREGATE_ATTRIBUTES_KEY)) .map(Boolean::parseBoolean).orElse(false); Collection attributes = @@ -96,7 +99,7 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper { attributes.removeAll(Collections.singleton(null)); if (!attributes.isEmpty()) { JsonUtils.mapClaim( - JsonUtils.splitClaimPath(claimName), + JsonUtils.splitClaimPath(Optional.ofNullable(claimName).orElse(userAttribute)), String.join(",", attributes), claims, false @@ -143,7 +146,14 @@ public class OID4VCUserAttributeMapper extends OID4VCMapper { String claimName = mapperModel.getConfig().get(CLAIM_NAME); final String userAttributeName = mapperModel.getConfig().get(USER_ATTRIBUTE_KEY); // Split claim name into path segments for metadata endpoint. - final List claimPath = Optional.ofNullable(claimName).map(JsonUtils::splitClaimPath).orElse(List.of(userAttributeName)); + final List claimPath = Optional.ofNullable(claimName) + .map(JsonUtils::splitClaimPath) + .orElse(Optional.ofNullable(userAttributeName) + .map(List::of) + .orElse(Collections.emptyList())); + if (claimPath.isEmpty()) { + return Collections.emptyList(); + } return ListUtils.union(getAttributePrefix(), claimPath); } } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java index b7c5bd54f5b..39dc3edddc9 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/Claim.java @@ -77,9 +77,14 @@ public class Claim { return Optional.empty(); } - claim.setName(String.join(".", mapper.getMetadataAttributePath())); + List attributePath = mapper.getMetadataAttributePath(); + if (attributePath == null || attributePath.isEmpty()) { + return Optional.empty(); + } - claim.setPath(mapper.getMetadataAttributePath()); + claim.setName(String.join(".", attributePath)); + + claim.setPath(attributePath); claim.setMandatory(protocolMapper.isMandatory()); String displayString = protocolMapper.getDisplay(); diff --git a/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java b/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java index 266331e920d..9e4a916a610 100644 --- a/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java +++ b/services/src/main/java/org/keycloak/services/DefaultKeycloakSession.java @@ -166,7 +166,6 @@ public abstract class DefaultKeycloakSession implements KeycloakSession { return getDatastoreProvider().users(); } - @SuppressWarnings("unchecked") @Override public T getProvider(Class clazz) { List key = List.of(clazz.getName()); @@ -174,6 +173,7 @@ public abstract class DefaultKeycloakSession implements KeycloakSession { } private T getOrCreateProvider(List key, Supplier> supplier) { + @SuppressWarnings("unchecked") T provider = (T) providers.get(key); // KEYCLOAK-11890 - Avoid using HashMap.computeIfAbsent() to implement logic in outer if() block below, // since per JDK-8071667 the remapping function should not modify the map during computation. While @@ -188,7 +188,6 @@ public abstract class DefaultKeycloakSession implements KeycloakSession { return provider; } - @SuppressWarnings("unchecked") @Override public T getProvider(Class clazz, String id) { List key = List.of(clazz.getName(), id); @@ -207,11 +206,10 @@ public abstract class DefaultKeycloakSession implements KeycloakSession { } @Override - @SuppressWarnings("unchecked") public T getComponentProvider(Class clazz, String componentId, Function modelGetter) { List key = List.of("component", clazz.getName(), componentId); final RealmModel realm = getContext().getRealm(); - return getOrCreateProvider(key, () -> factory.getProviderFactory(clazz, Optional.ofNullable(realm.getId()).orElse(null), componentId, modelGetter)); + return getOrCreateProvider(key, () -> factory.getProviderFactory(clazz, Optional.ofNullable(realm).map(RealmModel::getId).orElse(null), componentId, modelGetter)); } @Override diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/OfflineTokenBasicFlowTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/OfflineTokenBasicFlowTest.java new file mode 100644 index 00000000000..22a395d2687 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/OfflineTokenBasicFlowTest.java @@ -0,0 +1,1018 @@ +package org.keycloak.tests.oauth; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import jakarta.ws.rs.NotFoundException; + +import org.keycloak.OAuth2Constants; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.RoleResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.common.constants.ServiceAccountConstants; +import org.keycloak.crypto.Algorithm; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.jose.jws.JWSHeader; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.models.AdminRoles; +import org.keycloak.models.Constants; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.session.UserSessionPersisterProvider; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.protocol.oidc.encode.AccessTokenContext; +import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.RefreshToken; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ClientScopeRepresentation; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.testframework.admin.AdminClientFactory; +import org.keycloak.testframework.annotations.InjectAdminClient; +import org.keycloak.testframework.annotations.InjectAdminClientFactory; +import org.keycloak.testframework.annotations.InjectEvents; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.events.EventAssertion; +import org.keycloak.testframework.events.Events; +import org.keycloak.testframework.oauth.OAuthClient; +import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; +import org.keycloak.testframework.realm.ClientConfig; +import org.keycloak.testframework.realm.ClientConfigBuilder; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.testframework.realm.RealmConfigBuilder; +import org.keycloak.testframework.realm.RoleConfigBuilder; +import org.keycloak.testframework.remote.runonserver.InjectRunOnServer; +import org.keycloak.testframework.remote.runonserver.RunOnServerClient; +import org.keycloak.testframework.remote.timeoffset.InjectTimeOffSet; +import org.keycloak.testframework.remote.timeoffset.TimeOffSet; +import org.keycloak.testframework.ui.annotations.InjectWebDriver; +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; +import org.keycloak.tests.utils.admin.AdminApiUtil; +import org.keycloak.testsuite.util.AccountHelper; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.util.TokenUtil; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.keycloak.tests.utils.admin.AdminApiUtil.findRealmRoleByName; +import static org.keycloak.tests.utils.admin.AdminApiUtil.findUserByUsername; +import static org.keycloak.tests.utils.admin.AdminApiUtil.findUserByUsernameId; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@KeycloakIntegrationTest +public class OfflineTokenBasicFlowTest { + + private static final String OFFLINE_CLIENT_ID = "offline-client"; + private static final String OFFLINE_CLIENT_APP_URI = "http://localhost:8080/offline-client"; + private static final String TEST_APP_REDIRECT_URI = "http://localhost:8080/auth/realms/test/app/auth"; + private String userId; + private String serviceAccountUserId; + + @InjectRealm(config = OfflineTokenBasicFlowTest.OfflineTokenRealmConfig.class) + ManagedRealm realm; + + @InjectOAuthClient(config = OfflineTokenBasicFlowTest.OfflineAuthClientConfig.class) + OAuthClient oauth; + + @InjectEvents + Events events; + + @InjectAdminClient + Keycloak adminClient; + + @InjectWebDriver + ManagedWebDriver driver; + + @InjectRunOnServer + RunOnServerClient runOnServer; + + @InjectTimeOffSet + TimeOffSet timeOffSet; + + @InjectAdminClientFactory + AdminClientFactory adminClientFactory; + + @BeforeEach + public void clientConfiguration() { + + timeOffSet.set(0); + + // Reset OAuth client config to defaults + oauth.realm("test"); + oauth.client("test-app"); // Reset to default client + oauth.redirectUri(TEST_APP_REDIRECT_URI); // Reset to default redirect + oauth.scope(null); // Clear any scope + oauth.responseType(OAuth2Constants.CODE); // Reset to default + + // Force server-side logout + try { + adminClient.realm("test").logoutAll(); + } catch (NotFoundException e) { + // Expected behavior on the first run if the realm/sessions don't exist yet. Safe to ignore. + } + + // Fetch the auto-generated service account user + serviceAccountUserId = realm.admin().users() + .search(ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + OFFLINE_CLIENT_ID, true) + .get(0).getId(); + + userId = findUserByUsername(adminClient.realm("test"), "test-user@localhost").getId(); + + events.clear(); + } + + @Test + public void offlineTokenDisabledForClient() { + // Remove offline-access scope from client + ClientScopeRepresentation offlineScope = adminClient.realm("test").clientScopes().findAll().stream() + .filter((ClientScopeRepresentation clientScope) -> OAuth2Constants.OFFLINE_ACCESS.equals(clientScope.getName())) + .findFirst().orElseThrow(); + + ClientResource clientResource = realm.admin().clients() + .get(realm.admin().clients().findByClientId("offline-client").get(0).getId()); + + ClientRepresentation client = clientResource.toRepresentation(); + client.setFullScopeAllowed(false); + clientResource.update(client); + clientResource.removeOptionalClientScope(offlineScope.getId()); + + try { + // Test that offline access is denied + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.client("offline-client", "secret1"); + oauth.redirectUri(OFFLINE_CLIENT_APP_URI); + oauth.openLoginForm(); + + EventRepresentation errorEvent = events.poll(); + EventAssertion.assertError(errorEvent) + .type(EventType.LOGIN_ERROR) + .clientId("offline-client") + .error(Errors.INVALID_REQUEST) + .details(Details.REASON, "Invalid scopes: openid offline_access"); + + } finally { + // Revert changes + client.setFullScopeAllowed(true); + clientResource.update(client); + clientResource.addOptionalClientScope(offlineScope.getId()); + } + } + + @Test + public void offlineTokenUserNotAllowed() { + String userId = realm.admin().users() + .search("keycloak-user@localhost", true) + .stream() + .findFirst() + .orElseThrow(() -> new IllegalStateException("User 'keycloak-user@localhost' not found!")) + .getId(); + + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.client("offline-client", "secret1"); + oauth.redirectUri(OFFLINE_CLIENT_APP_URI); + oauth.doLogin("keycloak-user@localhost", "password"); + + EventRepresentation loginEvent = events.poll(); + EventAssertion.assertSuccess(loginEvent) + .type(EventType.LOGIN) + .clientId("offline-client") + .userId(userId) + .details(Details.REDIRECT_URI, OFFLINE_CLIENT_APP_URI); + + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.parseLoginResponse().getCode(); + + AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code); + + assertEquals(400, tokenResponse.getStatusCode()); + assertEquals("not_allowed", tokenResponse.getError()); + + EventRepresentation tokenEvent = events.poll(); + EventAssertion.assertError(tokenEvent) + .type(EventType.CODE_TO_TOKEN_ERROR) + .clientId("offline-client") + .userId(userId) + .sessionId(sessionId) + .error("not_allowed") + .details(Details.CODE_ID, codeId); + } + + @Test + public void offlineTokenBrowserFlow() { + setupCustomerUserRoles(); + + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.client("offline-client", "secret1"); + oauth.redirectUri(OFFLINE_CLIENT_APP_URI); + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.poll(); + EventAssertion.assertSuccess(loginEvent) + .type(EventType.LOGIN) + .clientId("offline-client") + .details(Details.REDIRECT_URI, OFFLINE_CLIENT_APP_URI); + + final String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + + String code = oauth.parseLoginResponse().getCode(); + + AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code); + AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString = tokenResponse.getRefreshToken(); + RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); + + EventRepresentation codeToTokenEvent = events.poll(); + EventAssertion.assertSuccess(codeToTokenEvent) + .type(EventType.CODE_TO_TOKEN) + .clientId("offline-client") + .sessionId(sessionId) + .details(Details.CODE_ID, codeId) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE); + + assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); + Assertions.assertNull(offlineToken.getExp()); + + AccessTokenContext ctx = runOnServer.fetch(session -> { + return session.getProvider(TokenContextEncoderProvider.class) + .getTokenContextFromTokenId(token.getId()); + }, AccessTokenContext.class); + Assertions.assertEquals(AccessTokenContext.SessionType.OFFLINE, ctx.getSessionType()); + Assertions.assertEquals(AccessTokenContext.TokenType.REGULAR, ctx.getTokenType()); + Assertions.assertEquals(OAuth2Constants.AUTHORIZATION_CODE, ctx.getGrantType()); + + assertTrue(tokenResponse.getScope().contains(OAuth2Constants.OFFLINE_ACCESS)); + + // check only offline session is created + checkNumberOfSessions(userId, "offline-client", offlineToken.getSessionId(), 0, 1); + + String newRefreshTokenString = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, sessionId, userId); + + // Change offset to very big value to ensure offline session expires + timeOffSet.set(3000000); + + AccessTokenResponse response = oauth.doRefreshTokenRequest(newRefreshTokenString); + RefreshToken newRefreshToken = oauth.parseRefreshToken(newRefreshTokenString); + Assertions.assertEquals(400, response.getStatusCode()); + assertEquals("invalid_grant", response.getError()); + + EventRepresentation refreshErrorEvent = events.poll(); + EventAssertion.assertError(refreshErrorEvent) + .type(EventType.REFRESH_TOKEN_ERROR) + .sessionId(newRefreshToken.getSessionId()) + //.userId(loginEvent.getUserId()) + .clientId("offline-client") + .error(Errors.INVALID_TOKEN) + .details(Details.REFRESH_TOKEN_SUB, loginEvent.getUserId()); + timeOffSet.set(0); + } + + + @Test + public void onlineOfflineTokenBrowserFlow() { + // request an online token for the client + oauth.scope(null); + oauth.client("offline-client", "secret1"); + oauth.redirectUri(OFFLINE_CLIENT_APP_URI); + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation onlineLoginEvent = events.poll(); + EventAssertion.assertSuccess(onlineLoginEvent) + .type(EventType.LOGIN) + .clientId("offline-client") + .details(Details.REDIRECT_URI, OFFLINE_CLIENT_APP_URI); + + final String onlineSessionId = onlineLoginEvent.getSessionId(); + String codeId = onlineLoginEvent.getDetails().get(Details.CODE_ID); + AccessTokenResponse onlineTokenResponse = oauth.doAccessTokenRequest(oauth.parseLoginResponse().getCode()); + RefreshToken onlineRefreshToken = assertRefreshToken(onlineTokenResponse, TokenUtil.TOKEN_TYPE_REFRESH); + + EventRepresentation onlineCodeToTokenEvent = events.poll(); + EventAssertion.assertSuccess(onlineCodeToTokenEvent) + .type(EventType.CODE_TO_TOKEN) + .clientId("offline-client") + .sessionId(onlineSessionId) + .details(Details.CODE_ID, codeId) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH); + assertEquals(TokenUtil.TOKEN_TYPE_REFRESH, onlineRefreshToken.getType()); + Assertions.assertNotNull(onlineRefreshToken.getExp()); + + // request an offline token for the same client + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.openLoginForm(); + EventRepresentation offlineLoginEvent = events.poll(); + EventAssertion.assertSuccess(offlineLoginEvent) + .type(EventType.LOGIN) + .clientId("offline-client") + .details(Details.REDIRECT_URI, OFFLINE_CLIENT_APP_URI); + AccessTokenResponse offlineTokenResponse = oauth.doAccessTokenRequest( + oauth.parseLoginResponse().getCode()); + RefreshToken offlineRefreshToken = assertRefreshToken(offlineTokenResponse, TokenUtil.TOKEN_TYPE_OFFLINE); + final String offlineSessionId = offlineLoginEvent.getSessionId(); + + EventRepresentation offlineCodeToTokenEvent = events.poll(); + EventAssertion.assertSuccess(offlineCodeToTokenEvent) + .type(EventType.CODE_TO_TOKEN) + .clientId("offline-client") + .sessionId(onlineSessionId) + .details(Details.CODE_ID, codeId) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE); + assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineRefreshToken.getType()); + Assertions.assertNull(offlineRefreshToken.getExp()); + assertTrue(offlineTokenResponse.getScope().contains(OAuth2Constants.OFFLINE_ACCESS)); + + // check both sessions are created + checkNumberOfSessions(userId, "offline-client", onlineRefreshToken.getSessionId(), 1, 1); + + // check online token can be refreshed + onlineTokenResponse = oauth.doRefreshTokenRequest(onlineTokenResponse.getRefreshToken()); + assertRefreshToken(onlineTokenResponse, TokenUtil.TOKEN_TYPE_REFRESH); + AccessToken renewedOnlineAccessToken = oauth.verifyToken(onlineTokenResponse.getAccessToken()); + + EventRepresentation onlineRefreshEvent = events.poll(); + EventAssertion.assertSuccess(onlineRefreshEvent) + .type(EventType.REFRESH_TOKEN) + .clientId("offline-client") + .userId(userId) + .sessionId(onlineSessionId) + .details(Details.TOKEN_ID, renewedOnlineAccessToken.getId()) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) + .details(Details.REFRESH_TOKEN_ID, onlineRefreshToken.getId()); + + // check offline token can be refreshed + offlineTokenResponse = oauth.doRefreshTokenRequest(offlineTokenResponse.getRefreshToken()); + assertRefreshToken(offlineTokenResponse, TokenUtil.TOKEN_TYPE_OFFLINE); + AccessToken renewedOfflineAccessToken = oauth.verifyToken(offlineTokenResponse.getAccessToken()); + + EventRepresentation offlineRefreshEvent = events.poll(); + EventAssertion.assertSuccess(offlineRefreshEvent) + .type(EventType.REFRESH_TOKEN) + .clientId("offline-client") + .userId(userId) + .sessionId(offlineSessionId) + .details(Details.TOKEN_ID, renewedOfflineAccessToken.getId()) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) + .details(Details.REFRESH_TOKEN_ID, offlineRefreshToken.getId()); + } + + @Test + public void offlineTokenDirectGrantFlow() { + setupCustomerUserRoles(); + + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.client("offline-client", "secret1"); + AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password"); + Assertions.assertNull(tokenResponse.getErrorDescription()); + AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString = tokenResponse.getRefreshToken(); + RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); + + EventRepresentation loginEvent = events.poll(); + EventAssertion.assertSuccess(loginEvent) + .type(EventType.LOGIN) + .clientId("offline-client") + .userId(userId) + .sessionId(token.getSessionId()) + .details(Details.GRANT_TYPE, OAuth2Constants.PASSWORD) + .details(Details.TOKEN_ID, token.getId()) + .details(Details.REFRESH_TOKEN_ID, offlineToken.getId()) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) + .details(Details.USERNAME, "test-user@localhost"); + + Assertions.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); + Assertions.assertNull(offlineToken.getExp()); + + // check only the offline session is created + checkNumberOfSessions(userId, "offline-client", offlineToken.getSessionId(), 0, 1); + + // refresh token + testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), userId); + + // Assert same token can be refreshed again + testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), userId); + } + + @Test + public void offlineTokenDirectGrantFlowWithRefreshTokensRevoked() { + setupCustomerUserRoles(); + realm.updateWithCleanup(r -> r.revokeRefreshToken(true)); + + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.client("offline-client", "secret1"); + AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password"); + + AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString = tokenResponse.getRefreshToken(); + RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); + + EventRepresentation loginEvent = events.poll(); + EventAssertion.assertSuccess(loginEvent) + .type(EventType.LOGIN) + .clientId("offline-client") + .userId(userId) + .sessionId(token.getSessionId()) + .details(Details.GRANT_TYPE, OAuth2Constants.PASSWORD) + .details(Details.TOKEN_ID, token.getId()) + .details(Details.REFRESH_TOKEN_ID, offlineToken.getId()) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) + .details(Details.USERNAME, "test-user@localhost"); + + Assertions.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); + Assertions.assertNull(offlineToken.getExp()); + + String offlineTokenString2 = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), userId); + RefreshToken offlineToken2 = oauth.parseRefreshToken(offlineTokenString2); + + // Clear the events queue to prevent any pollution from time-shifted events + // generated inside testRefreshWithOfflineToken + events.clear(); + + // Assert second refresh with same refresh token will fail + AccessTokenResponse response = oauth.doRefreshTokenRequest(offlineTokenString); + Assertions.assertEquals(400, response.getStatusCode()); + EventRepresentation refreshEvent = events.poll(); + EventAssertion.assertError(refreshEvent) + .type(EventType.REFRESH_TOKEN_ERROR) + .clientId("offline-client") + .userId(null) + .error(Errors.INVALID_TOKEN) + .sessionId(token.getSessionId()) + .details(Details.REFRESH_TOKEN_ID, offlineToken.getId()); + + // Refresh with new refreshToken fails as well (client session was invalidated because of attempt to refresh with revoked refresh token) + AccessTokenResponse response2 = oauth.doRefreshTokenRequest(offlineTokenString2); + Assertions.assertEquals(400, response2.getStatusCode()); + EventRepresentation refreshEvent2 = events.poll(); + EventAssertion.assertError(refreshEvent2) + .type(EventType.REFRESH_TOKEN_ERROR) + .clientId("offline-client") + .userId(null) + .error(Errors.INVALID_TOKEN) + .sessionId(offlineToken2.getSessionId()) + .details(Details.REFRESH_TOKEN_ID, offlineToken2.getId()); + + realm.updateWithCleanup(r -> r.revokeRefreshToken(false)); + } + + @Test + public void offlineTokenServiceAccountFlow() { + setupCustomerUserRoles(); + + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.client("offline-client", "secret1"); + AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); + + AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString = tokenResponse.getRefreshToken(); + RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); + + EventRepresentation loginEvent = events.poll(); + EventAssertion.assertSuccess(loginEvent) + .type(EventType.CLIENT_LOGIN) + .clientId("offline-client") + .userId(serviceAccountUserId) + .sessionId(token.getSessionId()) + .details(Details.TOKEN_ID, token.getId()) + .details(Details.REFRESH_TOKEN_ID, offlineToken.getId()) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) + .details(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client"); + + Assertions.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); + Assertions.assertNull(offlineToken.getExp()); + + // check only the offline session is created + checkNumberOfSessions(serviceAccountUserId, "offline-client", offlineToken.getSessionId(), 0, 1); + + testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId); + + // Now retrieve another offline token and verify that previous offline token is still valid + tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); + + AccessToken token2 = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString2 = tokenResponse.getRefreshToken(); + RefreshToken offlineToken2 = oauth.parseRefreshToken(offlineTokenString2); + + EventRepresentation loginEvent2 = events.poll(); + EventAssertion.assertSuccess(loginEvent2) + .type(EventType.CLIENT_LOGIN) + .clientId("offline-client") + .userId(serviceAccountUserId) + .sessionId(token2.getSessionId()) + .details(Details.TOKEN_ID, token2.getId()) + .details(Details.REFRESH_TOKEN_ID, offlineToken2.getId()) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) + .details(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client"); + + // check only the offline session is created + checkNumberOfSessions(serviceAccountUserId, "offline-client", offlineToken2.getSessionId(), 0, 1); + + // Refresh with both offline tokens is fine + testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId); + testRefreshWithOfflineToken(token2, offlineToken2, offlineTokenString2, token2.getSessionId(), serviceAccountUserId); + } + + + @Test + public void offlineTokenAllowedWithCompositeRole() { + setupCustomerUserRoles(); + RealmResource appRealm = adminClient.realm("test"); + UserResource testUser = findUserByUsernameId(appRealm, "test-user@localhost"); + RoleRepresentation offlineAccess = findRealmRoleByName(adminClient.realm("test"), + Constants.OFFLINE_ACCESS_ROLE).toRepresentation(); + + // Grant offline_access role indirectly through composite role + appRealm.roles().create(RoleConfigBuilder.create().name("composite").build()); + RoleResource roleResource = appRealm.roles().get("composite"); + roleResource.addComposites(Collections.singletonList(offlineAccess)); + + testUser.roles().realmLevel().remove(Collections.singletonList(offlineAccess)); + testUser.roles().realmLevel().add(Collections.singletonList(roleResource.toRepresentation())); + + // Integration test + offlineTokenDirectGrantFlow(); + + // Revert changes + testUser.roles().realmLevel().remove(Collections.singletonList(appRealm.roles().get("composite").toRepresentation())); + appRealm.roles().get("composite").remove(); + testUser.roles().realmLevel().add(Collections.singletonList(offlineAccess)); + } + + /** + * KEYCLOAK-4201 + * + */ + @Test + public void offlineTokenAdminRESTAccess() { + // Grant "view-realm" role to user + RealmResource appRealm = adminClient.realm("test"); + ClientResource realmMgmt = AdminApiUtil.findClientByClientId(appRealm, Constants.REALM_MANAGEMENT_CLIENT_ID); + assert realmMgmt != null; + String realmMgmtUuid = realmMgmt.toRepresentation().getId(); + RoleRepresentation roleRep = realmMgmt.roles().get(AdminRoles.VIEW_REALM).toRepresentation(); + + UserResource testUser = findUserByUsernameId(appRealm, "test-user@localhost"); + testUser.roles().clientLevel(realmMgmtUuid).add(Collections.singletonList(roleRep)); + + try { + // Login with offline token now + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.client("offline-client", "secret1"); + AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password"); + + events.clear(); + + // Set the time offset, so that "normal" userSession expires + timeOffSet.set(86400); + + // Remove expired sessions. This will remove "normal" userSession + runOnServer.run(session -> { + session.getProvider(UserSessionPersisterProvider.class).removeExpired(session.getContext().getRealm()); + }); + + // Refresh with the offline token + tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); + Assertions.assertNull(tokenResponse.getError(), "received error " + tokenResponse.getError() + ", " + tokenResponse.getErrorDescription()); + + // Use accessToken to admin REST request + try (Keycloak offlineTokenAdmin = adminClientFactory.create() + .realm("master") + .authorization(tokenResponse.getAccessToken()) + .clientId(Constants.ADMIN_CLI_CLIENT_ID) + .build()) { + RealmRepresentation testRealm = offlineTokenAdmin.realm("test").toRepresentation(); + Assertions.assertNotNull(testRealm); + } + } finally { + // clean up the admin role + testUser.roles().clientLevel(realmMgmtUuid).remove(Collections.singletonList(roleRep)); + } + } + + // KEYCLOAK-4525 + @Test + public void offlineTokenRemoveClientWithTokens() { + // Create new client + RealmResource appRealm = adminClient.realm("test"); + + ClientRepresentation clientRep = ClientConfigBuilder.create().clientId("offline-client-2") + .id(KeycloakModelUtils.generateId()) + .directAccessGrantsEnabled(true) + .secret("secret1").build(); + + appRealm.clients().create(clientRep).close(); + + // Direct grant login requesting offline token + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.client("offline-client-2", "secret1"); + AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password"); + Assertions.assertNull(tokenResponse.getErrorDescription()); + AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString = tokenResponse.getRefreshToken(); + RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); + + EventRepresentation loginEvent = events.poll(); + EventAssertion.assertSuccess(loginEvent) + .type(EventType.LOGIN) + .clientId("offline-client-2") + .userId(userId) + .sessionId(token.getSessionId()) + .details(Details.GRANT_TYPE, OAuth2Constants.PASSWORD) + .details(Details.TOKEN_ID, token.getId()) + .details(Details.REFRESH_TOKEN_ID, offlineToken.getId()) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) + .details(Details.USERNAME, "test-user@localhost"); + + // Confirm that offline-client-2 token was granted + List> userConsents = AccountHelper.getUserConsents(adminClient.realm("test"), "test-user@localhost"); + + String clientId2 = "", offlineAdditionalGrant = ""; + for (Map consent : userConsents) { + if (consent.get("clientId").equals("offline-client-2")) { + clientId2 = String.valueOf(consent.get("clientId")); + //noinspection unchecked + offlineAdditionalGrant = String.valueOf((((List>) consent.get("additionalGrants")).get(0)).get("key")); + } + } + + assertEquals("offline-client-2", clientId2); + assertEquals("Offline Token", offlineAdditionalGrant); + + // Now remove the client + ClientResource offlineTokenClient2 = AdminApiUtil.findClientByClientId(appRealm, "offline-client-2" ); + assert offlineTokenClient2 != null; + offlineTokenClient2.remove(); + + // Confirm that offline-client-2 token was deleted + assertNull(AdminApiUtil.findClientByClientId(appRealm, "offline-client-2")); + + // Login as admin and see consents of user + UserResource user = AdminApiUtil.findUserByUsernameId(appRealm, "test-user@localhost"); + List> consents = user.getConsents(); + for (Map consent : consents) { + assertNotEquals("offline-client-2", consent.get("clientId")); + } + } + + + @Test + public void offlineTokenRequest_ClientES256_RealmPS256() throws Exception { + conductOfflineTokenRequest(Constants.INTERNAL_SIGNATURE_ALGORITHM, Algorithm.ES256, Algorithm.PS256); + } + + @Test + public void offlineTokenRequest_ClientPS256_RealmES256() throws Exception { + conductOfflineTokenRequest(Constants.INTERNAL_SIGNATURE_ALGORITHM, Algorithm.PS256, Algorithm.ES256); + } + + private void setupCustomerUserRoles() { + String testAppClientUuid = realm.admin().clients().findByClientId("test-app").get(0).getId(); + ClientResource testAppClient = realm.admin().clients().get(testAppClientUuid); + + try { + RoleRepresentation customerUserRole = new RoleRepresentation(); + customerUserRole.setName("customer-user"); + testAppClient.roles().create(customerUserRole); + } catch (Exception e) { + // Role already exists + } + + RoleRepresentation customerUserRole = testAppClient.roles().get("customer-user").toRepresentation(); + + // Assign to test-user + UserResource testUser = realm.admin().users().get(userId); + testUser.roles().clientLevel(testAppClientUuid).add(Collections.singletonList(customerUserRole)); + + // Assign to service account + UserResource serviceAccountUser = realm.admin().users().get(serviceAccountUserId); + serviceAccountUser.roles().clientLevel(testAppClientUuid).add(Collections.singletonList(customerUserRole)); + + RoleRepresentation offlineAccessRole = realm.admin().roles().get(OAuth2Constants.OFFLINE_ACCESS).toRepresentation(); + RoleRepresentation userRole = realm.admin().roles().get("user").toRepresentation(); + serviceAccountUser.roles().realmLevel().add(java.util.Arrays.asList(offlineAccessRole, userRole)); + } + + + private void checkNumberOfSessions(String userId, String clientId, String sessionId, int onlineSessions, int offlineSessions) { + RealmResource realm = adminClient.realm("test"); + String clientUuid = Objects.requireNonNull(AdminApiUtil.findClientByClientId(realm, clientId)).toRepresentation().getId(); + Assertions.assertEquals(onlineSessions, realm.users().get(userId).getUserSessions() + .stream().filter(s -> sessionId.equals(s.getId())).count()); + Assertions.assertEquals(offlineSessions, realm.users().get(userId).getOfflineSessions(clientUuid) + .stream().filter(s -> sessionId.equals(s.getId())).count()); + } + + + private String testRefreshWithOfflineToken(AccessToken oldToken, RefreshToken offlineToken, String offlineTokenString, + final String sessionId, String userId) { + // Change offset to big value to ensure userSession expired + timeOffSet.set(99999); + assertFalse(oldToken.isActive()); + assertTrue(offlineToken.isActive()); + + // Assert userSession expired + 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) { + } + + AccessTokenResponse response = oauth.doRefreshTokenRequest(offlineTokenString); + AccessToken refreshedToken = oauth.verifyToken(response.getAccessToken()); + Assertions.assertEquals(200, response.getStatusCode()); + AccessTokenContext ctx = runOnServer.fetch(session -> { + return session.getProvider(TokenContextEncoderProvider.class) + .getTokenContextFromTokenId(refreshedToken.getId()); + }, AccessTokenContext.class); + Assertions.assertEquals(AccessTokenContext.SessionType.OFFLINE, ctx.getSessionType()); + Assertions.assertEquals(AccessTokenContext.TokenType.REGULAR, ctx.getTokenType()); + Assertions.assertEquals(OAuth2Constants.REFRESH_TOKEN, ctx.getGrantType()); + + // Assert new refreshToken in the response + String newRefreshToken = response.getRefreshToken(); + RefreshToken newRefreshTokenFull = oauth.parseRefreshToken(newRefreshToken); + Assertions.assertNotNull(newRefreshToken); + Assertions.assertNotEquals(oldToken.getId(), refreshedToken.getId()); + + // scope parameter either does not exist either contains offline_access + assertTrue(refreshedToken.getScope().contains(OAuth2Constants.OFFLINE_ACCESS)); + + // Assert refresh token scope parameter contains "offline_access" + assertTrue(newRefreshTokenFull.getScope().contains(OAuth2Constants.OFFLINE_ACCESS)); + Assertions.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, newRefreshTokenFull.getType()); + + Assertions.assertEquals(userId, refreshedToken.getSubject()); + + assertTrue(refreshedToken.getRealmAccess().isUserInRole("user")); + assertTrue(refreshedToken.getRealmAccess().isUserInRole(Constants.OFFLINE_ACCESS_ROLE)); + + Assertions.assertEquals(1, refreshedToken.getResourceAccess("test-app").getRoles().size()); + assertTrue(refreshedToken.getResourceAccess("test-app").isUserInRole("customer-user")); + + EventRepresentation refreshEvent = events.poll(); + EventAssertion.assertSuccess(refreshEvent) + .type(EventType.REFRESH_TOKEN) + .clientId("offline-client") + .userId(userId) + .sessionId(sessionId) + .details(Details.REFRESH_TOKEN_ID, offlineToken.getId()) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE); + Assertions.assertNotEquals(oldToken.getId(), refreshEvent.getDetails().get(Details.TOKEN_ID)); + + timeOffSet.set(0); + return newRefreshToken; + } + + // 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 void conductOfflineTokenRequest(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception { + try { + /// Realm Setting is used for ID Token Signature Algorithm + setupCustomerUserRoles(); + changeRealmTokenSignatureProvider(expectedIdTokenAlg); + changeClientAccessTokenSignatureProvider(AdminApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client"), expectedAccessAlg); + offlineTokenRequest(expectedRefreshAlg, expectedAccessAlg, expectedIdTokenAlg); + offlineTokenRequestWithScopeParameter(expectedRefreshAlg, expectedAccessAlg, expectedIdTokenAlg); + } finally { + changeRealmTokenSignatureProvider(Algorithm.RS256); + changeClientAccessTokenSignatureProvider(AdminApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client"), Algorithm.RS256); + } + } + + + private void offlineTokenRequest(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception { + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + oauth.client("offline-client", "secret1"); + AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); + + JWSHeader header; + String idToken = tokenResponse.getIdToken(); + String accessToken = tokenResponse.getAccessToken(); + String refreshToken = tokenResponse.getRefreshToken(); + if (idToken != null) { + header = new JWSInput(idToken).getHeader(); + assertEquals(expectedIdTokenAlg, header.getAlgorithm().name()); + assertEquals("JWT", header.getType()); + assertNull(header.getContentType()); + } + if (accessToken != null) { + header = new JWSInput(accessToken).getHeader(); + assertEquals(expectedAccessAlg, header.getAlgorithm().name()); + assertEquals("JWT", header.getType()); + assertNull(header.getContentType()); + } + if (refreshToken != null) { + header = new JWSInput(refreshToken).getHeader(); + assertEquals(expectedRefreshAlg, header.getAlgorithm().name()); + assertEquals("JWT", header.getType()); + assertNull(header.getContentType()); + } + + AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString = tokenResponse.getRefreshToken(); + RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); + + EventRepresentation clientLoginEvent = events.poll(); + EventAssertion.assertSuccess(clientLoginEvent) + .type(EventType.CLIENT_LOGIN) + .clientId("offline-client") + .userId(serviceAccountUserId) + .sessionId(token.getSessionId()) + .details(Details.TOKEN_ID, token.getId()) + .details(Details.REFRESH_TOKEN_ID, offlineToken.getId()) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) + .details(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client"); + + Assertions.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); + Assertions.assertNull(offlineToken.getExp()); + + testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId); + + // Now retrieve another offline token and decode that previous offline token is still valid + tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); + + AccessToken token2 = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString2 = tokenResponse.getRefreshToken(); + RefreshToken offlineToken2 = oauth.parseRefreshToken(offlineTokenString2); + + EventRepresentation clientLoginEvent2 = events.poll(); + EventAssertion.assertSuccess(clientLoginEvent2) + .type(EventType.CLIENT_LOGIN) + .clientId("offline-client") + .userId(serviceAccountUserId) + .sessionId(token2.getSessionId()) + .details(Details.TOKEN_ID, token2.getId()) + .details(Details.REFRESH_TOKEN_ID, offlineToken2.getId()) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) + .details(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client"); + + // Refresh with both offline tokens is fine + testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId); + testRefreshWithOfflineToken(token2, offlineToken2, offlineTokenString2, token2.getSessionId(), serviceAccountUserId); + } + + + private void offlineTokenRequestWithScopeParameter(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception { + ClientScopeRepresentation phoneScope = adminClient.realm("test").clientScopes().findAll().stream().filter((ClientScopeRepresentation clientScope) ->"phone".equals(clientScope.getName())).findFirst().get(); + ClientResource offlineClientResource = AdminApiUtil.findClientByClientId(adminClient.realm("test"), oauth.getClientId()); + offlineClientResource.addOptionalClientScope(phoneScope.getId()); + try { + oauth.scope(OAuth2Constants.OFFLINE_ACCESS+" phone"); + oauth.client("offline-client", "secret1"); + AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); + + JWSHeader header; + String idToken = tokenResponse.getIdToken(); + String accessToken = tokenResponse.getAccessToken(); + String refreshToken = tokenResponse.getRefreshToken(); + if (idToken != null) { + header = new JWSInput(idToken).getHeader(); + assertEquals(expectedIdTokenAlg, header.getAlgorithm().name()); + assertEquals("JWT", header.getType()); + assertNull(header.getContentType()); + } + if (accessToken != null) { + header = new JWSInput(accessToken).getHeader(); + assertEquals(expectedAccessAlg, header.getAlgorithm().name()); + assertEquals("JWT", header.getType()); + assertNull(header.getContentType()); + } + if (refreshToken != null) { + header = new JWSInput(refreshToken).getHeader(); + assertEquals(expectedRefreshAlg, header.getAlgorithm().name()); + assertEquals("JWT", header.getType()); + assertNull(header.getContentType()); + } + + AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); + String offlineTokenString = tokenResponse.getRefreshToken(); + RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); + + EventRepresentation clientLoginEvent3 = events.poll(); + EventAssertion.assertSuccess(clientLoginEvent3) + .type(EventType.CLIENT_LOGIN) + .clientId("offline-client") + .userId(serviceAccountUserId) + .sessionId(token.getSessionId()) + .details(Details.TOKEN_ID, token.getId()) + .details(Details.REFRESH_TOKEN_ID, offlineToken.getId()) + .details(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) + .details(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client"); + + Assertions.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); + Assertions.assertNull(offlineToken.getExp()); + } finally { + // remove the phone scope + offlineClientResource.removeOptionalClientScope(phoneScope.getId()); + } + } + + private void changeRealmTokenSignatureProvider(String toSigAlgName) { + RealmRepresentation rep = realm.admin().toRepresentation(); + rep.setDefaultSignatureAlgorithm(toSigAlgName); + realm.admin().update(rep); + } + + private void changeClientAccessTokenSignatureProvider(ClientResource client, String toSigAlgName) { + ClientRepresentation clientRep = client.toRepresentation(); + clientRep.getAttributes().put(OIDCConfigAttributes.ACCESS_TOKEN_SIGNED_RESPONSE_ALG, toSigAlgName); + client.update(clientRep); + } + + 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", + "CODE_TO_TOKEN_ERROR", + "REFRESH_TOKEN", + "REFRESH_TOKEN_ERROR", + "CLIENT_LOGIN" + )); + }); + + // 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"); + + builder.addUser("keycloak-user@localhost") + .name("Keycloak", "User") // <-- Add this to satisfy VERIFY_PROFILE + .email("keycloak-user@localhost") + .emailVerified(true) + .password("password") + .roles("user"); + + 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 + ); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/OfflineTokenRefreshTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/OfflineTokenRefreshTest.java new file mode 100644 index 00000000000..da64f1ad8c8 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/OfflineTokenRefreshTest.java @@ -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 + ); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/OfflineTokenSessionManagementTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/OfflineTokenSessionManagementTest.java new file mode 100644 index 00000000000..e4bb8904af7 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/OfflineTokenSessionManagementTest.java @@ -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 + ); + } + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIMapperEmptyConfigTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIMapperEmptyConfigTest.java new file mode 100644 index 00000000000..698f433e655 --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIMapperEmptyConfigTest.java @@ -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"); + } +} diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java index be2fc70dd2a..136ad8989fb 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java @@ -568,12 +568,9 @@ public class OID4VCIssuerWellKnownProviderTest extends OID4VCIssuerTestBase { assertEquals(credentialDefinitionTypes.size(), supportedConfig.getCredentialDefinition().getType().size()); } - List 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 signingAlgsSupported = supportedConfig.getCredentialSigningAlgValuesSupported(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BruteForceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BruteForceTest.java index 0ae50a8a2e9..fd7ecd75813 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BruteForceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BruteForceTest.java @@ -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(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java deleted file mode 100755 index 79718337bfb..00000000000 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java +++ /dev/null @@ -1,1644 +0,0 @@ -/* - * Copyright 2016 Red Hat, Inc. and/or its affiliates - * and other contributors as indicated by the @author tags. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.keycloak.testsuite.oauth; - -import java.util.Collections; -import java.util.List; -import java.util.Map; - -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.admin.client.resource.RoleResource; -import org.keycloak.admin.client.resource.UserResource; -import org.keycloak.common.Profile; -import org.keycloak.common.constants.ServiceAccountConstants; -import org.keycloak.crypto.Algorithm; -import org.keycloak.events.Details; -import org.keycloak.events.Errors; -import org.keycloak.events.EventType; -import org.keycloak.jose.jws.JWSHeader; -import org.keycloak.jose.jws.JWSInput; -import org.keycloak.models.AdminRoles; -import org.keycloak.models.AuthenticatedClientSessionModel; -import org.keycloak.models.ClientModel; -import org.keycloak.models.Constants; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.models.UserSessionModel; -import org.keycloak.models.utils.KeycloakModelUtils; -import org.keycloak.models.utils.SessionTimeoutHelper; -import org.keycloak.protocol.oidc.OIDCConfigAttributes; -import org.keycloak.protocol.oidc.encode.AccessTokenContext; -import org.keycloak.representations.AccessToken; -import org.keycloak.representations.RefreshToken; -import org.keycloak.representations.idm.ClientRepresentation; -import org.keycloak.representations.idm.ClientScopeRepresentation; -import org.keycloak.representations.idm.EventRepresentation; -import org.keycloak.representations.idm.RealmRepresentation; -import org.keycloak.representations.idm.RoleRepresentation; -import org.keycloak.representations.idm.UserRepresentation; -import org.keycloak.testsuite.AbstractKeycloakTest; -import org.keycloak.testsuite.AssertEvents; -import org.keycloak.testsuite.ProfileAssume; -import org.keycloak.testsuite.admin.ApiUtil; -import org.keycloak.testsuite.auth.page.AuthRealm; -import org.keycloak.testsuite.pages.LoginPage; -import org.keycloak.testsuite.updaters.ClientAttributeUpdater; -import org.keycloak.testsuite.updaters.RealmAttributeUpdater; -import org.keycloak.testsuite.util.AccountHelper; -import org.keycloak.testsuite.util.ClientBuilder; -import org.keycloak.testsuite.util.ClientManager; -import org.keycloak.testsuite.util.RealmBuilder; -import org.keycloak.testsuite.util.RealmManager; -import org.keycloak.testsuite.util.RoleBuilder; -import org.keycloak.testsuite.util.TokenSignatureUtil; -import org.keycloak.testsuite.util.UserBuilder; -import org.keycloak.testsuite.util.oauth.AccessTokenResponse; -import org.keycloak.testsuite.util.oauth.IntrospectionResponse; -import org.keycloak.testsuite.util.oauth.LogoutResponse; -import org.keycloak.testsuite.utils.tls.TLSUtils; -import org.keycloak.util.TokenUtil; - -import com.fasterxml.jackson.databind.JsonNode; -import org.jboss.arquillian.graphene.page.Page; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; - -import static org.keycloak.testsuite.AbstractAdminTest.loadJson; -import static org.keycloak.testsuite.Assert.assertExpiration; -import static org.keycloak.testsuite.admin.ApiUtil.findRealmRoleByName; -import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername; -import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsernameId; -import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; -import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot; -import static org.keycloak.testsuite.util.oauth.OAuthClient.APP_ROOT; - -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.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -/** - * @author Marek Posolda - */ -public class OfflineTokenTest extends AbstractKeycloakTest { - - private static String userId; - private static String offlineClientAppUri; - private static String serviceAccountUserId; - - @Page - protected LoginPage loginPage; - - @Rule - public AssertEvents events = new AssertEvents(this); - - @Override - public void beforeAbstractKeycloakTest() throws Exception { - super.beforeAbstractKeycloakTest(); - } - - @Before - public void clientConfiguration() { - userId = findUserByUsername(adminClient.realm("test"), "test-user@localhost").getId(); - oauth.client("test-app"); - } - - @Override - public void addTestRealms(List testRealms) { - - RealmRepresentation realmRepresentation = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); - - RealmBuilder realm = RealmBuilder.edit(realmRepresentation) - .accessTokenLifespan(10) - .ssoSessionIdleTimeout(30) - .testEventListener(); - - offlineClientAppUri = APP_ROOT + "/offline-client"; - - ClientRepresentation app = ClientBuilder.create().clientId("offline-client") - .id(KeycloakModelUtils.generateId()) - .adminUrl(offlineClientAppUri) - .redirectUris(offlineClientAppUri) - .directAccessGrants() - .serviceAccountsEnabled(true) - .attribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, "true") - .secret("secret1").build(); - - realm.client(app); - - UserRepresentation serviceAccountUser = UserBuilder.create() - .id(serviceAccountUserId) - .addRoles("user", "offline_access") - .role("test-app", "customer-user") - .username(ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + app.getClientId()) - .serviceAccountId(app.getClientId()).build(); - - realm.user(serviceAccountUser); - - testRealms.add(realm.build()); - - } - - @Override - public void importTestRealms() { - super.importTestRealms(); - serviceAccountUserId = adminClient.realm("test").users().search(ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client", true).get(0).getId(); - } - - @Test - public void offlineTokenDisabledForClient() { - // Remove offline-access scope from client - ClientScopeRepresentation offlineScope = adminClient.realm("test").clientScopes().findAll().stream() - .filter((ClientScopeRepresentation clientScope) -> OAuth2Constants.OFFLINE_ACCESS.equals(clientScope.getName())) - .findFirst().get(); - - ClientManager.realm(adminClient.realm("test")).clientId("offline-client") - .fullScopeAllowed(false) - .removeClientScope(offlineScope.getId(), false); - - - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - oauth.redirectUri(offlineClientAppUri); - oauth.openLoginForm(); - assertTrue(driver.getCurrentUrl().contains("error_description=Invalid+scopes")); - - // Revert changes - ClientManager.realm(adminClient.realm("test")).clientId("offline-client") - .fullScopeAllowed(true) - .addClientScope(offlineScope.getId(), false); - - } - - @Test - public void offlineTokenUserNotAllowed() { - String userId = findUserByUsername(adminClient.realm("test"), "keycloak-user@localhost").getId(); - - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - oauth.redirectUri(offlineClientAppUri); - oauth.doLogin("keycloak-user@localhost", "password"); - - EventRepresentation loginEvent = events.expectLogin() - .client("offline-client") - .user(userId) - .detail(Details.REDIRECT_URI, offlineClientAppUri) - .assertEvent(); - - String sessionId = loginEvent.getSessionId(); - String codeId = loginEvent.getDetails().get(Details.CODE_ID); - - String code = oauth.parseLoginResponse().getCode(); - - AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code); - - assertEquals(400, tokenResponse.getStatusCode()); - assertEquals("not_allowed", tokenResponse.getError()); - - events.expectCodeToToken(codeId, sessionId) - .client("offline-client") - .user(userId) - .error("not_allowed") - .clearDetails() - .assertEvent(); - } - - @Test - public void offlineTokenBrowserFlow() { - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - oauth.redirectUri(offlineClientAppUri); - oauth.doLogin("test-user@localhost", "password"); - - EventRepresentation loginEvent = events.expectLogin() - .client("offline-client") - .detail(Details.REDIRECT_URI, offlineClientAppUri) - .assertEvent(); - - final String sessionId = loginEvent.getSessionId(); - String codeId = loginEvent.getDetails().get(Details.CODE_ID); - - String code = oauth.parseLoginResponse().getCode(); - - AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code); - AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); - String offlineTokenString = tokenResponse.getRefreshToken(); - RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); - - events.expectCodeToToken(codeId, sessionId) - .client("offline-client") - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .assertEvent(); - - assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); - Assert.assertNull(offlineToken.getExp()); - - AccessTokenContext ctx = testingClient.testing("test").getTokenContext(token.getId()); - Assert.assertEquals(AccessTokenContext.SessionType.OFFLINE, ctx.getSessionType()); - Assert.assertEquals(AccessTokenContext.TokenType.REGULAR, ctx.getTokenType()); - Assert.assertEquals(OAuth2Constants.AUTHORIZATION_CODE, ctx.getGrantType()); - - assertTrue(tokenResponse.getScope().contains(OAuth2Constants.OFFLINE_ACCESS)); - - // check only offline session is created - checkNumberOfSessions(userId, "offline-client", offlineToken.getSessionId(), 0, 1); - - String newRefreshTokenString = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, sessionId, userId); - - // Change offset to very big value to ensure offline session expires - setTimeOffset(3000000); - - AccessTokenResponse response = oauth.doRefreshTokenRequest(newRefreshTokenString); - RefreshToken newRefreshToken = oauth.parseRefreshToken(newRefreshTokenString); - Assert.assertEquals(400, response.getStatusCode()); - assertEquals("invalid_grant", response.getError()); - - events.assertRefreshTokenErrorAndMaybeSessionExpired(newRefreshToken.getSessionId(), loginEvent.getUserId(), "offline-client"); - - setTimeOffset(0); - } - - @Test - public void onlineOfflineTokenBrowserFlow() { - // request an online token for the client - oauth.scope(null); - oauth.client("offline-client", "secret1"); - oauth.redirectUri(offlineClientAppUri); - oauth.doLogin("test-user@localhost", "password"); - EventRepresentation loginEvent = events.expectLogin() - .client("offline-client") - .detail(Details.REDIRECT_URI, offlineClientAppUri) - .assertEvent(); - final String sessionId = loginEvent.getSessionId(); - String codeId = loginEvent.getDetails().get(Details.CODE_ID); - AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(oauth.parseLoginResponse().getCode()); - RefreshToken onlineToken = assertRefreshToken(tokenResponse, TokenUtil.TOKEN_TYPE_REFRESH); - AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); - events.expectCodeToToken(codeId, sessionId) - .client("offline-client") - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) - .assertEvent(); - assertEquals(TokenUtil.TOKEN_TYPE_REFRESH, onlineToken.getType()); - Assert.assertNotNull(onlineToken.getExp()); - // request an offline token for the same client - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.openLoginForm(); - events.expectLogin() - .client("offline-client") - .detail(Details.REDIRECT_URI, offlineClientAppUri) - .assertEvent(); - AccessTokenResponse tokenOfflineResponse = oauth.doAccessTokenRequest( - oauth.parseLoginResponse().getCode()); - RefreshToken offlineToken = assertRefreshToken(tokenOfflineResponse, TokenUtil.TOKEN_TYPE_OFFLINE); - events.expectCodeToToken(codeId, sessionId) - .client("offline-client") - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .assertEvent(); - assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); - Assert.assertNull(offlineToken.getExp()); - assertTrue(tokenOfflineResponse.getScope().contains(OAuth2Constants.OFFLINE_ACCESS)); - // check both sessions are created - checkNumberOfSessions(userId, "offline-client", onlineToken.getSessionId(), 1, 1); - // check online token can be refreshed - tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); - assertRefreshToken(tokenResponse, TokenUtil.TOKEN_TYPE_REFRESH); - events.expectRefresh(token.getId(), sessionId) - .client("offline-client") - .user(userId) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) - .detail(Details.REFRESH_TOKEN_ID, onlineToken.getId()) - .assertEvent(); - // check offline token can be refreshed - tokenOfflineResponse = oauth.doRefreshTokenRequest(tokenOfflineResponse.getRefreshToken()); - assertRefreshToken(tokenOfflineResponse, TokenUtil.TOKEN_TYPE_OFFLINE); - events.expectRefresh(token.getId(), sessionId) - .client("offline-client") - .user(userId) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) - .assertEvent(); - } - - private String testRefreshWithOfflineToken(AccessToken oldToken, RefreshToken offlineToken, String offlineTokenString, - final String sessionId, String userId) { - // Change offset to big value to ensure userSession expired - setTimeOffset(99999); - assertFalse(oldToken.isActive()); - assertTrue(offlineToken.isActive()); - - // Assert userSession expired - testingClient.testing().removeExpired("test"); - try { - testingClient.testing().removeUserSession("test", sessionId); - } catch (NotFoundException nfe) { - // Ignore - } - - AccessTokenResponse response = oauth.doRefreshTokenRequest(offlineTokenString); - AccessToken refreshedToken = oauth.verifyToken(response.getAccessToken()); - Assert.assertEquals(200, response.getStatusCode()); - AccessTokenContext ctx = testingClient.testing("test").getTokenContext(refreshedToken.getId()); - Assert.assertEquals(AccessTokenContext.SessionType.OFFLINE, ctx.getSessionType()); - Assert.assertEquals(AccessTokenContext.TokenType.REGULAR, ctx.getTokenType()); - Assert.assertEquals(OAuth2Constants.REFRESH_TOKEN, ctx.getGrantType()); - - // Assert new refreshToken in the response - String newRefreshToken = response.getRefreshToken(); - RefreshToken newRefreshTokenFull = oauth.parseRefreshToken(newRefreshToken); - Assert.assertNotNull(newRefreshToken); - Assert.assertNotEquals(oldToken.getId(), refreshedToken.getId()); - - // scope parameter either does not exist either contains offline_access - assertTrue(refreshedToken.getScope().contains(OAuth2Constants.OFFLINE_ACCESS)); - // Assert refresh token scope parameter contains "offline_access" - assertTrue(newRefreshTokenFull.getScope().contains(OAuth2Constants.OFFLINE_ACCESS)); - Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, newRefreshTokenFull.getType()); - - Assert.assertEquals(userId, refreshedToken.getSubject()); - - assertTrue(refreshedToken.getRealmAccess().isUserInRole("user")); - assertTrue(refreshedToken.getRealmAccess().isUserInRole(Constants.OFFLINE_ACCESS_ROLE)); - - Assert.assertEquals(1, refreshedToken.getResourceAccess("test-app").getRoles().size()); - assertTrue(refreshedToken.getResourceAccess("test-app").isUserInRole("customer-user")); - - EventRepresentation refreshEvent = events.expectRefresh(offlineToken.getId(), sessionId) - .client("offline-client") - .user(userId) - .removeDetail(Details.UPDATED_REFRESH_TOKEN_ID) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .assertEvent(); - Assert.assertNotEquals(oldToken.getId(), refreshEvent.getDetails().get(Details.TOKEN_ID)); - - setTimeOffset(0); - return newRefreshToken; - } - - private void checkNumberOfSessions(String userId, String clientId, String sessionId, int onlineSessions, int offlineSessions) { - RealmResource realm = adminClient.realm("test"); - String clientUuid = ApiUtil.findClientByClientId(realm, clientId).toRepresentation().getId(); - Assert.assertEquals(onlineSessions, realm.users().get(userId).getUserSessions() - .stream().filter(s -> sessionId.equals(s.getId())).count()); - Assert.assertEquals(offlineSessions, realm.users().get(userId).getOfflineSessions(clientUuid) - .stream().filter(s -> sessionId.equals(s.getId())).count()); - } - - @Test - public void offlineTokenDirectGrantFlow() { - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password"); - Assert.assertNull(tokenResponse.getErrorDescription()); - AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); - String offlineTokenString = tokenResponse.getRefreshToken(); - RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); - - events.expectLogin() - .client("offline-client") - .user(userId) - .session(token.getSessionId()) - .detail(Details.GRANT_TYPE, OAuth2Constants.PASSWORD) - .detail(Details.TOKEN_ID, token.getId()) - .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .detail(Details.USERNAME, "test-user@localhost") - .removeDetail(Details.CODE_ID) - .removeDetail(Details.REDIRECT_URI) - .removeDetail(Details.CONSENT) - .assertEvent(); - - Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); - Assert.assertNull(offlineToken.getExp()); - - // check only the offline session is created - checkNumberOfSessions(userId, "offline-client", offlineToken.getSessionId(), 0, 1); - - // refresh token - testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), userId); - - // Assert same token can be refreshed again - testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), userId); - } - - @Test - public void offlineTokenDirectGrantFlowWithRefreshTokensRevoked() { - RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(true); - - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password"); - - AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); - String offlineTokenString = tokenResponse.getRefreshToken(); - RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); - - events.expectLogin() - .client("offline-client") - .user(userId) - .session(token.getSessionId()) - .detail(Details.GRANT_TYPE, OAuth2Constants.PASSWORD) - .detail(Details.TOKEN_ID, token.getId()) - .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .detail(Details.USERNAME, "test-user@localhost") - .removeDetail(Details.CODE_ID) - .removeDetail(Details.REDIRECT_URI) - .removeDetail(Details.CONSENT) - .assertEvent(); - - Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); - Assert.assertNull(offlineToken.getExp()); - - String offlineTokenString2 = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), userId); - RefreshToken offlineToken2 = oauth.parseRefreshToken(offlineTokenString2); - - // Assert second refresh with same refresh token will fail - AccessTokenResponse response = oauth.doRefreshTokenRequest(offlineTokenString); - Assert.assertEquals(400, response.getStatusCode()); - events.expectRefresh(offlineToken.getId(), token.getSessionId()) - .client("offline-client") - .user((String) null) - .error(Errors.INVALID_TOKEN) - .clearDetails() - .assertEvent(); - - // Refresh with new refreshToken fails as well (client session was invalidated because of attempt to refresh with revoked refresh token) - AccessTokenResponse response2 = oauth.doRefreshTokenRequest(offlineTokenString2); - Assert.assertEquals(400, response2.getStatusCode()); - events.expectRefresh(offlineToken2.getId(), offlineToken2.getSessionId()) - .client("offline-client") - .user((String) null) - .error(Errors.INVALID_TOKEN) - .clearDetails() - .assertEvent(); - - RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false); - } - - @Test - public void offlineTokenServiceAccountFlow() { - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); - - AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); - String offlineTokenString = tokenResponse.getRefreshToken(); - RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); - - events.expectClientLogin() - .client("offline-client") - .user(serviceAccountUserId) - .session(token.getSessionId()) - .detail(Details.TOKEN_ID, token.getId()) - .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client") - .assertEvent(); - - Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); - Assert.assertNull(offlineToken.getExp()); - - // check only the offline session is created - checkNumberOfSessions(serviceAccountUserId, "offline-client", offlineToken.getSessionId(), 0, 1); - - testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId); - - // Now retrieve another offline token and verify that previous offline token is still valid - tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); - - AccessToken token2 = oauth.verifyToken(tokenResponse.getAccessToken()); - String offlineTokenString2 = tokenResponse.getRefreshToken(); - RefreshToken offlineToken2 = oauth.parseRefreshToken(offlineTokenString2); - - events.expectClientLogin() - .client("offline-client") - .user(serviceAccountUserId) - .session(token2.getSessionId()) - .detail(Details.TOKEN_ID, token2.getId()) - .detail(Details.REFRESH_TOKEN_ID, offlineToken2.getId()) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client") - .assertEvent(); - - // check only the offline session is created - checkNumberOfSessions(serviceAccountUserId, "offline-client", offlineToken2.getSessionId(), 0, 1); - - // Refresh with both offline tokens is fine - testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId); - testRefreshWithOfflineToken(token2, offlineToken2, offlineTokenString2, token2.getSessionId(), serviceAccountUserId); - } - - @Test - public void offlineTokenAllowedWithCompositeRole() { - RealmResource appRealm = adminClient.realm("test"); - UserResource testUser = findUserByUsernameId(appRealm, "test-user@localhost"); - RoleRepresentation offlineAccess = findRealmRoleByName(adminClient.realm("test"), - Constants.OFFLINE_ACCESS_ROLE).toRepresentation(); - - // Grant offline_access role indirectly through composite role - appRealm.roles().create(RoleBuilder.create().name("composite").build()); - RoleResource roleResource = appRealm.roles().get("composite"); - roleResource.addComposites(Collections.singletonList(offlineAccess)); - - testUser.roles().realmLevel().remove(Collections.singletonList(offlineAccess)); - testUser.roles().realmLevel().add(Collections.singletonList(roleResource.toRepresentation())); - - // Integration test - offlineTokenDirectGrantFlow(); - - // Revert changes - testUser.roles().realmLevel().remove(Collections.singletonList(appRealm.roles().get("composite").toRepresentation())); - appRealm.roles().get("composite").remove(); - testUser.roles().realmLevel().add(Collections.singletonList(offlineAccess)); - - } - - /** - * KEYCLOAK-4201 - * - */ - @Test - public void offlineTokenAdminRESTAccess() { - // Grant "view-realm" role to user - RealmResource appRealm = adminClient.realm("test"); - ClientResource realmMgmt = ApiUtil.findClientByClientId(appRealm, Constants.REALM_MANAGEMENT_CLIENT_ID); - String realmMgmtUuid = realmMgmt.toRepresentation().getId(); - RoleRepresentation roleRep = realmMgmt.roles().get(AdminRoles.VIEW_REALM).toRepresentation(); - - UserResource testUser = findUserByUsernameId(appRealm, "test-user@localhost"); - testUser.roles().clientLevel(realmMgmtUuid).add(Collections.singletonList(roleRep)); - - // Login with offline token now - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password"); - - events.clear(); - - // Set the time offset, so that "normal" userSession expires - setTimeOffset(86400); - - // Remove expired sessions. This will remove "normal" userSession - testingClient.testing().removeExpired("test"); - - // Refresh with the offline token - tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); - Assert.assertNull("received error " + tokenResponse.getError() + ", " + tokenResponse.getErrorDescription(), tokenResponse.getError()); - - // Use accessToken to admin REST request - try (Keycloak offlineTokenAdmin = Keycloak.getInstance(getAuthServerContextRoot() + "/auth", - AuthRealm.MASTER, Constants.ADMIN_CLI_CLIENT_ID, tokenResponse.getAccessToken(), TLSUtils.initializeTLS())) { - RealmRepresentation testRealm = offlineTokenAdmin.realm("test").toRepresentation(); - Assert.assertNotNull(testRealm); - } - } - - - // KEYCLOAK-4525 - @Test - public void offlineTokenRemoveClientWithTokens() { - // Create new client - RealmResource appRealm = adminClient.realm("test"); - - ClientRepresentation clientRep = ClientBuilder.create().clientId("offline-client-2") - .id(KeycloakModelUtils.generateId()) - .directAccessGrants() - .secret("secret1").build(); - - appRealm.clients().create(clientRep).close(); - - // Direct grant login requesting offline token - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client-2", "secret1"); - AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest("test-user@localhost", "password"); - Assert.assertNull(tokenResponse.getErrorDescription()); - AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); - String offlineTokenString = tokenResponse.getRefreshToken(); - RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); - - events.expectLogin() - .client("offline-client-2") - .user(userId) - .session(token.getSessionId()) - .detail(Details.GRANT_TYPE, OAuth2Constants.PASSWORD) - .detail(Details.TOKEN_ID, token.getId()) - .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .detail(Details.USERNAME, "test-user@localhost") - .removeDetail(Details.CODE_ID) - .removeDetail(Details.REDIRECT_URI) - .removeDetail(Details.CONSENT) - .assertEvent(); - - // Confirm that offline-client-2 token was granted - List> userConsents = AccountHelper.getUserConsents(adminClient.realm(TEST), "test-user@localhost"); - - String clientId2 = "", offlineAdditionalGrant = ""; - for (Map consent : userConsents) { - if (consent.get("clientId").equals("offline-client-2")) { - clientId2 = String.valueOf(consent.get("clientId")); - //noinspection unchecked - offlineAdditionalGrant = String.valueOf((((List>) consent.get("additionalGrants")).get(0)).get("key")); - } - } - - assertEquals("offline-client-2", clientId2); - assertEquals("Offline Token", offlineAdditionalGrant); - - // Now remove the client - ClientResource offlineTokenClient2 = ApiUtil.findClientByClientId(appRealm, "offline-client-2" ); - offlineTokenClient2.remove(); - - // Confirm that offline-client-2 token was deleted - assertNull(ApiUtil.findClientByClientId(appRealm, "offline-client-2")); - - // Login as admin and see consents of user - UserResource user = ApiUtil.findUserByUsernameId(appRealm, "test-user@localhost"); - List> consents = user.getConsents(); - for (Map consent : consents) { - assertNotEquals("offline-client-2", consent.get("clientId")); - } - } - - @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(offlineClientAppUri); - oauth.doLogin("test-user@localhost", "password"); - - EventRepresentation loginEvent = events.expectLogin() - .client("offline-client") - .detail(Details.REDIRECT_URI, offlineClientAppUri) - .assertEvent(); - - 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); - - events.expectCodeToToken(codeId, sessionId) - .client("offline-client") - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .assertEvent(); - - assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); - assertNull(offlineToken.getExp()); - - String offlineUserSessionId = testingClient.server().fetch((KeycloakSession session) -> - session.sessions().getOfflineUserSession(session.realms().getRealmByName("test"), offlineToken.getSessionId()).getId(), String.class); - - // logout offline session - LogoutResponse logoutResponse = oauth.doLogout(offlineTokenString); - assertTrue(logoutResponse.isSuccess()); - events.expectLogout(offlineUserSessionId) - .client("offline-client") - .removeDetail(Details.REDIRECT_URI) - .assertEvent(); - - // 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.expectLogin() - .client("offline-client") - .detail(Details.REDIRECT_URI, offlineClientAppUri) - .assertEvent(); - - codeId = loginEvent.getDetails().get(Details.CODE_ID); - - events.expectCodeToToken(codeId, offlineToken2.getSessionId()) - .client("offline-client") - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .assertEvent(); - - assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken2.getType()); - Assert.assertNull(offlineToken.getExp()); - - // Assert session changed - assertNotEquals(offlineToken.getSessionId(), offlineToken2.getSessionId()); - } - - // 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(); - RealmBuilder realmBuilder = RealmBuilder.create(); - realmBuilder.offlineSessionMaxLifespanEnabled(isEnabled).offlineSessionMaxLifespan(sessionMax).offlineSessionIdleTimeout(sessionIdle) - .clientOfflineSessionMaxLifespan(clientSessionMax).clientOfflineSessionIdleTimeout(clientSessionIdle); - adminClient.realm("test").update(realmBuilder.build()); - return prev; - } - - 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(); - RealmBuilder realmBuilder = RealmBuilder.create(); - realmBuilder.ssoSessionIdleTimeout(ssoSessionIdle).accessTokenLifespan(accessTokenLifespan); - adminClient.realm("test").update(realmBuilder.build()); - return prev; - } - - @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 + (ProfileAssume.isFeatureEnabled(Profile.Feature.PERSISTENT_USER_SESSIONS) ? 0 : SessionTimeoutHelper.IDLE_TIMEOUT_WINDOW_SECONDS) + 60); - } - - // Issue 13706 - @Test - public void offlineTokenReauthenticationWhenOfflinClientSessionExpired() throws Exception { - // expect that offline session expired by idle timeout - final int MAX_LIFESPAN = 360000; - final int IDLE_LIFESPAN = 900; - - getTestingClient().testing().setTestingInfinispanTimeService(); - - int[] prev = null; - try (RealmAttributeUpdater ignored = new RealmAttributeUpdater(adminClient.realm("test")).setSsoSessionIdleTimeout(900).update()) { - 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(APP_ROOT + "/auth"); - oauth.doLogin("test-user@localhost", "password"); - String code = oauth.parseLoginResponse().getCode(); - AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code); - assertRefreshToken(tokenResponse, TokenUtil.TOKEN_TYPE_REFRESH); - - // Step 2 - offline login with "offline-client" - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - oauth.redirectUri(offlineClientAppUri); - - 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" - setTimeOffset(800); - oauth.client("test-app", "password"); - oauth.redirectUri(APP_ROOT + "/auth"); - 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 - setTimeOffset(900 + SessionTimeoutHelper.PERIODIC_CLEANER_IDLE_TIMEOUT_WINDOW_SECONDS + 20); - oauth.client("offline-client", "secret1"); - oauth.redirectUri(offlineClientAppUri); - oauth.openLoginForm(); - - code = oauth.parseLoginResponse().getCode(); - tokenResponse = oauth.doAccessTokenRequest(code); - assertOfflineToken(tokenResponse); - - } finally { - getTestingClient().testing().revertTestingInfinispanTimeService(); - changeOfflineSessionSettings(false, prev[0], prev[1], 0, 0); - } - } - - 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) { - Assert.assertEquals(200, tokenResponse.getStatusCode()); - String offlineTokenString = tokenResponse.getRefreshToken(); - RefreshToken refreshToken = oauth.parseRefreshToken(offlineTokenString); - assertEquals(tokenType, refreshToken.getType()); - return refreshToken; - } - - @Test - public void offlineTokenRequest_ClientES256_RealmPS256() throws Exception { - conductOfflineTokenRequest(Constants.INTERNAL_SIGNATURE_ALGORITHM, Algorithm.ES256, Algorithm.PS256); - } - - @Test - public void offlineTokenRequest_ClientPS256_RealmES256() throws Exception { - conductOfflineTokenRequest(Constants.INTERNAL_SIGNATURE_ALGORITHM, Algorithm.PS256, Algorithm.ES256); - } - - private void conductOfflineTokenRequest(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception { - try { - /// Realm Setting is used for ID Token Signature Algorithm - TokenSignatureUtil.changeRealmTokenSignatureProvider(adminClient, expectedIdTokenAlg); - TokenSignatureUtil.changeClientAccessTokenSignatureProvider(ApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client"), expectedAccessAlg); - offlineTokenRequest(expectedRefreshAlg, expectedAccessAlg, expectedIdTokenAlg); - offlineTokenRequestWithScopeParameter(expectedRefreshAlg, expectedAccessAlg, expectedIdTokenAlg); - } finally { - TokenSignatureUtil.changeRealmTokenSignatureProvider(adminClient, Algorithm.RS256); - TokenSignatureUtil.changeClientAccessTokenSignatureProvider(ApiUtil.findClientByClientId(adminClient.realm("test"), "offline-client"), Algorithm.RS256); - } - } - - private String getOfflineClientSessionUuid(final String userSessionId, final String clientId) { - return testingClient.server().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 int checkIfUserAndClientSessionExist(final String userSessionId, final String clientId, final String clientSessionId) { - return testingClient.server().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 void removeClientSessionStartedAtNote(final String userSessionId, final String clientId) { - testingClient.server().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); - } - } - }); - } - - private void testOfflineSessionExpiration(int idleTime, int maxLifespan, int offsetHalf, int offset) { - int prev[] = null; - getTestingClient().testing().setTestingInfinispanTimeService(); - try { - prev = changeOfflineSessionSettings(true, maxLifespan, idleTime, 0, 0); - - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - oauth.redirectUri(offlineClientAppUri); - oauth.doLogin("test-user@localhost", "password"); - - EventRepresentation loginEvent = events.expectLogin() - .client("offline-client") - .detail(Details.REDIRECT_URI, offlineClientAppUri) - .assertEvent(); - - 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 - setTimeOffset(offsetHalf); - - tokenResponse = oauth.doRefreshTokenRequest(offlineTokenString); - oauth.verifyToken(tokenResponse.getAccessToken()); - offlineTokenString = tokenResponse.getRefreshToken(); - oauth.parseRefreshToken(offlineTokenString); - - Assert.assertEquals(200, tokenResponse.getStatusCode()); - assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - - // wait to expire - setTimeOffset(offset); - - tokenResponse = oauth.doRefreshTokenRequest(offlineTokenString); - - Assert.assertEquals(400, tokenResponse.getStatusCode()); - assertEquals("invalid_grant", tokenResponse.getError()); - - // Assert userSession expired - assertEquals(0, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - testingClient.testing().removeExpired("test"); - try { - testingClient.testing().removeUserSession("test", sessionId); - } catch (NotFoundException nfe) { - // Ignore - } - - setTimeOffset(0); - - } finally { - getTestingClient().testing().revertTestingInfinispanTimeService(); - changeOfflineSessionSettings(false, prev[0], prev[1], prev[2], prev[3]); - } - } - - private void offlineTokenRequest(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception { - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); - - JWSHeader header; - String idToken = tokenResponse.getIdToken(); - String accessToken = tokenResponse.getAccessToken(); - String refreshToken = tokenResponse.getRefreshToken(); - if (idToken != null) { - header = new JWSInput(idToken).getHeader(); - assertEquals(expectedIdTokenAlg, header.getAlgorithm().name()); - assertEquals("JWT", header.getType()); - assertNull(header.getContentType()); - } - if (accessToken != null) { - header = new JWSInput(accessToken).getHeader(); - assertEquals(expectedAccessAlg, header.getAlgorithm().name()); - assertEquals("JWT", header.getType()); - assertNull(header.getContentType()); - } - if (refreshToken != null) { - header = new JWSInput(refreshToken).getHeader(); - assertEquals(expectedRefreshAlg, header.getAlgorithm().name()); - assertEquals("JWT", header.getType()); - assertNull(header.getContentType()); - } - - AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); - String offlineTokenString = tokenResponse.getRefreshToken(); - RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); - - events.expectClientLogin() - .client("offline-client") - .user(serviceAccountUserId) - .session(token.getSessionId()) - .detail(Details.TOKEN_ID, token.getId()) - .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client") - .assertEvent(); - - Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); - Assert.assertNull(offlineToken.getExp()); - - testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId); - - // Now retrieve another offline token and decode that previous offline token is still valid - tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); - - AccessToken token2 = oauth.verifyToken(tokenResponse.getAccessToken()); - String offlineTokenString2 = tokenResponse.getRefreshToken(); - RefreshToken offlineToken2 = oauth.parseRefreshToken(offlineTokenString2); - - events.expectClientLogin() - .client("offline-client") - .user(serviceAccountUserId) - .session(token2.getSessionId()) - .detail(Details.TOKEN_ID, token2.getId()) - .detail(Details.REFRESH_TOKEN_ID, offlineToken2.getId()) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client") - .assertEvent(); - - // Refresh with both offline tokens is fine - testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionId(), serviceAccountUserId); - testRefreshWithOfflineToken(token2, offlineToken2, offlineTokenString2, token2.getSessionId(), serviceAccountUserId); - } - - private void offlineTokenRequestWithScopeParameter(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception { - ClientScopeRepresentation phoneScope = adminClient.realm("test").clientScopes().findAll().stream().filter((ClientScopeRepresentation clientScope) ->"phone".equals(clientScope.getName())).findFirst().get(); - ClientManager.realm(adminClient.realm("test")).clientId(oauth.getClientId()).addClientScope(phoneScope.getId(),false); - oauth.scope(OAuth2Constants.OFFLINE_ACCESS+" phone"); - oauth.client("offline-client", "secret1"); - AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest(); - - JWSHeader header; - String idToken = tokenResponse.getIdToken(); - String accessToken = tokenResponse.getAccessToken(); - String refreshToken = tokenResponse.getRefreshToken(); - if (idToken != null) { - header = new JWSInput(idToken).getHeader(); - assertEquals(expectedIdTokenAlg, header.getAlgorithm().name()); - assertEquals("JWT", header.getType()); - assertNull(header.getContentType()); - } - if (accessToken != null) { - header = new JWSInput(accessToken).getHeader(); - assertEquals(expectedAccessAlg, header.getAlgorithm().name()); - assertEquals("JWT", header.getType()); - assertNull(header.getContentType()); - } - if (refreshToken != null) { - header = new JWSInput(refreshToken).getHeader(); - assertEquals(expectedRefreshAlg, header.getAlgorithm().name()); - assertEquals("JWT", header.getType()); - assertNull(header.getContentType()); - } - - AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); - String offlineTokenString = tokenResponse.getRefreshToken(); - RefreshToken offlineToken = oauth.parseRefreshToken(offlineTokenString); - - events.expectClientLogin() - .client("offline-client") - .user(serviceAccountUserId) - .session(token.getSessionId()) - .detail(Details.TOKEN_ID, token.getId()) - .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client") - .assertEvent(); - - Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); - Assert.assertNull(offlineToken.getExp()); - } - - @Test - public void refreshTokenUserClientMaxLifespanSmallerThanSession() { - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - oauth.redirectUri(offlineClientAppUri); - - int[] prev = changeOfflineSessionSettings(true, 3600, 7200, 1000, 7200); - getTestingClient().testing().setTestingInfinispanTimeService(); - try { - oauth.doLogin("test-user@localhost", "password"); - EventRepresentation loginEvent = events.expectLogin().client("offline-client") - .detail(Details.REDIRECT_URI, offlineClientAppUri).assertEvent(); - - 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("Invalid ExpiresIn", 0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 1000); - String clientSessionId = getOfflineClientSessionUuid(sessionId, loginEvent.getClientId()); - assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - - events.poll(); - - setTimeOffset(600); - String refreshId = oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getId(); - tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); - assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getType()); - assertTrue("Invalid ExpiresIn", 0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 400); - assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - events.expectRefresh(refreshId, sessionId).client("offline-client").detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE).assertEvent(); - - setTimeOffset(1100); - tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); - assertEquals(400, tokenResponse.getStatusCode()); - assertNull(tokenResponse.getAccessToken()); - assertNull(tokenResponse.getRefreshToken()); - events.expect(EventType.REFRESH_TOKEN).client("offline-client").error(Errors.INVALID_TOKEN).user((String) null).assertEvent(); - assertEquals(1, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - } finally { - changeOfflineSessionSettings(false, prev[0], prev[1], prev[2], prev[3]); - getTestingClient().testing().revertTestingInfinispanTimeService(); - events.clear(); - resetTimeOffset(); - } - } - - @Test - public void refreshTokenUserClientMaxLifespanGreaterThanSession() { - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - oauth.redirectUri(offlineClientAppUri); - - int[] prev = changeOfflineSessionSettings(true, 3600, 7200, 5000, 7200); - getTestingClient().testing().setTestingInfinispanTimeService(); - try { - oauth.doLogin("test-user@localhost", "password"); - EventRepresentation loginEvent = events.expectLogin().client("offline-client") - .detail(Details.REDIRECT_URI, offlineClientAppUri).assertEvent(); - - 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("Invalid ExpiresIn", 0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 3600); - String clientSessionId = getOfflineClientSessionUuid(sessionId, loginEvent.getClientId()); - assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - - events.poll(); - - setTimeOffset(1800); - String refreshId = oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getId(); - tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); - assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, oauth.parseRefreshToken(tokenResponse.getRefreshToken()).getType()); - assertTrue("Invalid ExpiresIn", 0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 1800); - assertEquals(2, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - events.expectRefresh(refreshId, sessionId).client("offline-client").detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE).assertEvent(); - - setTimeOffset(3700); - tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); - assertEquals(400, tokenResponse.getStatusCode()); - assertNull(tokenResponse.getAccessToken()); - assertNull(tokenResponse.getRefreshToken()); - events.expect(EventType.REFRESH_TOKEN).client("offline-client").error(Errors.INVALID_TOKEN).user((String) null).assertEvent(); - assertEquals(0, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - } finally { - changeOfflineSessionSettings(false, prev[0], prev[1], prev[2], prev[3]); - getTestingClient().testing().revertTestingInfinispanTimeService(); - events.clear(); - resetTimeOffset(); - } - } - - @Test - public void refreshTokenUserSessionMaxLifespanModifiedAfterTokenRefresh() { - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - oauth.redirectUri(offlineClientAppUri); - - RealmResource realmResource = adminClient.realm("test"); - getTestingClient().testing().setTestingInfinispanTimeService(); - - int[] prev = changeOfflineSessionSettings(true, 7200, 7200, 7200, 7200); - try { - oauth.doLogin("test-user@localhost", "password"); - EventRepresentation loginEvent = events.expectLogin().client("offline-client") - .detail(Details.REDIRECT_URI, offlineClientAppUri).assertEvent(); - - 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("Invalid ExpiresIn", 0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 7200); - 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); - - setTimeOffset(3700); - tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); - assertEquals(400, tokenResponse.getStatusCode()); - assertNull(tokenResponse.getAccessToken()); - assertNull(tokenResponse.getRefreshToken()); - events.assertRefreshTokenErrorAndMaybeSessionExpired(sessionId, loginEvent.getUserId(), "offline-client"); - assertEquals(0, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - } finally { - changeOfflineSessionSettings(false, prev[0], prev[1], prev[2], prev[3]); - getTestingClient().testing().revertTestingInfinispanTimeService(); - events.clear(); - resetTimeOffset(); - } - } - - @Test - public void refreshTokenClientSessionMaxLifespanModifiedAfterTokenRefresh() { - oauth.scope(OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - oauth.redirectUri(offlineClientAppUri); - - RealmResource realmResource = adminClient.realm("test"); - getTestingClient().testing().setTestingInfinispanTimeService(); - - int[] prev = changeOfflineSessionSettings(true, 7200, 7200, 7200, 7200); - try { - oauth.doLogin("test-user@localhost", "password"); - EventRepresentation loginEvent = events.expectLogin().client("offline-client") - .detail(Details.REDIRECT_URI, offlineClientAppUri).assertEvent(); - - 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("Invalid ExpiresIn", 0 < tokenResponse.getRefreshExpiresIn() && tokenResponse.getRefreshExpiresIn() <= 7200); - 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); - - setTimeOffset(3700); - tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken()); - assertEquals(400, tokenResponse.getStatusCode()); - assertNull(tokenResponse.getAccessToken()); - assertNull(tokenResponse.getRefreshToken()); - events.expect(EventType.REFRESH_TOKEN).client("offline-client").error(Errors.INVALID_TOKEN).session(sessionId).user((String) null).assertEvent(); - assertEquals(1, checkIfUserAndClientSessionExist(sessionId, loginEvent.getClientId(), clientSessionId)); - } finally { - changeOfflineSessionSettings(false, prev[0], prev[1], prev[2], prev[3]); - getTestingClient().testing().revertTestingInfinispanTimeService(); - events.clear(); - resetTimeOffset(); - } - } - - @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(offlineClientAppUri); - oauth.doLogin("test-user@localhost", "password"); - - events.expectLogin().client("offline-client").detail(Details.REDIRECT_URI, offlineClientAppUri).assertEvent(); - - 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()); - Assert.assertEquals("test-user@localhost", jsonNode.get("email").asText()); - assertThat(jsonNode.get("exp").asInt() - getCurrentTime(), - 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 = ApiUtil.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(offlineClientAppUri); - 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, null); - client.update(clientRepresentation); - } - } - - @Test - public void testClientOfflineSessionIdleTimeout() { - ClientResource client = ApiUtil.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(offlineClientAppUri); - 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, null); - client.update(clientRepresentation); - } - } - - @Test - public void offlineTokenRefreshWithoutOfflineAccessScope() { - ClientManager.realm(adminClient.realm("test")).clientId("offline-client").fullScopeAllowed(false); - - try { - oauth.scope("openid " + OAuth2Constants.OFFLINE_ACCESS); - oauth.client("offline-client", "secret1"); - oauth.redirectUri(offlineClientAppUri); - 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 - Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); - assertTrue(offlineToken.getScope().contains(OAuth2Constants.OFFLINE_ACCESS)); - } - finally { - ClientManager.realm(adminClient.realm("test")).clientId("offline-client").fullScopeAllowed(true); - } - } - - @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(offlineClientAppUri); - oauth.doLogin("test-user@localhost", "password"); - String code = oauth.parseLoginResponse().getCode(); - AccessTokenResponse response = oauth.doAccessTokenRequest(code); - - EventRepresentation loginEvent = events.expectLogin() - .client("offline-client") - .detail(Details.REDIRECT_URI, offlineClientAppUri) - .assertEvent(); - - // 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("Invalid ExpiresIn", 0 < response.getRefreshExpiresIn() && response.getRefreshExpiresIn() <= 3600); - - // check refresh a second time - response = oauth.doRefreshTokenRequest(response.getRefreshToken()); - assertEquals(200, response.getStatusCode()); - assertTrue("Invalid ExpiresIn", 0 < response.getRefreshExpiresIn() && response.getRefreshExpiresIn() <= 3600); - } finally { - changeOfflineSessionSettings(false, prevOfflineSession[0], prevOfflineSession[1], prevOfflineSession[2], prevOfflineSession[3]); - } - } - - @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(offlineClientAppUri); - oauth.doLogin("test-user@localhost", "password"); - String code = oauth.parseLoginResponse().getCode(); - AccessTokenResponse response = oauth.doAccessTokenRequest(code); - - EventRepresentation loginEvent = events.expectLogin() - .client("offline-client") - .detail(Details.REDIRECT_URI, offlineClientAppUri) - .assertEvent(); - - events.expectCodeToToken(loginEvent.getDetails().get(Details.CODE_ID), loginEvent.getSessionId()) - .client("offline-client") - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .assertEvent(); - - // check refresh is successful - RefreshToken offlineToken = oauth.parseRefreshToken(response.getRefreshToken()); - oauth.scope(null); - response = oauth.doRefreshTokenRequest(response.getRefreshToken()); - assertEquals(200, response.getStatusCode()); - Assert.assertEquals(0, response.getRefreshExpiresIn()); - events.expectRefresh(offlineToken.getId(), loginEvent.getSessionId()) - .client("offline-client") - .user(userId) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) - .assertEvent(); - offlineToken = oauth.parseRefreshToken(response.getRefreshToken()); - - IntrospectionResponse introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getAccessToken()); - assertTrue(introspectionResponse.asJsonNode().get("active").asBoolean()); - events.expect(EventType.INTROSPECT_TOKEN) - .client("offline-client") - .session(loginEvent.getSessionId()) - .assertEvent(); - - introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getRefreshToken()); - assertTrue(introspectionResponse.asJsonNode().get("active").asBoolean()); - events.expect(EventType.INTROSPECT_TOKEN) - .client("offline-client") - .session(loginEvent.getSessionId()) - .assertEvent(); - - // remove offline scope from the client and perform a second refresh - try (ClientAttributeUpdater ignored = ClientAttributeUpdater.forClient(adminClient, TEST, "offline-client") - .removeOptionalClientScope("offline_access").update()) { - - introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getAccessToken()); - assertFalse(introspectionResponse.asJsonNode().get("active").asBoolean()); - events.expect(EventType.INTROSPECT_TOKEN_ERROR) - .client("offline-client") - .session(loginEvent.getSessionId()) - .error(Errors.SESSION_EXPIRED) - .detail(Details.REASON, "Offline session invalid because offline access not granted anymore") - .assertEvent(); - - introspectionResponse = oauth.doIntrospectionAccessTokenRequest(response.getRefreshToken()); - assertFalse(introspectionResponse.asJsonNode().get("active").asBoolean()); - events.expect(EventType.INTROSPECT_TOKEN_ERROR) - .client("offline-client") - .session(loginEvent.getSessionId()) - .error(Errors.SESSION_EXPIRED) - .detail(Details.REASON, "Offline session invalid because offline access not granted anymore") - .assertEvent(); - - 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()); - events.expect(EventType.REFRESH_TOKEN_ERROR) - .client("offline-client") - .session(loginEvent.getSessionId()) - .user((String) null) - .error(Errors.INVALID_TOKEN) - .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) - .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) - .detail(Details.REASON, "Offline session invalid because offline access not granted anymore") - .assertEvent(); - } - } -} diff --git a/testsuite/integration-arquillian/tests/base/testsuites/clusterless-suite b/testsuite/integration-arquillian/tests/base/testsuites/clusterless-suite index bfc3ff39540..db8ddc9badc 100644 --- a/testsuite/integration-arquillian/tests/base/testsuites/clusterless-suite +++ b/testsuite/integration-arquillian/tests/base/testsuites/clusterless-suite @@ -10,7 +10,6 @@ KcOidcUserSessionLimitsBrokerTest KcSamlUserSessionLimitsBrokerTest AbstractUserSessionLimitsBrokerTest UserSessionLimitsTest -OfflineTokenTest AccessTokenTest LogoutTest ClientStorageTest diff --git a/testsuite/integration-arquillian/tests/base/testsuites/volatile-sessions-suite b/testsuite/integration-arquillian/tests/base/testsuites/volatile-sessions-suite index becb8c922c2..1f6b52e49cb 100644 --- a/testsuite/integration-arquillian/tests/base/testsuites/volatile-sessions-suite +++ b/testsuite/integration-arquillian/tests/base/testsuites/volatile-sessions-suite @@ -11,7 +11,6 @@ KcOidcUserSessionLimitsBrokerTest KcSamlUserSessionLimitsBrokerTest AbstractUserSessionLimitsBrokerTest UserSessionLimitsTest -OfflineTokenTest AccessTokenTest LogoutTest ClientStorageTest