Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MODLOGSAML-192: Allow callback endpoint to return RTR tokens when configured #171

Merged
merged 13 commits into from
Sep 12, 2024
4 changes: 2 additions & 2 deletions pom.xml
steveellis marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<groupId>org.folio</groupId>
<artifactId>mod-login-saml</artifactId>
<packaging>jar</packaging>
<version>2.8.1</version>
<version>2.8.2-SNAPSHOT</version>
<name>mod-login-saml</name>

<licenses>
Expand Down Expand Up @@ -97,7 +97,7 @@

<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<artifactId>log4j-slf4j2-impl</artifactId>
</dependency>

<dependency>
Expand Down
5 changes: 5 additions & 0 deletions ramls/schemas/SamlConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
steveellis marked this conversation as resolved.
Show resolved Hide resolved
"required": false
}
}
}
5 changes: 5 additions & 0 deletions ramls/schemas/SamlConfigRequest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
steveellis marked this conversation as resolved.
Show resolved Hide resolved
"required": false
}
}
}
7 changes: 7 additions & 0 deletions src/main/java/org/folio/config/model/SamlConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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() {
Expand Down Expand Up @@ -131,4 +134,8 @@ 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; }
steveellis marked this conversation as resolved.
Show resolved Hide resolved
}
28 changes: 22 additions & 6 deletions src/main/java/org/folio/rest/impl/SamlAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import java.time.Instant;
import java.util.*;

import javax.ws.rs.core.Configuration;
import javax.ws.rs.core.NewCookie;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
Expand Down Expand Up @@ -199,17 +200,17 @@ private String getRelayState(RoutingContext routingContext, String body) {
@Override
public void postSamlCallback(String body, RoutingContext routingContext, Map<String, String> okapiHeaders,
Handler<AsyncResult<Response>> 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<String, String> okapiHeaders,
Handler<AsyncResult<Response>> 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<String, String> okapiHeaders,
Handler<AsyncResult<Response>> asyncResultHandler, Context vertxContext, String tokenSignEndpoint) {
Handler<AsyncResult<Response>> asyncResultHandler, Context vertxContext) {

registerFakeSession(routingContext);

Expand Down Expand Up @@ -251,8 +252,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);
Expand Down Expand Up @@ -281,8 +283,16 @@ 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()) && (configuration.getUseSecureTokens() == null
|| "false".equals(configuration.getUseSecureTokens()));
steveellis marked this conversation as resolved.
Show resolved Hide resolved
}

private String getTokenSignEndpoint(SamlConfiguration configuration) {
if (isLegacyResponse(configuration)) {
return TOKEN_SIGN_ENDPOINT_LEGACY;
}
return TOKEN_SIGN_ENDPOINT;
}

private Future<JsonObject> fetchToken(WebClient client, JsonObject payload, OkapiHeaders parsedHeaders, String endpoint) {
Expand Down Expand Up @@ -469,6 +479,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 -> {
Expand All @@ -481,6 +494,8 @@ public void putSamlConfiguration(SamlConfigRequest updatedConfig, RoutingContext
});
}



private Future<SamlConfig> storeConfigEntries(RoutingContext rc, OkapiHeaders parsedHeaders,
Map<String, String> updateEntries, Context vertxContext) {

Expand Down Expand Up @@ -608,6 +623,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());
Expand Down
13 changes: 12 additions & 1 deletion src/main/java/org/folio/util/ConfigEntryUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,18 @@ public static void valueChanged(String oldValue, String newValue, Consumer<Strin
if (valueChanged(oldValue, newValue)) {
onChanged.accept(newValue);
}

}

/**
* If value changed, calls the provided {@link Consumer} with the newValue
*/
public static void valueChanged(String oldValue, Boolean newValue, Consumer<String> onChanged) {
Objects.requireNonNull(onChanged);
steveellis marked this conversation as resolved.
Show resolved Hide resolved

String newValueString = (newValue == null) ? null : newValue.toString();

if (valueChanged(oldValue, newValueString)) {
onChanged.accept(newValueString);
}
steveellis marked this conversation as resolved.
Show resolved Hide resolved
}
}
75 changes: 75 additions & 0 deletions src/test/java/org/folio/rest/impl/IdpCallbackTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package org.folio.rest.impl;

