Skip to content

Commit c77a401

Browse files
authored
Merge pull request #2735 from hongwei1/feature/ttksandbox
refactor/enhancedTokenProvider
2 parents 53f49c3 + 591a286 commit c77a401

5 files changed

Lines changed: 313 additions & 10 deletions

File tree

obp-api/src/main/scala/code/api/OAuth2.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -453,12 +453,12 @@ object OAuth2Login extends RestHelper with MdcLoggable {
453453
// First try to get provider from token's provider claim
454454
val providerFromToken = JwtUtil.getProvider(jwtToken)
455455

456-
providerFromToken match {
456+
providerFromToken.filter(_.trim.nonEmpty) match {
457457
case Some(provider) =>
458458
logger.debug(s"resolveProvider says: using provider from token claim: $provider")
459459
provider
460460
case None =>
461-
// Fallback to existing logic if provider claim is not present
461+
// Fallback to existing logic if provider claim is not present or blank
462462
HydraUtil.integrateWithHydra && isIssuer(jwtToken = jwtToken, identityProvider = hydraPublicUrl) match {
463463
case true if HydraUtil.hydraUsesObpUserCredentials => // Case that source of the truth of Hydra user management is the OBP-API mapper DB
464464
logger.debug(s"resolveProvider says: we are in Hydra, use Constant.localIdentityProvider ${Constant.localIdentityProvider}")

obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ object BerlinGroupCheck extends MdcLoggable {
2020

2121

2222
private val defaultMandatoryHeaders = "Content-Type,Date,Digest,PSU-Device-ID,PSU-Device-Name,PSU-IP-Address,Signature,TPP-Signature-Certificate,X-Request-ID"
23-
// Parse mandatory headers from a comma-separated string
24-
private val berlinGroupMandatoryHeaders: List[String] = APIUtil.getPropsValue("berlin_group_mandatory_headers", defaultValue = defaultMandatoryHeaders)
23+
// Parse mandatory headers from a comma-separated string (def so tests can override via Props)
24+
private def berlinGroupMandatoryHeaders: List[String] = APIUtil.getPropsValue("berlin_group_mandatory_headers", defaultValue = defaultMandatoryHeaders)
2525
.split(",")
2626
.map(_.trim.toLowerCase)
2727
.toList.filterNot(_.isEmpty)
28-
private val berlinGroupMandatoryHeaderConsent = APIUtil.getPropsValue("berlin_group_mandatory_header_consent", defaultValue = "TPP-Redirect-URI")
28+
private def berlinGroupMandatoryHeaderConsent: List[String] = APIUtil.getPropsValue("berlin_group_mandatory_header_consent", defaultValue = "TPP-Redirect-URI")
2929
.split(",")
3030
.map(_.trim.toLowerCase)
3131
.toList.filterNot(_.isEmpty)

obp-api/src/main/scala/code/api/util/DateTimeUtil.scala

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,16 @@ object DateTimeUtil {
3737
}
3838

3939
// Define the correct RFC 7231 date format (IMF-fixdate)
40-
private val dateFormat = rfc7231Date
41-
// Force timezone to be GMT
42-
dateFormat.setLenient(false)
40+
// Create a new instance per call to avoid SimpleDateFormat thread-safety issues
41+
private def newRfc7231Format = {
42+
val fmt = new java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", java.util.Locale.ENGLISH)
43+
fmt.setLenient(false)
44+
fmt
45+
}
4346

4447
def isValidRfc7231Date(dateStr: String): Boolean = {
4548
try {
46-
val parsedDate = dateFormat.parse(dateStr)
49+
newRfc7231Format.parse(dateStr)
4750
// Check that the timezone part is exactly "GMT"
4851
dateStr.endsWith(" GMT")
4952
} catch {

obp-api/src/main/scala/code/api/util/JwtUtil.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ object JwtUtil extends MdcLoggable {
173173
try {
174174
val signedJWT = SignedJWT.parse(jwtToken)
175175
// claims extraction...
176-
Some(signedJWT.getJWTClaimsSet.getStringClaim(name))
176+
Option(signedJWT.getJWTClaimsSet.getStringClaim(name)).filter(_.trim.nonEmpty)
177177
} catch {
178178
case e: Exception =>
179179
logger.debug(msg = s"code.api.util.JwtUtil.getClaim: $name")
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
package code.api.util
2+
3+
import code.api.berlin.group.v1_3.BerlinGroupServerSetupV1_3
4+
import net.liftweb.common.Failure
5+
import net.liftweb.http.provider.HTTPParam
6+
import org.scalatest.Tag
7+
8+
import java.util.UUID
9+
import scala.concurrent.Await
10+
import scala.concurrent.duration._
11+
12+
/**
13+
* Unit tests for BerlinGroupCheck mandatory headers validation.
14+
*
15+
* Extends BerlinGroupServerSetupV1_3 so Lift is bootstrapped and Props is
16+
* available. BerlinGroupCheck.berlinGroupMandatoryHeaders is a def, so
17+
* setPropsValues in beforeEach correctly controls which headers are required.
18+
*/
19+
class BerlinGroupMandatoryHeadersTest extends BerlinGroupServerSetupV1_3 {
20+
21+
object MandatoryHeaders extends Tag("BerlinGroupMandatoryHeaders")
22+
23+
// A Berlin Group v1.3 URL that triggers the mandatory header check
24+
private val bgUrl = "/berlin-group/v1.3/accounts"
25+
private val bgConsentUrl = "/berlin-group/v1.3/consents"
26+
27+
override def beforeEach(): Unit = {
28+
super.beforeEach()
29+
// Enable only X-Request-ID as mandatory header so tests stay focused
30+
setPropsValues(
31+
"berlin_group_mandatory_headers" -> "X-Request-ID",
32+
"berlin_group_mandatory_header_consent" -> ""
33+
)
34+
}
35+
36+
// Helper: build HTTPParam list from a Map
37+
private def toParams(headers: Map[String, String]): List[HTTPParam] =
38+
headers.map { case (k, v) => HTTPParam(k, List(v)) }.toList
39+
40+
// Helper: call BerlinGroupCheck.validate and block for result.
41+
// fullBoxOrException throws on validation errors, so we catch and wrap as Failure.
42+
private def callValidate(url: String, verb: String = "GET", headers: Map[String, String]): net.liftweb.common.Box[_] = {
43+
val params = toParams(headers)
44+
val emptyContext: (net.liftweb.common.Box[com.openbankproject.commons.model.User], Option[CallContext]) =
45+
(net.liftweb.common.Empty, None)
46+
try {
47+
val future = BerlinGroupCheck.validate(
48+
body = net.liftweb.common.Full("{}"),
49+
verb = verb,
50+
url = url,
51+
reqHeaders = params,
52+
forwardResult = emptyContext
53+
)
54+
Await.result(future, 5.seconds)._1
55+
} catch {
56+
case e: Exception => net.liftweb.common.Failure(e.getMessage, net.liftweb.common.Full(e), net.liftweb.common.Empty)
57+
}
58+
}
59+
60+
// ─── Missing header tests ────────────────────────────────────────────────
61+
62+
feature("BG mandatory headers - missing header") {
63+
64+
scenario("Request without X-Request-ID is rejected", MandatoryHeaders) {
65+
Given("A BG request with no headers at all")
66+
val result = callValidate(bgUrl, headers = Map.empty)
67+
68+
Then("Result is a Failure with missing header error")
69+
result shouldBe a[Failure]
70+
result.asInstanceOf[Failure].msg should include("OBP-20251")
71+
result.asInstanceOf[Failure].msg should include("x-request-id")
72+
}
73+
74+
scenario("Non-BG URL skips the check", MandatoryHeaders) {
75+
Given("A non-BG URL with no headers")
76+
val result = callValidate("/obp/v4.0.0/banks", headers = Map.empty)
77+
78+
Then("Validation is skipped — result is Empty")
79+
result shouldBe net.liftweb.common.Empty
80+
}
81+
}
82+
83+
// ─── X-Request-ID format tests ───────────────────────────────────────────
84+
85+
feature("BG mandatory headers - X-Request-ID format") {
86+
87+
scenario("Valid UUID X-Request-ID is accepted", MandatoryHeaders) {
88+
val result = callValidate(bgUrl, headers = Map("X-Request-ID" -> UUID.randomUUID().toString))
89+
result shouldBe net.liftweb.common.Empty
90+
}
91+
92+
scenario("Non-UUID X-Request-ID is rejected", MandatoryHeaders) {
93+
val result = callValidate(bgUrl, headers = Map("X-Request-ID" -> "not-a-uuid"))
94+
result shouldBe a[Failure]
95+
result.asInstanceOf[Failure].msg should include("OBP-20253")
96+
}
97+
98+
scenario("Empty X-Request-ID is rejected", MandatoryHeaders) {
99+
val result = callValidate(bgUrl, headers = Map("X-Request-ID" -> ""))
100+
result shouldBe a[Failure]
101+
}
102+
}
103+
104+
// ─── Date format tests ───────────────────────────────────────────────────
105+
106+
feature("BG mandatory headers - Date format") {
107+
108+
scenario("Valid RFC 7231 Date is accepted", MandatoryHeaders) {
109+
// Build a valid RFC 7231 date directly with the same format used by isValidRfc7231Date
110+
val fmt = new java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", java.util.Locale.ENGLISH)
111+
fmt.setTimeZone(java.util.TimeZone.getTimeZone("GMT"))
112+
val validDate = fmt.format(new java.util.Date())
113+
val result = callValidate(bgUrl, headers = Map(
114+
"X-Request-ID" -> UUID.randomUUID().toString,
115+
"Date" -> validDate
116+
))
117+
result shouldBe net.liftweb.common.Empty
118+
}
119+
120+
scenario("ISO date format is rejected (not RFC 7231)", MandatoryHeaders) {
121+
val result = callValidate(bgUrl, headers = Map(
122+
"X-Request-ID" -> UUID.randomUUID().toString,
123+
"Date" -> "2026-03-17"
124+
))
125+
result shouldBe a[Failure]
126+
result.asInstanceOf[Failure].msg should include("OBP-20257")
127+
}
128+
}
129+
130+
// ─── Consent endpoint extra header tests ─────────────────────────────────
131+
132+
feature("BG mandatory headers - /consents TPP-Redirect-URI") {
133+
134+
scenario("Consent request missing TPP-Redirect-URI is rejected", MandatoryHeaders) {
135+
setPropsValues(
136+
"berlin_group_mandatory_headers" -> "X-Request-ID",
137+
"berlin_group_mandatory_header_consent" -> "TPP-Redirect-URI"
138+
)
139+
val result = callValidate(bgConsentUrl, headers = Map("X-Request-ID" -> UUID.randomUUID().toString))
140+
result shouldBe a[Failure]
141+
result.asInstanceOf[Failure].msg should include("OBP-20251")
142+
result.asInstanceOf[Failure].msg should include("tpp-redirect-uri")
143+
}
144+
145+
scenario("Consent request with TPP-Redirect-URI passes header check", MandatoryHeaders) {
146+
setPropsValues(
147+
"berlin_group_mandatory_headers" -> "X-Request-ID",
148+
"berlin_group_mandatory_header_consent" -> "TPP-Redirect-URI"
149+
)
150+
val result = callValidate(bgConsentUrl, headers = Map(
151+
"X-Request-ID" -> UUID.randomUUID().toString,
152+
"TPP-Redirect-URI" -> "https://example.com/callback"
153+
))
154+
result should not be a[Failure]
155+
}
156+
}
157+
158+
// ─── Disabled check ───────────────────────────────────────────────────────
159+
160+
feature("BG mandatory headers - disabled when list is empty") {
161+
162+
scenario("All requests pass when mandatory headers list is empty", MandatoryHeaders) {
163+
setPropsValues("berlin_group_mandatory_headers" -> "")
164+
val result = callValidate(bgUrl, headers = Map.empty)
165+
result shouldBe net.liftweb.common.Empty
166+
}
167+
}
168+
169+
// ─── Multiple missing headers ─────────────────────────────────────────────
170+
171+
feature("BG mandatory headers - multiple missing headers") {
172+
173+
scenario("Multiple missing headers are all reported", MandatoryHeaders) {
174+
setPropsValues("berlin_group_mandatory_headers" -> "X-Request-ID,Content-Type,Date")
175+
val result = callValidate(bgUrl, headers = Map.empty)
176+
result shouldBe a[Failure]
177+
result.asInstanceOf[Failure].msg should include("OBP-20251")
178+
// All three missing headers should appear in the error message
179+
result.asInstanceOf[Failure].msg should include("x-request-id")
180+
result.asInstanceOf[Failure].msg should include("content-type")
181+
result.asInstanceOf[Failure].msg should include("date")
182+
}
183+
184+
scenario("Providing one of two required headers still fails", MandatoryHeaders) {
185+
setPropsValues("berlin_group_mandatory_headers" -> "X-Request-ID,Content-Type")
186+
val result = callValidate(bgUrl, headers = Map("X-Request-ID" -> UUID.randomUUID().toString))
187+
result shouldBe a[Failure]
188+
result.asInstanceOf[Failure].msg should include("content-type")
189+
}
190+
}
191+
192+
// ─── Content-Type header ──────────────────────────────────────────────────
193+
194+
feature("BG mandatory headers - Content-Type") {
195+
196+
scenario("Missing Content-Type is rejected", MandatoryHeaders) {
197+
setPropsValues("berlin_group_mandatory_headers" -> "Content-Type")
198+
val result = callValidate(bgUrl, headers = Map.empty)
199+
result shouldBe a[Failure]
200+
result.asInstanceOf[Failure].msg should include("content-type")
201+
}
202+
203+
scenario("Present Content-Type passes", MandatoryHeaders) {
204+
setPropsValues("berlin_group_mandatory_headers" -> "Content-Type")
205+
val result = callValidate(bgUrl, headers = Map("Content-Type" -> "application/json"))
206+
result shouldBe net.liftweb.common.Empty
207+
}
208+
}
209+
210+
// ─── Digest header ────────────────────────────────────────────────────────
211+
212+
feature("BG mandatory headers - Digest") {
213+
214+
scenario("Missing Digest is rejected", MandatoryHeaders) {
215+
setPropsValues("berlin_group_mandatory_headers" -> "Digest")
216+
val result = callValidate(bgUrl, headers = Map.empty)
217+
result shouldBe a[Failure]
218+
result.asInstanceOf[Failure].msg should include("digest")
219+
}
220+
221+
scenario("Present Digest passes header presence check", MandatoryHeaders) {
222+
setPropsValues("berlin_group_mandatory_headers" -> "Digest")
223+
val digest = "SHA-256=" + java.util.Base64.getEncoder.encodeToString(
224+
java.security.MessageDigest.getInstance("SHA-256").digest("{}".getBytes("UTF-8"))
225+
)
226+
val result = callValidate(bgUrl, headers = Map("Digest" -> digest))
227+
result shouldBe net.liftweb.common.Empty
228+
}
229+
}
230+
231+
// ─── PSU headers ──────────────────────────────────────────────────────────
232+
233+
feature("BG mandatory headers - PSU device headers") {
234+
235+
scenario("Missing PSU-IP-Address is rejected", MandatoryHeaders) {
236+
setPropsValues("berlin_group_mandatory_headers" -> "PSU-IP-Address")
237+
val result = callValidate(bgUrl, headers = Map.empty)
238+
result shouldBe a[Failure]
239+
result.asInstanceOf[Failure].msg should include("psu-ip-address")
240+
}
241+
242+
scenario("Missing PSU-Device-ID is rejected", MandatoryHeaders) {
243+
setPropsValues("berlin_group_mandatory_headers" -> "PSU-Device-ID")
244+
val result = callValidate(bgUrl, headers = Map.empty)
245+
result shouldBe a[Failure]
246+
result.asInstanceOf[Failure].msg should include("psu-device-id")
247+
}
248+
249+
scenario("Missing PSU-Device-Name is rejected", MandatoryHeaders) {
250+
setPropsValues("berlin_group_mandatory_headers" -> "PSU-Device-Name")
251+
val result = callValidate(bgUrl, headers = Map.empty)
252+
result shouldBe a[Failure]
253+
result.asInstanceOf[Failure].msg should include("psu-device-name")
254+
}
255+
256+
scenario("All PSU headers present passes", MandatoryHeaders) {
257+
setPropsValues("berlin_group_mandatory_headers" -> "PSU-IP-Address,PSU-Device-ID,PSU-Device-Name")
258+
val result = callValidate(bgUrl, headers = Map(
259+
"PSU-IP-Address" -> "192.168.1.1",
260+
"PSU-Device-ID" -> UUID.randomUUID().toString,
261+
"PSU-Device-Name" -> "Chrome/120"
262+
))
263+
result shouldBe net.liftweb.common.Empty
264+
}
265+
}
266+
267+
// ─── Signature + TPP-Signature-Certificate headers ────────────────────────
268+
269+
feature("BG mandatory headers - Signature and TPP-Signature-Certificate") {
270+
271+
scenario("Missing Signature is rejected", MandatoryHeaders) {
272+
setPropsValues("berlin_group_mandatory_headers" -> "Signature")
273+
val result = callValidate(bgUrl, headers = Map.empty)
274+
result shouldBe a[Failure]
275+
result.asInstanceOf[Failure].msg should include("signature")
276+
}
277+
278+
scenario("Missing TPP-Signature-Certificate is rejected", MandatoryHeaders) {
279+
setPropsValues("berlin_group_mandatory_headers" -> "TPP-Signature-Certificate")
280+
val result = callValidate(bgUrl, headers = Map.empty)
281+
result shouldBe a[Failure]
282+
result.asInstanceOf[Failure].msg should include("tpp-signature-certificate")
283+
}
284+
}
285+
286+
// ─── Full default header set ──────────────────────────────────────────────
287+
288+
feature("BG mandatory headers - full default set") {
289+
290+
scenario("All 9 default headers missing are all reported", MandatoryHeaders) {
291+
setPropsValues(
292+
"berlin_group_mandatory_headers" ->
293+
"Content-Type,Date,Digest,PSU-Device-ID,PSU-Device-Name,PSU-IP-Address,Signature,TPP-Signature-Certificate,X-Request-ID"
294+
)
295+
val result = callValidate(bgUrl, headers = Map.empty)
296+
result shouldBe a[Failure]
297+
result.asInstanceOf[Failure].msg should include("OBP-20251")
298+
}
299+
}
300+
}

0 commit comments

Comments
 (0)