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 (merge for CSP) #171

Merged
merged 13 commits into from
Sep 12, 2024
Merged
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.9.0-SNAPSHOT</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
2 changes: 1 addition & 1 deletion ramls/saml-login.raml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.",
"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.",
"required": false
}
}
}
11 changes: 11 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,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;
}
}
26 changes: 20 additions & 6 deletions src/main/java/org/folio/rest/impl/SamlAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -199,17 +199,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 +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);
Expand Down Expand Up @@ -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<JsonObject> fetchToken(WebClient client, JsonObject payload, OkapiHeaders parsedHeaders, String endpoint) {
Expand Down Expand Up @@ -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 -> {
Expand All @@ -481,6 +492,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 +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());
Expand Down
9 changes: 8 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,14 @@ 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) {
String newValueString = (newValue == null) ? null : newValue.toString();

valueChanged(oldValue, newValueString, onChanged);
}
}
137 changes: 137 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,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));
}
}
4 changes: 2 additions & 2 deletions src/test/java/org/folio/rest/impl/IdpLegacyTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading