Skip to content

Commit

Permalink
Merge pull request #149 from folio-org/MODLOGSAML-172
Browse files Browse the repository at this point in the history
MODLOGSAML-172: Support new mod-authtoken /token/sign endpoint
  • Loading branch information
steveellis authored Oct 13, 2023
2 parents 414d396 + 0c6970d commit 8f8f7d4
Show file tree
Hide file tree
Showing 29 changed files with 1,694 additions and 287 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ bin/
*.jks
.classpath
.project
saml-signing-cert.*
2 changes: 1 addition & 1 deletion GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Examples:

### SP metadata

An XML file that describes the Service Point's configuration like successful login callback URL, and the encryption keys.
An XML file that describes the Service Provider's configuration like successful login callback URL, and the encryption keys.

### SAML binding

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ Endpoints are documented in [RAML file](ramls/saml-login.raml)

### Environment variables

`TRUST_ALL_CERTIFICATES`: if value is `true` then HTTPS certificates not checked. This is a security issue in
production environment, use it for testing only! Default value is `false`.
`TRUST_ALL_CERTIFICATES`: if value is `true` then HTTPS certificates not checked. This is a security issue in production environment, use it for testing only! Default value is `false`.

`LOGIN_COOKIE_SAMESITE`: Configures the SameSite attribute of the login token cookies. Defaults to `Lax` if not set. If served from the same host name `Lax` allows deep links from other sites, for example from a wiki or webmail to an inventory instance record, whereas `Strict` doesn't allow them.

### Sample users for samltest.id