import io.restassured.RestAssured;
import io.vertx.core.Vertx;
import io.vertx.ext.unit.TestContext;
import io.vertx.ext.unit.junit.VertxUnitRunner;
import org.folio.config.SamlConfigHolder;
import org.folio.testutil.SimpleSamlPhpContainer;
import org.folio.util.SamlTestHelper;
import org.junit.*;
import org.junit.runner.RunWith;
import org.slf4j.LoggerFactory;
import org.testcontainers.containers.output.Slf4jLogConsumer;

/**
* 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 String TENANT = SamlTestHelper.TENANT;
private static final int MODULE_PORT = SamlTestHelper.MODULE_PORT;
private static final String OKAPI_URL = SamlTestHelper.OKAPI_URL;
private static final String CALLBACK = "callback";
private static Vertx VERTX;

@ClassRule
public static final SimpleSamlPhpContainer<?> IDP = new SimpleSamlPhpContainer<>(OKAPI_URL, 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.init();

SamlTestHelper.deployVerticle(VERTX, context);
}

@AfterClass
public static void tearDownOnce(TestContext context) {
VERTX.close()
.onComplete(context.asyncAssertSuccess());
}

@After
public void after() {
SamlConfigHolder.getInstance().removeClient(TENANT);
}

@Test
public void postCallback() {
IDP.setPostBinding();
SamlTestHelper.setOkapi("mock_idptest_post_secure_tokens.json", IDP);

for (int i = 0; i < 2; i++) {
SamlTestHelper.testPost(CALLBACK);
}
}

@Test
public void redirectCallback() {
IDP.setRedirectBinding();
SamlTestHelper.setOkapi("mock_idptest_redirect_secure_tokens.json", IDP);

for (int i = 0; i < 2; i++) {
SamlTestHelper.testRedirect(CALLBACK);
}
}
}
55 changes: 9 additions & 46 deletions src/test/java/org/folio/rest/impl/IdpLegacyTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,11 @@
import io.vertx.core.json.JsonObject;
import io.vertx.ext.unit.TestContext;
import io.vertx.ext.unit.junit.VertxUnitRunner;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Path;
import java.util.regex.Pattern;
import org.folio.config.SamlConfigHolder;
import org.folio.testutil.SimpleSamlPhpContainer;
import org.folio.rest.RestVerticle;
import org.folio.util.MockJson;
import org.folio.util.StringUtil;
Expand All @@ -32,9 +31,7 @@
import org.junit.Test;
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;

/**
* Test against a real IDP: https://simplesamlphp.org/ running in a Docker container.
Expand All @@ -43,49 +40,35 @@
public class IdpLegacyTest {
private static final org.slf4j.Logger logger = LoggerFactory.getLogger(IdpLegacyTest.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 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 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;

@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");
public static final SimpleSamlPhpContainer<?> IDP =
new SimpleSamlPhpContainer<>(OKAPI_URL, "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();

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");

IDP.init();

DeploymentOptions moduleOptions = new DeploymentOptions()
.setConfig(new JsonObject().put("http.port", MODULE_PORT)
Expand Down Expand Up @@ -113,7 +96,7 @@ public void after() {

@Test
public void post() {
setIdpBinding("POST");
IDP.setPostBinding();;
setOkapi("mock_idptest_post_legacy.json");

for (int i = 0; i < 2; i++) {
Expand Down Expand Up @@ -169,7 +152,7 @@ private void post0() {

@Test
public void redirect() {
setIdpBinding("Redirect");
IDP.setRedirectBinding();
setOkapi("mock_idptest_redirect_legacy.json");

for (int i = 0; i < 2; i++) {
Expand Down Expand Up @@ -235,28 +218,8 @@ private void redirect0() {
header("Location", startsWith("http://localhost:3000/sso-landing?ssoToken=saml-token"));
}

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));
OKAPI.setMockContent(resource, s -> s.replace("http://localhost:8888/simplesaml/", IDP.getBaseUrl()));
}

private String jsonEncode(String key, String value) {
Expand Down
Loading
Loading