From 230de0ea1b2d9fc9041d5be2dbaef8dc9670a362 Mon Sep 17 00:00:00 2001 From: Kai Helbig Date: Fri, 17 Jan 2025 16:33:20 +0100 Subject: [PATCH 1/9] make docker file run with new non-JBoss keycloak Signed-off-by: Kai Helbig --- example-integration-docker/docker-compose-real-keycloak.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/example-integration-docker/docker-compose-real-keycloak.yml b/example-integration-docker/docker-compose-real-keycloak.yml index e54bf22..b519549 100644 --- a/example-integration-docker/docker-compose-real-keycloak.yml +++ b/example-integration-docker/docker-compose-real-keycloak.yml @@ -2,13 +2,14 @@ version: '3' services: keycloak: image: keycloak/keycloak + command: + - start-dev ports: - - 8000:8000 + - 8000:8080 environment: - KEYCLOAK_USER=admin - KEYCLOAK_PASSWORD=admin - KEYCLOAK_IMPORT=/tmp/realm.json - - JAVA_OPTS_APPEND="-Djboss.http.port=8000" volumes: - ./realm.json:/tmp/realm.json # this is necessary so that the keycloak is visible on the same hostname both for frontend From 3aa68ee720cedd56b4fcdc995abec8ec3cb91ffd Mon Sep 17 00:00:00 2001 From: Kai Helbig Date: Fri, 17 Jan 2025 16:36:01 +0100 Subject: [PATCH 2/9] handle redirect URIs with parameters If the redirect_uri as given by the client already contained (escaped) query parameters or fragments, we previously ignored them and still appended a new query or fragment part. With this fix, we now append our return values to the existing parts, if necessary. Signed-off-by: Kai Helbig --- .../impl/helper/RedirectHelper.java | 93 +++++++++++++++---- .../impl/helper/RedirectHelperTest.java | 49 +++++++++- 2 files changed, 125 insertions(+), 17 deletions(-) diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/helper/RedirectHelper.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/helper/RedirectHelper.java index 65c4f38..bfdc008 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/helper/RedirectHelper.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/helper/RedirectHelper.java @@ -5,6 +5,11 @@ import com.tngtech.keycloakmock.impl.session.ResponseMode; import com.tngtech.keycloakmock.impl.session.ResponseType; import io.vertx.core.http.Cookie; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.inject.Inject; @@ -46,17 +51,66 @@ public String getRedirectLocation( return null; } - ResponseMode responseMode = responseType.getValidResponseMode(session.getResponseMode()); String originalRedirectUri = session.getRedirectUri(); - StringBuilder redirectUri; + URI redirectUriBase; if (OOB_REDIRECT.equals(originalRedirectUri)) { - redirectUri = - new StringBuilder(requestConfiguration.getOutOfBandLoginLoginEndpoint().toASCIIString()); + redirectUriBase = requestConfiguration.getOutOfBandLoginLoginEndpoint(); } else { - redirectUri = new StringBuilder(originalRedirectUri); + try { + redirectUriBase = new URI(originalRedirectUri); + } catch (URISyntaxException e) { + LOG.warn("Invalid redirect URI '{}'!", originalRedirectUri, e); + return null; + } + } + + ResponseMode responseMode = responseType.getValidResponseMode(session.getResponseMode()); + try { + switch (responseMode) { + case FRAGMENT: + String fragment = + appendResponseParameters( + session, requestConfiguration, responseType, redirectUriBase.getFragment()); + return new URI( + redirectUriBase.getScheme(), + redirectUriBase.getUserInfo(), + redirectUriBase.getHost(), + redirectUriBase.getPort(), + redirectUriBase.getPath(), + redirectUriBase.getQuery(), + fragment) + .toASCIIString(); + case QUERY: + String query = + appendResponseParameters( + session, requestConfiguration, responseType, redirectUriBase.getQuery()); + return new URI( + redirectUriBase.getScheme(), + redirectUriBase.getUserInfo(), + redirectUriBase.getHost(), + redirectUriBase.getPort(), + redirectUriBase.getPath(), + query, + redirectUriBase.getFragment()) + .toASCIIString(); + default: + LOG.warn("Invalid response mode '{}' encountered!", responseMode); + return null; + } + } catch (URISyntaxException e) { + LOG.warn("Error while generating final redirect URI from '{}'!", redirectUriBase, e); + return null; } - redirectUri.append(getResponseParameter(responseMode, SESSION_STATE, session.getSessionId())); - redirectUri.append(getResponseParameter(null, STATE, session.getState())); + } + + private String appendResponseParameters( + @Nonnull PersistentSession session, + @Nonnull UrlConfiguration requestConfiguration, + @Nonnull ResponseType responseType, + @Nullable String existingParameters) { + List parameters = new ArrayList<>(); + parameters.add(getResponseParameter(SESSION_STATE, session.getSessionId())); + parameters.add(getResponseParameter(STATE, session.getState())); String token = tokenHelper.getToken(session, requestConfiguration); if (token == null) { LOG.warn("No token available for session {}", session.getSessionId()); @@ -65,21 +119,29 @@ public String getRedirectLocation( switch (responseType) { case CODE: // for simplicity, use session ID as authorization code - redirectUri.append(getResponseParameter(null, CODE, session.getSessionId())); + parameters.add(getResponseParameter(CODE, session.getSessionId())); break; case ID_TOKEN: - redirectUri.append(getResponseParameter(null, ID_TOKEN, token)); + parameters.add(getResponseParameter(ID_TOKEN, token)); break; case ID_TOKEN_PLUS_TOKEN: - redirectUri.append(getResponseParameter(null, ID_TOKEN, token)); - redirectUri.append(getResponseParameter(null, ACCESS_TOKEN, token)); - redirectUri.append(getResponseParameter(null, TOKEN_TYPE, "bearer")); + parameters.add(getResponseParameter(ID_TOKEN, token)); + parameters.add(getResponseParameter(ACCESS_TOKEN, token)); + parameters.add(getResponseParameter(TOKEN_TYPE, "bearer")); break; case NONE: default: break; } - return redirectUri.toString(); + String parameterString = + parameters.stream().filter(s -> !s.isEmpty()).collect(Collectors.joining("&")); + if (parameterString.isEmpty()) { + return existingParameters; + } + if (existingParameters == null || existingParameters.isEmpty()) { + return parameterString; + } + return existingParameters + "&" + parameterString; } @Nonnull @@ -104,11 +166,10 @@ public Cookie invalidateSessionCookie(@Nonnull UrlConfiguration requestConfigura .setSecure(false); } - private String getResponseParameter( - @Nullable ResponseMode responseMode, @Nonnull String name, @Nullable String value) { + private String getResponseParameter(@Nonnull String name, @Nullable String value) { if (value == null) { return ""; } - return (responseMode != null ? responseMode.getSign() : "&") + name + "=" + value; + return name + "=" + value; } } diff --git a/mock/src/test/java/com/tngtech/keycloakmock/impl/helper/RedirectHelperTest.java b/mock/src/test/java/com/tngtech/keycloakmock/impl/helper/RedirectHelperTest.java index 06af02f..8a549c6 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/impl/helper/RedirectHelperTest.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/impl/helper/RedirectHelperTest.java @@ -132,6 +132,52 @@ void redirect_location_is_generated_correctly( assertThat(redirectLocation).isEqualTo(expectedRedirectUrl); } + static Stream originalUrlsAndModesAndExpectedUrl() { + return Stream.of( + Arguments.of( + "https://localhost:1234/gohere#existingFragment", + ResponseMode.QUERY, + "https://localhost:1234/gohere?session_state=session123&state=state123&code=session123#existingFragment"), + Arguments.of( + "https://localhost:1234/gohere#existingFragment", + ResponseMode.FRAGMENT, + "https://localhost:1234/gohere#existingFragment&session_state=session123&state=state123&code=session123"), + Arguments.of( + "https://localhost:1234/gohere?existingQuery=true", + ResponseMode.QUERY, + "https://localhost:1234/gohere?existingQuery=true&session_state=session123&state=state123&code=session123"), + Arguments.of( + "https://localhost:1234/gohere?existingQuery=true", + ResponseMode.FRAGMENT, + "https://localhost:1234/gohere?existingQuery=true#session_state=session123&state=state123&code=session123"), + Arguments.of( + "https://localhost:1234/gohere?existingQuery=true#existingFragment", + ResponseMode.QUERY, + "https://localhost:1234/gohere?existingQuery=true&session_state=session123&state=state123&code=session123#existingFragment"), + Arguments.of( + "https://localhost:1234/gohere?existingQuery=true#existingFragment", + ResponseMode.FRAGMENT, + "https://localhost:1234/gohere?existingQuery=true#existingFragment&session_state=session123&state=state123&code=session123")); + } + + @ParameterizedTest + @MethodSource("originalUrlsAndModesAndExpectedUrl") + void redirect_location_keeps_existing_parameters( + @Nonnull String originalRedirectUri, + @Nonnull ResponseMode mode, + @Nonnull String expectedRedirectUrl) { + doReturn(SESSION_ID).when(session).getSessionId(); + doReturn(STATE).when(session).getState(); + doReturn(originalRedirectUri).when(session).getRedirectUri(); + doReturn(ResponseType.CODE.toString()).when(session).getResponseType(); + doReturn(mode.toString()).when(session).getResponseMode(); + doReturn(TOKEN).when(tokenHelper).getToken(session, urlConfiguration); + + String redirectLocation = uut.getRedirectLocation(session, urlConfiguration); + + assertThat(redirectLocation).isEqualTo(expectedRedirectUrl); + } + @Test void oob_redirect_location_is_generated_correctly() { doReturn(SESSION_ID).when(session).getSessionId(); @@ -145,7 +191,8 @@ void oob_redirect_location_is_generated_correctly() { String redirectLocation = uut.getRedirectLocation(session, urlConfiguration); assertThat(redirectLocation) - .isEqualTo("file:///oob-dummy?session_state=session123&state=state123&code=session123"); + // converting to URI and back to String seems to drop the superfluous //, but that's OK + .isEqualTo("file:/oob-dummy?session_state=session123&state=state123&code=session123"); } @Test From 3b65601d8e46a7f058b37e5fe121d2e5a3b61992 Mon Sep 17 00:00:00 2001 From: Kai Helbig Date: Fri, 17 Jan 2025 19:12:37 +0100 Subject: [PATCH 3/9] update dependency versions Signed-off-by: Kai Helbig --- .github/workflows/build.yml | 4 +- build.gradle | 35 +++---- example-backend/build.gradle | 2 +- example-frontend-react/package.json | 10 +- example-frontend-react/yarn.lock | 119 ++++++++++++++--------- example-integration-docker/build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- mock/build.gradle | 5 +- standalone/build.gradle | 8 +- 9 files changed, 107 insertions(+), 80 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c19cb85..35bba56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: distribution: 'temurin' java-version: '21' - name: Run pre-commit check - uses: pre-commit/action@v3.0.1 + uses: cloudposse/github-action-pre-commit@v4.0.0 build_java: runs-on: ubuntu-latest @@ -52,7 +52,7 @@ jobs: - name: Run integration tests run: ./gradlew e2e - name: Publish test report - uses: mikepenz/action-junit-report@v4 + uses: mikepenz/action-junit-report@v5 if: always() with: report_paths: '**/build/test-results/test/TEST-*.xml' diff --git a/build.gradle b/build.gradle index ba0a402..19aea26 100644 --- a/build.gradle +++ b/build.gradle @@ -1,20 +1,20 @@ buildscript { repositories { maven { - url 'https://plugins.gradle.org/m2/' + url = 'https://plugins.gradle.org/m2/' } } } plugins { id 'com.github.ben-manes.versions' version '0.51.0' - id 'com.google.cloud.tools.jib' version '3.4.3' apply false - id 'org.sonarqube' version '5.1.0.4882' - id 'pl.allegro.tech.build.axion-release' version '1.18.13' + id 'com.google.cloud.tools.jib' version '3.4.4' apply false + id 'org.sonarqube' version '6.0.1.5171' + id 'pl.allegro.tech.build.axion-release' version '1.18.16' } wrapper { - gradleVersion = '8.10.2' + gradleVersion = '8.12' distributionType = Wrapper.DistributionType.ALL } @@ -42,17 +42,18 @@ allprojects { } ext { - assertj_version = '3.26.3' + assertj_version = '3.27.2' jjwt_version = '0.12.6' jsr305_version = '3.0.2' junit4_version = '4.13.2' - junit5_version = '5.11.2' - keycloak_version = '26.0.0' - mockito_version = '5.14.2' + junit5_version = '5.11.4' + keycloak_version = '26.0.4' + keycloak_js_version = '26.1.0' + mockito_version = '5.15.2' picocli_version = '4.7.6' restassured_version = '5.5.0' slf4j_version = '2.0.16' - vertx_version = '4.5.10' + vertx_version = '4.5.11' license_name = 'The Apache License, Version 2.0' license_url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' @@ -159,19 +160,19 @@ subprojects { } repositories.maven { if (project.version.endsWith('-SNAPSHOT')) { - credentials.username project.findProperty('sonatypeUsername') - credentials.password project.findProperty('sonatypePassword') - url 'https://oss.sonatype.org/content/repositories/snapshots/' + credentials.username = project.findProperty('sonatypeUsername') + credentials.password = project.findProperty('sonatypePassword') + url = 'https://oss.sonatype.org/content/repositories/snapshots/' } else { - credentials.username project.findProperty('sonatypeUsername') - credentials.password project.findProperty('sonatypePassword') - url 'https://oss.sonatype.org/service/local/staging/deploy/maven2/' + credentials.username = project.findProperty('sonatypeUsername') + credentials.password = project.findProperty('sonatypePassword') + url = 'https://oss.sonatype.org/service/local/staging/deploy/maven2/' } } } signing { - required { + required = { gradle.taskGraph.hasTask('uploadArchives') } if ('standalone' != project.name) { diff --git a/example-backend/build.gradle b/example-backend/build.gradle index 3a3d5e3..5e16659 100644 --- a/example-backend/build.gradle +++ b/example-backend/build.gradle @@ -1,6 +1,6 @@ buildscript { ext { - springBootVersion = '3.3.4' + springBootVersion = '3.4.1' } repositories { mavenCentral() diff --git a/example-frontend-react/package.json b/example-frontend-react/package.json index 77c51ae..eb78526 100644 --- a/example-frontend-react/package.json +++ b/example-frontend-react/package.json @@ -4,10 +4,10 @@ "private": true, "license": "Apache-2.0", "dependencies": { - "axios": "^1.7.7", - "keycloak-js": "^26.0.1", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "axios": "^1.7.9", + "keycloak-js": "^26.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", "react-scripts": "5.0.1" }, "scripts": { @@ -29,7 +29,7 @@ "not op_mini all" ], "devDependencies": { - "cypress": "^13.15.0", + "cypress": "^14.0.0", "cypress-keycloak": "^2.0.2" } } diff --git a/example-frontend-react/yarn.lock b/example-frontend-react/yarn.lock index e0d690c..059968c 100644 --- a/example-frontend-react/yarn.lock +++ b/example-frontend-react/yarn.lock @@ -2258,10 +2258,10 @@ resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-12.0.0.tgz#a9583a75c3f150667771f30b60d9f059473e62c4" integrity sha512-M0qqxAcwCsIVfpFQSlGN5XjXWu8l5JDZN+fPt1LeW5SZexQTgnaEvgXAY+CeygRw0EeppWHi12JxESWiWrB0Sg== -"@cypress/request@^3.0.4": - version "3.0.5" - resolved "https://registry.yarnpkg.com/@cypress/request/-/request-3.0.5.tgz#d893a6e68ce2636c085fcd8d7283c3186499ba63" - integrity sha512-v+XHd9XmWbufxF1/bTaVm2yhbxY+TB4YtWRqF2zaXBlDNMkls34KiATz0AVDLavL3iB6bQk9/7n3oY1EoLSWGA== +"@cypress/request@^3.0.6": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@cypress/request/-/request-3.0.7.tgz#6a74a4da98d9e5ae9121d6e2d9c14780c9b5cf1a" + integrity sha512-LzxlLEMbBOPYB85uXrDqvD4MgcenjRBLIns3zyhx7vTPj/0u2eQhzXvPiGcaJrV38Q9dbkExWp6cOHPJ+EtFYg== dependencies: aws-sign2 "~0.7.0" aws4 "^1.8.0" @@ -2276,9 +2276,9 @@ json-stringify-safe "~5.0.1" mime-types "~2.1.19" performance-now "^2.1.0" - qs "6.13.0" + qs "6.13.1" safe-buffer "^5.1.2" - tough-cookie "^4.1.3" + tough-cookie "^5.0.0" tunnel-agent "^0.6.0" uuid "^8.3.2" @@ -3710,10 +3710,10 @@ axe-core@^4.3.5: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.3.5.tgz#78d6911ba317a8262bfee292aeafcc1e04b49cc5" integrity sha512-WKTW1+xAzhMS5dJsxWkliixlO/PqC4VhmO9T4juNYcaTg9jzWiJsou6m5pxWYGfigWbwzJWeFY6z47a+4neRXA== -axios@^1.7.7: - version "1.7.7" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f" - integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q== +axios@^1.7.9: + version "1.7.9" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a" + integrity sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw== dependencies: follow-redirects "^1.15.6" form-data "^4.0.0" @@ -4222,6 +4222,11 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.3.0.tgz#b4ed1fb6818dea4803a55c623041f9165d2066b2" integrity sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw== +ci-info@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.1.0.tgz#92319d2fa29d2620180ea5afed31f589bc98cf83" + integrity sha512-HutrvTNsF48wnxkzERIXOe5/mlcfFcbfCmwcg6CJnizbSue78AbDt+1cgl26zwn61WFxhcPykPfZrbqjGmBb4A== + cjs-module-lexer@^1.0.0: version "1.2.2" resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40" @@ -4734,12 +4739,12 @@ cypress-keycloak@^2.0.2: base64-js "^1.5.1" js-sha256 "^0.9.0" -cypress@^13.15.0: - version "13.15.0" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.15.0.tgz#5eca5387ef34b2e611cfa291967c69c2cd39381d" - integrity sha512-53aO7PwOfi604qzOkCSzNlWquCynLlKE/rmmpSPcziRH6LNfaDUAklQT6WJIsD8ywxlIy+uVZsnTMCCQVd2kTw== +cypress@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-14.0.0.tgz#a71cb0a243a0bfeb97b6973ab9c5291ca5288e93" + integrity sha512-kEGqQr23so5IpKeg/dp6GVi7RlHx1NmW66o2a2Q4wk9gRaAblLZQSiZJuDI8UMC4LlG5OJ7Q6joAiqTrfRNbTw== dependencies: - "@cypress/request" "^3.0.4" + "@cypress/request" "^3.0.6" "@cypress/xvfb" "^1.2.4" "@types/sinonjs__fake-timers" "8.1.1" "@types/sizzle" "^2.3.2" @@ -4750,6 +4755,7 @@ cypress@^13.15.0: cachedir "^2.3.0" chalk "^4.1.0" check-more-types "^2.24.0" + ci-info "^4.0.0" cli-cursor "^3.1.0" cli-table3 "~0.6.1" commander "^6.2.1" @@ -4764,7 +4770,6 @@ cypress@^13.15.0: figures "^3.2.0" fs-extra "^9.1.0" getos "^3.2.1" - is-ci "^3.0.1" is-installed-globally "~0.4.0" lazy-ass "^1.6.0" listr2 "^3.8.3" @@ -4779,6 +4784,7 @@ cypress@^13.15.0: semver "^7.5.3" supports-color "^8.1.1" tmp "~0.2.3" + tree-kill "1.2.2" untildify "^4.0.0" yauzl "^2.10.0" @@ -6707,13 +6713,6 @@ is-callable@^1.2.4: resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945" integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w== -is-ci@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-3.0.1.tgz#db6ecbed1bd659c43dac0f45661e7674103d1867" - integrity sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ== - dependencies: - ci-info "^3.2.0" - is-core-module@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a" @@ -7588,10 +7587,10 @@ jsx-ast-utils@^3.2.1: array-includes "^3.1.3" object.assign "^4.1.2" -keycloak-js@^26.0.1: - version "26.0.1" - resolved "https://registry.yarnpkg.com/keycloak-js/-/keycloak-js-26.0.1.tgz#02042600c6cb8146389785bc5ce89b6c1bf92063" - integrity sha512-Fhn7a9FVKTpno2yfhL6/eiQrmEgBkiM+toVBJ1+g8kasG6CeiMKnI93byL5W8W3M7Ld3Im1QD3kuL/z4vJHGcg== +keycloak-js@^26.1.0: + version "26.1.0" + resolved "https://registry.yarnpkg.com/keycloak-js/-/keycloak-js-26.1.0.tgz#b3807b3d9d79935d00ad59bc77cde8189f16444a" + integrity sha512-3CTelLNADK6sIxGHCQmKlT3ezcIp8O3Iimmg+ybS78RHy+HAUkkoBaW/YuHGdYkfEDMBlrqD3u+CQ4vLsrmyFA== kind-of@^6.0.2: version "6.0.3" @@ -7786,7 +7785,7 @@ log-update@^4.0.0: slice-ansi "^4.0.0" wrap-ansi "^6.2.0" -loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -9181,6 +9180,13 @@ qs@6.13.0: dependencies: side-channel "^1.0.6" +qs@6.13.1: + version "6.13.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.1.tgz#3ce5fc72bd3a8171b85c99b93c65dd20b7d1b16e" + integrity sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg== + dependencies: + side-channel "^1.0.6" + querystringify@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" @@ -9267,13 +9273,12 @@ react-dev-utils@^12.0.1: strip-ansi "^6.0.1" text-table "^0.2.0" -react-dom@^18.3.1: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" - integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== +react-dom@^19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57" + integrity sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ== dependencies: - loose-envify "^1.1.0" - scheduler "^0.23.2" + scheduler "^0.25.0" react-error-overlay@^6.0.11: version "6.0.11" @@ -9350,12 +9355,10 @@ react-scripts@5.0.1: optionalDependencies: fsevents "^2.3.2" -react@^18.3.1: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" - integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== - dependencies: - loose-envify "^1.1.0" +react@^19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd" + integrity sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ== readable-stream@^2.0.1: version "2.3.7" @@ -9688,12 +9691,10 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" -scheduler@^0.23.2: - version "0.23.2" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" - integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== - dependencies: - loose-envify "^1.1.0" +scheduler@^0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015" + integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== schema-utils@2.7.0, schema-utils@^2.6.5: version "2.7.0" @@ -10467,6 +10468,18 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tldts-core@^6.1.72: + version "6.1.72" + resolved "https://registry.yarnpkg.com/tldts-core/-/tldts-core-6.1.72.tgz#32b38e1843f4adab57d2414a9ec4af9a81826bc0" + integrity sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g== + +tldts@^6.1.32: + version "6.1.72" + resolved "https://registry.yarnpkg.com/tldts/-/tldts-6.1.72.tgz#9b85f47e451e2ff079fab5801b4fa156ecda69f4" + integrity sha512-QNtgIqSUb9o2CoUjX9T5TwaIvUUJFU1+12PJkgt42DFV2yf9J6549yTF2uGloQsJ/JOC8X+gIB81ind97hRiIQ== + dependencies: + tldts-core "^6.1.72" + tmp@~0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" @@ -10494,7 +10507,7 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -tough-cookie@^4.0.0, tough-cookie@^4.1.3: +tough-cookie@^4.0.0: version "4.1.3" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== @@ -10504,6 +10517,13 @@ tough-cookie@^4.0.0, tough-cookie@^4.1.3: universalify "^0.2.0" url-parse "^1.5.3" +tough-cookie@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-5.1.0.tgz#0667b0f2fbb5901fe6f226c3e0b710a9a4292f87" + integrity sha512-rvZUv+7MoBYTiDmFPBrhL7Ujx9Sk+q9wwm22x8c8T5IJaR+Wsyc7TNxbVxo84kZoRJZZMazowFLqpankBEQrGg== + dependencies: + tldts "^6.1.32" + tr46@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" @@ -10525,6 +10545,11 @@ tr46@^2.1.0: dependencies: punycode "^2.1.1" +tree-kill@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + tryer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" diff --git a/example-integration-docker/build.gradle b/example-integration-docker/build.gradle index 5b9903d..484632e 100644 --- a/example-integration-docker/build.gradle +++ b/example-integration-docker/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'com.avast.gradle.docker-compose' version '0.17.10' + id 'com.avast.gradle.docker-compose' version '0.17.12' } composeUp { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 18330fc..8357d84 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/mock/build.gradle b/mock/build.gradle index 22dcfe5..4b43142 100644 --- a/mock/build.gradle +++ b/mock/build.gradle @@ -10,6 +10,7 @@ configurations { } ext { + // looks like starting from version 2.52, the included shadowed room library has Java compile version 11 dagger_version = '2.51.1' } @@ -21,13 +22,13 @@ dependencies { implementation "com.google.code.findbugs:jsr305:$jsr305_version" implementation "com.google.dagger:dagger:$dagger_version" implementation "org.slf4j:slf4j-api:$slf4j_version" - jsResourceJar "org.keycloak:keycloak-js-adapter:$keycloak_version@tar.gz" + jsResourceJar "org.keycloak:keycloak-js-adapter:$keycloak_js_version@tar.gz" htmlResourceJar "org.keycloak:keycloak-services:$keycloak_version" testImplementation 'io.fusionauth:fusionauth-jwt:5.3.3' testImplementation "io.rest-assured:rest-assured:$restassured_version" // required to mock RoutingContext testImplementation "io.vertx:vertx-codegen:$vertx_version" - testImplementation 'net.javacrumbs.json-unit:json-unit-assertj:3.4.1' + testImplementation 'net.javacrumbs.json-unit:json-unit-assertj:4.1.0' testImplementation "org.assertj:assertj-core:$assertj_version" testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "org.junit.jupiter:junit-jupiter-params" diff --git a/standalone/build.gradle b/standalone/build.gradle index 7a0dad2..c56680e 100644 --- a/standalone/build.gradle +++ b/standalone/build.gradle @@ -2,8 +2,8 @@ import com.github.jengelman.gradle.plugins.shadow.transformers.ApacheNoticeResou plugins { id 'application' - id 'com.github.gmazzo.buildconfig' version '5.5.0' - id 'com.gradleup.shadow' version '8.3.3' + id 'com.github.gmazzo.buildconfig' version '5.5.1' + id 'com.gradleup.shadow' version '8.3.5' } apply plugin: 'com.google.cloud.tools.jib' @@ -46,10 +46,10 @@ publishing { publication -> project.shadow.component(publication) artifact(fakeJar) { - classifier 'javadoc' + classifier = 'javadoc' } artifact(fakeJar) { - classifier 'sources' + classifier = 'sources' } } } From d52731f234825edefa70934567f00220d735605f Mon Sep 17 00:00:00 2001 From: Kai Helbig Date: Fri, 17 Jan 2025 21:19:26 +0100 Subject: [PATCH 4/9] change the way roles are assigned for login Instead of defining a list of resources to apply the roles to, the user can now define a list of default audiences to add. In a new, separate setting, the user can decide whether the roles given by the login shall be applied to the realm, all audiences (which double as resources), or both. This is a breaking API and behavior change! Signed-off-by: Kai Helbig --- .../keycloakmock/api/KeycloakMock.java | 1 + .../keycloakmock/api/LoginRoleMapping.java | 15 +++ .../keycloakmock/api/ServerConfig.java | 118 +++++++++++------- .../tngtech/keycloakmock/api/TokenConfig.java | 24 ++-- .../keycloakmock/impl/TokenGenerator.java | 12 +- .../impl/dagger/ServerModule.java | 39 +++--- .../impl/dagger/SignatureComponent.java | 11 +- .../keycloakmock/impl/helper/TokenHelper.java | 39 ++++-- .../api/KeycloakMockIntegrationTest.java | 8 +- .../keycloakmock/api/KeycloakMockTest.java | 13 ++ .../keycloakmock/api/TokenConfigTest.java | 2 +- .../keycloakmock/impl/TokenGeneratorTest.java | 67 ++++++++-- .../impl/helper/TokenHelperTest.java | 57 +++++++-- .../tngtech/keycloakmock/standalone/Main.java | 21 +++- 14 files changed, 307 insertions(+), 120 deletions(-) create mode 100644 mock/src/main/java/com/tngtech/keycloakmock/api/LoginRoleMapping.java diff --git a/mock/src/main/java/com/tngtech/keycloakmock/api/KeycloakMock.java b/mock/src/main/java/com/tngtech/keycloakmock/api/KeycloakMock.java index d8d6a27..ac53d25 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/api/KeycloakMock.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/api/KeycloakMock.java @@ -62,6 +62,7 @@ public KeycloakMock(@Nonnull final ServerConfig serverConfig) { this.signatureComponent = DaggerSignatureComponent.builder() .defaultScopes(serverConfig.getDefaultScopes()) + .defaultAudiences(serverConfig.getDefaultAudiences()) .defaultTokenLifespan(serverConfig.getDefaultTokenLifespan()) .build(); } diff --git a/mock/src/main/java/com/tngtech/keycloakmock/api/LoginRoleMapping.java b/mock/src/main/java/com/tngtech/keycloakmock/api/LoginRoleMapping.java new file mode 100644 index 0000000..c949789 --- /dev/null +++ b/mock/src/main/java/com/tngtech/keycloakmock/api/LoginRoleMapping.java @@ -0,0 +1,15 @@ +package com.tngtech.keycloakmock.api; + +/** Where to apply roles given in the Login page. */ +public enum LoginRoleMapping { + /** The roles should be added only to the realm. */ + TO_REALM, + /** + * The roles should be added only to the resources. + * + *

