diff --git a/README.md b/README.md
index 1762413..39c655f 100644
--- a/README.md
+++ b/README.md
@@ -32,20 +32,28 @@ Configuration information is specified in two forms:
### System Properties
-| Property | Default | Description |
-|-------------------------------|---------------------|----------------------------------------------------------------------------|
-| `port` | `8081` | Server port to listen on |
-| `okapi_url` | *required* | Where to find Okapi (URL) |
-| `secure_store` | `Ephemeral` | Type of secure store to use. Valid: `Ephemeral`, `AwsSsm`, `Vault` |
-| `secure_store_props` | `NA` | Path to a properties file specifying secure store configuration |
-| `token_cache_ttl_ms` | `3600000` | How long to cache JWTs, in milliseconds (ms) |
-| `null_token_cache_ttl_ms` | `30000` | How long to cache login failures (null JWTs), in milliseconds (ms) |
-| `token_cache_capacity` | `100` | Max token cache size |
-| `patron_id_cache_ttl_ms` | `3600000` | How long to cache patron ID mappings in milliseconds (ms) |
-| `null_patron_id_cache_ttl_ms` | `30000` | How long to cache patron lookup failures in milliseconds (ms) |
-| `patron_id_cache_capacity` | `1000` | Max token cache size |
-| `log_level` | `INFO` | Log4j Log Level |
-| `request_timeout_ms` | `30000` | Request Timeout |
+| Property | Default | Description |
+|----------------------------------|---------------------|----------------------------------------------------------------------------|
+| `port` | `8081` | Server port to listen on |
+| `okapi_url` | *required* | Where to find Okapi (URL) |
+| `secure_store` | `Ephemeral` | Type of secure store to use. Valid: `Ephemeral`, `AwsSsm`, `Vault` |
+| `secure_store_props` | `NA` | Path to a properties file specifying secure store configuration |
+| `token_cache_ttl_ms` | `3600000` | How long to cache JWTs, in milliseconds (ms) |
+| `null_token_cache_ttl_ms` | `30000` | How long to cache login failures (null JWTs), in milliseconds (ms) |
+| `token_cache_capacity` | `100` | Max token cache size |
+| `patron_id_cache_ttl_ms` | `3600000` | How long to cache patron ID mappings in milliseconds (ms) |
+| `null_patron_id_cache_ttl_ms` | `30000` | How long to cache patron lookup failures in milliseconds (ms) |
+| `patron_id_cache_capacity` | `1000` | Max token cache size |
+| `keycloak_key_cache_ttl_ms` | `3600000` | How long to cache patron ID mappings in milliseconds (ms) |
+| `null_keycloak_key_cache_ttl_ms` | `30000` | How long to cache patron lookup failures in milliseconds (ms) |
+| `keycloak_key_cache_capacity` | `1000` | Max token cache size |
+| `log_level` | `INFO` | Log4j Log Level |
+| `request_timeout_ms` | `30000` | Request Timeout |
+
+### Env variables for secure requests
+| Property | Default | Description |
+|----------|----------------|------------------------------------------|
+| `KC_URL` | `` | Keycloak url for secure token validation |
### Env variables for TLS configuration for Http server
diff --git a/pom.xml b/pom.xml
index 0e833e4..7423797 100644
--- a/pom.xml
+++ b/pom.xml
@@ -86,8 +86,13 @@
io.jsonwebtoken
- jjwt-api
- 0.11.5
+ jjwt-impl
+ 0.12.6
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
com.fasterxml.jackson.core
diff --git a/ramls/edge-patron.raml b/ramls/edge-patron.raml
index 6973136..e994c65 100644
--- a/ramls/edge-patron.raml
+++ b/ramls/edge-patron.raml
@@ -139,6 +139,324 @@ types:
body:
text/plain:
example: internal server error, contact administrator
+ get:
+ description: (Secure) Return account details for the user provided in access token(x-okapi-token)
+ queryParameters:
+ includeLoans:
+ description: |
+ Indicates whether or not to include the loans array in
+ the response
+ required: false
+ type: boolean
+ default: false
+ includeCharges:
+ description: |
+ Indicates whether or not to include the charges array in
+ the response
+ required: false
+ type: boolean
+ default: false
+ includeHolds:
+ description: |
+ Indicates whether or not to include the holds array in
+ the response
+ required: false
+ type: boolean
+ default: false
+ apikey:
+ description: "API Key"
+ type: string
+ sortBy:
+ description: |
+ Part of CQL query, indicates the order of records within the lists of holds, charges, loans
+ example: item.title/sort.ascending
+ required: false
+ type: string
+ offset:
+ description: |
+ Skip over a number of elements by specifying an offset value for the query
+ type: integer
+ required: false
+ example: 1
+ minimum: 0
+ maximum: 2147483647
+ limit:
+ description: |
+ Limit the number of elements returned in the response
+ type: integer
+ required: false
+ example: 10
+ minimum: 0
+ maximum: 2147483647
+ responses:
+ 200:
+ description: Returns the user account info
+ body:
+ application/json:
+ type: account
+ example: !include examples/account.json
+ 400:
+ description: Bad request
+ body:
+ text/plain:
+ example: unable to process request -- constraint violation
+ 401:
+ description: Not authorized to perform requested action
+ body:
+ text/plain:
+ example: unable to get account -- unauthorized
+ 404:
+ description: Item with a given ID not found
+ body:
+ text/plain:
+ example: account not found
+ 403:
+ description: Access Denied
+ body:
+ text/plain:
+ example: Access Denied
+ 500:
+ description: Internal server error, e.g. due to misconfiguration
+ body:
+ text/plain:
+ example: internal server error, contact administrator
+ /item:
+ /{itemId}:
+ uriParameters:
+ itemId:
+ description: The UUID of a FOLIO item
+ type: string
+ pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$
+ /hold:
+ displayName: Hold Management
+ description: (Secure) Services that provide hold management
+ post:
+ description: |
+ (Secure) Creates a hold request on an existing item for the user
+ queryParameters:
+ apikey:
+ description: "API Key"
+ type: string
+ body:
+ application/json:
+ type: hold
+ example: !include examples/hold.json
+ responses:
+ 201:
+ description: |
+ Returns data for a new hold request on the specified item
+ body:
+ application/json:
+ type: hold
+ example: !include examples/hold.json
+ 400:
+ description: Bad request
+ body:
+ text/plain:
+ example: unable to process request -- constraint violation
+ 401:
+ description: Not authorized to perform requested action
+ body:
+ text/plain:
+ example: unable to create hold -- unauthorized
+ 404:
+ description: Item with a given ID not found
+ body:
+ text/plain:
+ example: item not found
+ 403:
+ description: Access Denied
+ body:
+ text/plain:
+ example: Access Denied
+ 500:
+ description: |
+ Internal server error, e.g. due to misconfiguration
+ body:
+ text/plain:
+ example: internal server error, contact administrator
+ /allowed-service-points:
+ displayName: Allowed service points
+ description: Service that provides a list of allowed pickup service points
+ get:
+ description: |
+ (Secure) Returns a list of pickup service points allowed for a particular patron and instance
+ queryParameters:
+ apikey:
+ description: "API Key"
+ type: string
+ body:
+ application/json:
+ type: allowedServicePoints
+ example: !include examples/allowed-service-points-response.json
+ responses:
+ 200:
+ description: |
+ Successfully returns a list of allowed service points
+ body:
+ application/json:
+ type: allowedServicePoints
+ example: !include examples/allowed-service-points-response.json
+ 422:
+ description: Validation error
+ body:
+ application/json:
+ type: errors
+ 500:
+ description: |
+ Internal server error, e.g. due to misconfiguration
+ body:
+ text/plain:
+ example: internal server error, contact administrator
+ /instance:
+ /{instanceId}:
+ uriParameters:
+ instanceId:
+ description: The UUID of a FOLIO instance
+ type: string
+ pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$
+ /hold:
+ displayName: Hold Management
+ description: Services that provide hold management
+ post:
+ description: |
+ (Secure) Creates a hold request on an existing item by instance ID for the user
+ queryParameters:
+ apikey:
+ description: "API Key"
+ type: string
+ body:
+ application/json:
+ type: hold
+ example: !include examples/hold.json
+ responses:
+ 201:
+ description: |
+ Returns data for a new hold request on the selected item
+ body:
+ application/json:
+ type: hold
+ example: !include examples/hold.json
+ 400:
+ description: Bad request
+ body:
+ text/plain:
+ example: unable to process request -- constraint violation
+ 401:
+ description: Not authorized to perform requested action
+ body:
+ text/plain:
+ example: unable to create hold -- unauthorized
+ 404:
+ description: Instance with a given ID not found
+ body:
+ text/plain:
+ example: item not found
+ 403:
+ description: Access Denied
+ body:
+ text/plain:
+ example: Access Denied
+ 422:
+ description: Validation error
+ body:
+ application/json:
+ type : errors
+ 500:
+ description: |
+ Internal server error, e.g. due to misconfiguration
+ body:
+ text/plain:
+ example: internal server error, contact administrator
+ /allowed-service-points:
+ displayName: Allowed service points
+ description: Service that provides a list of allowed pickup service points
+ get:
+ description: |
+ (Secure) Returns a list of pickup service points allowed for a particular patron and instance
+ queryParameters:
+ apikey:
+ description: "API Key"
+ type: string
+ body:
+ application/json:
+ type: allowedServicePoints
+ example: !include examples/allowed-service-points-response.json
+ responses:
+ 200:
+ description: |
+ Successfully returns a list of allowed service points
+ body:
+ application/json:
+ type: allowedServicePoints
+ example: !include examples/allowed-service-points-response.json
+ 422:
+ description: Validation error
+ body:
+ application/json:
+ type: errors
+ 500:
+ description: |
+ Internal server error, e.g. due to misconfiguration
+ body:
+ text/plain:
+ example: internal server error, contact administrator
+ /hold:
+ displayName: Hold Management
+ description: Services that provide hold management
+ /{holdId}:
+ displayName: Hold Management By Id
+ description: Services that provide hold management by Id
+ uriParameters:
+ holdId:
+ description: The UUID of a FOLIO hold request
+ type: string
+ pattern: ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$
+ /cancel:
+ post:
+ description: (Secure) Removes the specified hold request
+ queryParameters:
+ apikey:
+ description: "API Key"
+ type: string
+ body:
+ application/json:
+ type: hold-cancellation
+ example: !include examples/hold-cancellation.json
+ responses:
+ 201:
+ description: The specified hold request was removed
+ body:
+ application/json:
+ type: hold
+ example: !include examples/hold.json
+ 400:
+ description: Bad request
+ body:
+ text/plain:
+ example: |
+ unable to process request -- constraint violation
+ 401:
+ description: Not authorized to perform requested action
+ body:
+ text/plain:
+ example: unable to cancel hold -- unauthorized
+ 404:
+ description: hold with a given ID not found
+ body:
+ text/plain:
+ example: hold not found
+ 403:
+ description: Access denied
+ body:
+ text/plain:
+ example: access denied
+ 500:
+ description: |
+ Internal server error, e.g. due to misconfiguration
+ body:
+ text/plain:
+ example: internal server error, contact administrator
/external-patrons:
displayName: Get Accounts of External Patrons
description: Get accounts of external patrons based on flag
diff --git a/src/main/java/org/folio/edge/patron/Constants.java b/src/main/java/org/folio/edge/patron/Constants.java
index 48c960c..7b60082 100644
--- a/src/main/java/org/folio/edge/patron/Constants.java
+++ b/src/main/java/org/folio/edge/patron/Constants.java
@@ -4,15 +4,24 @@
public class Constants {
+ public static final String KEYCLOAK_URL = "KC_URL";
public static final String SYS_PATRON_ID_CACHE_TTL_MS = "patron_id_cache_ttl_ms";
public static final String SYS_NULL_PATRON_ID_CACHE_TTL_MS = "null_patron_id_cache_ttl_ms";
public static final String SYS_PATRON_ID_CACHE_CAPACITY = "patron_id_cache_capacity";
+ public static final String SYS_KEYCLOAK_KEY_CACHE_TTL_MS = "keycloak_key_cache_ttl_ms";
+ public static final String SYS_NULL_KEYCLOAK_KEY_CACHE_TTL_MS = "null_keycloak_key_cache_ttl_ms";
+ public static final String SYS_KEYCLOAK_KEY_CACHE_CAPACITY = "keycloak_key_cache_capacity";
public static final String PARAM_EXPIRED = "expired";
+ public static final String VIP_CLAIM = "vip";
+ public static final String EXTERNAL_SYSTEM_ID_CLAIM = "externalSystemId";
public static final String DEFAULT_CURRENCY_CODE = Currency.getInstance("USD").getCurrencyCode();
public static final long DEFAULT_PATRON_ID_CACHE_TTL_MS = 60 * 60 * 1000L;
public static final long DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS = 30 * 1000L;
public static final int DEFAULT_PATRON_ID_CACHE_CAPACITY = 1000;
+ public static final long DEFAULT_KEYCLOAK_KEY_CACHE_TTL_MS = 60 * 60 * 1000L;
+ public static final long DEFAULT_NULL_KEYCLOAK_KEY_CACHE_TTL_MS = 30 * 1000L;
+ public static final int DEFAULT_KEYCLOAK_KEY_CACHE_CAPACITY = 50;
public static final String PARAM_SORT_BY = "sortBy";
public static final String PARAM_LIMIT = "limit";
diff --git a/src/main/java/org/folio/edge/patron/MainVerticle.java b/src/main/java/org/folio/edge/patron/MainVerticle.java
index daab13f..324d7c0 100644
--- a/src/main/java/org/folio/edge/patron/MainVerticle.java
+++ b/src/main/java/org/folio/edge/patron/MainVerticle.java
@@ -1,21 +1,31 @@
package org.folio.edge.patron;
+import static org.folio.edge.patron.Constants.DEFAULT_KEYCLOAK_KEY_CACHE_CAPACITY;
+import static org.folio.edge.patron.Constants.DEFAULT_KEYCLOAK_KEY_CACHE_TTL_MS;
+import static org.folio.edge.patron.Constants.DEFAULT_NULL_KEYCLOAK_KEY_CACHE_TTL_MS;
+import static org.folio.edge.patron.Constants.DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS;
+import static org.folio.edge.patron.Constants.DEFAULT_PATRON_ID_CACHE_CAPACITY;
+import static org.folio.edge.patron.Constants.DEFAULT_PATRON_ID_CACHE_TTL_MS;
+import static org.folio.edge.patron.Constants.KEYCLOAK_URL;
+import static org.folio.edge.patron.Constants.SYS_KEYCLOAK_KEY_CACHE_CAPACITY;
+import static org.folio.edge.patron.Constants.SYS_KEYCLOAK_KEY_CACHE_TTL_MS;
+import static org.folio.edge.patron.Constants.SYS_NULL_KEYCLOAK_KEY_CACHE_TTL_MS;
+import static org.folio.edge.patron.Constants.SYS_NULL_PATRON_ID_CACHE_TTL_MS;
+import static org.folio.edge.patron.Constants.SYS_PATRON_ID_CACHE_CAPACITY;
+import static org.folio.edge.patron.Constants.SYS_PATRON_ID_CACHE_TTL_MS;
+
import io.vertx.core.http.HttpMethod;
import io.vertx.ext.web.Router;
+import io.vertx.ext.web.client.WebClient;
import io.vertx.ext.web.handler.BodyHandler;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.folio.edge.core.EdgeVerticleHttp;
import org.folio.edge.core.utils.OkapiClientFactory;
import org.folio.edge.core.utils.OkapiClientFactoryInitializer;
+import org.folio.edge.patron.cache.KeycloakPublicKeyCache;
import org.folio.edge.patron.cache.PatronIdCache;
-
-import static org.folio.edge.patron.Constants.DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS;
-import static org.folio.edge.patron.Constants.DEFAULT_PATRON_ID_CACHE_CAPACITY;
-import static org.folio.edge.patron.Constants.DEFAULT_PATRON_ID_CACHE_TTL_MS;
-import static org.folio.edge.patron.Constants.SYS_NULL_PATRON_ID_CACHE_TTL_MS;
-import static org.folio.edge.patron.Constants.SYS_PATRON_ID_CACHE_CAPACITY;
-import static org.folio.edge.patron.Constants.SYS_PATRON_ID_CACHE_TTL_MS;
+import org.folio.edge.patron.utils.KeycloakClient;
public class MainVerticle extends EdgeVerticleHttp {
@@ -23,29 +33,52 @@ public class MainVerticle extends EdgeVerticleHttp {
public MainVerticle() {
super();
+ initializePatronIdCache();
+ initializeKeycloakKeyCache();
+ }
+ private void initializePatronIdCache() {
final String patronIdCacheTtlMs = System.getProperty(SYS_PATRON_ID_CACHE_TTL_MS);
final long cacheTtlMs = patronIdCacheTtlMs != null ? Long.parseLong(patronIdCacheTtlMs)
- : DEFAULT_PATRON_ID_CACHE_TTL_MS;
- logger.info("Using patronId cache TTL (ms): " + patronIdCacheTtlMs);
+ : DEFAULT_PATRON_ID_CACHE_TTL_MS;
final String nullTokenCacheTtlMs = System.getProperty(SYS_NULL_PATRON_ID_CACHE_TTL_MS);
final long failureCacheTtlMs = nullTokenCacheTtlMs != null ? Long.parseLong(nullTokenCacheTtlMs)
- : DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS;
- logger.info("Using patronId cache TTL (ms): " + failureCacheTtlMs);
+ : DEFAULT_NULL_PATRON_ID_CACHE_TTL_MS;
final String patronIdCacheCapacity = System.getProperty(SYS_PATRON_ID_CACHE_CAPACITY);
final int cacheCapacity = patronIdCacheCapacity != null ? Integer.parseInt(patronIdCacheCapacity)
- : DEFAULT_PATRON_ID_CACHE_CAPACITY;
- logger.info("Using patronId cache capacity: " + patronIdCacheCapacity);
+ : DEFAULT_PATRON_ID_CACHE_CAPACITY;
PatronIdCache.initialize(cacheTtlMs, failureCacheTtlMs, cacheCapacity);
}
+ private void initializeKeycloakKeyCache() {
+ final String keycloakKeyCacheTtlMs = retriveProperty(SYS_KEYCLOAK_KEY_CACHE_TTL_MS);
+ final long cacheTtlMs = keycloakKeyCacheTtlMs != null ? Long.parseLong(keycloakKeyCacheTtlMs)
+ : DEFAULT_KEYCLOAK_KEY_CACHE_TTL_MS;
+
+ final String nullTokenCacheTtlMs = retriveProperty(SYS_NULL_KEYCLOAK_KEY_CACHE_TTL_MS);
+ final long failureCacheTtlMs = nullTokenCacheTtlMs != null ? Long.parseLong(nullTokenCacheTtlMs)
+ : DEFAULT_NULL_KEYCLOAK_KEY_CACHE_TTL_MS;
+
+ final String keycloakKeyCacheCapacity = retriveProperty(SYS_KEYCLOAK_KEY_CACHE_CAPACITY);
+ final int cacheCapacity = keycloakKeyCacheCapacity != null ? Integer.parseInt(keycloakKeyCacheCapacity)
+ : DEFAULT_KEYCLOAK_KEY_CACHE_CAPACITY;
+
+ KeycloakPublicKeyCache.initialize(cacheTtlMs, failureCacheTtlMs, cacheCapacity);
+ }
+
@Override
public Router defineRoutes() {
OkapiClientFactory ocf = OkapiClientFactoryInitializer.createInstance(vertx, config());
- PatronHandler patronHandler = new PatronHandler(secureStore, ocf);
+ final String keycloakUrl = retriveProperty(KEYCLOAK_URL);
+ if(keycloakUrl == null || keycloakUrl.isEmpty()) {
+ logger.warn("Keycloak url is not defined. Secure endpoints will not work");
+ }
+ logger.info("Using keycloak url: {}", keycloakUrl);
+ KeycloakClient keycloakClient = new KeycloakClient(keycloakUrl, WebClient.create(vertx));
+ PatronHandler patronHandler = new PatronHandler(secureStore, ocf, keycloakClient);
Router router = Router.router(vertx);
router.route().handler(BodyHandler.create());
@@ -56,30 +89,56 @@ public Router defineRoutes() {
router.route(HttpMethod.GET, "/patron/account/:patronId")
.handler(patronHandler::handleGetAccount);
+ router.route(HttpMethod.GET, "/patron/account")
+ .handler(patronHandler::handleSecureGetAccount);
+
router.route(HttpMethod.POST, "/patron/account/:patronId/item/:itemId/renew")
.handler(patronHandler::handleRenew);
router.route(HttpMethod.POST, "/patron/account/:patronId/item/:itemId/hold")
.handler(patronHandler::handlePlaceItemHold);
+ router.route(HttpMethod.POST, "/patron/account/item/:itemId/hold")
+ .handler(patronHandler::handleSecurePlaceItemHold);
+
router.route(HttpMethod.POST, "/patron")
.handler(patronHandler::handlePostPatronRequest);
router.route(HttpMethod.POST, "/patron/account/:patronId/instance/:instanceId/hold")
.handler(patronHandler::handlePlaceInstanceHold);
- router.route(HttpMethod.GET, "/patron/account/:patronId/instance/:instanceId/" +
- "allowed-service-points").handler(patronHandler::handleGetAllowedServicePointsForInstance);
+ router.route(HttpMethod.POST, "/patron/account/instance/:instanceId/hold")
+ .handler(patronHandler::handleSecurePlaceInstanceHold);
+
+ router.route(HttpMethod.GET, "/patron/account/:patronId/instance/:instanceId/allowed-service-points")
+ .handler(patronHandler::handleGetAllowedServicePointsForInstance);
+
+ router.route(HttpMethod.GET, "/patron/account/instance/:instanceId/allowed-service-points")
+ .handler(patronHandler::handleSecureGetAllowedServicePointsForInstance);
+
+ router.route(HttpMethod.GET, "/patron/account/:patronId/item/:itemId/allowed-service-points")
+ .handler(patronHandler::handleGetAllowedServicePointsForItem);
- router.route(HttpMethod.GET, "/patron/account/:patronId/item/:itemId/" +
- "allowed-service-points").handler(patronHandler::handleGetAllowedServicePointsForItem);
+ router.route(HttpMethod.GET, "/patron/account/item/:itemId/allowed-service-points")
+ .handler(patronHandler::handleSecureGetAllowedServicePointsForItem);
router.route(HttpMethod.POST, "/patron/account/:patronId/hold/:holdId/cancel")
.handler(patronHandler::handleCancelHold);
+ router.route(HttpMethod.POST, "/patron/account/hold/:holdId/cancel")
+ .handler(patronHandler::handleSecureCancelHold);
+
router.route(HttpMethod.GET, "/patron/registration-status")
.handler(patronHandler::handleGetPatronRegistrationStatus);
return router;
}
+
+ private String retriveProperty(String name) {
+ var property = System.getProperty(name);
+ if (property == null) {
+ property = System.getenv().get(name);
+ }
+ return property;
+ }
}
diff --git a/src/main/java/org/folio/edge/patron/PatronHandler.java b/src/main/java/org/folio/edge/patron/PatronHandler.java
index 4c4c362..7b71aa0 100644
--- a/src/main/java/org/folio/edge/patron/PatronHandler.java
+++ b/src/main/java/org/folio/edge/patron/PatronHandler.java
@@ -1,5 +1,29 @@
package org.folio.edge.patron;
+import static org.folio.edge.core.Constants.APPLICATION_JSON;
+import static org.folio.edge.core.Constants.X_OKAPI_TENANT;
+import static org.folio.edge.core.Constants.X_OKAPI_TOKEN;
+import static org.folio.edge.patron.Constants.EXTERNAL_SYSTEM_ID_CLAIM;
+import static org.folio.edge.patron.Constants.FIELD_EXPIRATION_DATE;
+import static org.folio.edge.patron.Constants.FIELD_REQUEST_DATE;
+import static org.folio.edge.patron.Constants.MSG_ACCESS_DENIED;
+import static org.folio.edge.patron.Constants.MSG_HOLD_NOBODY;
+import static org.folio.edge.patron.Constants.MSG_INTERNAL_SERVER_ERROR;
+import static org.folio.edge.patron.Constants.MSG_REQUEST_TIMEOUT;
+import static org.folio.edge.patron.Constants.PARAM_EMAIL_ID;
+import static org.folio.edge.patron.Constants.PARAM_HOLD_ID;
+import static org.folio.edge.patron.Constants.PARAM_INCLUDE_CHARGES;
+import static org.folio.edge.patron.Constants.PARAM_INCLUDE_HOLDS;
+import static org.folio.edge.patron.Constants.PARAM_INCLUDE_LOANS;
+import static org.folio.edge.patron.Constants.PARAM_INSTANCE_ID;
+import static org.folio.edge.patron.Constants.PARAM_ITEM_ID;
+import static org.folio.edge.patron.Constants.PARAM_LIMIT;
+import static org.folio.edge.patron.Constants.PARAM_OFFSET;
+import static org.folio.edge.patron.Constants.PARAM_PATRON_ID;
+import static org.folio.edge.patron.Constants.PARAM_SORT_BY;
+import static org.folio.edge.patron.Constants.VIP_CLAIM;
+import static org.folio.edge.patron.model.HoldCancellationValidator.validateCancelHoldRequest;
+
import com.amazonaws.util.StringUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.vertx.core.buffer.Buffer;
@@ -9,6 +33,15 @@
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.client.HttpResponse;
+import java.text.SimpleDateFormat;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TimeZone;
+import java.util.function.Consumer;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.folio.edge.core.Handler;
@@ -19,46 +52,20 @@
import org.folio.edge.patron.model.error.Error;
import org.folio.edge.patron.model.error.ErrorMessage;
import org.folio.edge.patron.model.error.Errors;
+import org.folio.edge.patron.utils.KeycloakClient;
+import org.folio.edge.patron.utils.KeycloakTokenHelper;
import org.folio.edge.patron.utils.PatronIdHelper;
import org.folio.edge.patron.utils.PatronOkapiClient;
-import java.text.SimpleDateFormat;
-import java.util.Collections;
-import java.util.Date;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.TimeZone;
-
-import static org.folio.edge.core.Constants.APPLICATION_JSON;
-import static org.folio.edge.patron.Constants.FIELD_EXPIRATION_DATE;
-import static org.folio.edge.patron.Constants.FIELD_REQUEST_DATE;
-import static org.folio.edge.patron.Constants.MSG_ACCESS_DENIED;
-import static org.folio.edge.patron.Constants.MSG_HOLD_NOBODY;
-import static org.folio.edge.patron.Constants.MSG_INTERNAL_SERVER_ERROR;
-import static org.folio.edge.patron.Constants.MSG_REQUEST_TIMEOUT;
-import static org.folio.edge.patron.Constants.PARAM_EMAIL_ID;
-import static org.folio.edge.patron.Constants.PARAM_HOLD_ID;
-import static org.folio.edge.patron.Constants.PARAM_INCLUDE_CHARGES;
-import static org.folio.edge.patron.Constants.PARAM_INCLUDE_HOLDS;
-import static org.folio.edge.patron.Constants.PARAM_INCLUDE_LOANS;
-import static org.folio.edge.patron.Constants.PARAM_INSTANCE_ID;
-import static org.folio.edge.patron.Constants.PARAM_ITEM_ID;
-import static org.folio.edge.patron.Constants.PARAM_LIMIT;
-import static org.folio.edge.patron.Constants.PARAM_OFFSET;
-import static org.folio.edge.patron.Constants.PARAM_PATRON_ID;
-import static org.folio.edge.patron.Constants.PARAM_SORT_BY;
-import static org.folio.edge.patron.model.HoldCancellationValidator.validateCancelHoldRequest;
-
public class PatronHandler extends Handler {
public static final String WRONG_INTEGER_PARAM_MESSAGE = "'%s' parameter is incorrect."
+ " parameter value {%s} is not valid: must be an integer, greater than or equal to 0";
private static final Logger logger = LogManager.getLogger(Handler.class);
-
- public PatronHandler(SecureStore secureStore, OkapiClientFactory ocf) {
+ private final KeycloakClient keycloakClient;
+ public PatronHandler(SecureStore secureStore, OkapiClientFactory ocf, KeycloakClient keycloakClient) {
super(secureStore, ocf);
+ this.keycloakClient = keycloakClient;
}
@Override
@@ -103,6 +110,41 @@ protected void handleCommon(RoutingContext ctx, String[] requiredParams, String[
});
}
+ private void handleSecureCommon(RoutingContext ctx, Consumer handler) {
+ var token = ctx.request().getHeader(X_OKAPI_TOKEN);
+ var tenant = ctx.request().getHeader(X_OKAPI_TENANT);
+
+ if (token == null || token.isEmpty()) {
+ badRequest(ctx, "Missing access token");
+ return;
+ }
+
+ if (tenant == null || tenant.isEmpty()) {
+ badRequest(ctx, "Missing tenant id");
+ return;
+ }
+ KeycloakTokenHelper.getClaimsFromToken(token, tenant, keycloakClient)
+ .onSuccess(claims -> {
+ var vip = claims.get(VIP_CLAIM, Boolean.class);
+ var externalSystemId = claims.get(EXTERNAL_SYSTEM_ID_CLAIM, String.class);
+ if (vip == null || externalSystemId == null) {
+ logger.error("Token doesn't contain required claims");
+ badRequest(ctx, "Token doesn't contain required claims");
+ return;
+ }
+ if (!vip) {
+ accessDenied(ctx, "Patron is not allowed to call secure endpoints");
+ return;
+ }
+ ctx.request().params().add(PARAM_PATRON_ID, externalSystemId);
+ handler.accept(ctx);
+ })
+ .onFailure(ex -> {
+ logger.error("Failed to get claims from token", ex);
+ badRequest(ctx, "Failed to validate access token");
+ });
+ }
+
public void handleGetAccount(RoutingContext ctx) {
handleCommon(ctx,
new String[] {},
@@ -128,6 +170,10 @@ public void handleGetAccount(RoutingContext ctx) {
});
}
+ public void handleSecureGetAccount(RoutingContext ctx) {
+ handleSecureCommon(ctx, this::handleGetAccount);
+ }
+
public void handleRenew(RoutingContext ctx) {
handleCommon(ctx,
new String[] { PARAM_ITEM_ID },
@@ -157,6 +203,10 @@ public void handlePlaceItemHold(RoutingContext ctx) {
t -> handleProxyException(ctx, t)));
}
+ public void handleSecurePlaceItemHold(RoutingContext ctx) {
+ handleSecureCommon(ctx, this::handlePlaceItemHold);
+ }
+
public void handlePostPatronRequest(RoutingContext ctx) {
if (ctx.body().asJsonObject() == null) {
logger.warn("handlePostPatronRequest:: missing body found");
@@ -202,6 +252,10 @@ public void handleCancelHold(RoutingContext ctx) {
);
}
+ public void handleSecureCancelHold(RoutingContext ctx) {
+ handleSecureCommon(ctx, this::handleCancelHold);
+ }
+
public void handlePlaceInstanceHold(RoutingContext ctx) {
if (ctx.body().asJsonObject() == null) {
badRequest(ctx, MSG_HOLD_NOBODY);
@@ -219,6 +273,10 @@ public void handlePlaceInstanceHold(RoutingContext ctx) {
t -> handleProxyException(ctx, t)));
}
+ public void handleSecurePlaceInstanceHold(RoutingContext ctx) {
+ handleSecureCommon(ctx, this::handlePlaceInstanceHold);
+ }
+
public void handleGetAllowedServicePointsForInstance(RoutingContext ctx) {
handleCommon(ctx,
new String[] { PARAM_PATRON_ID, PARAM_INSTANCE_ID },
@@ -230,6 +288,10 @@ public void handleGetAllowedServicePointsForInstance(RoutingContext ctx) {
t -> handleProxyException(ctx, t)));
}
+ public void handleSecureGetAllowedServicePointsForInstance(RoutingContext ctx) {
+ handleSecureCommon(ctx, this::handleGetAllowedServicePointsForInstance);
+ }
+
public void handleGetAllowedServicePointsForItem(RoutingContext ctx) {
handleCommon(ctx,
@@ -242,6 +304,10 @@ public void handleGetAllowedServicePointsForItem(RoutingContext ctx) {
t -> handleProxyException(ctx, t)));
}
+ public void handleSecureGetAllowedServicePointsForItem(RoutingContext ctx) {
+ handleSecureCommon(ctx, this::handleGetAllowedServicePointsForItem);
+ }
+
public void handleGetPatronRegistrationStatus(RoutingContext ctx) {
logger.debug("handleGetPatronRegistrationStatus:: Fetching patron registration");
String emailId = ctx.request().getParam(PARAM_EMAIL_ID);
diff --git a/src/main/java/org/folio/edge/patron/cache/KeycloakPublicKeyCache.java b/src/main/java/org/folio/edge/patron/cache/KeycloakPublicKeyCache.java
new file mode 100644
index 0000000..80f0bf9
--- /dev/null
+++ b/src/main/java/org/folio/edge/patron/cache/KeycloakPublicKeyCache.java
@@ -0,0 +1,79 @@
+package org.folio.edge.patron.cache;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.folio.edge.core.cache.Cache;
+import org.folio.edge.core.cache.Cache.Builder;
+import org.folio.edge.core.cache.Cache.CacheValue;
+
+public class KeycloakPublicKeyCache {
+
+ private static final Logger logger = LogManager.getLogger(KeycloakPublicKeyCache.class);
+
+ private static KeycloakPublicKeyCache instance = null;
+
+ private Cache cache;
+
+ private KeycloakPublicKeyCache(long ttl, long nullTokenTtl, int capacity) {
+ logger.info("Using TTL: {}", ttl);
+ logger.info("Using null token TTL: {}", nullTokenTtl);
+ logger.info("Using capacity: {}", capacity);
+ cache = new Builder()
+ .withTTL(ttl)
+ .withNullValueTTL(nullTokenTtl)
+ .withCapacity(capacity)
+ .build();
+ }
+
+ /**
+ * Get the KeycloakPublicKeyCache singleton. the singleton must be initialized before
+ * calling this method.
+ *
+ * @see {@link #initialize(long, long, int)}
+ *
+ * @return the KeycloakPublicKeyCache singleton instance.
+ */
+ public static synchronized KeycloakPublicKeyCache getInstance() {
+ if (instance == null) {
+ throw new KeycloakPublicKeyCacheNotInitializedException(
+ "You must call KeycloakPublicKeyCache.initialize(ttl, capacity) before you can get the singleton instance");
+ }
+ return instance;
+ }
+
+ /**
+ * Creates a new KeycloakPublicKeyCache instance, replacing the existing one if it
+ * already exists; in which case all pre-existing cache entries will be lost.
+ *
+ * @param ttl
+ * cache entry time to live in ms
+ * @param capacity
+ * maximum number of entries this cache will hold before pruning
+ * @return the new KeycloakPublicKeyCache singleton instance
+ */
+ public static synchronized KeycloakPublicKeyCache initialize(long ttl, long nullValueTtl, int capacity) {
+ if (instance != null) {
+ logger.warn("Reinitializing cache. All cached entries will be lost");
+ }
+ instance = new KeycloakPublicKeyCache(ttl, nullValueTtl, capacity);
+ return instance;
+ }
+
+ public String get(String realm) {
+ return cache.get(realm);
+ }
+
+ public CacheValue put(String realm, String publicKey) {
+ return cache.put(realm, publicKey);
+ }
+
+ public static class KeycloakPublicKeyCacheNotInitializedException extends RuntimeException {
+
+ private static final long serialVersionUID = -8622978462142499585L;
+
+ public KeycloakPublicKeyCacheNotInitializedException(String msg) {
+ super(msg);
+ }
+ }
+
+}
diff --git a/src/main/java/org/folio/edge/patron/cache/PatronIdCache.java b/src/main/java/org/folio/edge/patron/cache/PatronIdCache.java
index 13bc25a..c392800 100644
--- a/src/main/java/org/folio/edge/patron/cache/PatronIdCache.java
+++ b/src/main/java/org/folio/edge/patron/cache/PatronIdCache.java
@@ -15,9 +15,9 @@ public class PatronIdCache {
private Cache cache;
private PatronIdCache(long ttl, long nullTokenTtl, int capacity) {
- logger.info("Using TTL: {0}", ttl);
- logger.info("Using null token TTL: {0}", nullTokenTtl);
- logger.info("Using capcity: {0}", capacity);
+ logger.info("Using TTL: {}", ttl);
+ logger.info("Using null token TTL: {}", nullTokenTtl);
+ logger.info("Using capacity: {}", capacity);
cache = new Builder()
.withTTL(ttl)
.withNullValueTTL(nullTokenTtl)
diff --git a/src/main/java/org/folio/edge/patron/utils/KeycloakClient.java b/src/main/java/org/folio/edge/patron/utils/KeycloakClient.java
new file mode 100644
index 0000000..4440d70
--- /dev/null
+++ b/src/main/java/org/folio/edge/patron/utils/KeycloakClient.java
@@ -0,0 +1,36 @@
+package org.folio.edge.patron.utils;
+
+import io.vertx.core.Future;
+import io.vertx.core.Promise;
+import io.vertx.ext.web.client.WebClient;
+import org.apache.http.HttpStatus;
+import org.folio.edge.patron.cache.KeycloakPublicKeyCache;
+
+public class KeycloakClient {
+
+ private static final String REALM_INFO_URI = "/realms/%s/protocol/openid-connect/certs";
+ private final String keycloakUrl;
+ private final WebClient webClient;
+
+ public KeycloakClient(String keycloakUrl, WebClient webClient) {
+ this.keycloakUrl = keycloakUrl;
+ this.webClient = webClient;
+ }
+
+ public Future getPublicKeys(String realm) {
+ Promise promise = Promise.promise();
+ String uri = String.format(REALM_INFO_URI, realm);
+ webClient.getAbs(keycloakUrl + uri).send()
+ .onSuccess(response -> {
+ if (HttpStatus.SC_OK == response.statusCode()) {
+ var body = response.bodyAsString();
+ KeycloakPublicKeyCache.getInstance().put(realm, body);
+ promise.complete(body);
+ } else {
+ promise.fail(new RuntimeException("Request failed with status: " + response.statusCode()));
+ }
+ })
+ .onFailure(promise::fail);
+ return promise.future();
+ }
+}
diff --git a/src/main/java/org/folio/edge/patron/utils/KeycloakTokenHelper.java b/src/main/java/org/folio/edge/patron/utils/KeycloakTokenHelper.java
new file mode 100644
index 0000000..4ac29f0
--- /dev/null
+++ b/src/main/java/org/folio/edge/patron/utils/KeycloakTokenHelper.java
@@ -0,0 +1,70 @@
+package org.folio.edge.patron.utils;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.Locator;
+import io.jsonwebtoken.ProtectedHeader;
+import io.jsonwebtoken.security.JwkSet;
+import io.jsonwebtoken.security.Jwks;
+import io.vertx.core.Future;
+import io.vertx.core.Promise;
+import java.security.Key;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.folio.edge.patron.cache.KeycloakPublicKeyCache;
+
+public class KeycloakTokenHelper {
+
+ private static final Logger logger = LogManager.getLogger(KeycloakTokenHelper.class);
+
+ private KeycloakTokenHelper() {
+ }
+
+ public static Future getClaimsFromToken(String accessToken, String realm, KeycloakClient client) {
+ Promise promise = Promise.promise();
+ getKeycloakPublicKey(realm, client).onSuccess(keys -> {
+ var jwks = Jwks.setParser().build().parse(keys);
+ var parser = Jwts.parser().keyLocator(locateKey(jwks)).build();
+ try {
+ var claims = parser.parseSignedClaims(accessToken).getPayload();
+ promise.complete(claims);
+ } catch (Exception ex) {
+ promise.fail(ex);
+ }
+ }).onFailure(ex -> {
+ logger.error("Failed to get public key from keycloak", ex);
+ promise.fail(ex);
+ });
+ return promise.future();
+ }
+
+ private static Locator locateKey(JwkSet jwks) {
+ return header -> {
+ if (header instanceof ProtectedHeader ph) {
+ var key = jwks.getKeys().stream().filter(jwk -> jwk.getId().equals(ph.getKeyId())).findFirst();
+ if (key.isEmpty()) {
+ return null;
+ }
+ return key.get().toKey();
+ } else {
+ return null;
+ }
+ };
+ }
+
+ private static Future getKeycloakPublicKey(String realm, KeycloakClient client) {
+ String publicKey = null;
+ try {
+ var cache = KeycloakPublicKeyCache.getInstance();
+ publicKey = cache.get(realm);
+ } catch (KeycloakPublicKeyCache.KeycloakPublicKeyCacheNotInitializedException ex) {
+ logger.warn("Keycloak cache not initialized");
+ }
+ if (publicKey != null) {
+ return Future.succeededFuture(publicKey);
+ }
+
+ return client.getPublicKeys(realm);
+ }
+
+}
diff --git a/src/test/java/org/folio/edge/patron/MainVerticleTest.java b/src/test/java/org/folio/edge/patron/MainVerticleTest.java
index 9eaadaa..ecc3f70 100644
--- a/src/test/java/org/folio/edge/patron/MainVerticleTest.java
+++ b/src/test/java/org/folio/edge/patron/MainVerticleTest.java
@@ -1,41 +1,5 @@
package org.folio.edge.patron;
-import io.restassured.RestAssured;
-import io.restassured.config.DecoderConfig;
-import io.restassured.config.DecoderConfig.ContentDecoder;
-import io.restassured.response.Response;
-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.apache.commons.lang3.StringUtils;
-import org.apache.http.HttpHeaders;
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
-import org.folio.edge.core.utils.ApiKeyUtils;
-import org.folio.edge.core.utils.test.TestUtils;
-import org.folio.edge.patron.model.Account;
-import org.folio.edge.patron.model.Hold;
-import org.folio.edge.patron.model.Loan;
-import org.folio.edge.patron.model.error.ErrorMessage;
-import org.folio.edge.patron.utils.PatronMockOkapi;
-import org.junit.After;
-import org.junit.AfterClass;
-import org.junit.Assert;
-import org.junit.BeforeClass;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-import java.io.IOException;
-import java.text.SimpleDateFormat;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.List;
-import java.util.Optional;
-import java.util.UUID;
-
import static org.folio.edge.core.Constants.APPLICATION_JSON;
import static org.folio.edge.core.Constants.DAY_IN_MILLIS;
import static org.folio.edge.core.Constants.SYS_LOG_LEVEL;
@@ -45,6 +9,9 @@
import static org.folio.edge.core.Constants.SYS_RESPONSE_COMPRESSION;
import static org.folio.edge.core.Constants.SYS_SECURE_STORE_PROP_FILE;
import static org.folio.edge.core.Constants.TEXT_PLAIN;
+import static org.folio.edge.core.Constants.X_OKAPI_TENANT;
+import static org.folio.edge.core.Constants.X_OKAPI_TOKEN;
+import static org.folio.edge.patron.Constants.KEYCLOAK_URL;
import static org.folio.edge.patron.Constants.MSG_ACCESS_DENIED;
import static org.folio.edge.patron.Constants.MSG_HOLD_NOBODY;
import static org.folio.edge.patron.Constants.MSG_REQUEST_TIMEOUT;
@@ -69,6 +36,42 @@
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
+import io.restassured.RestAssured;
+import io.restassured.config.DecoderConfig;
+import io.restassured.config.DecoderConfig.ContentDecoder;
+import io.restassured.response.Response;
+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 java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.http.HttpHeaders;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.folio.edge.core.utils.ApiKeyUtils;
+import org.folio.edge.core.utils.test.TestUtils;
+import org.folio.edge.patron.model.Account;
+import org.folio.edge.patron.model.Hold;
+import org.folio.edge.patron.model.Loan;
+import org.folio.edge.patron.model.error.ErrorMessage;
+import org.folio.edge.patron.utils.JwtTokenUtil;
+import org.folio.edge.patron.utils.PatronMockOkapi;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
@RunWith(VertxUnitRunner.class)
public class MainVerticleTest {
@@ -86,6 +89,7 @@ public class MainVerticleTest {
private static Vertx vertx;
private static PatronMockOkapi mockOkapi;
+ private static JwtTokenUtil jwtTokenUtil;
@BeforeClass
public static void setUpOnce(TestContext context) throws Exception {
@@ -96,6 +100,7 @@ public static void setUpOnce(TestContext context) throws Exception {
knownTenants.add(ApiKeyUtils.parseApiKey(apiKey).tenantId);
vertx = Vertx.vertx();
+ jwtTokenUtil = new JwtTokenUtil();
System.setProperty(SYS_PORT, String.valueOf(serverPort));
System.setProperty(SYS_OKAPI_URL, "http://localhost:" + okapiPort);
@@ -103,6 +108,7 @@ public static void setUpOnce(TestContext context) throws Exception {
System.setProperty(SYS_LOG_LEVEL, "DEBUG");
System.setProperty(SYS_RESPONSE_COMPRESSION, "true");
System.setProperty(SYS_REQUEST_TIMEOUT_MS, String.valueOf(requestTimeoutMs));
+ System.setProperty(KEYCLOAK_URL, "http://localhost:" + okapiPort);
mockOkapi = spy(new PatronMockOkapi(okapiPort, knownTenants));
mockOkapi.start()
@@ -254,6 +260,126 @@ public void testGetAccountPatronNotFound(TestContext context) throws Exception {
assertEquals(expectedStatusCode, msg.httpStatusCode);
}
+ @Test
+ public void testSecureGetAccount(TestContext context) {
+ final String expected = PatronMockOkapi.getAccountJson(patronId, false, false, false);
+ RestAssured
+ .given()
+ .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, true))
+ .header(X_OKAPI_TENANT, "diku")
+ .get(String.format("/patron/account?apikey=%s", apiKey))
+ .then()
+ .statusCode(200)
+ .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
+ .body(is(expected));
+ }
+
+ @Test
+ public void testSecureGetAccountInvalidToken(TestContext context) throws Exception {
+ var resp = RestAssured
+ .given()
+ .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, true) + "001")
+ .header(X_OKAPI_TENANT, "diku")
+ .get(String.format("/patron/account?apikey=%s", apiKey))
+ .then()
+ .statusCode(400)
+ .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
+ .extract()
+ .response();
+
+ var error = ErrorMessage.fromJson(resp.body().asString());
+ assertEquals("Failed to validate access token", error.message);
+ assertEquals(400, error.httpStatusCode);
+ }
+
+ @Test
+ public void testSecureGetAccountMissingTenant(TestContext context) throws Exception {
+ var resp = RestAssured
+ .given()
+ .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, true) + "001")
+ .get(String.format("/patron/account?apikey=%s", apiKey))
+ .then()
+ .statusCode(400)
+ .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
+ .extract()
+ .response();
+
+ var error = ErrorMessage.fromJson(resp.body().asString());
+ assertEquals("Missing tenant id", error.message);
+ assertEquals(400, error.httpStatusCode);
+ }
+
+ @Test
+ public void testSecureGetAccountMissingToken(TestContext context) throws Exception {
+ var resp = RestAssured
+ .given()
+ .header(X_OKAPI_TENANT, "diku")
+ .get(String.format("/patron/account?apikey=%s", apiKey))
+ .then()
+ .statusCode(400)
+ .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
+ .extract()
+ .response();
+
+ var error = ErrorMessage.fromJson(resp.body().asString());
+ assertEquals("Missing access token", error.message);
+ assertEquals(400, error.httpStatusCode);
+ }
+
+ @Test
+ public void testSecureGetAccountMissingKeycloakPublicKey(TestContext context) throws Exception {
+ var resp = RestAssured
+ .given()
+ .header(X_OKAPI_TENANT, "test")
+ .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, true))
+ .get(String.format("/patron/account?apikey=%s", apiKey))
+ .then()
+ .statusCode(400)
+ .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
+ .extract()
+ .response();
+
+ var error = ErrorMessage.fromJson(resp.body().asString());
+ assertEquals("Failed to validate access token", error.message);
+ assertEquals(400, error.httpStatusCode);
+ }
+
+ @Test
+ public void testSecureGetAccountWithMissingClaims(TestContext context) throws Exception {
+ var resp = RestAssured
+ .given()
+ .header(X_OKAPI_TENANT, "diku")
+ .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken())
+ .get(String.format("/patron/account?apikey=%s", apiKey))
+ .then()
+ .statusCode(400)
+ .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
+ .extract()
+ .response();
+
+ var error = ErrorMessage.fromJson(resp.body().asString());
+ assertEquals("Token doesn't contain required claims", error.message);
+ assertEquals(400, error.httpStatusCode);
+ }
+
+ @Test
+ public void testSecureGetAccountWithPatronNotVip(TestContext context) throws Exception {
+ var resp = RestAssured
+ .given()
+ .header(X_OKAPI_TENANT, "diku")
+ .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(extPatronId, false))
+ .get(String.format("/patron/account?apikey=%s", apiKey))
+ .then()
+ .statusCode(401)
+ .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
+ .extract()
+ .response();
+
+ var error = ErrorMessage.fromJson(resp.body().asString());
+ assertEquals("Access Denied", error.message);
+ assertEquals(401, error.httpStatusCode);
+ }
+
@Test
public void testGetPatronRegistrationStatusWithoutEmail(TestContext context) {
@@ -780,6 +906,28 @@ public void testPlaceInstanceHoldSuccess(TestContext context) throws Exception {
validateHolds(expected, actual);
}
+ @Test
+ public void testSecurePlaceInstanceHoldSuccess(TestContext context) throws Exception {
+ Hold hold = PatronMockOkapi.getHold(instanceId);
+
+ final Response resp = RestAssured
+ .with()
+ .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(patronId, true))
+ .header(X_OKAPI_TENANT, "diku")
+ .body(hold.toJson())
+ .contentType(APPLICATION_JSON)
+ .post(
+ String.format("/patron/account/instance/%s/hold?apikey=%s", instanceId, apiKey))
+ .then()
+ .statusCode(201)
+ .extract()
+ .response();
+
+ Hold expected = Hold.fromJson(PatronMockOkapi.getPlacedHoldJson(hold));
+ Hold actual = Hold.fromJson(resp.body().asString());
+ validateHolds(expected, actual);
+ }
+
@Test
public void testPlaceInstanceHoldPatronNotFound(TestContext context) throws Exception {
logger.info("=== Test place instance hold w/ patron not found ===");
@@ -1277,6 +1425,28 @@ public void testAllowedServicePointsSuccess(TestContext context) throws Exceptio
JsonObject actual = new JsonObject(resp.body().asString());
assertEquals(expected, actual);
}
+
+ @Test
+ public void testSecureAllowedServicePointsSuccess(TestContext context) {
+
+ final Response resp = RestAssured
+ .with()
+ .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(patronId, true))
+ .header(X_OKAPI_TENANT, "diku")
+ .get(String.format("/patron/account/instance/%s/allowed-service-points?apikey=%s",
+ instanceId, apiKey))
+ .then()
+ .statusCode(200)
+ .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
+ .extract()
+ .response();
+
+ JsonObject expected = new JsonObject(readMockFile(
+ "/allowed_sp_mod_patron_expected_response.json"));
+ JsonObject actual = new JsonObject(resp.body().asString());
+ assertEquals(expected, actual);
+ }
+
@Test
public void testAllowedServicePointsForItemError(TestContext context) throws Exception {
logger.info("=== Test validation error during allowed service points request ===");
@@ -1346,6 +1516,35 @@ public void testCancelHoldSuccess(TestContext context) throws Exception {
assertEquals(expected.canceledByUserId, actual.canceledByUserId);
}
+ @Test
+ public void testSecureCancelHoldSuccess(TestContext context) throws Exception {
+ String cancedHoldJson = PatronMockOkapi.getHoldCancellation(holdCancellationHoldId, patronId);
+
+ final Response resp = RestAssured
+ .with()
+ .header(X_OKAPI_TOKEN, jwtTokenUtil.generateToken(patronId, true))
+ .header(X_OKAPI_TENANT, "diku")
+ .contentType(APPLICATION_JSON)
+ .body(cancedHoldJson)
+ .post(
+ String.format("/patron/account/hold/%s/cancel?apikey=%s", holdCancellationHoldId, apiKey))
+ .then()
+ .statusCode(200)
+ .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
+ .extract()
+ .response();
+
+ Hold expected = Hold.fromJson(PatronMockOkapi.getRemovedHoldJson(holdCancellationHoldId));
+ Hold actual = Hold.fromJson(resp.body().asString());
+
+ assertEquals(expected, actual);
+ assertEquals(holdCancellationHoldId, expected.requestId);
+ assertEquals(PatronMockOkapi.holdCancellationReasonId, actual.cancellationReasonId);
+ assertEquals(Hold.Status.CLOSED_CANCELED, actual.status);
+ assertEquals(0, actual.queuePosition);
+ assertEquals(expected.canceledByUserId, actual.canceledByUserId);
+ }
+
@Test
public void testCancelHoldSuccessWithNonUUIDCanceledById(TestContext context) throws Exception {
logger.info("=== Test cancel hold success ===");
diff --git a/src/test/java/org/folio/edge/patron/utils/JwtTokenUtil.java b/src/test/java/org/folio/edge/patron/utils/JwtTokenUtil.java
new file mode 100644
index 0000000..67f248f
--- /dev/null
+++ b/src/test/java/org/folio/edge/patron/utils/JwtTokenUtil.java
@@ -0,0 +1,31 @@
+package org.folio.edge.patron.utils;
+
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Jwk;
+import io.jsonwebtoken.security.Jwks;
+
+public class JwtTokenUtil {
+ private Jwk key;
+
+ public JwtTokenUtil() {
+ key = Jwks.parser().build().parse(this.getClass().getResourceAsStream("/test_jwk.json"));
+ }
+
+ public String generateToken(String externalSystemId, boolean vip) {
+ return Jwts.builder()
+ .header().keyId(key.getId()).and()
+ .subject("test")
+ .signWith(key.toKey())
+ .claim("externalSystemId", externalSystemId)
+ .claim("vip", vip)
+ .compact();
+ }
+
+ public String generateToken() {
+ return Jwts.builder()
+ .header().keyId(key.getId()).and()
+ .subject("test")
+ .signWith(key.toKey())
+ .compact();
+ }
+}
diff --git a/src/test/java/org/folio/edge/patron/utils/PatronMockOkapi.java b/src/test/java/org/folio/edge/patron/utils/PatronMockOkapi.java
index 33f1bbe..5f29689 100644
--- a/src/test/java/org/folio/edge/patron/utils/PatronMockOkapi.java
+++ b/src/test/java/org/folio/edge/patron/utils/PatronMockOkapi.java
@@ -1,5 +1,23 @@
package org.folio.edge.patron.utils;
+import static java.util.Collections.singletonList;
+import static org.folio.edge.core.Constants.APPLICATION_JSON;
+import static org.folio.edge.core.Constants.DAY_IN_MILLIS;
+import static org.folio.edge.core.Constants.TEXT_PLAIN;
+import static org.folio.edge.core.Constants.X_OKAPI_TOKEN;
+import static org.folio.edge.patron.Constants.PARAM_EMAIL_ID;
+import static org.folio.edge.patron.Constants.PARAM_HOLD_ID;
+import static org.folio.edge.patron.Constants.PARAM_INCLUDE_CHARGES;
+import static org.folio.edge.patron.Constants.PARAM_INCLUDE_HOLDS;
+import static org.folio.edge.patron.Constants.PARAM_INCLUDE_LOANS;
+import static org.folio.edge.patron.Constants.PARAM_INSTANCE_ID;
+import static org.folio.edge.patron.Constants.PARAM_ITEM_ID;
+import static org.folio.edge.patron.Constants.PARAM_LIMIT;
+import static org.folio.edge.patron.Constants.PARAM_OFFSET;
+import static org.folio.edge.patron.Constants.PARAM_PATRON_ID;
+import static org.folio.edge.patron.Constants.PARAM_REQUEST_ID;
+import static org.folio.edge.patron.Constants.PARAM_SORT_BY;
+
import com.fasterxml.jackson.core.JsonProcessingException;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.http.HttpMethod;
@@ -7,6 +25,16 @@
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Currency;
+import java.util.Date;
+import java.util.List;
+import java.util.UUID;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -20,35 +48,6 @@
import org.folio.edge.patron.model.Loan;
import org.folio.edge.patron.model.Money;
-import java.io.IOException;
-import java.io.UncheckedIOException;
-import java.nio.charset.StandardCharsets;
-import java.text.ParseException;
-import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Currency;
-import java.util.Date;
-import java.util.List;
-import java.util.UUID;
-
-import static java.util.Collections.singletonList;
-import static org.folio.edge.core.Constants.APPLICATION_JSON;
-import static org.folio.edge.core.Constants.DAY_IN_MILLIS;
-import static org.folio.edge.core.Constants.TEXT_PLAIN;
-import static org.folio.edge.core.Constants.X_OKAPI_TOKEN;
-import static org.folio.edge.patron.Constants.PARAM_EMAIL_ID;
-import static org.folio.edge.patron.Constants.PARAM_HOLD_ID;
-import static org.folio.edge.patron.Constants.PARAM_INCLUDE_CHARGES;
-import static org.folio.edge.patron.Constants.PARAM_INCLUDE_HOLDS;
-import static org.folio.edge.patron.Constants.PARAM_INCLUDE_LOANS;
-import static org.folio.edge.patron.Constants.PARAM_INSTANCE_ID;
-import static org.folio.edge.patron.Constants.PARAM_ITEM_ID;
-import static org.folio.edge.patron.Constants.PARAM_LIMIT;
-import static org.folio.edge.patron.Constants.PARAM_OFFSET;
-import static org.folio.edge.patron.Constants.PARAM_PATRON_ID;
-import static org.folio.edge.patron.Constants.PARAM_REQUEST_ID;
-import static org.folio.edge.patron.Constants.PARAM_SORT_BY;
-
public class PatronMockOkapi extends MockOkapi {
private static final Logger logger = LogManager.getLogger(PatronMockOkapi.class);
@@ -168,9 +167,19 @@ public Router defineRoutes() {
router.route(HttpMethod.GET, "/patron/registration-status/:emailId")
.handler(this::getRegistrationStatusHandler);
+ router.route(HttpMethod.GET, "/realms/diku/protocol/openid-connect/certs")
+ .handler(this::getKeycloakPublicKeysHandler);
+
return router;
}
+ public void getKeycloakPublicKeysHandler(RoutingContext ctx) {
+ ctx.response()
+ .setStatusCode(200)
+ .putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
+ .end(readMockFile("/keycloak_certs_response.json"));
+ }
+
public void getPatronHandler(RoutingContext ctx) {
String query = ctx.request().getParam(PARAM_QUERY);
String token = ctx.request().getHeader(X_OKAPI_TOKEN);
diff --git a/src/test/resources/keycloak_certs_response.json b/src/test/resources/keycloak_certs_response.json
new file mode 100644
index 0000000..206692b
--- /dev/null
+++ b/src/test/resources/keycloak_certs_response.json
@@ -0,0 +1,16 @@
+{
+ "keys": [
+ {
+ "kty": "RSA",
+ "use": "sig",
+ "key_ops": [
+ "sign",
+ "verify"
+ ],
+ "alg": "RS256",
+ "kid": "5f1fdf63-2a16-47c8-b882-66d17f1f0d39",
+ "n": "wl-JE22eQrjVGiq1kR2KouA1ne6mddotGqG5n89ejnw3FPUWRk7TlWG87O0mXsjmvnjnnJTKdItaFJlHwoQy4bMWPGDnwDc4kGi2iQ52UgpUq0M4Ss3U_RDsCoNVsclmJMmfzmS1SK9NOExdWmaWXj9wuItzwy4vZ-skg-R_ldd-_go6kP_og5Xn9_DRQI1Zv68rTxm-2usjinoJHE6aqta6_JZURQU1PF34SkUqFLF0vBgOUwTF_94-q7z2BDIJgYnk3yudVQOEDXLo7mspdOcNiavyH4w3gKqnT02FSaszeWlZ1hmGPt0Lz1tMPAoNjZx8esE1b8ze7aKeMGi5NQ",
+ "e": "AQAB"
+ }
+ ]
+}
diff --git a/src/test/resources/test_jwk.json b/src/test/resources/test_jwk.json
new file mode 100644
index 0000000..80f33a0
--- /dev/null
+++ b/src/test/resources/test_jwk.json
@@ -0,0 +1,18 @@
+{
+ "kty": "RSA",
+ "use": "sig",
+ "key_ops": [
+ "sign",
+ "verify"
+ ],
+ "alg": "RS256",
+ "kid": "5f1fdf63-2a16-47c8-b882-66d17f1f0d39",
+ "d": "Bg8qW8OwCk5uvjkUneh42Oj6Yuj8oXHitBDUk4nIXdK7eNjHD_wHFoIMfKpL5uqGXuuv9K6ivQ1XCotYtFSgrW6Cw_EVaGWQStgo0I7120rdJtWanKJcAGOVqCI9-qPXnk_2vl1fVVu5oYan5liKWXfK0MXwHuaCrc-jvMTTwVz_acDojcJbPoFK03D2BUxMBH6Gon6MI09NESk7Ni09BHgrlR2IHGUv_Q2C14uo8RH86h_7kjyW5x0lInTuimKqtvat0DXK9Nt2Rs9y1Gcbtlxu9yplv1E0XrodCwvoJwnK6gIVRvAlFUT4f8Bw1q9Lkp-ARp6BG9a3F2Q5zbTVwQ",
+ "n": "wl-JE22eQrjVGiq1kR2KouA1ne6mddotGqG5n89ejnw3FPUWRk7TlWG87O0mXsjmvnjnnJTKdItaFJlHwoQy4bMWPGDnwDc4kGi2iQ52UgpUq0M4Ss3U_RDsCoNVsclmJMmfzmS1SK9NOExdWmaWXj9wuItzwy4vZ-skg-R_ldd-_go6kP_og5Xn9_DRQI1Zv68rTxm-2usjinoJHE6aqta6_JZURQU1PF34SkUqFLF0vBgOUwTF_94-q7z2BDIJgYnk3yudVQOEDXLo7mspdOcNiavyH4w3gKqnT02FSaszeWlZ1hmGPt0Lz1tMPAoNjZx8esE1b8ze7aKeMGi5NQ",
+ "e": "AQAB",
+ "p": "yQGC_gFCE3DojmUdHkJ9sLiVNNQ3wM7Y6oudme-jvcmQY2kKhJVc9QuUYn0xStzIT4o11NzQpNizUOdjJja3ZRDkx8ay7j4hc3NaZVZb9-ukLAYbltoDxgo4xFIoQ5Y5rj0nCid_3FnY1tqbIlEJG4Fd178RPWOZrW5nX8h3mSU",
+ "q": "9413iOoZLg65trWQOgotEU71e41nvWg9yCmNkfVZ5A8c_BE2s0A5Dmn9WYWXIaoSv67TmguMU9BTHu_S24qqPv0QRzZX9U33GXbrUt5dHo5haiXKIir5Yg8BxETTqFVYdxnM3SZW_41LqQYs-wmv1x4HZXBH37IAdAVk8jqAStE",
+ "dp": "DGy2fyLuxareBSdE5IDxqgHO30Qa6iUfDWhx5nkEow-ZiDuO9eERrOf5VRkt-dWp4BjH-Q9pKjdm5iJXY55QOcQQkDS9DLL0eGFx_f-XkbyUGlCKVgnF3_Dzz1bQvFTF3fpTtnH4mlNHbwh2PGnL6VJWzaY215eXgTvo0effVK0",
+ "dq": "EgZ3Ab0qADSKSUeHLPK4vV3megyd1SjV9tEvwcT_up9vGNuYBA1VGjuVewNDMexUWSi9t6XHngK5SrNjwyChrNx4ZvcKCI6Yw33pPKt8VFFBvpzpzvsaFY3KLyRj1QoB2wpB5Ih6JTmAnNoaRF08NIm3OCeo1Bz983TBGPIxjUE",
+ "qi": "PmUhgOYkxBPWgNCIPzbls1tW0SHjRi1hpljZX-VBKd_UYqJtJldEijt6pFRXZiPOKEt0j1bACdQoF3owUGPXK8Z7IUPqN3FR0cAQ_zIV74gJMDku_Sqtw5BccP2nNzQ_CNWsSHX2qn9lSNeTa1HtCFSqZ0ypXk2ajKQjuiugfPY"
+}