Expand Down
21 changes: 19 additions & 2 deletions descriptors/ModuleDescriptor-template.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"provides": [
{
"id": "login-saml",
"version": "2.0",
"version": "2.1",
"handlers": [
{
"methods": [
Expand All @@ -30,6 +30,19 @@
"users.collection.get"
]
},
{
"methods": [
"POST"
],
"pathPattern": "/saml/callback-with-expiry",
"permissionsRequired": [],
"delegateCORS": true,
"modulePermissions": [
"auth.signtoken",
"configuration.entries.collection.get",
"users.collection.get"
]
},
{
"methods": [
"GET"
Expand Down Expand Up @@ -123,7 +136,11 @@
"requires": [
{
"id": "authtoken",
"version": "1.0 2.0"
"version": "2.0"
},
{
"id": "authtoken2",
"version": "1.0"
},
{
"id": "users",
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

<raml-module-builder-version>35.1.0</raml-module-builder-version>
<generate_routing_context>/saml/callback,/saml/regenerate,/saml/login,/saml/check,/saml/configuration
<generate_routing_context>/saml/callback,/saml/callback-with-expiry,/saml/regenerate,/saml/login,/saml/check,/saml/configuration
</generate_routing_context>

<aspectj.version>1.9.20.1</aspectj.version>
Expand Down
45 changes: 44 additions & 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.
description: Redirect browser to sso-landing page with generated token. Deprecated.
body:
application/octet-stream:
type: string
Expand Down Expand Up @@ -102,6 +102,49 @@ types:
body:
text/plain:
example: "Bad request"
/callback-with-expiry:
post:
description: Redirect browser to sso-landing page with expiring access and refresh tokens.
body:
application/octet-stream:
type: string
application/x-www-form-urlencoded:
type: string
responses:
302:
description: "Generate JWT token and set cookie"
headers:
Location:
400:
description: "Bad request"
body:
text/plain:
example: "Bad request"
401:
description: "Unauthorized"
body:
text/plain:
example: "Unauthorized"
403:
description: "Forbidden"
body:
text/plain:
example: "Forbidden"
500:
description: "Internal server error"
body:
text/plain:
example: "Internal server error"
options:
description: "Preflight CORS for /saml/callback-with-expiry"
responses:
204:
description: "Return with appropriate CORS headers"
400:
description: "Bad request"
body:
text/plain:
example: "Bad request"
/check:
get:
description: Decides if SSO login is configured properly, returns true or false
Expand Down
5 changes: 5 additions & 0 deletions ramls/schemas/SamlConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
"type": "string",
"format": "uri",
"required": true
},
"callback": {
"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
}
}
}
5 changes: 5 additions & 0 deletions ramls/schemas/SamlConfigRequest.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
"type": "string",
"format": "uri",
"required": true
},
"callback": {
"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
}
}
}
104 changes: 65 additions & 39 deletions src/main/java/org/folio/config/SamlClientLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,25 @@
*/
public class SamlClientLoader {

public static final String CALLBACK_ENDPOINT = "/saml/callback";
public static final String SAML = "/saml/";
public static final String CALLBACK_WITH_EXPIRY = "callback-with-expiry";
public static final String CALLBACK = "callback";
private static final Logger log = LogManager.getLogger(SamlClientLoader.class);

private SamlClientLoader() {
public static class SamlIdpUrlFormationException extends RuntimeException {
public SamlIdpUrlFormationException(String message) {
super(message);
}
}

public static class InvalidCallbackUrlException extends RuntimeException {
public InvalidCallbackUrlException(String message) {
super(message);
}
}

private SamlClientLoader() {}

public static Future<SamlClientComposite> loadFromConfiguration(RoutingContext routingContext,
boolean generateMissingKeyStore, Context vertxContext) {
OkapiHeaders okapiHeaders = OkapiHelper.okapiHeaders(routingContext);
Expand All @@ -57,8 +69,10 @@ public static Future<SamlClientComposite> loadFromConfiguration(RoutingContext r
final String privateKeyPassword = samlConfiguration.getPrivateKeyPassword();
final String samlBinding = samlConfiguration.getSamlBinding();
final Resource idpMetadata = samlConfiguration.getIdpMetadata() != null ?
new ByteArrayResource(samlConfiguration.getIdpMetadata().getBytes()) : null;
new ByteArrayResource(samlConfiguration.getIdpMetadata().getBytes()) : null;
final String okapiUrl = samlConfiguration.getOkapiUrl();
final String callback = samlConfiguration.getCallback() == null ?
CALLBACK_WITH_EXPIRY : samlConfiguration.getCallback();

if (StringUtils.isBlank(idpUrl)) {
return Future.failedFuture("There is no IdP configuration stored!");
Expand All @@ -68,16 +82,16 @@ public static Future<SamlClientComposite> loadFromConfiguration(RoutingContext r
return Future.failedFuture("No KeyStore stored in configuration and regeneration is not allowed.");
}
// Generate new KeyStore

final String randomId = RandomStringUtils.randomAlphanumeric(12);
final String randomFileName = RandomStringUtils.randomAlphanumeric(12);

final String actualKeystorePassword = StringUtils.isBlank(keystorePassword) ? randomId : keystorePassword;
final String actualPrivateKeyPassword = StringUtils.isBlank(privateKeyPassword) ? randomId : privateKeyPassword;
final String keystoreFileName = "temp_" + randomFileName + ".jks";

SAML2Client saml2Client = configureSaml2Client(okapiUrl, tenantId, idpUrl, actualKeystorePassword,
actualPrivateKeyPassword, keystoreFileName, samlBinding, idpMetadata, vertxContext);
var cfg = getSaml2ConfigurationForFileResource(keystoreFileName, actualKeystorePassword,
actualPrivateKeyPassword, idpUrl, idpMetadata);
var saml2Client = assembleSaml2Client(okapiUrl, tenantId, cfg, samlBinding, vertxContext, callback);

return vertx.executeBlocking(blockingHandler -> {
saml2Client.init();
Expand All @@ -88,12 +102,15 @@ public static Future<SamlClientComposite> loadFromConfiguration(RoutingContext r
ByteArrayResource keystoreResource = new ByteArrayResource(keystoreBytes.getBytes());
try {
UrlResource idpUrlResource = new UrlResource(idpUrl);
SAML2Client reinitedSaml2Client = configureSaml2Client(okapiUrl, tenantId, actualKeystorePassword,
actualPrivateKeyPassword, idpUrlResource, keystoreResource, samlBinding, idpMetadata, vertxContext);
var reinitializedConfig = getSaml2ConfigurationForByteArrayResource(keystoreResource,
actualKeystorePassword, actualPrivateKeyPassword, idpUrlResource, idpMetadata);
var reinitializedSaml2Client = assembleSaml2Client(okapiUrl, tenantId, reinitializedConfig,
samlBinding, vertxContext, callback);

return new SamlClientComposite(reinitedSaml2Client, samlConfiguration);
return new SamlClientComposite(reinitializedSaml2Client, samlConfiguration);
} catch (MalformedURLException e) {
throw new RuntimeException(e);
log.error("Saml IdP url was malformed", e);
throw new SamlIdpUrlFormationException(e.getMessage());
}
})
);
Expand All @@ -103,17 +120,18 @@ public static Future<SamlClientComposite> loadFromConfiguration(RoutingContext r
ByteArrayResource keystoreResource = new ByteArrayResource(keystoreBytes.getBytes());
try {
UrlResource idpUrlResource = new UrlResource(idpUrl);
SAML2Client saml2Client = configureSaml2Client(okapiUrl, tenantId, keystorePassword, privateKeyPassword,
idpUrlResource, keystoreResource, samlBinding, idpMetadata, vertxContext);
var cfg = getSaml2ConfigurationForByteArrayResource(keystoreResource, keystorePassword,
privateKeyPassword, idpUrlResource, idpMetadata);
var saml2Client = assembleSaml2Client(okapiUrl, tenantId, cfg, samlBinding, vertxContext, callback);

return Future.succeededFuture(new SamlClientComposite(saml2Client, samlConfiguration));
} catch (MalformedURLException e) {
throw new RuntimeException(e);
log.error("Saml IdP url was malformed", e);
throw new SamlIdpUrlFormationException(e.getMessage());
}
});
}


/**
* Store KeyStore (as Base64 string), KeyStorePassword and PrivateKeyPassword in mod-configuration,
* complete returned future with original file bytes.
Expand Down Expand Up @@ -143,39 +161,42 @@ private static Future<Buffer> storeKeystore(OkapiHeaders okapiHeaders, Vertx ver
});
}


private static SAML2Client configureSaml2Client(String okapiUrl, String tenantId, String idpUrl,
String keystorePassword, String actualPrivateKeyPassword, String keystoreFileName, String samlBinding, Resource idpMetadata,
Context vertxContext) {

final SAML2Configuration cfg = new SAML2Configuration(keystoreFileName,
protected static SAML2Configuration getSaml2ConfigurationForByteArrayResource(ByteArrayResource keystoreResource,
String keystorePassword,
String keystorePrivateKeyPassword,
UrlResource idpUrlResource,
Resource idpMetadata) {
final var cfg = new SAML2Configuration(keystoreResource,
keystorePassword,
actualPrivateKeyPassword,
idpUrl);
if(idpMetadata != null) {
keystorePrivateKeyPassword,
idpUrlResource);
if (idpMetadata != null) {
cfg.setIdentityProviderMetadataResource(idpMetadata);
}
cfg.setMaximumAuthenticationLifetime(18000);

return assembleSaml2Client(okapiUrl, tenantId, cfg, samlBinding, vertxContext);
return cfg;
}

protected static SAML2Client configureSaml2Client(String okapiUrl, String tenantId, String keystorePassword, String privateKeyPassword, UrlResource idpUrlResource, ByteArrayResource keystoreResource, String samlBinding, Resource idpMetadata, Context vertxContext) {

final SAML2Configuration byteArrayCfg = new SAML2Configuration(keystoreResource,
protected static SAML2Configuration getSaml2ConfigurationForFileResource(String keystoreFileName,
String keystorePassword,
String keystorePrivateKeyPassword,
String idpUrlResource,
Resource idpMetadata) {
final var cfg = new SAML2Configuration(keystoreFileName,
keystorePassword,
privateKeyPassword,
keystorePrivateKeyPassword,
idpUrlResource);
if(idpMetadata != null) {
byteArrayCfg.setIdentityProviderMetadataResource(idpMetadata);
if (idpMetadata != null) {
cfg.setIdentityProviderMetadataResource(idpMetadata);
}
byteArrayCfg.setMaximumAuthenticationLifetime(18000);
cfg.setMaximumAuthenticationLifetime(18000);

return assembleSaml2Client(okapiUrl, tenantId, byteArrayCfg, samlBinding, vertxContext);
return cfg;
}

private static SAML2Client assembleSaml2Client(String okapiUrl, String tenantId, SAML2Configuration cfg,
String samlBinding, Context vertxContext) {
protected static SAML2Client assembleSaml2Client(String okapiUrl, String tenantId, SAML2Configuration cfg,
String samlBinding, Context vertxContext, String callback) {

if ("REDIRECT".equals(samlBinding)) {
cfg.setAuthnRequestBindingType(SAMLConstants.SAML2_REDIRECT_BINDING_URI);
Expand All @@ -187,17 +208,22 @@ private static SAML2Client assembleSaml2Client(String okapiUrl, String tenantId,
Boolean mock = vertxContext.config().getBoolean("mock", false);
SAML2Client saml2Client = Boolean.TRUE.equals(mock) ? new SAML2ClientMock(cfg) : new SAML2Client(cfg);
saml2Client.setName(tenantId);
saml2Client.setCallbackUrl(buildCallbackUrl(okapiUrl, tenantId));
saml2Client.setCallbackUrl(buildCallbackUrl(okapiUrl, tenantId, callback));
saml2Client.setRedirectionActionBuilder(new JsonReponseSaml2RedirectActionBuilder(saml2Client));
saml2Client.setStateGenerator(new SAML2StateGenerator(saml2Client));


return saml2Client;
}

private static String buildCallbackUrl(String okapiUrl, String tenantId) {
return okapiUrl + "/_/invoke/tenant/" + CommonHelper.urlEncode(tenantId) + CALLBACK_ENDPOINT;
}
public static String buildCallbackUrl(String okapiUrl, String tenantId, String callback) {
if (isValidCallbackUrl(callback)) {
return okapiUrl + "/_/invoke/tenant/" + CommonHelper.urlEncode(tenantId) + SAML + callback;
}

throw new InvalidCallbackUrlException("Callback url is invalid");
}

protected static boolean isValidCallbackUrl(String callbackUrl) {
return CALLBACK.equals(callbackUrl) || CALLBACK_WITH_EXPIRY.equals(callbackUrl);
}
}
11 changes: 9 additions & 2 deletions src/main/java/org/folio/config/model/SamlConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class SamlConfiguration {
public static final String USER_PROPERTY_CODE = "user.property";
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";

@JsonProperty(IDP_URL_CODE)
private String idpUrl;
Expand All @@ -40,10 +41,10 @@ public class SamlConfiguration {
private String idpMetadata;
@JsonProperty(METADATA_INVALIDATED_CODE)
private String metadataInvalidated = "true";


@JsonProperty(OKAPI_URL)
private String okapiUrl;
@JsonProperty(SAML_CALLBACK)
private String callback;


public String getIdpUrl() {
Expand Down Expand Up @@ -124,4 +125,10 @@ public String getIdpMetadata() {
public void setIdpMetadata(String idpMetadata) {
this.idpMetadata = idpMetadata;
}

public String getCallback() { return callback; }

public void setCallback(String callback) {
this.callback = callback;
}
}
Loading

0 comments on commit 8f8f7d4

Please sign in to comment.