From 733477a03295eb3bc9da166d0448d23ab7e0bbe9 Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 17 Mar 2026 22:48:57 +0100 Subject: [PATCH 1/3] bugfix/fix-getOptionalClaim-returning-Some-null-when-claim-is-absent --- obp-api/src/main/scala/code/api/util/JwtUtil.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/JwtUtil.scala b/obp-api/src/main/scala/code/api/util/JwtUtil.scala index 33b1edecf4..6fd4f3cee5 100644 --- a/obp-api/src/main/scala/code/api/util/JwtUtil.scala +++ b/obp-api/src/main/scala/code/api/util/JwtUtil.scala @@ -173,7 +173,7 @@ object JwtUtil extends MdcLoggable { try { val signedJWT = SignedJWT.parse(jwtToken) // claims extraction... - Some(signedJWT.getJWTClaimsSet.getStringClaim(name)) + Option(signedJWT.getJWTClaimsSet.getStringClaim(name)).filter(_.trim.nonEmpty) } catch { case e: Exception => logger.debug(msg = s"code.api.util.JwtUtil.getClaim: $name") From 849ca5ef8db55737d8a565e5cc0ccd3d826ac2b9 Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 17 Mar 2026 23:04:50 +0100 Subject: [PATCH 2/3] bugfix/fix-resolveProvider-treating-blank-provider-claim-as-valid-in-OAuth2 --- obp-api/src/main/scala/code/api/OAuth2.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala index 9cee12f436..94bbf4ef05 100644 --- a/obp-api/src/main/scala/code/api/OAuth2.scala +++ b/obp-api/src/main/scala/code/api/OAuth2.scala @@ -453,12 +453,12 @@ object OAuth2Login extends RestHelper with MdcLoggable { // First try to get provider from token's provider claim val providerFromToken = JwtUtil.getProvider(jwtToken) - providerFromToken match { + providerFromToken.filter(_.trim.nonEmpty) match { case Some(provider) => logger.debug(s"resolveProvider says: using provider from token claim: $provider") provider case None => - // Fallback to existing logic if provider claim is not present + // Fallback to existing logic if provider claim is not present or blank HydraUtil.integrateWithHydra && isIssuer(jwtToken = jwtToken, identityProvider = hydraPublicUrl) match { case true if HydraUtil.hydraUsesObpUserCredentials => // Case that source of the truth of Hydra user management is the OBP-API mapper DB logger.debug(s"resolveProvider says: we are in Hydra, use Constant.localIdentityProvider ${Constant.localIdentityProvider}") From 591a286e2fc5b22814fef39e24b2e01c4c475871 Mon Sep 17 00:00:00 2001 From: hongwei Date: Wed, 18 Mar 2026 00:06:19 +0100 Subject: [PATCH 3/3] test/add berlin group mandatory headers validation tests --- .../code/api/util/BerlinGroupCheck.scala | 6 +- .../scala/code/api/util/DateTimeUtil.scala | 11 +- .../BerlinGroupMandatoryHeadersTest.scala | 300 ++++++++++++++++++ 3 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/util/BerlinGroupMandatoryHeadersTest.scala diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala index df62ba7f7c..6abb90f692 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -20,12 +20,12 @@ object BerlinGroupCheck extends MdcLoggable { private val defaultMandatoryHeaders = "Content-Type,Date,Digest,PSU-Device-ID,PSU-Device-Name,PSU-IP-Address,Signature,TPP-Signature-Certificate,X-Request-ID" - // Parse mandatory headers from a comma-separated string - private val berlinGroupMandatoryHeaders: List[String] = APIUtil.getPropsValue("berlin_group_mandatory_headers", defaultValue = defaultMandatoryHeaders) + // Parse mandatory headers from a comma-separated string (def so tests can override via Props) + private def berlinGroupMandatoryHeaders: List[String] = APIUtil.getPropsValue("berlin_group_mandatory_headers", defaultValue = defaultMandatoryHeaders) .split(",") .map(_.trim.toLowerCase) .toList.filterNot(_.isEmpty) - private val berlinGroupMandatoryHeaderConsent = APIUtil.getPropsValue("berlin_group_mandatory_header_consent", defaultValue = "TPP-Redirect-URI") + private def berlinGroupMandatoryHeaderConsent: List[String] = APIUtil.getPropsValue("berlin_group_mandatory_header_consent", defaultValue = "TPP-Redirect-URI") .split(",") .map(_.trim.toLowerCase) .toList.filterNot(_.isEmpty) diff --git a/obp-api/src/main/scala/code/api/util/DateTimeUtil.scala b/obp-api/src/main/scala/code/api/util/DateTimeUtil.scala index eabf58648f..e2a108ac42 100644 --- a/obp-api/src/main/scala/code/api/util/DateTimeUtil.scala +++ b/obp-api/src/main/scala/code/api/util/DateTimeUtil.scala @@ -37,13 +37,16 @@ object DateTimeUtil { } // Define the correct RFC 7231 date format (IMF-fixdate) - private val dateFormat = rfc7231Date - // Force timezone to be GMT - dateFormat.setLenient(false) + // Create a new instance per call to avoid SimpleDateFormat thread-safety issues + private def newRfc7231Format = { + val fmt = new java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", java.util.Locale.ENGLISH) + fmt.setLenient(false) + fmt + } def isValidRfc7231Date(dateStr: String): Boolean = { try { - val parsedDate = dateFormat.parse(dateStr) + newRfc7231Format.parse(dateStr) // Check that the timezone part is exactly "GMT" dateStr.endsWith(" GMT") } catch { diff --git a/obp-api/src/test/scala/code/api/util/BerlinGroupMandatoryHeadersTest.scala b/obp-api/src/test/scala/code/api/util/BerlinGroupMandatoryHeadersTest.scala new file mode 100644 index 0000000000..7c9de1b014 --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/BerlinGroupMandatoryHeadersTest.scala @@ -0,0 +1,300 @@ +package code.api.util + +import code.api.berlin.group.v1_3.BerlinGroupServerSetupV1_3 +import net.liftweb.common.Failure +import net.liftweb.http.provider.HTTPParam +import org.scalatest.Tag + +import java.util.UUID +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** + * Unit tests for BerlinGroupCheck mandatory headers validation. + * + * Extends BerlinGroupServerSetupV1_3 so Lift is bootstrapped and Props is + * available. BerlinGroupCheck.berlinGroupMandatoryHeaders is a def, so + * setPropsValues in beforeEach correctly controls which headers are required. + */ +class BerlinGroupMandatoryHeadersTest extends BerlinGroupServerSetupV1_3 { + + object MandatoryHeaders extends Tag("BerlinGroupMandatoryHeaders") + + // A Berlin Group v1.3 URL that triggers the mandatory header check + private val bgUrl = "/berlin-group/v1.3/accounts" + private val bgConsentUrl = "/berlin-group/v1.3/consents" + + override def beforeEach(): Unit = { + super.beforeEach() + // Enable only X-Request-ID as mandatory header so tests stay focused + setPropsValues( + "berlin_group_mandatory_headers" -> "X-Request-ID", + "berlin_group_mandatory_header_consent" -> "" + ) + } + + // Helper: build HTTPParam list from a Map + private def toParams(headers: Map[String, String]): List[HTTPParam] = + headers.map { case (k, v) => HTTPParam(k, List(v)) }.toList + + // Helper: call BerlinGroupCheck.validate and block for result. + // fullBoxOrException throws on validation errors, so we catch and wrap as Failure. + private def callValidate(url: String, verb: String = "GET", headers: Map[String, String]): net.liftweb.common.Box[_] = { + val params = toParams(headers) + val emptyContext: (net.liftweb.common.Box[com.openbankproject.commons.model.User], Option[CallContext]) = + (net.liftweb.common.Empty, None) + try { + val future = BerlinGroupCheck.validate( + body = net.liftweb.common.Full("{}"), + verb = verb, + url = url, + reqHeaders = params, + forwardResult = emptyContext + ) + Await.result(future, 5.seconds)._1 + } catch { + case e: Exception => net.liftweb.common.Failure(e.getMessage, net.liftweb.common.Full(e), net.liftweb.common.Empty) + } + } + + // ─── Missing header tests ──────────────────────────────────────────────── + + feature("BG mandatory headers - missing header") { + + scenario("Request without X-Request-ID is rejected", MandatoryHeaders) { + Given("A BG request with no headers at all") + val result = callValidate(bgUrl, headers = Map.empty) + + Then("Result is a Failure with missing header error") + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("OBP-20251") + result.asInstanceOf[Failure].msg should include("x-request-id") + } + + scenario("Non-BG URL skips the check", MandatoryHeaders) { + Given("A non-BG URL with no headers") + val result = callValidate("/obp/v4.0.0/banks", headers = Map.empty) + + Then("Validation is skipped — result is Empty") + result shouldBe net.liftweb.common.Empty + } + } + + // ─── X-Request-ID format tests ─────────────────────────────────────────── + + feature("BG mandatory headers - X-Request-ID format") { + + scenario("Valid UUID X-Request-ID is accepted", MandatoryHeaders) { + val result = callValidate(bgUrl, headers = Map("X-Request-ID" -> UUID.randomUUID().toString)) + result shouldBe net.liftweb.common.Empty + } + + scenario("Non-UUID X-Request-ID is rejected", MandatoryHeaders) { + val result = callValidate(bgUrl, headers = Map("X-Request-ID" -> "not-a-uuid")) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("OBP-20253") + } + + scenario("Empty X-Request-ID is rejected", MandatoryHeaders) { + val result = callValidate(bgUrl, headers = Map("X-Request-ID" -> "")) + result shouldBe a[Failure] + } + } + + // ─── Date format tests ─────────────────────────────────────────────────── + + feature("BG mandatory headers - Date format") { + + scenario("Valid RFC 7231 Date is accepted", MandatoryHeaders) { + // Build a valid RFC 7231 date directly with the same format used by isValidRfc7231Date + val fmt = new java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", java.util.Locale.ENGLISH) + fmt.setTimeZone(java.util.TimeZone.getTimeZone("GMT")) + val validDate = fmt.format(new java.util.Date()) + val result = callValidate(bgUrl, headers = Map( + "X-Request-ID" -> UUID.randomUUID().toString, + "Date" -> validDate + )) + result shouldBe net.liftweb.common.Empty + } + + scenario("ISO date format is rejected (not RFC 7231)", MandatoryHeaders) { + val result = callValidate(bgUrl, headers = Map( + "X-Request-ID" -> UUID.randomUUID().toString, + "Date" -> "2026-03-17" + )) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("OBP-20257") + } + } + + // ─── Consent endpoint extra header tests ───────────────────────────────── + + feature("BG mandatory headers - /consents TPP-Redirect-URI") { + + scenario("Consent request missing TPP-Redirect-URI is rejected", MandatoryHeaders) { + setPropsValues( + "berlin_group_mandatory_headers" -> "X-Request-ID", + "berlin_group_mandatory_header_consent" -> "TPP-Redirect-URI" + ) + val result = callValidate(bgConsentUrl, headers = Map("X-Request-ID" -> UUID.randomUUID().toString)) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("OBP-20251") + result.asInstanceOf[Failure].msg should include("tpp-redirect-uri") + } + + scenario("Consent request with TPP-Redirect-URI passes header check", MandatoryHeaders) { + setPropsValues( + "berlin_group_mandatory_headers" -> "X-Request-ID", + "berlin_group_mandatory_header_consent" -> "TPP-Redirect-URI" + ) + val result = callValidate(bgConsentUrl, headers = Map( + "X-Request-ID" -> UUID.randomUUID().toString, + "TPP-Redirect-URI" -> "https://example.com/callback" + )) + result should not be a[Failure] + } + } + + // ─── Disabled check ─────────────────────────────────────────────────────── + + feature("BG mandatory headers - disabled when list is empty") { + + scenario("All requests pass when mandatory headers list is empty", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "") + val result = callValidate(bgUrl, headers = Map.empty) + result shouldBe net.liftweb.common.Empty + } + } + + // ─── Multiple missing headers ───────────────────────────────────────────── + + feature("BG mandatory headers - multiple missing headers") { + + scenario("Multiple missing headers are all reported", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "X-Request-ID,Content-Type,Date") + val result = callValidate(bgUrl, headers = Map.empty) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("OBP-20251") + // All three missing headers should appear in the error message + result.asInstanceOf[Failure].msg should include("x-request-id") + result.asInstanceOf[Failure].msg should include("content-type") + result.asInstanceOf[Failure].msg should include("date") + } + + scenario("Providing one of two required headers still fails", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "X-Request-ID,Content-Type") + val result = callValidate(bgUrl, headers = Map("X-Request-ID" -> UUID.randomUUID().toString)) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("content-type") + } + } + + // ─── Content-Type header ────────────────────────────────────────────────── + + feature("BG mandatory headers - Content-Type") { + + scenario("Missing Content-Type is rejected", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "Content-Type") + val result = callValidate(bgUrl, headers = Map.empty) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("content-type") + } + + scenario("Present Content-Type passes", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "Content-Type") + val result = callValidate(bgUrl, headers = Map("Content-Type" -> "application/json")) + result shouldBe net.liftweb.common.Empty + } + } + + // ─── Digest header ──────────────────────────────────────────────────────── + + feature("BG mandatory headers - Digest") { + + scenario("Missing Digest is rejected", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "Digest") + val result = callValidate(bgUrl, headers = Map.empty) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("digest") + } + + scenario("Present Digest passes header presence check", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "Digest") + val digest = "SHA-256=" + java.util.Base64.getEncoder.encodeToString( + java.security.MessageDigest.getInstance("SHA-256").digest("{}".getBytes("UTF-8")) + ) + val result = callValidate(bgUrl, headers = Map("Digest" -> digest)) + result shouldBe net.liftweb.common.Empty + } + } + + // ─── PSU headers ────────────────────────────────────────────────────────── + + feature("BG mandatory headers - PSU device headers") { + + scenario("Missing PSU-IP-Address is rejected", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "PSU-IP-Address") + val result = callValidate(bgUrl, headers = Map.empty) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("psu-ip-address") + } + + scenario("Missing PSU-Device-ID is rejected", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "PSU-Device-ID") + val result = callValidate(bgUrl, headers = Map.empty) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("psu-device-id") + } + + scenario("Missing PSU-Device-Name is rejected", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "PSU-Device-Name") + val result = callValidate(bgUrl, headers = Map.empty) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("psu-device-name") + } + + scenario("All PSU headers present passes", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "PSU-IP-Address,PSU-Device-ID,PSU-Device-Name") + val result = callValidate(bgUrl, headers = Map( + "PSU-IP-Address" -> "192.168.1.1", + "PSU-Device-ID" -> UUID.randomUUID().toString, + "PSU-Device-Name" -> "Chrome/120" + )) + result shouldBe net.liftweb.common.Empty + } + } + + // ─── Signature + TPP-Signature-Certificate headers ──────────────────────── + + feature("BG mandatory headers - Signature and TPP-Signature-Certificate") { + + scenario("Missing Signature is rejected", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "Signature") + val result = callValidate(bgUrl, headers = Map.empty) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("signature") + } + + scenario("Missing TPP-Signature-Certificate is rejected", MandatoryHeaders) { + setPropsValues("berlin_group_mandatory_headers" -> "TPP-Signature-Certificate") + val result = callValidate(bgUrl, headers = Map.empty) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("tpp-signature-certificate") + } + } + + // ─── Full default header set ────────────────────────────────────────────── + + feature("BG mandatory headers - full default set") { + + scenario("All 9 default headers missing are all reported", MandatoryHeaders) { + setPropsValues( + "berlin_group_mandatory_headers" -> + "Content-Type,Date,Digest,PSU-Device-ID,PSU-Device-Name,PSU-IP-Address,Signature,TPP-Signature-Certificate,X-Request-ID" + ) + val result = callValidate(bgUrl, headers = Map.empty) + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should include("OBP-20251") + } + } +}