The audiences of the token are used as the list of resources. + */ + TO_RESOURCE, + /** The roles should be added both to the realm and the resources. */ + TO_BOTH +} diff --git a/mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java b/mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java index 7a5a576..c4c8f6a 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java @@ -24,9 +24,10 @@ public final class ServerConfig { @Nonnull private final String defaultHostname; @Nonnull private final String contextPath; @Nonnull private final String defaultRealm; - @Nonnull private final List resourcesToMapRolesTo; + @Nonnull private final List defaultAudiences; @Nonnull private final List defaultScopes; @Nonnull private final Duration defaultTokenLifespan; + @Nonnull private final LoginRoleMapping loginRoleMapping; private ServerConfig(@Nonnull final Builder builder) { this.port = builder.port; @@ -34,9 +35,14 @@ private ServerConfig(@Nonnull final Builder builder) { this.defaultHostname = builder.defaultHostname; this.contextPath = builder.contextPath; this.defaultRealm = builder.defaultRealm; - this.resourcesToMapRolesTo = builder.resourcesToMapRolesTo; + if (builder.defaultAudiences.isEmpty()) { + this.defaultAudiences = Collections.singletonList("server"); + } else { + this.defaultAudiences = builder.defaultAudiences; + } this.defaultScopes = builder.defaultScopes; this.defaultTokenLifespan = builder.defaultTokenLifespan; + this.loginRoleMapping = builder.loginRoleMapping; } /** @@ -69,19 +75,13 @@ public Protocol getProtocol() { } /** - * The resources for which roles will be set. - * - *

If this list is empty, a login via the built-in login page will take the comma-separated - * roles from the password field and assign it as realm roles. If it contains at least one - * resource, the roles will be mapped only to this resource. + * The audiences to add to the token by default. * - * @return the list of resources to which roles are mapped - * @see Realm Roles - * @see Client Roles + * @return the list of default audiences */ @Nonnull - public List getResourcesToMapRolesTo() { - return Collections.unmodifiableList(resourcesToMapRolesTo); + public List getDefaultAudiences() { + return Collections.unmodifiableList(defaultAudiences); } /** @@ -161,6 +161,16 @@ public Duration getDefaultTokenLifespan() { return defaultTokenLifespan; } + /** + * Get mapping logic for roles passed through login page. + * + * @return login role mapping + */ + @Nonnull + public LoginRoleMapping getLoginRoleMapping() { + return loginRoleMapping; + } + /** * Builder for {@link ServerConfig}. * @@ -173,9 +183,10 @@ public static final class Builder { @Nonnull private String defaultHostname = DEFAULT_HOSTNAME; @Nonnull private String contextPath = DEFAULT_CONTEXT_PATH; @Nonnull private String defaultRealm = DEFAULT_REALM; - @Nonnull private final List resourcesToMapRolesTo = new ArrayList<>(); + @Nonnull private final List defaultAudiences = new ArrayList<>(); @Nonnull private final List defaultScopes = new ArrayList<>(); @Nonnull private Duration defaultTokenLifespan = DEFAULT_TOKEN_LIFESPAN; + @Nonnull private LoginRoleMapping loginRoleMapping = LoginRoleMapping.TO_REALM; private Builder() { defaultScopes.add(DEFAULT_SCOPE); @@ -276,22 +287,41 @@ public Builder withDefaultRealm(@Nonnull final String defaultRealm) { } /** - * Set resources for which roles will be set. + * Add default audiences. + * + *

The audience that is issued in tokens if no explicit audience is configured for the token. + * + *

If no default audience is set, it will default to 'server'. + * + * @param audiences the audiences to add + * @return builder + * @see #withDefaultAudience(String) + * @see TokenConfig.Builder#withAudience(String) + * @see TokenConfig.Builder#withAudiences(Collection) + */ + @Nonnull + public Builder withDefaultAudiences(@Nonnull Collection audiences) { + defaultAudiences.addAll(audiences); + return this; + } + + /** + * Add a default audience. * - *

If this list is empty, a login via the built-in login page will take the comma-separated - * roles from the password field and assign it as realm roles. If it contains at least one - * resource, the roles will be mapped only to this resource. + *

The audience that is issued in tokens if no explicit audience is configured for the token. * - * @param resources the list of resources to which roles will be mapped + *

If no default audience is set, it will default to 'server'. + * + * @param resource an audience to add * @return builder - * @see #withResourceToMapRolesTo(String) - * @see Realm Roles - * @see Client - * Roles + * @see #withDefaultAudiences(Collection) (Collection) + * @see TokenConfig.Builder#withAudience(String) + * @see TokenConfig.Builder#withAudiences(Collection) */ + @SuppressWarnings("unused") @Nonnull - public Builder withResourcesToMapRolesTo(@Nonnull List resources) { - resourcesToMapRolesTo.addAll(resources); + public Builder withDefaultAudience(@Nonnull String resource) { + defaultAudiences.add(Objects.requireNonNull(resource)); return this; } @@ -327,26 +357,6 @@ public Builder withNoContextPath() { return this; } - /** - * Add a resource for which roles will be set. - * - *

If no resource is set, a login via the built-in login page will take the comma-separated - * roles from the password field and assign it as realm roles. If at least one resource is - * configured, the roles will be mapped only to this resource. - * - * @param resource a resource to which roles will be mapped - * @return builder - * @see #withResourcesToMapRolesTo(List) - * @see Realm Roles - * @see Client - * Roles - */ - @Nonnull - public Builder withResourceToMapRolesTo(@Nonnull String resource) { - resourcesToMapRolesTo.add(Objects.requireNonNull(resource)); - return this; - } - /** * Set default client scopes. * @@ -394,6 +404,26 @@ public Builder withDefaultTokenLifespan(@Nonnull final Duration tokenLifespan) { return this; } + /** + * Set the role mapping to use for the login route. + * + *

When using the login flow, the roles can only be given as a list. This setting allows + * specifying where they should be applied. + * + *

The default setting is {@link LoginRoleMapping#TO_REALM}. + * + *

This setting only applies when using the login flow via Browser, not when generating + * tokens programmatically. + * + * @param loginRoleMapping the role mapping + * @return builder + */ + @Nonnull + public Builder withLoginRoleMapping(@Nonnull final LoginRoleMapping loginRoleMapping) { + this.loginRoleMapping = loginRoleMapping; + return this; + } + /** * Build the server configuration. * diff --git a/mock/src/main/java/com/tngtech/keycloakmock/api/TokenConfig.java b/mock/src/main/java/com/tngtech/keycloakmock/api/TokenConfig.java index 1ce6316..0585d71 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/api/TokenConfig.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/api/TokenConfig.java @@ -60,11 +60,7 @@ public class TokenConfig { @Nullable private final String authenticationContextClassReference; private TokenConfig(@Nonnull final Builder builder) { - if (builder.audience.isEmpty()) { - audience = Collections.singleton("server"); - } else { - audience = builder.audience; - } + audience = builder.audience; authorizedParty = builder.authorizedParty; subject = builder.subject; generateUserDataFromSubject = builder.generateUserDataFromSubject; @@ -376,10 +372,14 @@ private String getRealm(@Nonnull final URI issuer) { /** * Add an audience. * - *

An audience is an identifier of a recipient of the token. + *

An audience is an identifier of a recipient of the token. If no audience is set explicitly + * here or in the {@link ServerConfig}, then the default value 'server' will be used. * * @param audience the audience to add * @return builder + * @see #withAudience(String) + * @see ServerConfig.Builder#withDefaultAudience(String) + * @see ServerConfig.Builder#withDefaultAudiences(Collection) * @see ID token */ @Nonnull @@ -391,10 +391,14 @@ public Builder withAudience(@Nonnull final String audience) { /** * Add a collection of audiences. * - *

An audience is an identifier of a recipient of the token. + *

An audience is an identifier of a recipient of the token. If no audience is set explicitly + * here or in the {@link ServerConfig}, then the default value 'server' will be used. * * @param audiences the audiences to add * @return builder + * @see #withAudiences(Collection) + * @see ServerConfig.Builder#withDefaultAudience(String) + * @see ServerConfig.Builder#withDefaultAudiences(Collection) * @see ID token */ @Nonnull @@ -568,6 +572,9 @@ public Builder withRealmRole(@Nonnull final String role) { * *

Resource roles only apply to a specific client or resource. * + *

Resources which have at least one role will automatically be added as audience to the + * token. + * * @param resource the resource or client for which to add the roles * @param roles the roles to add * @return builder @@ -589,6 +596,9 @@ public Builder withResourceRoles( * *

Resource roles only apply to a specific client or resource. * + *

Resources which have at least one role will automatically be added as audience to the + * token. + * * @param resource the resource or client for which to add the roles * @param role the role to add * @return builder diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/TokenGenerator.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/TokenGenerator.java index 4ad9b5e..eafd942 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/TokenGenerator.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/TokenGenerator.java @@ -10,6 +10,7 @@ import java.security.Key; import java.security.PublicKey; import java.time.Duration; +import java.util.Collection; import java.util.Date; import java.util.List; import java.util.Map; @@ -25,7 +26,8 @@ public class TokenGenerator { @Nonnull private final PublicKey publicKey; @Nonnull private final Key privateKey; @Nonnull private final String keyId; - @Nonnull private final List defaultScopes; + @Nonnull private final Collection defaultAudiences; + @Nonnull private final Collection defaultScopes; @Nonnull private final Duration defaultTokenLifespan; @Inject @@ -33,12 +35,14 @@ public class TokenGenerator { @Nonnull PublicKey publicKey, @Nonnull Key privateKey, @Nonnull @Named("keyId") String keyId, - @Nonnull @Named("scopes") List defaultScopes, - @Nonnull Duration defaultTokenLifespan) { + @Nonnull @Named("audiences") Collection defaultAudiences, + @Nonnull @Named("scopes") Collection defaultScopes, + @Nonnull @Named("tokenLifespan") Duration defaultTokenLifespan) { this.publicKey = publicKey; this.privateKey = privateKey; this.keyId = keyId; this.defaultScopes = defaultScopes; + this.defaultAudiences = defaultAudiences; this.defaultTokenLifespan = defaultTokenLifespan; } @@ -52,7 +56,7 @@ public String getToken( .type("JWT") .and() .audience() - .add(tokenConfig.getAudience()) + .add(tokenConfig.getAudience().isEmpty() ? defaultAudiences : tokenConfig.getAudience()) .and() .issuedAt(new Date(tokenConfig.getIssuedAt().toEpochMilli())) .claim("auth_time", tokenConfig.getAuthenticationTime().getEpochSecond()) diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java index 8ea3e51..fc1303d 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java @@ -1,5 +1,6 @@ package com.tngtech.keycloakmock.impl.dagger; +import com.tngtech.keycloakmock.api.LoginRoleMapping; import com.tngtech.keycloakmock.api.ServerConfig; import com.tngtech.keycloakmock.impl.UrlConfiguration; import com.tngtech.keycloakmock.impl.handler.AuthenticationRoute; @@ -34,8 +35,7 @@ import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; -import java.time.Duration; -import java.util.List; +import java.util.Collection; import javax.annotation.Nonnull; import javax.inject.Named; import javax.inject.Singleton; @@ -81,27 +81,6 @@ ResourceFileHandler provideKeycloakJsHandler() { return new ResourceFileHandler("/package/lib/keycloak.js"); } - @Provides - @Singleton - @Named("resources") - List provideResources(@Nonnull ServerConfig serverConfig) { - return serverConfig.getResourcesToMapRolesTo(); - } - - @Provides - @Singleton - @Named("scopes") - List provideScopes(@Nonnull ServerConfig serverConfig) { - return serverConfig.getDefaultScopes(); - } - - @Provides - @Singleton - @Named("tokenLifespan") - Duration provideTokenLifespan(@Nonnull ServerConfig serverConfig) { - return serverConfig.getDefaultTokenLifespan(); - } - @Provides @Singleton Buffer keystoreBuffer(@Nonnull KeyStore keyStore) { @@ -129,6 +108,7 @@ HttpServerOptions provideHttpServerOptions( @Provides @Singleton + @SuppressWarnings("java:S107") Router provideRouter( @Nonnull UrlConfiguration defaultConfiguration, @Nonnull Vertx vertx, @@ -191,4 +171,17 @@ HttpServer provideServer( .requestHandler(router) .exceptionHandler(t -> LOG.error("Exception while processing request", t)); } + + @Provides + @Singleton + LoginRoleMapping provideLoginRoleMapping(@Nonnull ServerConfig serverConfig) { + return serverConfig.getLoginRoleMapping(); + } + + @Provides + @Singleton + @Named("audiences") + Collection provideDefaultAudiences(@Nonnull ServerConfig serverConfig) { + return serverConfig.getDefaultAudiences(); + } } diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/SignatureComponent.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/SignatureComponent.java index e91e51c..627312b 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/SignatureComponent.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/SignatureComponent.java @@ -6,7 +6,7 @@ import java.security.KeyStore; import java.security.PublicKey; import java.time.Duration; -import java.util.List; +import java.util.Collection; import javax.inject.Named; import javax.inject.Singleton; @@ -28,10 +28,15 @@ public interface SignatureComponent { @Component.Builder abstract class Builder { @BindsInstance - public abstract Builder defaultScopes(@Named("scopes") List defaultScopes); + public abstract Builder defaultScopes(@Named("scopes") Collection defaultScopes); @BindsInstance - public abstract Builder defaultTokenLifespan(Duration defaultTokenLifespan); + public abstract Builder defaultAudiences( + @Named("audiences") Collection defaultAudiences); + + @BindsInstance + public abstract Builder defaultTokenLifespan( + @Named("tokenLifespan") Duration defaultTokenLifespan); public abstract SignatureComponent build(); } diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/helper/TokenHelper.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/helper/TokenHelper.java index 2fed6da..8c49a09 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/helper/TokenHelper.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/helper/TokenHelper.java @@ -2,12 +2,13 @@ import static com.tngtech.keycloakmock.api.TokenConfig.aTokenConfig; +import com.tngtech.keycloakmock.api.LoginRoleMapping; import com.tngtech.keycloakmock.api.TokenConfig.Builder; import com.tngtech.keycloakmock.impl.TokenGenerator; import com.tngtech.keycloakmock.impl.UrlConfiguration; import com.tngtech.keycloakmock.impl.session.Session; import com.tngtech.keycloakmock.impl.session.UserData; -import java.util.List; +import java.util.Collection; import java.util.Map; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -21,14 +22,17 @@ public class TokenHelper { private static final String NONCE = "nonce"; @Nonnull private final TokenGenerator tokenGenerator; - @Nonnull private final List resourcesToMapRolesTo; + @Nonnull private final Collection defaultAudiences; + @Nonnull private final LoginRoleMapping loginRoleMapping; @Inject TokenHelper( @Nonnull TokenGenerator tokenGenerator, - @Nonnull @Named("resources") List resourcesToMapRolesTo) { + @Nonnull @Named("audiences") Collection defaultAudiences, + @Nonnull LoginRoleMapping loginRoleMapping) { this.tokenGenerator = tokenGenerator; - this.resourcesToMapRolesTo = resourcesToMapRolesTo; + this.defaultAudiences = defaultAudiences; + this.loginRoleMapping = loginRoleMapping; } @Nullable @@ -37,8 +41,8 @@ public String getToken(@Nonnull Session session, @Nonnull UrlConfiguration reque Builder builder = aTokenConfig() .withAuthorizedParty(session.getClientId()) - // at the moment, there is no explicit way of setting an audience .withAudience(session.getClientId()) + .withAudiences(defaultAudiences) .withSubject(userData.getSubject()) .withPreferredUsername(userData.getPreferredUsername()) .withGivenName(userData.getGivenName()) @@ -52,18 +56,31 @@ public String getToken(@Nonnull Session session, @Nonnull UrlConfiguration reque if (session.getNonce() != null) { builder.withClaim(NONCE, session.getNonce()); } - if (resourcesToMapRolesTo.isEmpty()) { - builder.withRealmRoles(session.getRoles()); - } else { - for (String resource : resourcesToMapRolesTo) { - builder.withResourceRoles(resource, session.getRoles()); - } + switch (loginRoleMapping) { + case TO_REALM: + builder.withRealmRoles(session.getRoles()); + break; + case TO_RESOURCE: + setResourceRoles(builder, session); + break; + case TO_BOTH: + builder.withRealmRoles(session.getRoles()); + setResourceRoles(builder, session); + break; } // for simplicity, the access token is the same as the ID token return tokenGenerator.getToken(builder.build(), requestConfiguration); } + private void setResourceRoles(@Nonnull Builder builder, @Nonnull Session session) { + // we always set the client ID as audience, so we also need to set the roles + builder.withResourceRoles(session.getClientId(), session.getRoles()); + for (String audience : defaultAudiences) { + builder.withResourceRoles(audience, session.getRoles()); + } + } + @Nonnull public Map parseToken(@Nonnull String token) { return tokenGenerator.parseToken(token); diff --git a/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockIntegrationTest.java b/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockIntegrationTest.java index 47baabc..7164680 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockIntegrationTest.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockIntegrationTest.java @@ -552,7 +552,7 @@ private void logoutAndExpectSessionCookieReset(Method method) { assertThat(tokenConfig.getPreferredUsername()).isEqualTo("username"); assertThat(tokenConfig.getRealmAccess().getRoles()) .containsExactlyInAnyOrder("role1", "role2", "role3"); - assertThat(tokenConfig.getAudience()).containsExactly("client"); + assertThat(tokenConfig.getAudience()).containsExactlyInAnyOrder("client", "server"); } @Test @@ -583,7 +583,7 @@ void mock_server_login_with_resource_owner_password_credentials_flow_works() { assertThat(tokenConfig.getPreferredUsername()).isEqualTo("username"); assertThat(tokenConfig.getRealmAccess().getRoles()) .containsExactlyInAnyOrder("role1", "role2", "role3"); - assertThat(tokenConfig.getAudience()).containsExactly("client"); + assertThat(tokenConfig.getAudience()).containsExactlyInAnyOrder("client", "server"); } @Test @@ -612,7 +612,7 @@ void mock_server_login_with_client_credentials_flow_works() { assertThat(tokenConfig.getPreferredUsername()).isEqualTo("client"); assertThat(tokenConfig.getRealmAccess().getRoles()) .containsExactlyInAnyOrder("role1", "role2", "role3"); - assertThat(tokenConfig.getAudience()).containsExactly("client"); + assertThat(tokenConfig.getAudience()).containsExactlyInAnyOrder("client", "server"); } @Test @@ -640,7 +640,7 @@ void mock_server_login_with_client_credentials_flow_using_form_works() { assertThat(tokenConfig.getPreferredUsername()).isEqualTo("client"); assertThat(tokenConfig.getRealmAccess().getRoles()) .containsExactlyInAnyOrder("role1", "role2", "role3"); - assertThat(tokenConfig.getAudience()).containsExactly("client"); + assertThat(tokenConfig.getAudience()).containsExactlyInAnyOrder("client", "server"); } private static class ClientRequest { diff --git a/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockTest.java b/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockTest.java index 9f30da0..3535741 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockTest.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockTest.java @@ -54,4 +54,17 @@ void contains_default_client_scope_during_server_configuration() { assertThat(jwt.getPayload()).containsEntry("scope", "openid"); } + + @Test + void contains_default_audiences() { + Set resources = Sets.set("audience1", "audience2"); + KeycloakMock keycloakMock = + new KeycloakMock(aServerConfig().withDefaultAudiences(resources).build()); + + String token = keycloakMock.getAccessToken(TokenConfig.aTokenConfig().build()); + + Jws jwt = jwtParser.parseSignedClaims(token); + + assertThat(jwt.getPayload().getAudience()).containsExactlyInAnyOrder("audience1", "audience2"); + } } diff --git a/mock/src/test/java/com/tngtech/keycloakmock/api/TokenConfigTest.java b/mock/src/test/java/com/tngtech/keycloakmock/api/TokenConfigTest.java index c91d4a4..7eeb1dd 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/api/TokenConfigTest.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/api/TokenConfigTest.java @@ -30,7 +30,7 @@ void default_values_are_used() { TokenConfig config = aTokenConfig().build(); Instant now = Instant.now(); - assertThat(config.getAudience()).containsExactly("server"); + assertThat(config.getAudience()).isEmpty(); assertThat(config.getAuthenticationTime()).isBetween(now.minusSeconds(1), now); assertThat(config.getAuthorizedParty()).isEqualTo("client"); assertThat(config.getClaims()).isEmpty(); diff --git a/mock/src/test/java/com/tngtech/keycloakmock/impl/TokenGeneratorTest.java b/mock/src/test/java/com/tngtech/keycloakmock/impl/TokenGeneratorTest.java index 2d544c5..f2be9ed 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/impl/TokenGeneratorTest.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/impl/TokenGeneratorTest.java @@ -19,10 +19,12 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Map; +import org.assertj.core.util.Sets; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -71,9 +73,13 @@ void setup() throws URISyntaxException { doReturn(new URI(ISSUER)).when(urlConfiguration).getIssuer(); } - private TokenGenerator setupUut(List defaultScopes, Duration defaultLifespan) { + private TokenGenerator setupUut( + Collection defaultScopes, + Collection defaultAudiences, + Duration defaultLifespan) { return DaggerSignatureComponent.builder() .defaultScopes(defaultScopes) + .defaultAudiences(defaultAudiences) .defaultTokenLifespan(defaultLifespan) .build() .tokenGenerator(); @@ -82,7 +88,7 @@ private TokenGenerator setupUut(List defaultScopes, Duration defaultLife @Test @SuppressWarnings("unchecked") void config_is_correctly_applied() { - uut = setupUut(Collections.emptyList(), Duration.ofHours(10)); + uut = setupUut(Collections.emptyList(), Collections.emptyList(), Duration.ofHours(10)); String token = uut.getToken( @@ -131,7 +137,7 @@ void config_is_correctly_applied() { assertThat(claims.getIssuer()).isEqualTo(ISSUER); assertThat(claims.getSubject()).isEqualTo(SUBJECT); assertThat(claims) - .containsEntry("aud", Collections.singleton(AUDIENCE)) + .containsEntry("aud", Sets.set(AUDIENCE)) .containsEntry("azp", AUTHORIZED_PARTY) // openid is always added to the scope .containsEntry("scope", "openid " + SCOPE) @@ -163,7 +169,7 @@ void config_is_correctly_applied() { @Test void user_data_is_not_generated() { - uut = setupUut(Collections.emptyList(), Duration.ofHours(10)); + uut = setupUut(Collections.emptyList(), Collections.emptyList(), Duration.ofHours(10)); String token = uut.getToken(aTokenConfig().withSubject("foo.bar").build(), urlConfiguration); @@ -177,7 +183,7 @@ void user_data_is_not_generated() { @Test void user_data_is_generated() { - uut = setupUut(Collections.emptyList(), Duration.ofHours(10)); + uut = setupUut(Collections.emptyList(), Collections.emptyList(), Duration.ofHours(10)); doReturn("example.com").when(urlConfiguration).getHostname(); String token = @@ -198,7 +204,7 @@ void user_data_is_generated() { @Test void explicit_user_data_takes_preference() { - uut = setupUut(Collections.emptyList(), Duration.ofHours(10)); + uut = setupUut(Collections.emptyList(), Collections.emptyList(), Duration.ofHours(10)); doReturn("example.com").when(urlConfiguration).getHostname(); String token = @@ -226,7 +232,11 @@ void explicit_user_data_takes_preference() { @Test void default_scopes_are_used() { - uut = setupUut(Arrays.asList("scope1", "scope2", "scope3"), Duration.ofHours(10)); + uut = + setupUut( + Arrays.asList("scope1", "scope2", "scope3"), + Collections.emptyList(), + Duration.ofHours(10)); String token = uut.getToken(aTokenConfig().build(), urlConfiguration); @@ -238,7 +248,11 @@ void default_scopes_are_used() { @Test void default_scopes_are_overridden() { - uut = setupUut(Arrays.asList("scope1", "scope2", "scope3"), Duration.ofHours(10)); + uut = + setupUut( + Arrays.asList("scope1", "scope2", "scope3"), + Collections.emptyList(), + Duration.ofHours(10)); String token = uut.getToken( @@ -253,7 +267,7 @@ void default_scopes_are_overridden() { @Test void duplicate_scopes_are_removed() { - uut = setupUut(Collections.emptyList(), Duration.ofHours(10)); + uut = setupUut(Collections.emptyList(), Collections.emptyList(), Duration.ofHours(10)); String token = uut.getToken( @@ -268,7 +282,7 @@ void duplicate_scopes_are_removed() { @Test void default_lifespan_is_used() { - uut = setupUut(Collections.emptyList(), Duration.ofHours(5)); + uut = setupUut(Collections.emptyList(), Collections.emptyList(), Duration.ofHours(5)); String token = uut.getToken(aTokenConfig().build(), urlConfiguration); @@ -282,7 +296,7 @@ void default_lifespan_is_used() { @Test void default_lifespan_is_overridden_with_token_lifespan() { - uut = setupUut(Collections.emptyList(), Duration.ofHours(5)); + uut = setupUut(Collections.emptyList(), Collections.emptyList(), Duration.ofHours(5)); String token = uut.getToken( @@ -295,4 +309,35 @@ void default_lifespan_is_overridden_with_token_lifespan() { .isAfter(Instant.now().plus(19, ChronoUnit.HOURS).plus(59, ChronoUnit.MINUTES)) .isBefore(Instant.now().plus(20, ChronoUnit.HOURS)); } + + @Test + void default_audiences_are_used() { + String audience1 = "audience1"; + String audience2 = "audience2"; + + uut = setupUut(Collections.emptyList(), Sets.set(audience1, audience2), Duration.ofHours(10)); + + String token = uut.getToken(aTokenConfig().build(), urlConfiguration); + + Claims claims = + Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token).getPayload(); + + assertThat(claims.getAudience()).containsExactlyInAnyOrder(audience1, audience2); + } + + @Test + void token_audience_overrides_defaults() { + String audience1 = "audience1"; + String audience2 = "audience2"; + + uut = setupUut(Collections.emptyList(), Sets.set(audience1, audience2), Duration.ofHours(10)); + + String token = + uut.getToken(aTokenConfig().withAudience("look-only-at-me").build(), urlConfiguration); + + Claims claims = + Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token).getPayload(); + + assertThat(claims.getAudience()).containsExactlyInAnyOrder("look-only-at-me"); + } } diff --git a/mock/src/test/java/com/tngtech/keycloakmock/impl/helper/TokenHelperTest.java b/mock/src/test/java/com/tngtech/keycloakmock/impl/helper/TokenHelperTest.java index 6d447b4..4dc93a8 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/impl/helper/TokenHelperTest.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/impl/helper/TokenHelperTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doReturn; +import com.tngtech.keycloakmock.api.LoginRoleMapping; import com.tngtech.keycloakmock.api.TokenConfig; import com.tngtech.keycloakmock.impl.TokenGenerator; import com.tngtech.keycloakmock.impl.UrlConfiguration; @@ -15,6 +16,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import org.assertj.core.util.Lists; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -32,7 +34,6 @@ class TokenHelperTest { private static final UserData USER = UserData.fromUsernameAndHostname("jane.user", "example.com"); private static final String TOKEN = "token123"; private static final List ROLES = Arrays.asList("role1", "role2"); - private static final List CONFIGURED_RESOURCES = Arrays.asList("resource1", "resource2"); @Mock private TokenGenerator tokenGenerator; @@ -55,7 +56,7 @@ void setup() { @Test void token_is_correctly_generated() { - uut = new TokenHelper(tokenGenerator, Collections.emptyList()); + uut = new TokenHelper(tokenGenerator, Collections.emptyList(), LoginRoleMapping.TO_REALM); uut.getToken(session, urlConfiguration); @@ -80,17 +81,59 @@ void token_is_correctly_generated() { } @Test - void resource_roles_are_used_if_configured() { - uut = new TokenHelper(tokenGenerator, CONFIGURED_RESOURCES); + void default_audiences_are_added() { + uut = + new TokenHelper( + tokenGenerator, Lists.list("audience1", "audience2"), LoginRoleMapping.TO_REALM); uut.getToken(session, urlConfiguration); TokenConfig tokenConfig = configCaptor.getValue(); + assertThat(tokenConfig.getAudience()) + .containsExactlyInAnyOrder(CLIENT_ID, "audience1", "audience2"); + assertThat(tokenConfig.getResourceAccess()).isEmpty(); + } + + @Test + void resource_roles_are_added() { + uut = + new TokenHelper( + tokenGenerator, Lists.list("audience1", "audience2"), LoginRoleMapping.TO_RESOURCE); + + uut.getToken(session, urlConfiguration); + + TokenConfig tokenConfig = configCaptor.getValue(); + assertThat(tokenConfig.getAudience()) + .containsExactlyInAnyOrder(CLIENT_ID, "audience1", "audience2"); assertThat(tokenConfig.getRealmAccess().getRoles()).isEmpty(); - assertThat(tokenConfig.getResourceAccess()).containsOnlyKeys("resource1", "resource2"); - assertThat(tokenConfig.getResourceAccess().get("resource1").getRoles()) + assertThat(tokenConfig.getResourceAccess()) + .containsOnlyKeys(CLIENT_ID, "audience1", "audience2"); + assertThat(tokenConfig.getResourceAccess().get(CLIENT_ID).getRoles()) + .containsExactlyInAnyOrderElementsOf(ROLES); + assertThat(tokenConfig.getResourceAccess().get("audience1").getRoles()) + .containsExactlyInAnyOrderElementsOf(ROLES); + assertThat(tokenConfig.getResourceAccess().get("audience2").getRoles()) + .containsExactlyInAnyOrderElementsOf(ROLES); + } + + @Test + void resource_and_realm_roles_are_added() { + uut = + new TokenHelper( + tokenGenerator, Lists.list("audience1", "audience2"), LoginRoleMapping.TO_BOTH); + + uut.getToken(session, urlConfiguration); + + TokenConfig tokenConfig = configCaptor.getValue(); + assertThat(tokenConfig.getAudience()) + .containsExactlyInAnyOrder(CLIENT_ID, "audience1", "audience2"); + assertThat(tokenConfig.getResourceAccess()) + .containsOnlyKeys(CLIENT_ID, "audience1", "audience2"); + assertThat(tokenConfig.getResourceAccess().get(CLIENT_ID).getRoles()) + .containsExactlyInAnyOrderElementsOf(ROLES); + assertThat(tokenConfig.getResourceAccess().get("audience1").getRoles()) .containsExactlyInAnyOrderElementsOf(ROLES); - assertThat(tokenConfig.getResourceAccess().get("resource2").getRoles()) + assertThat(tokenConfig.getResourceAccess().get("audience2").getRoles()) .containsExactlyInAnyOrderElementsOf(ROLES); } } diff --git a/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java b/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java index 5aac88c..5fc418e 100644 --- a/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java +++ b/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java @@ -3,6 +3,7 @@ import static com.tngtech.keycloakmock.api.ServerConfig.aServerConfig; import com.tngtech.keycloakmock.api.KeycloakMock; +import com.tngtech.keycloakmock.api.LoginRoleMapping; import java.time.Duration; import java.util.Collections; import java.util.List; @@ -49,11 +50,12 @@ public class Main implements Callable { @SuppressWarnings("FieldMayBeFinal") @Option( - names = {"-r", "--mapRolesToResources"}, - description = "If set, roles will be assigned to these resources instead of the realm.", - paramLabel = "RESOURCE", + names = {"-a", "--audiences"}, + description = + "Audiences to set in the token in addition to the client_id (default: [server]).", + paramLabel = "AUDIENCE", split = ",") - private List resourcesToMapRolesTo = Collections.emptyList(); + private List audiences = Collections.emptyList(); @SuppressWarnings("FieldMayBeFinal") @Option( @@ -71,6 +73,14 @@ public class Main implements Callable { + " '15m', '3m45s'.") private String tokenLifespan = "10h"; + @SuppressWarnings("FieldMayBeFinal") + @Option( + names = {"-rm", "--roleMapping"}, + description = + "Where to add the roles given in the login dialog (default: ${DEFAULT-VALUE}). Valid" + + " options: ${COMPLETION-CANDIDATES}") + private LoginRoleMapping loginRoleMapping = LoginRoleMapping.TO_REALM; + public static void main(@Nonnull final String[] args) { if (System.getProperty("org.slf4j.simpleLogger.logFile") == null) { System.setProperty("org.slf4j.simpleLogger.logFile", "System.out"); @@ -90,9 +100,10 @@ public Void call() { .withPort(port) .withTls(tls) .withContextPath(usedContextPath) - .withResourcesToMapRolesTo(resourcesToMapRolesTo) + .withDefaultAudiences(audiences) .withDefaultScopes(scopes) .withDefaultTokenLifespan(getParsedLifespan()) + .withLoginRoleMapping(loginRoleMapping) .build()) .start(); From 9224a4ec784fefc35edcaf7dc61bfb43961c79d8 Mon Sep 17 00:00:00 2001 From: Kai Helbig Date: Fri, 17 Jan 2025 21:22:34 +0100 Subject: [PATCH 5/9] use context path for keycloak.js Yes, I know that Keycloak proper has stopped serving the keycloak.js file. But since I want to stay somewhat backward-compatible regarding old Keycloak versions, I should still ensure correctness. Signed-off-by: Kai Helbig --- .../com/tngtech/keycloakmock/impl/UrlConfiguration.java | 9 +++++++-- .../tngtech/keycloakmock/impl/dagger/ServerModule.java | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/UrlConfiguration.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/UrlConfiguration.java index 0cc3c2b..a8787fc 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/UrlConfiguration.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/UrlConfiguration.java @@ -77,14 +77,19 @@ URI getBaseUrl() { } } + @Nonnull + public URI getContextPath(String path) { + return getBaseUrl().resolve(contextPath + path); + } + @Nonnull public URI getIssuer() { - return getBaseUrl().resolve(contextPath + ISSUER_PATH + realm); + return getContextPath(ISSUER_PATH + realm); } @Nonnull public URI getIssuerPath() { - return getBaseUrl().resolve(contextPath + ISSUER_PATH + realm + "/"); + return getContextPath(ISSUER_PATH + realm + "/"); } @Nonnull diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java index fc1303d..8007381 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java @@ -158,7 +158,7 @@ Router provideRouter( .method(HttpMethod.POST) .handler(logoutRoute); router.get(routing.getOutOfBandLoginLoginEndpoint().getPath()).handler(outOfBandLoginRoute); - router.route("/auth/js/keycloak.js").handler(keycloakJsRoute); + router.route(routing.getContextPath("/js/keycloak.js").getPath()).handler(keycloakJsRoute); return router; } From 5175ab55460b7292cbf2b340a79cd2bc06c6861c Mon Sep 17 00:00:00 2001 From: Kai Helbig Date: Fri, 17 Jan 2025 21:23:23 +0100 Subject: [PATCH 6/9] remove deprecated methods They have been deprecated for almost 4 years now. Let's drop them. Signed-off-by: Kai Helbig --- .../keycloakmock/api/ServerConfig.java | 58 ------------------- 1 file changed, 58 deletions(-) diff --git a/mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java b/mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java index c4c8f6a..f3ca7d9 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java @@ -84,18 +84,6 @@ public List getDefaultAudiences() { return Collections.unmodifiableList(defaultAudiences); } - /** - * The default hostname used in issuer claim. - * - * @return default hostname - * @deprecated use {@link #getDefaultHostname()} instead - */ - @Nonnull - @Deprecated - public String getHostname() { - return getDefaultHostname(); - } - /** * The default hostname used in issuer claim. * @@ -119,18 +107,6 @@ public String getContextPath() { return contextPath; } - /** - * The default realm used in issuer claim. - * - * @return default realm - * @deprecated use {@link #getDefaultRealm()} instead - */ - @Nonnull - @Deprecated - public String getRealm() { - return getDefaultRealm(); - } - /** * The default realm used in issuer claim. * @@ -220,23 +196,6 @@ public Builder withPort(final int port) { return this; } - /** - * Set default hostname. - * - *

The hostname that is used as token issuer if no explicit hostname is configured for the - * token. Default value is 'localhost'. - * - * @param defaultHostname the hostname to use - * @return builder - * @see TokenConfig.Builder#withHostname(String) - * @deprecated use {@link #withDefaultHostname(String)} instead - */ - @Nonnull - @Deprecated - public Builder withHostname(@Nonnull final String defaultHostname) { - return withDefaultHostname(defaultHostname); - } - /** * Set default hostname. * @@ -253,23 +212,6 @@ public Builder withDefaultHostname(@Nonnull final String defaultHostname) { return this; } - /** - * Set default realm. - * - *

The realm that is used in issued tokens if no explicit realm is configured for the token. - * Default value is 'master'. - * - * @param defaultRealm the realm to use - * @return builder - * @see TokenConfig.Builder#withRealm(String) - * @deprecated use {@link #withDefaultRealm(String)} instead - */ - @Nonnull - @Deprecated - public Builder withRealm(@Nonnull final String defaultRealm) { - return withDefaultRealm(defaultRealm); - } - /** * Set default realm. * From 3d7c0a6a553c33c3ef71f35c8012c04f1d0b9e61 Mon Sep 17 00:00:00 2001 From: Kai Helbig Date: Fri, 17 Jan 2025 23:29:36 +0100 Subject: [PATCH 7/9] add documentation endpoint and UI improvements for login page Signed-off-by: Kai Helbig --- README.md | 5 ++ .../impl/dagger/ServerModule.java | 50 +++++++++++-- .../impl/handler/DocumentationRoute.java | 69 ++++++++++++++++++ .../keycloakmock/impl/handler/LoginRoute.java | 35 +++++---- mock/src/main/resources/documentation.ftl | 27 +++++++ mock/src/main/resources/loginPage.ftl | 8 +-- mock/src/main/resources/style.css | 72 +++++++++++++++++++ .../api/KeycloakMockIntegrationTest.java | 67 ++++++++++------- .../tngtech/keycloakmock/standalone/Main.java | 8 +-- 9 files changed, 287 insertions(+), 54 deletions(-) create mode 100644 mock/src/main/java/com/tngtech/keycloakmock/impl/handler/DocumentationRoute.java create mode 100644 mock/src/main/resources/documentation.ftl create mode 100644 mock/src/main/resources/style.css diff --git a/README.md b/README.md index dc799d0..0f61a34 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,11 @@ You can even use it as a replacement in end-to-end tests, as the server is e.g. `cypress-keycloak`. Have a look at the [example-frontend-react](example-frontend-react) project on this can be set up. +## Server Method documentation + +You can get a list of all implemented endpoints of the mock at `http://localhost:8000/docs`. This is mainly meant for +checking if a specific endpoint you want to use is supported by the mock (yet). + ## License This project is licensed under the Apache 2.0 license (see [LICENSE](LICENSE)). diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java index 8007381..b85ba11 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java @@ -5,6 +5,7 @@ import com.tngtech.keycloakmock.impl.UrlConfiguration; import com.tngtech.keycloakmock.impl.handler.AuthenticationRoute; import com.tngtech.keycloakmock.impl.handler.CommonHandler; +import com.tngtech.keycloakmock.impl.handler.DocumentationRoute; import com.tngtech.keycloakmock.impl.handler.FailureHandler; import com.tngtech.keycloakmock.impl.handler.IFrameRoute; import com.tngtech.keycloakmock.impl.handler.JwksRoute; @@ -81,6 +82,13 @@ ResourceFileHandler provideKeycloakJsHandler() { return new ResourceFileHandler("/package/lib/keycloak.js"); } + @Provides + @Singleton + @Named("stylesheet") + ResourceFileHandler provideStylesheetHandler() { + return new ResourceFileHandler("/style.css"); + } + @Provides @Singleton Buffer keystoreBuffer(@Nonnull KeyStore keyStore) { @@ -125,7 +133,9 @@ Router provideRouter( @Nonnull @Named("cookie2") ResourceFileHandler thirdPartyCookies2Route, @Nonnull LogoutRoute logoutRoute, @Nonnull OutOfBandLoginRoute outOfBandLoginRoute, - @Nonnull @Named("keycloakJs") ResourceFileHandler keycloakJsRoute) { + @Nonnull @Named("keycloakJs") ResourceFileHandler keycloakJsRoute, + @Nonnull @Named("stylesheet") ResourceFileHandler stylesheetRoute, + @Nonnull DocumentationRoute documentationRoute) { UrlConfiguration routing = defaultConfiguration.forRequestContext(null, ":realm"); Router router = Router.router(vertx); router @@ -133,32 +143,58 @@ Router provideRouter( .handler(commonHandler) .failureHandler(failureHandler) .failureHandler(ErrorHandler.create(vertx)); - router.get(routing.getJwksUri().getPath()).handler(jwksRoute); - router.get(routing.getIssuerPath().resolve(".well-known/*").getPath()).handler(wellKnownRoute); - router.get(routing.getAuthorizationEndpoint().getPath()).handler(loginRoute); + router.get(routing.getJwksUri().getPath()).setName("key signing data").handler(jwksRoute); + router + .get(routing.getIssuerPath().resolve(".well-known/*").getPath()) + .setName("configuration discovery data") + .handler(wellKnownRoute); + router + .get(routing.getAuthorizationEndpoint().getPath()) + .setName("login page") + .handler(loginRoute); router .post(routing.getAuthenticationCallbackEndpoint(":sessionId").getPath()) + .setName("custom authentication endpoint used by login page") .handler(BodyHandler.create()) .handler(authenticationRoute); router .post(routing.getTokenEndpoint().getPath()) + .setName("token endpoint") .handler(BodyHandler.create()) .handler(basicAuthHandler) .handler(tokenRoute); - router.get(routing.getOpenIdPath("login-status-iframe.html*").getPath()).handler(iframeRoute); + router + .get(routing.getOpenIdPath("login-status-iframe.html*").getPath()) + .setName("Keycloak login iframe") + .handler(iframeRoute); router .get(routing.getOpenIdPath("3p-cookies/step1.html").getPath()) + .setName("keycloak third party cookies - step 1") .handler(thirdPartyCookies1Route); router .get(routing.getOpenIdPath("3p-cookies/step2.html").getPath()) + .setName("Keycloak third party cookies - step 2") .handler(thirdPartyCookies2Route); router .route(routing.getEndSessionEndpoint().getPath()) + .setName("logout endpoint") .method(HttpMethod.GET) .method(HttpMethod.POST) .handler(logoutRoute); - router.get(routing.getOutOfBandLoginLoginEndpoint().getPath()).handler(outOfBandLoginRoute); - router.route(routing.getContextPath("/js/keycloak.js").getPath()).handler(keycloakJsRoute); + router + .get(routing.getOutOfBandLoginLoginEndpoint().getPath()) + .setName("out-of-band login endpoint") + .handler(outOfBandLoginRoute); + router + .get(routing.getContextPath("/js/keycloak.js").getPath()) + .setName("provided keycloak.js") + .handler(keycloakJsRoute); + router.get("/style.css").handler(stylesheetRoute); + router + .get("/docs") + .setName("documentation endpoint") + .produces("text/html") + .handler(documentationRoute); return router; } diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/DocumentationRoute.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/DocumentationRoute.java new file mode 100644 index 0000000..3b8e54f --- /dev/null +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/DocumentationRoute.java @@ -0,0 +1,69 @@ +package com.tngtech.keycloakmock.impl.handler; + +import dagger.Lazy; +import io.vertx.core.Handler; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.Route; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.common.template.TemplateEngine; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.inject.Inject; +import javax.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public class DocumentationRoute implements Handler { + private static final Logger LOG = LoggerFactory.getLogger(DocumentationRoute.class); + + @Nonnull private final Lazy lazyRouter; + @Nonnull private final TemplateEngine engine; + + @Inject + public DocumentationRoute(@Nonnull Lazy lazyRouter, @Nonnull TemplateEngine engine) { + this.lazyRouter = lazyRouter; + this.engine = engine; + } + + @Override + public void handle(RoutingContext routingContext) { + List descriptions = + lazyRouter.get().getRoutes().stream() + // annoyingly, if a path is set but the name is null, the path is returned instead + .filter(r -> r.getName() != null && !Objects.equals(r.getName(), r.getPath())) + .sorted(Comparator.comparing(Route::getPath)) + .collect(Collectors.toList()); + if ("application/json".equals(routingContext.getAcceptableContentType())) { + JsonObject result = new JsonObject(); + descriptions.forEach( + r -> { + JsonObject routeDescription = new JsonObject(); + routeDescription.put( + "methods", + r.methods().stream().map(HttpMethod::name).sorted().collect(Collectors.toList())); + routeDescription.put("description", r.getName()); + result.put(r.getPath(), routeDescription); + }); + routingContext.response().putHeader("content-type", "application/json").end(result.encode()); + } else { + routingContext.put("descriptions", descriptions); + engine + .render(routingContext.data(), "documentation.ftl") + .onSuccess( + b -> + routingContext.response().putHeader(HttpHeaders.CONTENT_TYPE, "text/html").end(b)) + .onFailure( + t -> { + LOG.error("Unable to render documentation page", t); + routingContext.fail(t); + }); + } + } +} diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/LoginRoute.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/LoginRoute.java index a81dafa..3dc88b9 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/LoginRoute.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/LoginRoute.java @@ -60,20 +60,27 @@ public void handle(@Nonnull RoutingContext routingContext) { .map(sessionRepository::getSession); // for now, we just override the settings of the session with values of the new client - SessionRequest request = - new SessionRequest.Builder() - .setClientId(routingContext.queryParams().get(CLIENT_ID)) - .setState(routingContext.queryParams().get(STATE)) - .setRedirectUri(routingContext.queryParams().get(REDIRECT_URI)) - .setSessionId( - existingSession - .map(PersistentSession::getSessionId) - .orElseGet(() -> UUID.randomUUID().toString())) - .setResponseType(routingContext.queryParams().get(RESPONSE_TYPE)) - // optional parameter - .setNonce(routingContext.queryParams().get(NONCE)) - .setResponseMode(routingContext.queryParams().get(RESPONSE_MODE)) - .build(); + SessionRequest request; + try { + request = + new SessionRequest.Builder() + .setClientId(routingContext.queryParams().get(CLIENT_ID)) + .setRedirectUri(routingContext.queryParams().get(REDIRECT_URI)) + .setSessionId( + existingSession + .map(PersistentSession::getSessionId) + .orElseGet(() -> UUID.randomUUID().toString())) + .setResponseType(routingContext.queryParams().get(RESPONSE_TYPE)) + // optional parameter + .setState(routingContext.queryParams().get(STATE)) + .setNonce(routingContext.queryParams().get(NONCE)) + .setResponseMode(routingContext.queryParams().get(RESPONSE_MODE)) + .build(); + } catch (NullPointerException e) { + LOG.warn("Mandatory parameter missing", e); + routingContext.fail(400); + return; + } UrlConfiguration requestConfiguration = baseConfiguration.forRequestContext(routingContext); if (existingSession.isPresent()) { diff --git a/mock/src/main/resources/documentation.ftl b/mock/src/main/resources/documentation.ftl new file mode 100644 index 0000000..449b472 --- /dev/null +++ b/mock/src/main/resources/documentation.ftl @@ -0,0 +1,27 @@ + + + + + + Documentation + + + +

Keycloak Mock API

+

These are the endpoints that are currently supported by Keycloak Mock.

+ + + + + + + <#list descriptions as description> + + + + + + +
MethodsPathDescription
${description.methods()?join(", ")}${description.getPath()}${description.getName()}
+ + diff --git a/mock/src/main/resources/loginPage.ftl b/mock/src/main/resources/loginPage.ftl index b1cdbda..9635f51 100644 --- a/mock/src/main/resources/loginPage.ftl +++ b/mock/src/main/resources/loginPage.ftl @@ -4,6 +4,8 @@ Login + +

Keycloak Mock

@@ -12,14 +14,12 @@

-
- +

-
- +

diff --git a/mock/src/main/resources/style.css b/mock/src/main/resources/style.css new file mode 100644 index 0000000..ee0a905 --- /dev/null +++ b/mock/src/main/resources/style.css @@ -0,0 +1,72 @@ +body { + font-family: Arial, sans-serif; + margin: 1em; + background-color: #f0f0f0; +} + +h1 { + color: #00698f; +} + +form { + width: 20em; + margin: 1em auto; + padding: 1em; + background-color: #fff; + border: 0.1em solid #ddd; + border-radius: 1em; + box-shadow: 0 0 1em rgba(0, 0, 0, 0.1); +} + +label { + display: block; + margin-bottom: 0.5em; +} + +input[type="text"] { + width: calc(100% - 2em); + height: 2.5em; + margin-bottom: 1em; + padding: 0 0.5em; + border: 0.1em solid #ccc; + border-radius: 0.25em; +} + +button[type="submit"] { + width: 100%; + height: 2.5em; + background-color: #00698f; + color: #fff; + padding: 0.5em; + border: none; + border-radius: 0.25em; + cursor: pointer; +} + +button[type="submit"]:hover { + background-color: #004d6f; +} + +table { + border-collapse: collapse; + width: 100%; + margin-top: 1em; +} + +th, td { + border: 0.1em solid #ddd; + padding: 0.5em; + text-align: left; +} + +th { + background-color: #f2f2f2; +} + +tr:nth-child(even) { + background-color: #fff; +} + +tr:hover { + background-color: #ddd; +} diff --git a/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockIntegrationTest.java b/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockIntegrationTest.java index 7164680..d4bcc67 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockIntegrationTest.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/api/KeycloakMockIntegrationTest.java @@ -2,7 +2,9 @@ import static com.tngtech.keycloakmock.api.ServerConfig.aServerConfig; import static com.tngtech.keycloakmock.test.KeyHelper.loadValidKey; +import static io.restassured.RestAssured.enableLoggingOfRequestAndResponseIfValidationFails; import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; import static io.restassured.config.RedirectConfig.redirectConfig; import static io.restassured.config.RestAssuredConfig.config; import static io.restassured.http.ContentType.HTML; @@ -24,7 +26,6 @@ import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; -import io.restassured.RestAssured; import io.restassured.http.ContentType; import io.restassured.http.Cookie; import io.restassured.http.Method; @@ -63,7 +64,7 @@ static void setupJwtsParser() { @BeforeAll static void setupRestAssured() { - RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + enableLoggingOfRequestAndResponseIfValidationFails(); } @AfterEach @@ -99,7 +100,7 @@ void mock_server_can_be_started_and_stopped_twice() { private void assertServerMockRunnning(boolean running) { try { - RestAssured.when() + when() .get("http://localhost:8000/auth/realms/master/protocol/openid-connect/certs") .then() .statusCode(200) @@ -124,7 +125,7 @@ void mock_server_fails_when_port_is_claimed() { void mock_server_endpoint_is_correctly_configured(int port, boolean tls) { keycloakMock = new KeycloakMock(aServerConfig().withPort(port).withTls(tls).build()); keycloakMock.start(); - RestAssured.given() + given() .relaxedHTTPSValidation() .when() .get( @@ -213,7 +214,7 @@ void mock_server_uses_host_header_as_server_host() { keycloakMock = new KeycloakMock(); keycloakMock.start(); String issuer = - RestAssured.given() + given() .when() .header("Host", hostname) .get("http://localhost:8000/auth/realms/test/.well-known/openid-configuration") @@ -243,7 +244,7 @@ private static Stream resourcesWithContent() { void mock_server_answers_204_on_iframe_init() { keycloakMock = new KeycloakMock(); keycloakMock.start(); - RestAssured.given() + given() .when() .get( "http://localhost:8000/auth/realms/test/protocol/openid-connect/login-status-iframe.html/init") @@ -261,7 +262,7 @@ void mock_server_properly_returns_resources( keycloakMock = new KeycloakMock(); keycloakMock.start(); String body = - RestAssured.given() + given() .when() .get("http://localhost:8000/auth" + resource) .then() @@ -280,12 +281,7 @@ void mock_server_properly_returns_resources( void mock_server_returns_404_on_nonexistent_resource() { keycloakMock = new KeycloakMock(); keycloakMock.start(); - RestAssured.given() - .when() - .get("http://localhost:8000/i-do-not-exist") - .then() - .assertThat() - .statusCode(404); + given().when().get("http://localhost:8000/i-do-not-exist").then().assertThat().statusCode(404); } @Test @@ -347,7 +343,7 @@ void mock_server_login_with_authorization_code_flow_works() throws Exception { } private String openLoginPageAndGetCallbackUrl(ClientRequest request) { - return RestAssured.given() + return given() .when() .get(request.getLoginPageUrl()) .then() @@ -364,7 +360,7 @@ private String openLoginPageAndGetCallbackUrl(ClientRequest request) { private Cookie loginAndValidateAndReturnSessionCookie(ClientRequest request, String callbackUrl) throws URISyntaxException { ExtractableResponse extractableResponse = - RestAssured.given() + given() .config(config().redirect(redirectConfig().followRedirects(false))) .when() .formParam("username", "username") @@ -384,7 +380,7 @@ private Cookie loginAndValidateAndReturnSessionCookie(ClientRequest request, Str private String loginAndValidateAndReturnAuthCode(ClientRequest request, String callbackUrl) throws URISyntaxException { ExtractableResponse extractableResponse = - RestAssured.given() + given() .config(config().redirect(redirectConfig().followRedirects(false))) .when() .formParam("username", "username") @@ -402,7 +398,7 @@ private String loginAndValidateAndReturnAuthCode(ClientRequest request, String c private String validateAuthorizationAndRetrieveToken(String authorizationCode, String nonce) { ExtractableResponse extractableResponse = - RestAssured.given() + given() .config(config().redirect(redirectConfig().followRedirects(false))) .when() .formParam("grant_type", "authorization_code") @@ -422,7 +418,7 @@ private String validateAuthorizationAndRetrieveToken(String authorizationCode, S private void validateRefreshTokenFlow(String refreshToken, String nonce) { ExtractableResponse extractableResponse = - RestAssured.given() + given() .config(config().redirect(redirectConfig().followRedirects(false))) .when() .formParam("grant_type", "refresh_token") @@ -450,7 +446,7 @@ private void validateToken(String accessToken, String nonce) { private void openLoginPageAgainAndExpectToBeLoggedInAlready( ClientRequest request, Cookie keycloakSession) throws URISyntaxException { String location = - RestAssured.given() + given() .config(config().redirect(redirectConfig().followRedirects(false))) .when() .cookie(keycloakSession) @@ -512,7 +508,7 @@ private String validateCookieAndReturnSessionId(Cookie keycloakSession) { } private void logoutAndExpectSessionCookieReset(Method method) { - RestAssured.given() + given() .config(config().redirect(redirectConfig().followRedirects(false))) .when() .request( @@ -532,7 +528,7 @@ private void logoutAndExpectSessionCookieReset(Method method) { keycloakMock.start(); ExtractableResponse extractableResponse = - RestAssured.given() + given() .when() .formParam("client_id", "client") .formParam("username", "username") @@ -561,7 +557,7 @@ void mock_server_login_with_resource_owner_password_credentials_flow_works() { keycloakMock.start(); ExtractableResponse extractableResponse = - RestAssured.given() + given() .auth() .preemptive() .basic("client", "does not matter") @@ -592,7 +588,7 @@ void mock_server_login_with_client_credentials_flow_works() { keycloakMock.start(); ExtractableResponse extractableResponse = - RestAssured.given() + given() .auth() .preemptive() .basic("client", "role1,role2,role3") @@ -621,7 +617,7 @@ void mock_server_login_with_client_credentials_flow_using_form_works() { keycloakMock.start(); ExtractableResponse extractableResponse = - RestAssured.given() + given() .when() .formParam("grant_type", "client_credentials") .formParam("client_id", "client") @@ -643,6 +639,29 @@ void mock_server_login_with_client_credentials_flow_using_form_works() { assertThat(tokenConfig.getAudience()).containsExactlyInAnyOrder("client", "server"); } + @Test + void documentation_works() { + keycloakMock = new KeycloakMock(); + keycloakMock.start(); + + ExtractableResponse extractableResponse = + given() + .when() + .get("http://localhost:8000/docs") + .then() + .assertThat() + .statusCode(200) + .extract(); + + assertThat(extractableResponse.body().asPrettyString()) + .contains( + " \n" + + " GET\n" + + " /docs\n" + + " documentation endpoint\n" + + " "); + } + private static class ClientRequest { private final String redirectUri; diff --git a/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java b/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java index 5fc418e..0b03c33 100644 --- a/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java +++ b/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java @@ -107,11 +107,9 @@ public Void call() { .build()) .start(); - LOG.info( - "Server is running on {}://localhost:{}{}", - (tls ? "https" : "http"), - port, - usedContextPath); + String url = (tls ? "https" : "http") + "://localhost:" + port; + LOG.info("Server is running on {}{}", url, usedContextPath); + LOG.info("A documentation of all endpoints is available at {}/docs", url); return null; } From e10ae71e34ec4aec393301e11c22f2079b677fc7 Mon Sep 17 00:00:00 2001 From: Kai Helbig Date: Sat, 18 Jan 2025 10:57:37 +0100 Subject: [PATCH 8/9] downgrade keycloak-js if you are still relying on the shipped js, you probably also want the old version, as v26 introduced some breaking changes Signed-off-by: Kai Helbig --- build.gradle | 3 ++- mock/build.gradle | 4 ++-- .../com/tngtech/keycloakmock/impl/dagger/ServerModule.java | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 19aea26..5f4939d 100644 --- a/build.gradle +++ b/build.gradle @@ -48,7 +48,8 @@ ext { junit4_version = '4.13.2' junit5_version = '5.11.4' keycloak_version = '26.0.4' - keycloak_js_version = '26.1.0' + // the last version that was shipped together with keycloak server + keycloak_js_version = '25.0.6' mockito_version = '5.15.2' picocli_version = '4.7.6' restassured_version = '5.5.0' diff --git a/mock/build.gradle b/mock/build.gradle index 4b43142..56f99ad 100644 --- a/mock/build.gradle +++ b/mock/build.gradle @@ -41,8 +41,8 @@ tasks.register('addResources', Copy) { // make sure to fail if both JARs contain a NOTICE, then we need to merge the files somehow ... duplicatesStrategy = DuplicatesStrategy.FAIL from files(tarTree(configurations.jsResourceJar.singleFile)) { - include '/package/lib/keycloak.js' - include '/META-INF/NOTICE' + include '/package/dist/keycloak.js' + include '/package/LICENSE.txt' } from files(zipTree(configurations.htmlResourceJar.singleFile)) { include '/org/keycloak/protocol/oidc/endpoints/3p-cookies-step1.html' diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java index b85ba11..cf7df55 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/dagger/ServerModule.java @@ -79,7 +79,7 @@ ResourceFileHandler provideCookie2Handler() { @Singleton @Named("keycloakJs") ResourceFileHandler provideKeycloakJsHandler() { - return new ResourceFileHandler("/package/lib/keycloak.js"); + return new ResourceFileHandler("/package/dist/keycloak.js"); } @Provides From 9ac87bb76fd03bff96c0c71eb518623dfcb5e50a Mon Sep 17 00:00:00 2001 From: Kai Helbig Date: Mon, 20 Jan 2025 08:36:34 +0100 Subject: [PATCH 9/9] disable coverage check of standalone main as there's no useful way of testing this Signed-off-by: Kai Helbig --- standalone/build.gradle | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/standalone/build.gradle b/standalone/build.gradle index c56680e..1e3a662 100644 --- a/standalone/build.gradle +++ b/standalone/build.gradle @@ -70,6 +70,12 @@ dependencies { implementation "org.slf4j:slf4j-simple:$slf4j_version" } +sonar { + properties { + property "sonar.coverage.exclusions", "src/main/java/com/tngtech/keycloakmock/standalone/Main.java" + } +} + application { mainClass = 'com.tngtech.keycloakmock.standalone.Main' }