diff --git a/pom.xml b/pom.xml
index 5d875682..99a883eb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -3,7 +3,7 @@
org.folio
mod-login-saml
jar
- 2.9.0-SNAPSHOT
+ 2.8.2-SNAPSHOT
mod-login-saml
@@ -97,7 +97,7 @@
org.apache.logging.log4j
- log4j-slf4j-impl
+ log4j-slf4j2-impl
diff --git a/ramls/saml-login.raml b/ramls/saml-login.raml
index 3f2c2bf0..0310fda2 100644
--- a/ramls/saml-login.raml
+++ b/ramls/saml-login.raml
@@ -59,7 +59,7 @@ types:
example: "Bad request"
/callback:
post:
- description: Redirect browser to sso-landing page with generated token. Deprecated.
+ description: Redirect browser to sso-landing page with generated token.
body:
application/octet-stream:
type: string
diff --git a/ramls/schemas/SamlConfig.json b/ramls/schemas/SamlConfig.json
index 8f6f4508..51ea8c83 100644
--- a/ramls/schemas/SamlConfig.json
+++ b/ramls/schemas/SamlConfig.json
@@ -43,6 +43,11 @@
"description": "Where the IDP should call back after login is successful. Either callback or callback-with-expiry. Defaults to callback-with-expiry if not present.",
"type": "string",
"required": false
+ },
+ "useSecureTokens": {
+ "type": "boolean",
+ "description": "When present, and true, and when callback is configured with the value 'callback', enables the refresh token payload on the /callback endpoint.",
+ "required": false
}
}
}
diff --git a/ramls/schemas/SamlConfigRequest.json b/ramls/schemas/SamlConfigRequest.json
index 9000fb27..5aceaea3 100644
--- a/ramls/schemas/SamlConfigRequest.json
+++ b/ramls/schemas/SamlConfigRequest.json
@@ -43,6 +43,11 @@
"description": "Where the IDP should call back after login is successful. Either callback or callback-with-expiry. Defaults to callback-with-expiry if not present.",
"type": "string",
"required": false
+ },
+ "useSecureTokens": {
+ "type": "boolean",
+ "description": "When present, and 'true', and when callback is configured with the value 'callback', enables the refresh token payload on the /callback endpoint.",
+ "required": false
}
}
}
diff --git a/src/main/java/org/folio/config/model/SamlConfiguration.java b/src/main/java/org/folio/config/model/SamlConfiguration.java
index ff820786..613bcf0a 100644
--- a/src/main/java/org/folio/config/model/SamlConfiguration.java
+++ b/src/main/java/org/folio/config/model/SamlConfiguration.java
@@ -22,6 +22,7 @@ public class SamlConfiguration {
public static final String METADATA_INVALIDATED_CODE = "metadata.invalidated";
public static final String OKAPI_URL= "okapi.url";
public static final String SAML_CALLBACK = "saml.callback";
+ public static final String SAML_USE_SECURE_TOKENS = "saml.useSecureTokens";
@JsonProperty(IDP_URL_CODE)
private String idpUrl;
@@ -45,6 +46,8 @@ public class SamlConfiguration {
private String okapiUrl;
@JsonProperty(SAML_CALLBACK)
private String callback;
+ @JsonProperty(SAML_USE_SECURE_TOKENS)
+ private String useSecureTokens;
public String getIdpUrl() {
@@ -131,4 +134,12 @@ public void setIdpMetadata(String idpMetadata) {
public void setCallback(String callback) {
this.callback = callback;
}
+
+ public String getUseSecureTokens() {
+ return useSecureTokens;
+ }
+
+ public void setUseSecureTokens(String useSecureTokens) {
+ this.useSecureTokens = useSecureTokens;
+ }
}
diff --git a/src/main/java/org/folio/rest/impl/SamlAPI.java b/src/main/java/org/folio/rest/impl/SamlAPI.java
index 3b8087f3..9135dff9 100644
--- a/src/main/java/org/folio/rest/impl/SamlAPI.java
+++ b/src/main/java/org/folio/rest/impl/SamlAPI.java
@@ -199,17 +199,17 @@ private String getRelayState(RoutingContext routingContext, String body) {
@Override
public void postSamlCallback(String body, RoutingContext routingContext, Map okapiHeaders,
Handler> asyncResultHandler, Context vertxContext) {
- doPostSamlCallback(body, routingContext, okapiHeaders, asyncResultHandler, vertxContext, TOKEN_SIGN_ENDPOINT_LEGACY);
+ doPostSamlCallback(body, routingContext, okapiHeaders, asyncResultHandler, vertxContext);
}
@Override
public void postSamlCallbackWithExpiry(String body, RoutingContext routingContext, Map okapiHeaders,
Handler> asyncResultHandler, Context vertxContext) {
- doPostSamlCallback(body, routingContext, okapiHeaders, asyncResultHandler, vertxContext, TOKEN_SIGN_ENDPOINT);
+ doPostSamlCallback(body, routingContext, okapiHeaders, asyncResultHandler, vertxContext);
}
private void doPostSamlCallback(String body, RoutingContext routingContext, Map okapiHeaders,
- Handler> asyncResultHandler, Context vertxContext, String tokenSignEndpoint) {
+ Handler> asyncResultHandler, Context vertxContext) {
registerFakeSession(routingContext);
@@ -251,8 +251,9 @@ private void doPostSamlCallback(String body, RoutingContext routingContext, Map<
JsonObject payload = new JsonObject().put("payload",
new JsonObject().put("sub", userObject.getString(USERNAME)).put("user_id", userId));
+ var tokenSignEndpoint = getTokenSignEndpoint(configuration);
return fetchToken(webClient, payload, parsedHeaders, tokenSignEndpoint).map(jsonResponse -> {
- if (isLegacyResponse(tokenSignEndpoint)) {
+ if (isLegacyResponse(configuration)) {
return redirectResponseLegacy(jsonResponse, stripesBaseUrl, originalUrl);
} else {
return redirectResponse(jsonResponse, stripesBaseUrl, originalUrl);
@@ -281,8 +282,15 @@ private PostSamlCallbackResponse failCallbackResponse(Throwable cause, RoutingCo
return response;
}
- private boolean isLegacyResponse(String endpoint) {
- return endpoint.equals(TOKEN_SIGN_ENDPOINT_LEGACY);
+ private boolean isLegacyResponse(SamlConfiguration configuration) {
+ return "callback".equals(configuration.getCallback()) && ! "true".equals(configuration.getUseSecureTokens());
+ }
+
+ private String getTokenSignEndpoint(SamlConfiguration configuration) {
+ if (isLegacyResponse(configuration)) {
+ return TOKEN_SIGN_ENDPOINT_LEGACY;
+ }
+ return TOKEN_SIGN_ENDPOINT;
}
private Future fetchToken(WebClient client, JsonObject payload, OkapiHeaders parsedHeaders, String endpoint) {
@@ -469,6 +477,9 @@ public void putSamlConfiguration(SamlConfigRequest updatedConfig, RoutingContext
ConfigEntryUtil.valueChanged(config.getCallback(), updatedConfig.getCallback(), callback ->
updateEntries.put(SamlConfiguration.SAML_CALLBACK, callback));
+ ConfigEntryUtil.valueChanged(config.getUseSecureTokens(), updatedConfig.getUseSecureTokens(), useSecureTokens ->
+ updateEntries.put(SamlConfiguration.SAML_USE_SECURE_TOKENS, useSecureTokens));
+
return storeConfigEntries(rc, parsedHeaders, updateEntries, vertxContext);
})
.onFailure(cause -> {
@@ -481,6 +492,8 @@ public void putSamlConfiguration(SamlConfigRequest updatedConfig, RoutingContext
});
}
+
+
private Future storeConfigEntries(RoutingContext rc, OkapiHeaders parsedHeaders,
Map updateEntries, Context vertxContext) {
@@ -608,6 +621,7 @@ private SamlConfig configToDto(SamlConfiguration config) {
.withSamlAttribute(config.getSamlAttribute())
.withUserProperty(config.getUserProperty())
.withCallback(config.getCallback())
+ .withUseSecureTokens(Boolean.valueOf(config.getUseSecureTokens()))
.withMetadataInvalidated(Boolean.valueOf(config.getMetadataInvalidated()));
try {
URI uri = URI.create(config.getOkapiUrl());
diff --git a/src/main/java/org/folio/util/ConfigEntryUtil.java b/src/main/java/org/folio/util/ConfigEntryUtil.java
index 1144b80c..b49edcbd 100644
--- a/src/main/java/org/folio/util/ConfigEntryUtil.java
+++ b/src/main/java/org/folio/util/ConfigEntryUtil.java
@@ -33,7 +33,14 @@ public static void valueChanged(String oldValue, String newValue, Consumer onChanged) {
+ String newValueString = (newValue == null) ? null : newValue.toString();
+
+ valueChanged(oldValue, newValueString, onChanged);
+ }
}
diff --git a/src/test/java/org/folio/rest/impl/IdpCallbackTest.java b/src/test/java/org/folio/rest/impl/IdpCallbackTest.java
new file mode 100644
index 00000000..7aa6bea8
--- /dev/null
+++ b/src/test/java/org/folio/rest/impl/IdpCallbackTest.java
@@ -0,0 +1,137 @@
+package org.folio.rest.impl;
+
+import io.restassured.RestAssured;
+import io.vertx.core.DeploymentOptions;
+import io.vertx.core.Vertx;
+import io.vertx.core.json.JsonObject;
+import io.vertx.ext.unit.TestContext;
+import io.vertx.ext.unit.junit.VertxUnitRunner;
+import org.folio.config.SamlConfigHolder;
+import org.folio.rest.RestVerticle;
+import org.folio.util.MockJson;
+import org.folio.util.SamlTestHelper;
+import org.junit.*;
+import org.junit.runner.RunWith;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.images.builder.ImageFromDockerfile;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+/**
+ * Test against a real IDP: https://simplesamlphp.org/ running in a Docker container.
+ */
+@RunWith(VertxUnitRunner.class)
+public class IdpCallbackTest {
+ private static final org.slf4j.Logger logger = LoggerFactory.getLogger(IdpTest.class);
+ private static final boolean DEBUG = false;
+ private static final ImageFromDockerfile simplesamlphp =
+ new ImageFromDockerfile().withFileFromPath(".", Path.of("src/test/resources/simplesamlphp/"));
+
+ private static final String TENANT = "diku";
+
+ private static final int MODULE_PORT = 9231;
+ private static final int OKAPI_PORT = 9230;
+ private static final String OKAPI_URL = "http://localhost:" + OKAPI_PORT;
+ private static final String CALLBACK = "callback";
+
+ private static int IDP_PORT;
+ private static String IDP_BASE_URL;
+ private static MockJson OKAPI;
+
+ private static Vertx VERTX;
+
+ @ClassRule
+ public static final GenericContainer> IDP = new GenericContainer<>(simplesamlphp)
+ .withExposedPorts(8080)
+ .withEnv("SIMPLESAMLPHP_SP_ENTITY_ID", OKAPI_URL + "/_/invoke/tenant/diku/saml/callback")
+ .withEnv("SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE",
+ OKAPI_URL + "/_/invoke/tenant/diku/saml/callback");
+
+ @BeforeClass
+ public static void setupOnce(TestContext context) {
+ RestAssured.port = MODULE_PORT;
+ RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
+ VERTX = Vertx.vertx();
+
+ if (DEBUG) {
+ IDP.followOutput(new Slf4jLogConsumer(logger).withSeparateOutputStreams());
+ }
+ IDP_PORT = IDP.getFirstMappedPort();
+ IDP_BASE_URL = "http://" + IDP.getHost() + ":" + IDP_PORT + "/simplesaml/";
+ String baseurlpath = IDP_BASE_URL.replace("/", "\\/");
+ exec("sed", "-i", "s/'baseurlpath' =>.*/'baseurlpath' => '" + baseurlpath + "',/",
+ "/var/www/simplesamlphp/config/config.php");
+ exec("sed", "-i", "s/'auth' =>.*/'auth' => 'example-static',/",
+ "/var/www/simplesamlphp/metadata/saml20-idp-hosted.php");
+
+ DeploymentOptions moduleOptions = new DeploymentOptions()
+ .setConfig(new JsonObject().put("http.port", MODULE_PORT)
+ .put("mock", true)); // to use SAML2ClientMock
+
+ OKAPI = new MockJson();
+ DeploymentOptions okapiOptions = new DeploymentOptions()
+ .setConfig(new JsonObject().put("http.port", OKAPI_PORT));
+
+ VERTX.deployVerticle(new RestVerticle(), moduleOptions)
+ .compose(x -> VERTX.deployVerticle(OKAPI, okapiOptions))
+ .onComplete(context.asyncAssertSuccess());
+ }
+
+ @AfterClass
+ public static void tearDownOnce(TestContext context) {
+ VERTX.close()
+ .onComplete(context.asyncAssertSuccess());
+ }
+
+ @After
+ public void after() {
+ SamlConfigHolder.getInstance().removeClient(TENANT);
+ }
+
+ @Test
+ public void postCallback() {
+ setIdpBinding("POST");
+ setOkapi("mock_idptest_post_secure_tokens.json");
+
+ for (int i = 0; i < 2; i++) {
+ SamlTestHelper.testPost(CALLBACK);
+ }
+ }
+
+ @Test
+ public void redirectCallback() {
+ setIdpBinding("Redirect");
+ setOkapi("mock_idptest_redirect_secure_tokens.json");
+
+ for (int i = 0; i < 2; i++) {
+ SamlTestHelper.testRedirect(CALLBACK);
+ }
+ }
+
+ private void setIdpBinding(String binding) {
+ // append entry at end, last entry wins
+ exec("sed", "-i",
+ "s/];/'SingleSignOnServiceBinding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-" + binding + "',\\n];/",
+ "/var/www/simplesamlphp/metadata/saml20-idp-hosted.php");
+ }
+
+ private static void exec(String... command) {
+ try {
+ var result = IDP.execInContainer(command);
+ if (result.getExitCode() > 0) {
+ System.out.println(result.getStdout());
+ System.err.println(result.getStderr());
+ throw new RuntimeException("failure in IDP.execInContainer");
+ }
+ } catch (UnsupportedOperationException | IOException | InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private void setOkapi(String resource) {
+ OKAPI.setMockContent(resource, s -> s.replace("http://localhost:8888/simplesaml/", IDP_BASE_URL));
+ }
+}
diff --git a/src/test/java/org/folio/rest/impl/IdpLegacyTest.java b/src/test/java/org/folio/rest/impl/IdpLegacyTest.java
index 4b99ce5d..43a411a6 100644
--- a/src/test/java/org/folio/rest/impl/IdpLegacyTest.java
+++ b/src/test/java/org/folio/rest/impl/IdpLegacyTest.java
@@ -71,7 +71,7 @@ public class IdpLegacyTest {
OKAPI_URL + "/_/invoke/tenant/diku/saml/callback");
@BeforeClass
- public static void setupOnce(TestContext context) throws Exception {
+ public static void setupOnce(TestContext context) {
RestAssured.port = MODULE_PORT;
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
VERTX = Vertx.vertx();
@@ -248,7 +248,7 @@ private static void exec(String... command) {
if (result.getExitCode() > 0) {
System.out.println(result.getStdout());
System.err.println(result.getStderr());
- throw new RuntimeException("failure in IDP.execInContainer");
+ throw new RuntimeException("failure in IDP.execInContainer");
}
} catch (UnsupportedOperationException | IOException | InterruptedException e) {
throw new RuntimeException(e);
diff --git a/src/test/java/org/folio/rest/impl/IdpTest.java b/src/test/java/org/folio/rest/impl/IdpTest.java
index afd3090d..5ff6c7ec 100644
--- a/src/test/java/org/folio/rest/impl/IdpTest.java
+++ b/src/test/java/org/folio/rest/impl/IdpTest.java
@@ -1,13 +1,8 @@
package org.folio.rest.impl;
import io.restassured.RestAssured;
-import io.restassured.http.Cookie;
-import io.restassured.http.Header;
-import io.restassured.response.ExtractableResponse;
-import io.restassured.response.Response;
import io.vertx.core.DeploymentOptions;
import io.vertx.core.Vertx;
-import io.vertx.core.http.CookieSameSite;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.unit.TestContext;
import io.vertx.ext.unit.junit.VertxUnitRunner;
@@ -15,7 +10,6 @@
import org.folio.rest.RestVerticle;
import org.folio.util.MockJson;
import org.folio.util.SamlTestHelper;
-import org.folio.util.StringUtil;
import org.junit.*;
import org.junit.runner.RunWith;
import org.slf4j.LoggerFactory;
@@ -24,14 +18,7 @@
import org.testcontainers.images.builder.ImageFromDockerfile;
import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
import java.nio.file.Path;
-import java.util.regex.Pattern;
-
-import static io.restassured.RestAssured.given;
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.*;
/**
* Test against a real IDP: https://simplesamlphp.org/ running in a Docker container.
@@ -41,24 +28,17 @@ public class IdpTest {
private static final org.slf4j.Logger logger = LoggerFactory.getLogger(IdpTest.class);
private static final boolean DEBUG = false;
private static final ImageFromDockerfile simplesamlphp =
- new ImageFromDockerfile().withFileFromPath(".", Path.of("src/test/resources/simplesamlphp/"));
+ new ImageFromDockerfile().withFileFromPath(".", Path.of("src/test/resources/simplesamlphp/"));
private static final String TENANT = "diku";
- private static final Header TENANT_HEADER = new Header("X-Okapi-Tenant", TENANT);
- private static final Header TOKEN_HEADER = new Header("X-Okapi-Token", "mytoken");
- private static final Header JSON_CONTENT_TYPE_HEADER = new Header("Content-Type", "application/json");
- private static final String STRIPES_URL = "http://localhost:3000";
private static final int MODULE_PORT = 9231;
- private static final String MODULE_URL = "http://localhost:" + MODULE_PORT;
private static final int OKAPI_PORT = 9230;
private static final String OKAPI_URL = "http://localhost:" + OKAPI_PORT;
-
- private static final String TEST_PATH = "/test/path";
+ private static final String CALLBACK_WITH_EXPIRY = "callback-with-expiry";
private static int IDP_PORT;
private static String IDP_BASE_URL;
- private static final Header OKAPI_URL_HEADER = new Header("X-Okapi-Url", OKAPI_URL);
private static MockJson OKAPI;
private static Vertx VERTX;
@@ -71,7 +51,7 @@ public class IdpTest {
OKAPI_URL + "/_/invoke/tenant/diku/saml/callback-with-expiry");
@BeforeClass
- public static void setupOnce(TestContext context) throws Exception {
+ public static void setupOnce(TestContext context) {
RestAssured.port = MODULE_PORT;
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
VERTX = Vertx.vertx();
@@ -88,16 +68,16 @@ public static void setupOnce(TestContext context) throws Exception {
"/var/www/simplesamlphp/metadata/saml20-idp-hosted.php");
DeploymentOptions moduleOptions = new DeploymentOptions()
- .setConfig(new JsonObject().put("http.port", MODULE_PORT)
- .put("mock", true)); // to use SAML2ClientMock
+ .setConfig(new JsonObject().put("http.port", MODULE_PORT)
+ .put("mock", true)); // to use SAML2ClientMock
OKAPI = new MockJson();
DeploymentOptions okapiOptions = new DeploymentOptions()
- .setConfig(new JsonObject().put("http.port", OKAPI_PORT));
+ .setConfig(new JsonObject().put("http.port", OKAPI_PORT));
VERTX.deployVerticle(new RestVerticle(), moduleOptions)
- .compose(x -> VERTX.deployVerticle(OKAPI, okapiOptions))
- .onComplete(context.asyncAssertSuccess());
+ .compose(x -> VERTX.deployVerticle(OKAPI, okapiOptions))
+ .onComplete(context.asyncAssertSuccess());
}
@AfterClass
@@ -117,100 +97,18 @@ public void post() {
setOkapi("mock_idptest_post.json");
for (int i = 0; i < 2; i++) {
- post0();
+ SamlTestHelper.testPost(CALLBACK_WITH_EXPIRY);
}
}
- private void post0() {
- ExtractableResponse resp = given()
- .header(TENANT_HEADER)
- .header(TOKEN_HEADER)
- .header(OKAPI_URL_HEADER)
- .header(JSON_CONTENT_TYPE_HEADER)
- .body(jsonEncode("stripesUrl", STRIPES_URL + TEST_PATH))
- .post("/saml/login")
- .then()
- .statusCode(200)
- .body("bindingMethod", is("POST"))
- .extract();
-
- String location = resp.body().jsonPath().getString("location");
- String samlRequest = resp.body().jsonPath().getString("samlRequest");
- String relayState = resp.body().jsonPath().getString(SamlAPI.RELAY_STATE);
- Cookie cookie = resp.detailedCookie(SamlAPI.RELAY_STATE);
- assertThat(cookie.getValue(), is(relayState));
-
- String body = given()
- .formParams("RelayState", relayState)
- .formParams("SAMLRequest", samlRequest)
- .post(location)
- .then()
- .statusCode(200)
- .body(containsString("