From 7eb619918f7e975090843541cb2f80928200371e Mon Sep 17 00:00:00 2001 From: Craig McClendon Date: Fri, 7 Oct 2022 15:29:54 -0500 Subject: [PATCH] update to keycloak 18.x.x quarkus version Signed-off-by: Craig McClendon --- Dockerfile | 24 +- README.md | 10 +- jboss-fhir-provider/pom.xml | 49 -- .../src/main/resources/modules.xml | 37 -- keycloak-config/Dockerfile | 4 +- keycloak-config/pom.xml | 4 +- .../config/keycloak-config-with-idp.json | 2 +- .../config/util/PropertyGroupTest.java | 429 +++++++------- .../src/test/resources/keycloak-config.json | 2 +- keycloak-extensions/pom.xml | 55 +- .../keycloak/PatientSelectionForm.java | 551 +++++++++--------- .../keycloak/KeycloakContainerTest.java | 250 ++++---- .../utils/SeleniumOauthInteraction.java | 438 +++++++------- .../src/test/resources/jboss/module.xml | 20 - pom.xml | 30 +- 15 files changed, 924 insertions(+), 981 deletions(-) delete mode 100644 jboss-fhir-provider/pom.xml delete mode 100644 jboss-fhir-provider/src/main/resources/modules.xml delete mode 100644 keycloak-extensions/src/test/resources/jboss/module.xml diff --git a/Dockerfile b/Dockerfile index 52011e8..69b0e40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,21 +5,31 @@ # ---------------------------------------------------------------------------- # Build stage -FROM maven:3-jdk-11-slim AS build +FROM maven:3-openjdk-18-slim AS build COPY pom.xml ./ COPY keycloak-config ./keycloak-config -COPY jboss-fhir-provider ./jboss-fhir-provider COPY keycloak-extensions ./keycloak-extensions RUN mvn -B clean package -DskipTests # Package stage -FROM quay.io/keycloak/keycloak:18.0.0-legacy +FROM quay.io/keycloak/keycloak:18.0.2 # This can be overridden, but without this I've found the db vendor-detection in Keycloak to be brittle -ENV DB_VENDOR=H2 +ENV KC_HEALTH_ENABLED=true + +# Install custom providers +#RUN curl -sL https://github.com/aerogear/keycloak-metrics-spi/releases/download/2.5.3/keycloak-metrics-spi-2.5.3.jar -o /opt/keycloak/providers/keycloak-metrics-spi-2.5.3.jar + +COPY --from=build keycloak-extensions/target/keycloak-extensions-*.jar /opt/keycloak/providers/ + +RUN /opt/keycloak/bin/kc.sh build --health-enabled=true + +#for debug, show the config +RUN /opt/keycloak/bin/kc.sh show-config + +#NOTE - This will run the server in developer mode. Production deployments should change 'start-dev' to 'start' +# and will require additional configuration. See: https://www.keycloak.org/server/configuration +ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start-dev"] -COPY --from=build keycloak-extensions/target/keycloak-extensions-*.jar /opt/jboss/keycloak/standalone/deployments/ -COPY --from=build jboss-fhir-provider/target/jboss-modules/ /opt/jboss/keycloak/modules/system/layers/base/ -RUN rm -rf /opt/jboss/keycloak/docs diff --git a/README.md b/README.md index 11d40bf..9953663 100644 --- a/README.md +++ b/README.md @@ -64,17 +64,21 @@ Published Docker images from this project: * [alvearie/smart-keycloak](https://quay.io/repository/alvearie/smart-keycloak) extends the official Keycloak image with the `keycloak-extensions` and their dependencies * [alvearie/keycloak-config](https://quay.io/repository/alvearie/keycloak-config) packages the `keycloak-config` module on top of `adoptopenjdk/openjdk11-openj9:ubi` (for configuring Keycloak realms) +**Warning** : +The `alvearie/smart-keycloak` image starts Keycloak in development mode. Non-test deployments will need to use their own Dockerfile which starts the Keycloak in production mode and will require further configuration. See the Keycloak guides for more information. https://www.keycloak.org/guides + + By default, the `alvearie/smart-keycloak` image will behave identical to the Keycloak image from which it extends. Here is an example for running the image with a keycloak username and password of admin/admin: ``` -docker run -p 8080:8080 -p 8443:8443 -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=admin alvearie/smart-keycloak +docker run -p 8080:8080 -p 8443:8443 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin alvearie/smart-keycloak ``` Once you have it running, execute the `alvearie/keycloak-config` image to create or update a Keycloak realm with SMART App Launch support. By default, `alvearie/keycloak-config` will use the following environment variables to connect to Keycloak and configure the KEYCLOAK_REALM with SMART App Launch support for a FHIR server at FHIR_BASE_URL: - * KEYCLOAK_BASE_URL=http://host.docker.internal:8080/auth + * KEYCLOAK_BASE_URL=http://host.docker.internal:8080 * KEYCLOAK_USER=admin * KEYCLOAK_PASSWORD=admin * KEYCLOAK_REALM=test @@ -85,7 +89,7 @@ Additionally, the default keycloak-config image will create a single Keycloak us It is possible to override these environment variables via the command line (using the `-e` flag), or even to pass an entirely different configuration file by specifying a docker run command. For example, to update a Keycloak server that is listening on port 8081 of the docker host with a custom configuration, you could run a command like the following: ``` -docker run -v /local/config/dir:/config -e KEYCLOAK_BASE_URL=http://host.docker.internal:8081/auth alvearie/keycloak-config -configFile config/keycloak-config.json +docker run -v /local/config/dir:/config -e KEYCLOAK_BASE_URL=http://host.docker.internal:8081 alvearie/keycloak-config -configFile config/keycloak-config.json ``` See https://github.com/Alvearie/keycloak-extensions-for-fhir/tree/main/keycloak-config/src/main/resources/config for the example configurations that are shipped with this image. diff --git a/jboss-fhir-provider/pom.xml b/jboss-fhir-provider/pom.xml deleted file mode 100644 index 74af1f3..0000000 --- a/jboss-fhir-provider/pom.xml +++ /dev/null @@ -1,49 +0,0 @@ - - 4.0.0 - - org.alvearie - keycloak-extensions-parent - 0.5.0-SNAPSHOT - - jboss-fhir-provider - jar - - - - com.ibm.fhir - fhir-provider - - - - - - - de.smartics.maven.plugin - smartics-jboss-modules-maven-plugin - 2.1.5 - - - build-module - - create-modules-archive - - package - - - - src/main/resources - - - com.github.ben-manes.caffeine - - - com.github.stephenc - - - - - - - diff --git a/jboss-fhir-provider/src/main/resources/modules.xml b/jboss-fhir-provider/src/main/resources/modules.xml deleted file mode 100644 index 8cbeec5..0000000 --- a/jboss-fhir-provider/src/main/resources/modules.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - com.ibm.fhir - - - org.glassfish - jakarta.json - - - - - - - - - - - - - .+ - - - com.github.ben-manes..+ - com.github.stephenc..+ - com.google.errorprone..+ - - - - true - - - - - \ No newline at end of file diff --git a/keycloak-config/Dockerfile b/keycloak-config/Dockerfile index 1475b64..a00cee2 100644 --- a/keycloak-config/Dockerfile +++ b/keycloak-config/Dockerfile @@ -9,7 +9,7 @@ # > docker build . -f keycloak-config/Dockerfile # Build stage -FROM maven:3-jdk-11-slim AS build +FROM maven:3-openjdk-18-slim AS build COPY pom.xml ./ COPY keycloak-config ./keycloak-config @@ -27,7 +27,7 @@ COPY --from=build --chown=1001:0 keycloak-config/target/dependency/* /jars/ COPY --from=build --chown=1001:0 keycloak-config/src/main/resources/config/keycloak-config.json /config/ COPY --from=build --chown=1001:0 keycloak-config/run.sh / -ENV KEYCLOAK_BASE_URL=http://host.docker.internal:8080/auth \ +ENV KEYCLOAK_BASE_URL=http://host.docker.internal:8080 \ KEYCLOAK_REALM=test \ KEYCLOAK_USER=admin \ KEYCLOAK_PASSWORD=admin \ diff --git a/keycloak-config/pom.xml b/keycloak-config/pom.xml index d6b2b69..c83d054 100644 --- a/keycloak-config/pom.xml +++ b/keycloak-config/pom.xml @@ -32,8 +32,8 @@ commons-text - junit - junit + org.junit.jupiter + junit-jupiter-api diff --git a/keycloak-config/src/main/resources/config/keycloak-config-with-idp.json b/keycloak-config/src/main/resources/config/keycloak-config-with-idp.json index 156c5e9..ee28240 100644 --- a/keycloak-config/src/main/resources/config/keycloak-config-with-idp.json +++ b/keycloak-config/src/main/resources/config/keycloak-config-with-idp.json @@ -1,6 +1,6 @@ { "keycloak": { - "serverUrl": "http://keycloak:8080/auth", + "serverUrl": "http://keycloak:8080", "adminUser": "${KEYCLOAK_USER}", "adminPassword": "${KEYCLOAK_PASSWORD}", "adminClientId": "admin-cli", diff --git a/keycloak-config/src/test/java/org/alvearie/keycloak/config/util/PropertyGroupTest.java b/keycloak-config/src/test/java/org/alvearie/keycloak/config/util/PropertyGroupTest.java index 999a2ac..5f11ba7 100644 --- a/keycloak-config/src/test/java/org/alvearie/keycloak/config/util/PropertyGroupTest.java +++ b/keycloak-config/src/test/java/org/alvearie/keycloak/config/util/PropertyGroupTest.java @@ -5,19 +5,19 @@ */ package org.alvearie.keycloak.config.util; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; import java.util.Collections; import java.util.List; import java.util.Map; -import org.alvearie.keycloak.config.util.PropertyGroup.PropertyEntry; -import org.junit.BeforeClass; -import org.junit.Test; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; import jakarta.json.Json; import jakarta.json.JsonBuilderFactory; @@ -27,206 +27,215 @@ import jakarta.json.stream.JsonGeneratorFactory; public class PropertyGroupTest { - private static final JsonBuilderFactory BUILDER_FACTORY = Json.createBuilderFactory(null); - private static JsonObject obj = null; - - private static boolean DEBUG = true; - - @BeforeClass - public static void setup() { - // Build a JSON object for testing. - obj = BUILDER_FACTORY.createObjectBuilder() - .add("level1", BUILDER_FACTORY.createObjectBuilder() - .add("level2", BUILDER_FACTORY.createObjectBuilder() - .add("scalars", BUILDER_FACTORY.createObjectBuilder() - .add("stringProp", "stringValue") - .add("intProp", 123) - .add("booleanProp", true) - .add("booleanProp-2", "true")) - .add("arrays", BUILDER_FACTORY.createObjectBuilder() - .add("int-array", BUILDER_FACTORY.createArrayBuilder() - .add(1) - .add(2) - .add(3)) - .add("string-array", BUILDER_FACTORY.createArrayBuilder() - .add("one") - .add("two")) - .add("object-array", BUILDER_FACTORY.createArrayBuilder() - .add(BUILDER_FACTORY.createObjectBuilder() - .add("attr1", "val1")) - .add(BUILDER_FACTORY.createObjectBuilder() - .add("attr2", "val2")))) - .add("nulls", BUILDER_FACTORY.createObjectBuilder() - .add("nullProp", JsonValue.NULL) - .add("nullArrayProp", BUILDER_FACTORY.createArrayBuilder() - .add(JsonValue.NULL))))) - .build(); - - if (DEBUG) { - Map config = Collections.singletonMap(JsonGenerator.PRETTY_PRINTING, true); - JsonGeneratorFactory factory = Json.createGeneratorFactory(config); - JsonGenerator generator = factory.createGenerator(System.out); - generator.write(obj); - generator.flush(); - System.out.println(); - } - } - - @Test - public void testGetPropertyGroup() throws Exception { - PropertyGroup pg = new PropertyGroup(obj); - - PropertyGroup scalars = pg.getPropertyGroup("level1|level2|scalars"); - assertNotNull(scalars); - PropertyGroup result = scalars.getPropertyGroup("scalars"); - assertNull(result); - String value = scalars.getStringProperty("stringProp"); - assertNotNull(value); - assertEquals("stringValue", value); - } - - @Test - public void testStringProperty() throws Exception { - PropertyGroup pg = new PropertyGroup(obj); - String value = pg.getStringProperty("level1|level2|scalars|stringProp"); - assertNotNull(value); - assertEquals("stringValue", value); - } - - @Test - public void testIntProperty() { - PropertyGroup pg = new PropertyGroup(obj); - Integer value = pg.getIntProperty("level1|level2|scalars|intProp"); - assertNotNull(value); - assertEquals(123, value.intValue()); - } - - @Test - public void testBooleanProperty() { - PropertyGroup pg = new PropertyGroup(obj); - Boolean value = pg.getBooleanProperty("level1|level2|scalars|booleanProp"); - assertNotNull(value); - assertEquals(Boolean.TRUE, value); - - value = pg.getBooleanProperty("level1|level2|scalars|booleanProp-2"); - assertNotNull(value); - assertEquals(Boolean.TRUE, value); - } - - @Test - public void testArrayProperty() throws Exception { - PropertyGroup pg = new PropertyGroup(obj); - Object[] array = pg.getArrayProperty("level1|level2|arrays|string-array"); - assertNotNull(array); - assertEquals(2, array.length); - assertEquals("one", array[0]); - assertEquals("two", array[1]); - } - - @Test - public void testStringListProperty() throws Exception { - PropertyGroup pg = new PropertyGroup(obj); - List strings = pg.getStringListProperty("level1|level2|arrays|string-array"); - assertNotNull(strings); - assertEquals(2, strings.size()); - assertEquals("one", strings.get(0)); - assertEquals("two", strings.get(1)); - - strings = pg.getStringListProperty("level1|level2|arrays|int-array"); - assertNotNull(strings); - assertEquals(3, strings.size()); - assertEquals("1", strings.get(0)); - assertEquals("2", strings.get(1)); - assertEquals("3", strings.get(2)); - } - - @Test - public void testObjectArrayProperty() throws Exception { - PropertyGroup pg = new PropertyGroup(obj); - Object[] array = pg.getArrayProperty("level1|level2|arrays|object-array"); - assertNotNull(array); - assertEquals(2, array.length); - if (!(array[0] instanceof PropertyGroup)) { - fail("array element 0 not a PropertyGroup!"); - } - if (!(array[1] instanceof PropertyGroup)) { - fail("array element 1 not a PropertyGroup!"); - } - - // Check the first element. - PropertyGroup pg0 = (PropertyGroup) array[0]; - String val1 = pg0.getStringProperty("attr1"); - assertNotNull(val1); - assertEquals("val1", val1); - - // Check the second element. - PropertyGroup pg1 = (PropertyGroup) array[1]; - String val2 = pg1.getStringProperty("attr2"); - assertNotNull(val2); - assertEquals("val2", val2); - } - - @Test - public void testNullProperty() throws Exception { - PropertyGroup pg = new PropertyGroup(obj); - assertNull(pg.getJsonValue("level1|level2|nulls|nullProp")); - assertNull(pg.getStringProperty("level1|level2|nulls|nullProp")); - assertNull(pg.getBooleanProperty("level1|level2|nulls|nullProp")); - assertNull(pg.getIntProperty("level1|level2|nulls|nullProp")); - assertNull(pg.getDoubleProperty("level1|level2|nulls|nullProp")); - assertNull(pg.getStringListProperty("level1|level2|nulls|nullProp")); - } - - @Test - public void testNonExistentProperty() throws Exception { - PropertyGroup pg = new PropertyGroup(obj); - assertNull(pg.getJsonValue("bogus")); - assertNull(pg.getStringProperty("bogus")); - assertNull(pg.getBooleanProperty("bogus")); - assertNull(pg.getIntProperty("bogus")); - assertNull(pg.getDoubleProperty("bogus")); - assertNull(pg.getStringListProperty("bogus")); - - assertNull(pg.getJsonValue("level1|bogus")); - assertNull(pg.getStringProperty("level1|bogus")); - assertNull(pg.getBooleanProperty("level1|bogus")); - assertNull(pg.getIntProperty("level1|bogus")); - assertNull(pg.getDoubleProperty("level1|bogus")); - assertNull(pg.getStringListProperty("level1|bogus")); - - assertNull(pg.getJsonValue("bogus|bogus")); - assertNull(pg.getStringProperty("bogus|bogus")); - assertNull(pg.getBooleanProperty("bogus|bogus")); - assertNull(pg.getIntProperty("bogus|bogus")); - assertNull(pg.getDoubleProperty("bogus|bogus")); - assertNull(pg.getStringListProperty("bogus|bogus")); - } - - @Test(expected = IllegalArgumentException.class) - public void testStringPropertyException() throws Exception { - PropertyGroup pg = new PropertyGroup(obj); - Object result = pg.getStringProperty("level1|level2|scalars|intProp"); - System.err.println("Unexpected result: " + result); - } - - @Test(expected = IllegalArgumentException.class) - public void testIntPropertyException() { - PropertyGroup pg = new PropertyGroup(obj); - Object result = pg.getIntProperty("level1|level2|scalars|stringProp"); - System.err.println("Unexpected result: " + result); - } - - @Test(expected = IllegalArgumentException.class) - public void testBooleanPropertyException() { - PropertyGroup pg = new PropertyGroup(obj); - Object result = pg.getBooleanProperty("level1|level2|scalars|intProp"); - System.err.println("Unexpected result: " + result); - } - - @Test(expected = IllegalArgumentException.class) - public void testArrayPropertyException() throws Exception { - PropertyGroup pg = new PropertyGroup(obj); - Object result = pg.getArrayProperty("level1|level2"); - System.err.println("Unexpected result: " + result); - } + private static final JsonBuilderFactory BUILDER_FACTORY = Json.createBuilderFactory(null); + private static JsonObject obj = null; + + private static boolean DEBUG = true; + + @BeforeAll + public static void setup() { + // Build a JSON object for testing. + obj = BUILDER_FACTORY.createObjectBuilder() + .add("level1", BUILDER_FACTORY.createObjectBuilder() + .add("level2", BUILDER_FACTORY.createObjectBuilder() + .add("scalars", BUILDER_FACTORY.createObjectBuilder() + .add("stringProp", "stringValue") + .add("intProp", 123) + .add("booleanProp", true) + .add("booleanProp-2", "true")) + .add("arrays", BUILDER_FACTORY.createObjectBuilder() + .add("int-array", BUILDER_FACTORY.createArrayBuilder() + .add(1) + .add(2) + .add(3)) + .add("string-array", BUILDER_FACTORY.createArrayBuilder() + .add("one") + .add("two")) + .add("object-array", BUILDER_FACTORY.createArrayBuilder() + .add(BUILDER_FACTORY.createObjectBuilder() + .add("attr1", "val1")) + .add(BUILDER_FACTORY.createObjectBuilder() + .add("attr2", "val2")))) + .add("nulls", BUILDER_FACTORY.createObjectBuilder() + .add("nullProp", JsonValue.NULL) + .add("nullArrayProp", BUILDER_FACTORY.createArrayBuilder() + .add(JsonValue.NULL))))) + .build(); + + if (DEBUG) { + Map config = Collections.singletonMap(JsonGenerator.PRETTY_PRINTING, true); + JsonGeneratorFactory factory = Json.createGeneratorFactory(config); + JsonGenerator generator = factory.createGenerator(System.out); + generator.write(obj); + generator.flush(); + System.out.println(); + } + } + + @Test + public void testGetPropertyGroup() throws Exception { + PropertyGroup pg = new PropertyGroup(obj); + + PropertyGroup scalars = pg.getPropertyGroup("level1|level2|scalars"); + assertNotNull(scalars); + PropertyGroup result = scalars.getPropertyGroup("scalars"); + assertNull(result); + String value = scalars.getStringProperty("stringProp"); + assertNotNull(value); + assertEquals("stringValue", value); + } + + @Test + public void testStringProperty() throws Exception { + PropertyGroup pg = new PropertyGroup(obj); + String value = pg.getStringProperty("level1|level2|scalars|stringProp"); + assertNotNull(value); + assertEquals("stringValue", value); + } + + @Test + public void testIntProperty() { + PropertyGroup pg = new PropertyGroup(obj); + Integer value = pg.getIntProperty("level1|level2|scalars|intProp"); + assertNotNull(value); + assertEquals(123, value.intValue()); + } + + @Test + public void testBooleanProperty() { + PropertyGroup pg = new PropertyGroup(obj); + Boolean value = pg.getBooleanProperty("level1|level2|scalars|booleanProp"); + assertNotNull(value); + assertEquals(Boolean.TRUE, value); + + value = pg.getBooleanProperty("level1|level2|scalars|booleanProp-2"); + assertNotNull(value); + assertEquals(Boolean.TRUE, value); + } + + @Test + public void testArrayProperty() throws Exception { + PropertyGroup pg = new PropertyGroup(obj); + Object[] array = pg.getArrayProperty("level1|level2|arrays|string-array"); + assertNotNull(array); + assertEquals(2, array.length); + assertEquals("one", array[0]); + assertEquals("two", array[1]); + } + + @Test + public void testStringListProperty() throws Exception { + PropertyGroup pg = new PropertyGroup(obj); + List strings = pg.getStringListProperty("level1|level2|arrays|string-array"); + assertNotNull(strings); + assertEquals(2, strings.size()); + assertEquals("one", strings.get(0)); + assertEquals("two", strings.get(1)); + + strings = pg.getStringListProperty("level1|level2|arrays|int-array"); + assertNotNull(strings); + assertEquals(3, strings.size()); + assertEquals("1", strings.get(0)); + assertEquals("2", strings.get(1)); + assertEquals("3", strings.get(2)); + } + + @Test + public void testObjectArrayProperty() throws Exception { + PropertyGroup pg = new PropertyGroup(obj); + Object[] array = pg.getArrayProperty("level1|level2|arrays|object-array"); + assertNotNull(array); + assertEquals(2, array.length); + if (!(array[0] instanceof PropertyGroup)) { + fail("array element 0 not a PropertyGroup!"); + } + if (!(array[1] instanceof PropertyGroup)) { + fail("array element 1 not a PropertyGroup!"); + } + + // Check the first element. + PropertyGroup pg0 = (PropertyGroup) array[0]; + String val1 = pg0.getStringProperty("attr1"); + assertNotNull(val1); + assertEquals("val1", val1); + + // Check the second element. + PropertyGroup pg1 = (PropertyGroup) array[1]; + String val2 = pg1.getStringProperty("attr2"); + assertNotNull(val2); + assertEquals("val2", val2); + } + + @Test + public void testNullProperty() throws Exception { + PropertyGroup pg = new PropertyGroup(obj); + assertNull(pg.getJsonValue("level1|level2|nulls|nullProp")); + assertNull(pg.getStringProperty("level1|level2|nulls|nullProp")); + assertNull(pg.getBooleanProperty("level1|level2|nulls|nullProp")); + assertNull(pg.getIntProperty("level1|level2|nulls|nullProp")); + assertNull(pg.getDoubleProperty("level1|level2|nulls|nullProp")); + assertNull(pg.getStringListProperty("level1|level2|nulls|nullProp")); + } + + @Test + public void testNonExistentProperty() throws Exception { + PropertyGroup pg = new PropertyGroup(obj); + assertNull(pg.getJsonValue("bogus")); + assertNull(pg.getStringProperty("bogus")); + assertNull(pg.getBooleanProperty("bogus")); + assertNull(pg.getIntProperty("bogus")); + assertNull(pg.getDoubleProperty("bogus")); + assertNull(pg.getStringListProperty("bogus")); + + assertNull(pg.getJsonValue("level1|bogus")); + assertNull(pg.getStringProperty("level1|bogus")); + assertNull(pg.getBooleanProperty("level1|bogus")); + assertNull(pg.getIntProperty("level1|bogus")); + assertNull(pg.getDoubleProperty("level1|bogus")); + assertNull(pg.getStringListProperty("level1|bogus")); + + assertNull(pg.getJsonValue("bogus|bogus")); + assertNull(pg.getStringProperty("bogus|bogus")); + assertNull(pg.getBooleanProperty("bogus|bogus")); + assertNull(pg.getIntProperty("bogus|bogus")); + assertNull(pg.getDoubleProperty("bogus|bogus")); + assertNull(pg.getStringListProperty("bogus|bogus")); + } + + @Test + public void testStringPropertyException() throws Exception { + assertThrows(IllegalArgumentException.class, () -> { + PropertyGroup pg = new PropertyGroup(obj); + Object result = pg.getStringProperty("level1|level2|scalars|intProp"); + System.err.println("Unexpected result: " + result); + }); + } + + @Test + public void testIntPropertyException() { + assertThrows(IllegalArgumentException.class, () -> { + PropertyGroup pg = new PropertyGroup(obj); + Object result = pg.getIntProperty("level1|level2|scalars|stringProp"); + System.err.println("Unexpected result: " + result); + }); + } + + @Test + public void testBooleanPropertyException() { + assertThrows(IllegalArgumentException.class, () -> { + PropertyGroup pg = new PropertyGroup(obj); + Object result = pg.getBooleanProperty("level1|level2|scalars|intProp"); + System.err.println("Unexpected result: " + result); + }); + } + + @Test + public void testArrayPropertyException() { + assertThrows(IllegalArgumentException.class, () -> { + + PropertyGroup pg = new PropertyGroup(obj); + Object result = pg.getArrayProperty("level1|level2"); + System.err.println("Unexpected result: " + result); + }); + } } diff --git a/keycloak-config/src/test/resources/keycloak-config.json b/keycloak-config/src/test/resources/keycloak-config.json index a60b8ee..fff7b3c 100644 --- a/keycloak-config/src/test/resources/keycloak-config.json +++ b/keycloak-config/src/test/resources/keycloak-config.json @@ -1,6 +1,6 @@ { "keycloak": { - "serverUrl": "http://localhost:55095/auth", + "serverUrl": "http://localhost:55095", "adminUser": "admin", "adminPassword": "admin", "adminClientId": "admin-cli", diff --git a/keycloak-extensions/pom.xml b/keycloak-extensions/pom.xml index c024d4c..e40a931 100644 --- a/keycloak-extensions/pom.xml +++ b/keycloak-extensions/pom.xml @@ -11,18 +11,6 @@ jar - - - com.ibm.fhir - fhir-provider - provided - - - com.ibm.fhir - fhir-model - provided - org.keycloak keycloak-server-spi @@ -44,6 +32,7 @@ com.github.dasniko testcontainers-keycloak + test io.rest-assured @@ -73,10 +62,41 @@ com.squareup.okhttp3 mockwebserver + + org.junit.jupiter + junit-jupiter-api + + + ca.uhn.hapi.fhir + hapi-fhir-structures-r4 + ${hapi.fhir.version} + + + ca.uhn.hapi.fhir + hapi-fhir-client + ${hapi.fhir.version} + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.3.0 + + + package + + shade + + + + + maven-dependency-plugin 3.3.0 @@ -88,15 +108,18 @@ copy-dependencies + + org.keycloak + - fhir-provider,fhir-config,fhir-model,fhir-core,antlr4-runtime,commons-io,commons-lang3,commons-text,encoder,jakarta.annotation-api,jakarta.json,jcip-annotations + hapi-fhir-base, hapi-fhir-client, hapi-fhir-structures-r4, org.hl7.fhir.r4, org.hl7.fhir.utilities, commons-text compile - - + + diff --git a/keycloak-extensions/src/main/java/org/alvearie/keycloak/PatientSelectionForm.java b/keycloak-extensions/src/main/java/org/alvearie/keycloak/PatientSelectionForm.java index e42aef0..a891075 100644 --- a/keycloak-extensions/src/main/java/org/alvearie/keycloak/PatientSelectionForm.java +++ b/keycloak-extensions/src/main/java/org/alvearie/keycloak/PatientSelectionForm.java @@ -2,26 +2,32 @@ (C) Copyright IBM Corp. 2021 SPDX-License-Identifier: Apache-2.0 -*/ + */ package org.alvearie.keycloak; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Objects; import java.util.stream.Collectors; import java.util.stream.Stream; -import javax.ws.rs.RuntimeType; -import javax.ws.rs.client.Client; -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import org.alvearie.keycloak.freemarker.PatientStruct; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.Bundle.BundleEntryComponent; +import org.hl7.fhir.r4.model.Bundle.BundleEntryRequestComponent; +import org.hl7.fhir.r4.model.Bundle.BundleType; +import org.hl7.fhir.r4.model.Bundle.HTTPVerb; +import org.hl7.fhir.r4.model.HumanName; +import org.hl7.fhir.r4.model.Patient; +import org.hl7.fhir.r4.model.Resource; import org.jboss.logging.Logger; -import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; import org.jboss.resteasy.util.HttpHeaderNames; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.AuthenticationFlowError; @@ -43,17 +49,9 @@ import org.keycloak.services.util.DefaultClientSessionContext; import org.keycloak.sessions.AuthenticationSessionModel; -import com.ibm.fhir.core.FHIRMediaType; -import com.ibm.fhir.model.config.FHIRModelConfig; -import com.ibm.fhir.model.resource.Bundle; -import com.ibm.fhir.model.resource.Bundle.Entry; -import com.ibm.fhir.model.resource.Patient; -import com.ibm.fhir.model.type.Date; -import com.ibm.fhir.model.type.HumanName; -import com.ibm.fhir.model.type.Url; -import com.ibm.fhir.model.type.code.BundleType; -import com.ibm.fhir.model.type.code.HTTPVerb; -import com.ibm.fhir.provider.FHIRProvider; +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum; /** * Present a patient context picker when the client requests the launch/patient scope and the @@ -62,258 +60,267 @@ */ public class PatientSelectionForm implements Authenticator { - private static final Logger LOG = Logger.getLogger(PatientSelectionForm.class); - - private static final String SMART_AUDIENCE_PARAM = "client_request_param_aud"; - private static final String SMART_SCOPE_PATIENT_READ = "patient/Patient.read"; - private static final String SMART_SCOPE_LAUNCH_PATIENT = "launch/patient"; - - private static final String ATTRIBUTE_RESOURCE_ID = "resourceId"; - - private Client fhirClient; - - public PatientSelectionForm() { - FHIRModelConfig.setExtendedCodeableConceptValidation(false); - fhirClient = ResteasyClientBuilder.newClient() - .register(new FHIRProvider(RuntimeType.CLIENT)); - } - - @Override - public void authenticate(AuthenticationFlowContext context) { - AuthenticationSessionModel authSession = context.getAuthenticationSession(); - ClientModel client = authSession.getClient(); - - String requestedScopesString = authSession.getClientNote(OIDCLoginProtocol.SCOPE_PARAM); - Stream clientScopes = TokenManager.getRequestedClientScopes(requestedScopesString, client); - - if (clientScopes.noneMatch(s -> SMART_SCOPE_LAUNCH_PATIENT.equals(s.getName()))) { - // no launch/patient scope == no-op - context.success(); - return; - } - - if (context.getUser() == null) { - fail(context, "Expected a user but found null"); - return; - } - - List resourceIds = getResourceIdsForUser(context); - if (resourceIds.size() == 0) { - fail(context, "Expected user to have one or more resourceId attributes, but found none"); - return; - } - if (resourceIds.size() == 1) { - succeed(context, resourceIds.get(0)); - return; - } - - AuthenticatorConfigModel config = context.getAuthenticatorConfig(); - if (config == null || !config.getConfig().containsKey(PatientSelectionFormFactory.INTERNAL_FHIR_URL_PROP_NAME)) { - fail(context, "The Patient Selection Authenticator must be configured with a valid FHIR base URL"); - return; - } - - String accessToken = buildInternalAccessToken(context, resourceIds); - - Bundle requestBundle = buildRequestBundle(resourceIds); - try (Response fhirResponse = fhirClient - .target(config.getConfig().get(PatientSelectionFormFactory.INTERNAL_FHIR_URL_PROP_NAME)) - .request(MediaType.APPLICATION_JSON) - .header(HttpHeaderNames.AUTHORIZATION, "Bearer " + accessToken) - .post(Entity.entity(requestBundle, FHIRMediaType.APPLICATION_FHIR_JSON_TYPE))) { - - if (fhirResponse.getStatus() != 200) { - String msg = "Error while retrieving Patient resources for the selection form"; - LOG.warnf(msg); - LOG.warnf("Response with code " + fhirResponse.getStatus() + "%n%s", fhirResponse.readEntity(String.class)); - context.failure(AuthenticationFlowError.INTERNAL_ERROR, - Response.status(302) - .header("Location", context.getAuthenticationSession().getRedirectUri() + - "?error=server_error" + - "&error_description=" + msg) - .build()); - return; - } - - List patients = gatherPatientInfo(fhirResponse.readEntity(Bundle.class)); - if (patients.isEmpty()) { - succeed(context, resourceIds.get(0)); - return; - } - - if (patients.size() == 1) { - succeed(context, patients.get(0).getId()); - } else { - Response response = context.form() - .setAttribute("patients", patients) - .createForm("patient-select-form.ftl"); - - context.challenge(response); - } - } - } - - private List getResourceIdsForUser(AuthenticationFlowContext context) { - return context.getUser().getAttributeStream(ATTRIBUTE_RESOURCE_ID) - .flatMap(a -> Arrays.stream(a.split(" "))) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toList()); - } - - private String buildInternalAccessToken(AuthenticationFlowContext context, List resourceIds) { - KeycloakSession session = context.getSession(); - AuthenticationSessionModel authSession = context.getAuthenticationSession(); - UserModel user = context.getUser(); - ClientModel client = authSession.getClient(); - - UserSessionModel userSession = session.sessions().createUserSession(context.getRealm(), user, user.getUsername(), - context.getConnection().getRemoteAddr(), null, false, null, null); - - AuthenticatedClientSessionModel authedClientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); - if (authedClientSession == null) { - authedClientSession = session.sessions().createClientSession(context.getRealm(), client, userSession); - } - authedClientSession.setNote(OIDCLoginProtocol.ISSUER, - Urls.realmIssuer(session.getContext().getUri().getBaseUri(), context.getRealm().getName())); - - // Note: this depends on the corresponding string being registered as a valid scope for this client - ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter(authedClientSession, - SMART_SCOPE_PATIENT_READ, session); - - String requestedAudience = authSession.getClientNote(SMART_AUDIENCE_PARAM); - if (requestedAudience == null) { - String internalFhirUrl = context.getAuthenticatorConfig().getConfig().get(PatientSelectionFormFactory.INTERNAL_FHIR_URL_PROP_NAME); - LOG.info("Client request is missing the 'aud' parameter, using '" + internalFhirUrl + "' from config."); - requestedAudience = internalFhirUrl; - } - - // Explicit decision not to check the requested audience against the configured internal FHIR URL - // Checking of the requested audience should be performed in a previous step by the AudienceValidator - TokenManager tokenManager = new TokenManager(); - AccessToken accessToken = tokenManager.createClientAccessToken(session, context.getRealm(), authSession.getClient(), - context.getUser(), userSession, clientSessionCtx); - - // Explicitly override the scope string with what we need (less brittle than depending on this to exist as a client scope) - accessToken.setScope(SMART_SCOPE_PATIENT_READ); - - JsonWebToken jwt = accessToken.audience(requestedAudience); - jwt.setOtherClaims("patient_id", resourceIds); - return session.tokens().encode(jwt); - } - - private Bundle buildRequestBundle(List resourceIds) { - Bundle.Builder requestBuilder = Bundle.builder() - .type(BundleType.BATCH); - resourceIds.stream() - .map(id -> Entry.Request.builder() - .method(HTTPVerb.GET) - .url(Url.of("Patient/" + id)) - .build()) - .map(request -> Entry.builder() - .request(request) - .build()) - .forEach(entry -> requestBuilder.entry(entry)); - return requestBuilder.build(); - } - - private void fail(AuthenticationFlowContext context, String msg) { - LOG.warn(msg); - context.failure(AuthenticationFlowError.INTERNAL_ERROR, - Response.status(302) - .header("Location", context.getAuthenticationSession().getRedirectUri() + - "?error=server_error" + - "&error_description=" + msg) - .build()); - } - - private void succeed(AuthenticationFlowContext context, String patient) { - // Add selected information to authentication session - context.getAuthenticationSession().setUserSessionNote("patient_id", patient); - context.success(); - } - - private List gatherPatientInfo(Bundle fhirResponse) { - List patients = new ArrayList<>(); - - for (Entry entry : fhirResponse.getEntry()) { - if (entry.getResponse() == null || !entry.getResponse().getStatus().hasValue() || - !entry.getResponse().getStatus().getValue().startsWith("200")) { - continue; - } - - Patient patient = entry.getResource().as(Patient.class); - - String patientId = patient.getId(); - - String patientName = "Missing Name"; - if (patient.getName().isEmpty()) { - LOG.warn("Patient[id=" + patient.getId() + "] has no name; using placeholder"); - } else { - if (patient.getName().size() > 1) { - LOG.warn("Patient[id=" + patient.getId() + "] has multiple names; using the first one"); - } - patientName = constructSimpleName(patient.getName().get(0)); - } - - String patientDOB = patient.getBirthDate() == null ? "missing" - : Date.PARSER_FORMATTER.format(patient.getBirthDate().getValue()); - - patients.add(new PatientStruct(patientId, patientName, patientDOB)); - } - - return patients; - } - - private String constructSimpleName(HumanName name) { - if (name.getText() != null && name.getText().hasValue()) { - return name.getText().getValue(); - } - - return Stream.concat(name.getGiven().stream(), Stream.of(name.getFamily())) - .map(n -> n.getValue()) - .filter(Objects::nonNull) - .collect(Collectors.joining(" ")); - } - - @Override - public boolean requiresUser() { - return true; - } - - @Override - public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { - return true; - } - - @Override - public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { - } - - @Override - public void action(AuthenticationFlowContext context) { - - MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); - String patient = formData.getFirst("patient"); - - LOG.debugf("The user selected patient '%s'", patient); - - if (patient == null || patient.trim().isEmpty() || !getResourceIdsForUser(context).contains(patient.trim())) { - LOG.warnf("The patient selection '%s' is not valid for the authenticated user.", patient.trim()); - context.cancelLogin(); - - // reauthenticate... - authenticate(context); - return; - } - - succeed(context, patient.trim()); - } - - @Override - public void close() { - if (fhirClient != null) { - fhirClient.close(); - } - } + private static final Logger LOG = Logger.getLogger(PatientSelectionForm.class); + + private static final String SMART_AUDIENCE_PARAM = "client_request_param_aud"; + + private static final String SMART_SCOPE_PATIENT_READ = "patient/Patient.read"; + private static final String SMART_SCOPE_LAUNCH_PATIENT = "launch/patient"; + + private static final String ATTRIBUTE_RESOURCE_ID = "resourceId"; + + + // creating the fhirContext is expensive, you only want to create it once + private static final FhirContext fhirCtx = FhirContext.forR4(); + static { + // turn off server validation (capability statement pre-checks) + fhirCtx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER); + } + + public PatientSelectionForm() { + + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + ClientModel client = authSession.getClient(); + + String requestedScopesString = authSession.getClientNote(OIDCLoginProtocol.SCOPE_PARAM); + Stream clientScopes = TokenManager.getRequestedClientScopes(requestedScopesString, client); + + if (clientScopes.noneMatch(s -> SMART_SCOPE_LAUNCH_PATIENT.equals(s.getName()))) { + // no launch/patient scope == no-op + context.success(); + return; + } + + if (context.getUser() == null) { + fail(context, "Expected a user but found null"); + return; + } + + List resourceIds = getResourceIdsForUser(context); + if (resourceIds.size() == 0) { + fail(context, "Expected user to have one or more resourceId attributes, but found none"); + return; + } + if (resourceIds.size() == 1) { + succeed(context, resourceIds.get(0)); + return; + } + + AuthenticatorConfigModel config = context.getAuthenticatorConfig(); + if (config == null || !config.getConfig().containsKey(PatientSelectionFormFactory.INTERNAL_FHIR_URL_PROP_NAME)) { + fail(context, "The Patient Selection Authenticator must be configured with a valid FHIR base URL"); + return; + } + + String accessToken = buildInternalAccessToken(context, resourceIds); + + Bundle requestBundle = buildRequestBundle(resourceIds); + + String fhirBaseUrl = config.getConfig().get(PatientSelectionFormFactory.INTERNAL_FHIR_URL_PROP_NAME); + IGenericClient hapiClient = fhirCtx.newRestfulGenericClient(fhirBaseUrl); + + try { + Bundle returnBundle = hapiClient.transaction().withBundle(requestBundle) + .withAdditionalHeader(HttpHeaderNames.AUTHORIZATION, "Bearer " + accessToken) + .execute(); + + List patients = gatherPatientInfo(returnBundle); + if (patients.isEmpty()) { + succeed(context, resourceIds.get(0)); + return; + } + + if (patients.size() == 1) { + succeed(context, patients.get(0).getId()); + } else { + Response response = context.form() + .setAttribute("patients", patients) + .createForm("patient-select-form.ftl"); + + context.challenge(response); + } + } catch (Exception ex) { + String msg = "Error while retrieving Patient resources for the selection form"; + LOG.warn(msg); + LOG.warn("Exception caught: " + ex); + context.failure(AuthenticationFlowError.INTERNAL_ERROR, + Response.status(302).header("Location", context.getAuthenticationSession().getRedirectUri() + + "?error=server_error" + "&error_description=" + msg).build()); + return; + } + } + + private List getResourceIdsForUser(AuthenticationFlowContext context) { + return context.getUser().getAttributeStream(ATTRIBUTE_RESOURCE_ID) + .flatMap(a -> Arrays.stream(a.split(" "))) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + + private String buildInternalAccessToken(AuthenticationFlowContext context, List resourceIds) { + KeycloakSession session = context.getSession(); + AuthenticationSessionModel authSession = context.getAuthenticationSession(); + UserModel user = context.getUser(); + ClientModel client = authSession.getClient(); + + UserSessionModel userSession = session.sessions().createUserSession(context.getRealm(), user, user.getUsername(), + context.getConnection().getRemoteAddr(), null, false, null, null); + + AuthenticatedClientSessionModel authedClientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); + if (authedClientSession == null) { + authedClientSession = session.sessions().createClientSession(context.getRealm(), client, userSession); + } + authedClientSession.setNote(OIDCLoginProtocol.ISSUER, + Urls.realmIssuer(session.getContext().getUri().getBaseUri(), context.getRealm().getName())); + + // Note: this depends on the corresponding string being registered as a valid scope for this client + ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter(authedClientSession, + SMART_SCOPE_PATIENT_READ, session); + + String requestedAudience = authSession.getClientNote(SMART_AUDIENCE_PARAM); + if (requestedAudience == null) { + String internalFhirUrl = context.getAuthenticatorConfig().getConfig().get(PatientSelectionFormFactory.INTERNAL_FHIR_URL_PROP_NAME); + LOG.info("Client request is missing the 'aud' parameter, using '" + internalFhirUrl + "' from config."); + requestedAudience = internalFhirUrl; + } + + // Explicit decision not to check the requested audience against the configured internal FHIR URL + // Checking of the requested audience should be performed in a previous step by the AudienceValidator + TokenManager tokenManager = new TokenManager(); + AccessToken accessToken = tokenManager.createClientAccessToken(session, context.getRealm(), authSession.getClient(), + context.getUser(), userSession, clientSessionCtx); + + // Explicitly override the scope string with what we need (less brittle than depending on this to exist as a client scope) + accessToken.setScope(SMART_SCOPE_PATIENT_READ); + + JsonWebToken jwt = accessToken.audience(requestedAudience); + jwt.setOtherClaims("patient_id", resourceIds); + return session.tokens().encode(jwt); + } + + private Bundle buildRequestBundle(List resourceIds) { + + Bundle searchBundle = new Bundle(); + searchBundle.setType(BundleType.BATCH); + + for (String id : resourceIds) { + BundleEntryComponent bec = searchBundle.addEntry(); + BundleEntryRequestComponent request = new BundleEntryRequestComponent(); + request.setMethod(HTTPVerb.GET); + request.setUrl("Patient/" + id); + bec.setRequest(request); + } + return searchBundle; + } + + private void fail(AuthenticationFlowContext context, String msg) { + LOG.warn(msg); + context.failure(AuthenticationFlowError.INTERNAL_ERROR, + Response.status(302) + .header("Location", context.getAuthenticationSession().getRedirectUri() + + "?error=server_error" + + "&error_description=" + msg) + .build()); + } + + private void succeed(AuthenticationFlowContext context, String patient) { + // Add selected information to authentication session + context.getAuthenticationSession().setUserSessionNote("patient_id", patient); + context.success(); + } + + private List gatherPatientInfo(Bundle fhirResponse) { + List patients = new ArrayList<>(); + + for (BundleEntryComponent entry : fhirResponse.getEntry()) { + String status = entry.getResponse() == null ? null : entry.getResponse().getStatus(); + if (status == null || status.isBlank() || !status.startsWith("200")) { + continue; + } + + Resource resource = entry.getResource(); + if (!(resource instanceof Patient)) { + continue; + } + + Patient patient = (Patient) resource; + String patientId = patient.getIdElement().getIdPart(); + + String patientName = "Missing Name"; + if (patient.getName().isEmpty()) { + LOG.warn("Patient[id=" + patient.getId() + "] has no name; using placeholder"); + } else { + if (patient.getName().size() > 1) { + LOG.warn("Patient[id=" + patient.getId() + "] has multiple names; using the first one"); + } + patientName = constructSimpleName(patient.getName().get(0)); + } + + String patientDOB = "missing"; + if (patient.getBirthDate() != null) { + LocalDate ld = patient.getBirthDate().toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + patientDOB = ld.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)); + } + + LOG.debugf("Adding patient to return struct %s, %s", patientId, patientName); + patients.add(new PatientStruct(patientId, patientName, patientDOB)); + } + + return patients; + } + + private String constructSimpleName(HumanName name) { + if (name == null) { + return null; + } + String firstNames = name.getGivenAsSingleString(); + String lastName = name.getFamily(); + firstNames = firstNames == null ? "" : firstNames; + lastName = lastName == null ? "" : lastName; + return (firstNames + lastName).trim(); + } + + @Override + public boolean requiresUser() { + return true; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public void action(AuthenticationFlowContext context) { + + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + String patient = formData.getFirst("patient"); + + LOG.debugf("The user selected patient '%s'", patient); + + if (patient == null || patient.trim().isEmpty() || !getResourceIdsForUser(context).contains(patient.trim())) { + LOG.warnf("The patient selection '%s' is not valid for the authenticated user.", patient); + context.cancelLogin(); + + // reauthenticate... + authenticate(context); + return; + } + + succeed(context, patient.trim()); + } + + @Override + public void close() { + // nothing to do + } } diff --git a/keycloak-extensions/src/test/java/org/alvearie/keycloak/KeycloakContainerTest.java b/keycloak-extensions/src/test/java/org/alvearie/keycloak/KeycloakContainerTest.java index 745f9fa..6fb7571 100644 --- a/keycloak-extensions/src/test/java/org/alvearie/keycloak/KeycloakContainerTest.java +++ b/keycloak-extensions/src/test/java/org/alvearie/keycloak/KeycloakContainerTest.java @@ -2,11 +2,12 @@ (C) Copyright IBM Corp. 2021 SPDX-License-Identifier: Apache-2.0 -*/ + */ package org.alvearie.keycloak; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.nio.charset.Charset; @@ -16,17 +17,16 @@ import org.alvearie.keycloak.config.KeycloakConfigurator; import org.alvearie.keycloak.config.util.KeycloakConfig; + import org.alvearie.utils.SeleniumOauthInteraction; import org.apache.commons.io.IOUtils; -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.Test; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.KeycloakBuilder; import org.mockito.Mockito; import org.testcontainers.Testcontainers; -import org.testcontainers.containers.BindMode; - import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -37,121 +37,121 @@ import okhttp3.mockwebserver.RecordedRequest; public class KeycloakContainerTest { - private static final String MASTER_REALM = "master"; - private static final String ADMIN_CLIENT_ID = "admin-cli"; - private static final String USERNAME = "a"; - private static final String PASSWORD = "a"; - private static final String KC_CLIENT = "test"; - private static final String REDIRECT_URI = "http://localhost"; - private static final String AUTH_ENDPOINT = "/auth/realms/test/protocol/openid-connect/auth"; - private static final String TOKEN_ENDPOINT = "/auth/realms/test/protocol/openid-connect/token"; - private static final String AUDIENCE = "https://localhost:9443/fhir-server/api/v4"; - - // per https://www.testcontainers.org/features/networking/#exposing-host-ports-to-the-container - // the host's port should be known prior to starting the container - private static MockWebServer mockFhirServer; - static { - mockFhirServer = new MockWebServer(); - setupFHIRPortEnvVar(mockFhirServer.getPort()); - Testcontainers.exposeHostPorts(mockFhirServer.getPort()); - } - - // per the testcontainers doc, the contain should be started in a static block before JUnit starts up - private static KeycloakContainer keycloak; - static { - keycloak = new KeycloakContainer().withExtensionClassesFrom("target/classes"); - keycloak.addFileSystemBind("target/dependency", "/opt/jboss/keycloak/modules/system/layers/base/com/ibm/fhir/main", BindMode.READ_ONLY); - // Shouldn't be needed, but sometimes is: https://github.com/dasniko/testcontainers-keycloak/issues/15 - keycloak.withEnv("DB_VENDOR", "H2"); - // Uncomment this to keep the container running after the tests complete -// keycloak.withReuse(true); - keycloak.start(); - } - - private static Keycloak adminClient; - - private static void setupFHIRPortEnvVar(int port) { - Map env = new HashMap<>(KeycloakConfig.EnvironmentVariables.get()); - env.put("FHIR_PORT", Integer.toString(port)); - Mockito.mockStatic(KeycloakConfig.EnvironmentVariables.class); - Mockito.when(KeycloakConfig.EnvironmentVariables.get()).thenReturn(env); - } - - @BeforeClass - public static void setUp() throws Exception { - mockFhirServer.enqueue(new MockResponse() - .setHeader("Content-Type", "application/fhir+json") - .setBody(IOUtils.resourceToString("/mock_fhir_response.json", Charset.forName("UTF-8"))) - ); - - adminClient = KeycloakBuilder.builder() - .serverUrl(keycloak.getAuthServerUrl()) - .realm(MASTER_REALM) - .username(keycloak.getAdminUsername()) - .password(keycloak.getAdminPassword()) - .clientId(ADMIN_CLIENT_ID) - .build(); - KeycloakConfigurator configurator = new KeycloakConfigurator(adminClient); - - KeycloakConfig config = new KeycloakConfig("keycloak-config.json"); - configurator.initializeRealm("test", config.getPropertyGroup("test")); - } - - @AfterClass - public static void tearDown() throws IOException { - mockFhirServer.shutdown(); - - System.out.println("\n\n" + "Dumping container logs:" + "\n"); - System.out.println(keycloak.getLogs()); - - // no explicit container cleanup to enable re-use - //keycloak.close(); - } - - @Test - public void testLogin() throws Exception { - String host = "http://" + keycloak.getHost(); - Integer port = keycloak.getHttpPort(); - SeleniumOauthInteraction s = new SeleniumOauthInteraction(KC_CLIENT, REDIRECT_URI, - host + ":" + port + AUTH_ENDPOINT, host + ":" + port + TOKEN_ENDPOINT); - - Map authResponse = s.fetchCode(USERNAME, PASSWORD, AUDIENCE, "fhirUser", "launch/patient"); - - System.out.println("Auth response: " + authResponse); - assertTrue(authResponse.containsKey("code")); - - // verify that the underlying request from Keycloak to the mock FHIR server looks like we want - RecordedRequest recordedRequest = mockFhirServer.takeRequest(); - assertEquals("POST", recordedRequest.getMethod()); - assertEquals("/fhir-server/api/v4", recordedRequest.getPath()); - String auth = recordedRequest.getHeader("Authorization"); - assertTrue(auth.startsWith("Bearer ")); - verifyToken(auth.substring("Bearer ".length())); - String request = recordedRequest.getBody().readUtf8(); - Map json = new ObjectMapper().readValue(request, HashMap.class); - assertEquals("Bundle", json.get("resourceType")); - - - Map tokenResponse = s.fetchToken(authResponse.get("code")); - - System.out.println("Token response: " + tokenResponse); - assertTrue(tokenResponse.containsKey("access_token")); - verifyToken(tokenResponse.get("access_token")); - - assertTrue(tokenResponse.containsKey("refresh_token")); - String[] refreshTokenParts = tokenResponse.get("access_token").split("\\."); - assertEquals(3, refreshTokenParts.length); - - assertTrue(tokenResponse.containsKey("patient")); - assertEquals("PatientA", tokenResponse.get("patient")); - } - - private void verifyToken(String accessToken) - throws IOException, JsonParseException, JsonMappingException { - String[] accessTokenParts = accessToken.split("\\."); - assertEquals(3, accessTokenParts.length); - Map claims = new ObjectMapper().readValue(Base64.getDecoder().decode(accessTokenParts[1]), HashMap.class); - assertTrue(claims.containsKey("patient_id")); - System.out.println("patient_id claim: " + claims.get("patient_id")); - } + private static final String MASTER_REALM = "master"; + private static final String ADMIN_CLIENT_ID = "admin-cli"; + private static final String USERNAME = "a"; + private static final String PASSWORD = "a"; + private static final String KC_CLIENT = "test"; + private static final String REDIRECT_URI = "http://localhost"; + private static final String AUTH_ENDPOINT = "/realms/test/protocol/openid-connect/auth"; + private static final String TOKEN_ENDPOINT = "/realms/test/protocol/openid-connect/token"; + private static final String AUDIENCE = "https://localhost:9443/fhir-server/api/v4"; + + + // per https://www.testcontainers.org/features/networking/#exposing-host-ports-to-the-container + // the host's port should be known prior to starting the container + private static MockWebServer mockFhirServer; + static { + mockFhirServer = new MockWebServer(); + setupFHIRPortEnvVar(mockFhirServer.getPort()); + Testcontainers.exposeHostPorts(mockFhirServer.getPort()); + } + + // per the testcontainers doc, the contain should be started in a static block before JUnit starts up + private static KeycloakContainer keycloak; + static { + keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:18.0.0").withProviderClassesFrom("target/classes") + .withFileSystemBind("target/dependency", "/opt/keycloak/providers/"); + keycloak.withEnv("DB_VENDOR", "H2"); + // Uncomment this to keep the container running after the tests complete + // keycloak.withReuse(true); + keycloak.start(); + } + + private static Keycloak adminClient; + + private static void setupFHIRPortEnvVar(int port) { + Map env = new HashMap<>(KeycloakConfig.EnvironmentVariables.get()); + env.put("FHIR_PORT", Integer.toString(port)); + Mockito.mockStatic(KeycloakConfig.EnvironmentVariables.class); + Mockito.when(KeycloakConfig.EnvironmentVariables.get()).thenReturn(env); + } + + @BeforeAll + public static void setUp() throws Exception { + mockFhirServer.enqueue(new MockResponse() + .setHeader("Content-Type", "application/fhir+json") + .setBody(IOUtils.resourceToString("/mock_fhir_response.json", Charset.forName("UTF-8"))) + ); + + adminClient = KeycloakBuilder.builder() + .serverUrl(keycloak.getAuthServerUrl()) + .realm(MASTER_REALM) + .username(keycloak.getAdminUsername()) + .password(keycloak.getAdminPassword()) + .clientId(ADMIN_CLIENT_ID) + .build(); + KeycloakConfigurator configurator = new KeycloakConfigurator(adminClient); + + KeycloakConfig config = new KeycloakConfig("keycloak-config.json"); + configurator.initializeRealm("test", config.getPropertyGroup("test")); + } + + @AfterAll + public static void tearDown() throws IOException { + mockFhirServer.shutdown(); + + System.out.println("\n\n" + "Dumping container logs:" + "\n"); + System.out.println(keycloak.getLogs()); + + // no explicit container cleanup to enable re-use + //keycloak.close(); + } + + @Test + public void testLogin() throws Exception { + String host = "http://" + keycloak.getHost(); + Integer port = keycloak.getHttpPort(); + SeleniumOauthInteraction s = new SeleniumOauthInteraction(KC_CLIENT, REDIRECT_URI, + host + ":" + port + AUTH_ENDPOINT, host + ":" + port + TOKEN_ENDPOINT); + + Map authResponse = s.fetchCode(USERNAME, PASSWORD, AUDIENCE, "fhirUser", "launch/patient"); + + System.out.println("Auth response: " + authResponse); + assertTrue(authResponse.containsKey("code")); + + // verify that the underlying request from Keycloak to the mock FHIR server looks like we want + RecordedRequest recordedRequest = mockFhirServer.takeRequest(); + assertEquals("POST", recordedRequest.getMethod()); + assertEquals("/fhir-server/api/v4", recordedRequest.getPath()); + String auth = recordedRequest.getHeader("Authorization"); + assertTrue(auth.startsWith("Bearer ")); + verifyToken(auth.substring("Bearer ".length())); + String request = recordedRequest.getBody().readUtf8(); + Map json = new ObjectMapper().readValue(request, HashMap.class); + assertEquals("Bundle", json.get("resourceType")); + + + Map tokenResponse = s.fetchToken(authResponse.get("code")); + + System.out.println("Token response: " + tokenResponse); + assertTrue(tokenResponse.containsKey("access_token")); + verifyToken(tokenResponse.get("access_token")); + + assertTrue(tokenResponse.containsKey("refresh_token")); + String[] refreshTokenParts = tokenResponse.get("access_token").split("\\."); + assertEquals(3, refreshTokenParts.length); + + assertTrue(tokenResponse.containsKey("patient")); + assertEquals("PatientA", tokenResponse.get("patient")); + } + + private void verifyToken(String accessToken) + throws IOException, JsonParseException, JsonMappingException { + String[] accessTokenParts = accessToken.split("\\."); + assertEquals(3, accessTokenParts.length); + Map claims = new ObjectMapper().readValue(Base64.getDecoder().decode(accessTokenParts[1]), HashMap.class); + assertTrue(claims.containsKey("patient_id")); + System.out.println("patient_id claim: " + claims.get("patient_id")); + } } diff --git a/keycloak-extensions/src/test/java/org/alvearie/utils/SeleniumOauthInteraction.java b/keycloak-extensions/src/test/java/org/alvearie/utils/SeleniumOauthInteraction.java index b689118..fb41094 100644 --- a/keycloak-extensions/src/test/java/org/alvearie/utils/SeleniumOauthInteraction.java +++ b/keycloak-extensions/src/test/java/org/alvearie/utils/SeleniumOauthInteraction.java @@ -39,223 +39,223 @@ public class SeleniumOauthInteraction { - private static final Logger LOGGER = LoggerFactory.getLogger(SeleniumOauthInteraction.class); - - private String appClientId; - private String appRedirectUri; - private String oauthAuthUrl; - private String oauthTokenUrl; - - private WebDriver driver; - - public SeleniumOauthInteraction(String appclient_id, String appredirect_uri, - String oauth_auth_url, String oauth_token_url){ - appClientId = appclient_id; - appRedirectUri = appredirect_uri; - oauthAuthUrl = oauth_auth_url; - oauthTokenUrl = oauth_token_url; - - WebDriverManager.chromedriver().setup(); - ChromeOptions options = new ChromeOptions(); - options.setHeadless(true); - driver = new ChromeDriver(options); - } - - /** - * Hits the configured auth url with the configured client_id and redirect_uri, as well as - * the passed audience and scopes, then tests the login forms with the passed username and password via - * Selenium WebDriver. - * - * @param user - * @param pass - * @param aud - * @param scope one or more requested scopes - * @return a map of key-value pairs from the query string of the redirect location on the auth response - * @throws Exception - */ - public Map fetchCode(String user, String pass, String aud, String... scope) throws Exception { - Map response = new HashMap(); - - try { - BasicNameValuePair[] params = new BasicNameValuePair[] { - new BasicNameValuePair("response_type", "code"), - new BasicNameValuePair("state", UUID.randomUUID().toString()), - new BasicNameValuePair("client_id", appClientId), - new BasicNameValuePair("redirect_uri", appRedirectUri), - new BasicNameValuePair("aud", aud), - new BasicNameValuePair("scope", String.join(" ", scope)), - }; - String queryString = URLEncodedUtils.format(Arrays.asList(params), UTF_8); - - // launch Firefox and direct it to the Base URL - driver.get(oauthAuthUrl + "?" + queryString); - - WebElement dynamicElement = (new WebDriverWait(driver, 10)) - .until(ExpectedConditions.presenceOfElementLocated(By.id("username"))); - dynamicElement.sendKeys(user); - - driver.findElement(By.id("password")).sendKeys(pass); - driver.findElement(By.id("kc-login")).click(); - - Boolean loginButtonDisappeared = (new WebDriverWait(driver, 5, 200)) - .until(ExpectedConditions.invisibilityOfElementLocated(By.id("kc-login"))); - LOGGER.debug("Login button is visible?? " + !loginButtonDisappeared); - - // At this point we'll either find: - // A) patient selection form - // B) consent grant form - // C) URL with 'code=' in the query string - // D) 'Update Account Information' - user created with sign_up API. - - // Wait up to 3 seconds for screen showing 'Select patient' - // Page contents: - // div class=login-pf-page - // div class=card-pf - // header.div kc-username - // div kc-content - // form id=patient-selection - // input id= (one per patient that the user has access to) - // input id=submit - try { - // wait up to 3 seconds - poll for element every 200 ms - new WebDriverWait(driver, 3, 200) - .until(ExpectedConditions.presenceOfElementLocated(By.id("patient-selection"))); - - // simulate choosing the patient that has an id of "PatientA" - driver.findElement(By.id("PatientA")).click(); - driver.findElement(By.id("submit")).click(); - - } catch ( TimeoutException e ) { - LOGGER.error("Expected the patient selection form but didn't find it", e); - fail("Expected the patient selection form but didn't find it"); - } - - // wait up to 2 seconds for screen showing YES/NO page for first-time users. - // Page contents: - // kc-page-title=Grant Access to inferno - // li - permissions granted - // input id=kc-cancel NO - // input kd=kc-login YES - try { - // either the button is found or a TimeoutException is generated - WebElement grantAccessButton = (new WebDriverWait(driver, 1,200)) - .until(ExpectedConditions.presenceOfElementLocated(By.id("kc-login"))); - - grantAccessButton.click(); - } catch ( TimeoutException e ) { - // Didn't find YES button with id='kc-login'. - // Ignore exception; probably not the first sign-on for this user. - LOGGER.error("Didn't find YES button with id='kc-login' - ignore exception" + e.getMessage()); - } - - // poll at 500 ms interval until 'code' is present in URL query parameter list. - // Usually this loop completes in under 2.5 seconds. - for ( int i =0; i< 100; i++) { - Thread.sleep(500); - response = getQueryMap( driver.getCurrentUrl() ); - if ( response.keySet().contains("code") ) - { - break; - } - } - } catch (Exception e) { - if (driver == null || driver.getCurrentUrl() == null) { - throw e; - } - // adding the while loop to avoid the timing issues and reliably - // extract the grant code from next page after clicking the login - // button - // Note: the handling in exception is required because on some - // operating systems the selenium webdriver exits browser when - // the recipient host is unable to connect. - int cnt = 0; - while (!driver.getCurrentUrl().contains("code=")) { - try { - Thread.sleep(500); - cnt++; - if (cnt > 10) { - LOGGER.debug("Waiting for page to retrieve grant code, Round ... " + cnt); - break; - } - } catch (InterruptedException e1) { - // do nothing - } - } - if (!driver.getCurrentUrl().contains("code=")) { - LOGGER.error("Something went wrong during the oauth code retreival process", e); - } else { - response = getQueryMap(driver.getCurrentUrl()); - } - } finally { - if (driver != null) { - // close Firefox - driver.close(); - } - } - return response; - } - - /** - * Exchange the code for a token (with default params) - * - * @param code - * @return - * @throws Exception - */ - public Map fetchToken(String code) throws Exception { - Map headers = new HashMap<>(); - headers.put("Content-Type", "application/x-www-form-urlencoded"); - - Map params = new HashMap<>(); - params.put("grant_type", "authorization_code"); - params.put("code", code); - params.put("client_id", appClientId); - params.put("redirect_uri", appRedirectUri); - - return fetchTokenWith(params); - } - - /** - * Invoke the token endpoint with the passed params. - * - * @param requestParams - * @return - * @throws Exception - */ - public Map fetchTokenWith(Map requestParams) throws Exception { - Client client = ClientBuilder.newClient(); - WebTarget target = client.target(oauthTokenUrl); - - Response response = target.request().post(Entity.form(new MultivaluedHashMap(requestParams))); - - @SuppressWarnings("unchecked") - Map result = response.readEntity(HashMap.class); - return result; - } - - private Map getQueryMap(String url) throws Exception { - Map response = new HashMap(); - String[] pairs = new URI(url).getQuery().split("&"); - for (String pair : pairs) { - int idx = pair.indexOf("="); - response.put(URLDecoder.decode(pair.substring(0, idx), "UTF-8"), - URLDecoder.decode(pair.substring(idx + 1), "UTF-8")); - } - return response; - } - - // main method allows for easy standalone testing. This could be moved to a separate file. - public static void main(String[] args) throws Exception { - String realm = "test"; - String baseUrl = "http://localhost:8080/auth/realms/" + realm + "/protocol/openid-connect/"; - - SeleniumOauthInteraction s = new SeleniumOauthInteraction("test", "https://localhost", - baseUrl + "auth", baseUrl + "token"); - - Map authResponse = s.fetchCode("a", "a", "https://localhost:9443/fhir-server/api/v4", - "openid", "launch/patient"); - Map tokenResponse = s.fetchToken(authResponse.get("code")); - - System.out.println(tokenResponse); - } + private static final Logger LOGGER = LoggerFactory.getLogger(SeleniumOauthInteraction.class); + + private String appClientId; + private String appRedirectUri; + private String oauthAuthUrl; + private String oauthTokenUrl; + + private WebDriver driver; + + public SeleniumOauthInteraction(String appclient_id, String appredirect_uri, + String oauth_auth_url, String oauth_token_url){ + appClientId = appclient_id; + appRedirectUri = appredirect_uri; + oauthAuthUrl = oauth_auth_url; + oauthTokenUrl = oauth_token_url; + + WebDriverManager.chromedriver().setup(); + ChromeOptions options = new ChromeOptions(); + options.setHeadless(true); + driver = new ChromeDriver(options); + } + + /** + * Hits the configured auth url with the configured client_id and redirect_uri, as well as + * the passed audience and scopes, then tests the login forms with the passed username and password via + * Selenium WebDriver. + * + * @param user + * @param pass + * @param aud + * @param scope one or more requested scopes + * @return a map of key-value pairs from the query string of the redirect location on the auth response + * @throws Exception + */ + public Map fetchCode(String user, String pass, String aud, String... scope) throws Exception { + Map response = new HashMap(); + + try { + BasicNameValuePair[] params = new BasicNameValuePair[] { + new BasicNameValuePair("response_type", "code"), + new BasicNameValuePair("state", UUID.randomUUID().toString()), + new BasicNameValuePair("client_id", appClientId), + new BasicNameValuePair("redirect_uri", appRedirectUri), + new BasicNameValuePair("aud", aud), + new BasicNameValuePair("scope", String.join(" ", scope)), + }; + String queryString = URLEncodedUtils.format(Arrays.asList(params), UTF_8); + + // launch Firefox and direct it to the Base URL + driver.get(oauthAuthUrl + "?" + queryString); + + WebElement dynamicElement = (new WebDriverWait(driver, 30)) + .until(ExpectedConditions.presenceOfElementLocated(By.id("username"))); + dynamicElement.sendKeys(user); + + driver.findElement(By.id("password")).sendKeys(pass); + driver.findElement(By.id("kc-login")).click(); + + Boolean loginButtonDisappeared = (new WebDriverWait(driver, 5, 200)) + .until(ExpectedConditions.invisibilityOfElementLocated(By.id("kc-login"))); + LOGGER.debug("Login button is visible?? " + !loginButtonDisappeared); + + // At this point we'll either find: + // A) patient selection form + // B) consent grant form + // C) URL with 'code=' in the query string + // D) 'Update Account Information' - user created with sign_up API. + + // Wait up to 3 seconds for screen showing 'Select patient' + // Page contents: + // div class=login-pf-page + // div class=card-pf + // header.div kc-username + // div kc-content + // form id=patient-selection + // input id= (one per patient that the user has access to) + // input id=submit + try { + // wait up to 3 seconds - poll for element every 200 ms + new WebDriverWait(driver, 5, 200) + .until(ExpectedConditions.presenceOfElementLocated(By.id("patient-selection"))); + + // simulate choosing the patient that has an id of "PatientA" + driver.findElement(By.id("PatientA")).click(); + driver.findElement(By.id("submit")).click(); + + } catch ( TimeoutException e ) { + LOGGER.error("Expected the patient selection form but didn't find it", e); + fail("Expected the patient selection form but didn't find it"); + } + + // wait up to 2 seconds for screen showing YES/NO page for first-time users. + // Page contents: + // kc-page-title=Grant Access to inferno + // li - permissions granted + // input id=kc-cancel NO + // input kd=kc-login YES + try { + // either the button is found or a TimeoutException is generated + WebElement grantAccessButton = (new WebDriverWait(driver, 1,200)) + .until(ExpectedConditions.presenceOfElementLocated(By.id("kc-login"))); + + grantAccessButton.click(); + } catch ( TimeoutException e ) { + // Didn't find YES button with id='kc-login'. + // Ignore exception; probably not the first sign-on for this user. + LOGGER.error("Didn't find YES button with id='kc-login' - ignore exception" + e.getMessage()); + } + + // poll at 500 ms interval until 'code' is present in URL query parameter list. + // Usually this loop completes in under 2.5 seconds. + for ( int i =0; i< 100; i++) { + Thread.sleep(500); + response = getQueryMap( driver.getCurrentUrl() ); + if ( response.keySet().contains("code") ) + { + break; + } + } + } catch (Exception e) { + if (driver == null || driver.getCurrentUrl() == null) { + throw e; + } + // adding the while loop to avoid the timing issues and reliably + // extract the grant code from next page after clicking the login + // button + // Note: the handling in exception is required because on some + // operating systems the selenium webdriver exits browser when + // the recipient host is unable to connect. + int cnt = 0; + while (!driver.getCurrentUrl().contains("code=")) { + try { + Thread.sleep(500); + cnt++; + if (cnt > 10) { + LOGGER.debug("Waiting for page to retrieve grant code, Round ... " + cnt); + break; + } + } catch (InterruptedException e1) { + // do nothing + } + } + if (!driver.getCurrentUrl().contains("code=")) { + LOGGER.error("Something went wrong during the oauth code retreival process", e); + } else { + response = getQueryMap(driver.getCurrentUrl()); + } + } finally { + if (driver != null) { + // close Firefox + driver.close(); + } + } + return response; + } + + /** + * Exchange the code for a token (with default params) + * + * @param code + * @return + * @throws Exception + */ + public Map fetchToken(String code) throws Exception { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/x-www-form-urlencoded"); + + Map params = new HashMap<>(); + params.put("grant_type", "authorization_code"); + params.put("code", code); + params.put("client_id", appClientId); + params.put("redirect_uri", appRedirectUri); + + return fetchTokenWith(params); + } + + /** + * Invoke the token endpoint with the passed params. + * + * @param requestParams + * @return + * @throws Exception + */ + public Map fetchTokenWith(Map requestParams) throws Exception { + Client client = ClientBuilder.newClient(); + WebTarget target = client.target(oauthTokenUrl); + + Response response = target.request().post(Entity.form(new MultivaluedHashMap(requestParams))); + + @SuppressWarnings("unchecked") + Map result = response.readEntity(HashMap.class); + return result; + } + + private Map getQueryMap(String url) throws Exception { + Map response = new HashMap(); + String[] pairs = new URI(url).getQuery().split("&"); + for (String pair : pairs) { + int idx = pair.indexOf("="); + response.put(URLDecoder.decode(pair.substring(0, idx), "UTF-8"), + URLDecoder.decode(pair.substring(idx + 1), "UTF-8")); + } + return response; + } + + // main method allows for easy standalone testing. This could be moved to a separate file. + public static void main(String[] args) throws Exception { + String realm = "test"; + String baseUrl = "http://localhost:8080/realms/" + realm + "/protocol/openid-connect/"; + + SeleniumOauthInteraction s = new SeleniumOauthInteraction("test", "https://localhost", + baseUrl + "auth", baseUrl + "token"); + + Map authResponse = s.fetchCode("a", "a", "https://localhost:9443/fhir-server/api/v4", + "openid", "launch/patient"); + Map tokenResponse = s.fetchToken(authResponse.get("code")); + + System.out.println(tokenResponse); + } } diff --git a/keycloak-extensions/src/test/resources/jboss/module.xml b/keycloak-extensions/src/test/resources/jboss/module.xml deleted file mode 100644 index 7e0a2b2..0000000 --- a/keycloak-extensions/src/test/resources/jboss/module.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/pom.xml b/pom.xml index d945bf7..d1b21f0 100644 --- a/pom.xml +++ b/pom.xml @@ -10,17 +10,17 @@ pom - 18.0.0 - 1.10.0 - 4.11.1 + 18.0.2 + + 2.2.2 11 11 + 5.7.2 keycloak-config keycloak-extensions - jboss-fhir-provider @@ -75,19 +75,9 @@ 1.9 - com.ibm.fhir - fhir-model - ${ibm-fhir-server.version} - - - com.ibm.fhir - fhir-provider - ${ibm-fhir-server.version} - - - junit - junit - 4.13.2 + org.junit.jupiter + junit-jupiter-api + 5.9.1 test @@ -144,6 +134,12 @@ + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M7 + org.apache.maven.plugins maven-jar-plugin