diff --git a/obp-api/src/main/resources/docs/introductory_system_documentation.md b/obp-api/src/main/resources/docs/introductory_system_documentation.md
index e86d38b42e..1e60c2f2ba 100644
--- a/obp-api/src/main/resources/docs/introductory_system_documentation.md
+++ b/obp-api/src/main/resources/docs/introductory_system_documentation.md
@@ -3470,8 +3470,8 @@ GET /obp/v5.1.0/management/logs/INFO
**Testing Endpoints:**
```bash
-# Test delay/timeout handling
-GET /obp/v5.1.0/development/waiting-for-godot?sleep=1000
+# Test delay/timeout handling (needs the Role CanGetWaitingForGodot; the delay is capped per instance)
+GET /obp/v5.1.0/waiting-for-godot?sleep=1000
# Test rate limiting
GET /obp/v5.1.0/rate-limiting
diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template
index 511cf059db..8be6c442ad 100644
--- a/obp-api/src/main/resources/props/sample.props.template
+++ b/obp-api/src/main/resources/props/sample.props.template
@@ -971,9 +971,22 @@ super_admin_user_ids=USER_ID1,USER_ID2,
# Configure Bearer-JWT validation via the oauth2.* props further below
# (oauth2.jwk_set.url, oauth2.keycloak.*, oauth2.obp_oidc.*).
+# The longest delay GET /obp/v5.1.0/waiting-for-godot?sleep=N will make, in milliseconds.
+# A larger requested value is reduced to this. (The endpoint also needs the Role CanGetWaitingForGodot.)
+#waiting_for_godot_max_sleep_in_milliseconds = 5000
+
# When new consumers inserted they should use this setting.
consumers_enabled_by_default=true
+# Optional. Limits which hosts a Consumer redirect URL may point to.
+# Empty (the default) puts no limit on hosts: any host that passes the redirect URL rules
+# (https, http only for localhost, reverse-domain app schemes) is accepted.
+# When set: a comma-separated list of exact hosts (portal.example.com) or domains (.example.com,
+# which allows example.com and any host under it). The API's own host (hostname) is always included.
+# Checked when a Consumer redirect URL is created or updated. App schemes, Berlin Group dynamic
+# registration and TPP-Redirect-URI headers, and redirect URLs already stored are not affected.
+#redirect_url_allowed_hosts =
+
# Autocomplete for login form has to be explicitly set
autocomplete_at_login_form_enabled=false
@@ -1682,6 +1695,10 @@ default_auth_context_update_request_key=CUSTOMER_NUMBER
#berlin_group_mandatory_headers = Content-Type,Date,Digest,PSU-Device-ID,PSU-Device-Name,PSU-IP-Address,Signature,TPP-Signature-Certificate,X-Request-ID
#berlin_group_mandatory_header_consent = TPP-Redirect-URI
+# Require the TPP signature to cover TPP-Redirect-URI and TPP-Nok-Redirect-URI whenever they are sent.
+# When true, the Signature header's headers list must include each of them that the request carries.
+#berlin_group_require_signed_tpp_redirect_uri = false
+
## Berlin Group Create Consent Frequency per Day Upper Limit
#berlin_group_frequency_per_day_upper_limit = 4
diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
index 162debf7bb..1398389be6 100644
--- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
+++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
@@ -525,6 +525,7 @@ class Boot extends MdcLoggable {
logger.info("running mode: " + runningMode)
logger.info(s"ApiPathZero (the bit before version) is $ApiPathZero")
+ logger.info(s"Boot says: redirect URL host allow list (redirect_url_allowed_hosts): ${code.api.util.RedirectUrlValidation.allowedHostsDescription}")
logger.debug(s"If you can read this, logging level is debug")
diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala
index a47bb6fabe..874a0ca74c 100644
--- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala
+++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala
@@ -2959,7 +2959,7 @@ object SwaggerDefinitionsJSON {
app_type = "Web",
description = "Account Management",
developer_email = ExampleValue.emailExample.value,
- redirect_url = "www.openbankproject.com",
+ redirect_url = redirectUrlExample.value,
created_by_user_id = ExampleValue.userIdExample.value,
created_by_user = resourceUserJSON,
enabled = true,
@@ -3051,7 +3051,7 @@ object SwaggerDefinitionsJSON {
app_type = "Web",
description = "Account Management",
developer_email = ExampleValue.emailExample.value,
- redirect_url = "www.openbankproject.com",
+ redirect_url = redirectUrlExample.value,
created_by_user = resourceUserJSON,
enabled = true,
created = DateWithDayExampleObject
@@ -3068,7 +3068,7 @@ object SwaggerDefinitionsJSON {
|client_certificate_content
|-----END CERTIFICATE-----""".stripMargin,
developer_email = ExampleValue.emailExample.value,
- redirect_url = "www.openbankproject.com",
+ redirect_url = redirectUrlExample.value,
created_by_user_id = ExampleValue.userIdExample.value,
created_by_user = resourceUserJSON,
enabled = true,
diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala
index 3e61f5037b..9b797377d9 100644
--- a/obp-api/src/main/scala/code/api/util/ApiRole.scala
+++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala
@@ -121,6 +121,10 @@ object ApiRole extends MdcLoggable{
case class CanGetCustomersAtOneBank(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetCustomersAtOneBank = CanGetCustomersAtOneBank()
+ // Reading every CRM event (customer name, number and the staff notes) at one bank.
+ case class CanGetCrmEvents(requiresBankId: Boolean = true) extends ApiRole
+ lazy val canGetCrmEvents = CanGetCrmEvents()
+
case class CanGetCustomersMinimalAtOneBank(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetCustomersMinimalAtOneBank = CanGetCustomersMinimalAtOneBank()
@@ -153,6 +157,10 @@ object ApiRole extends MdcLoggable{
case class CanGetSystemLogCacheAll(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetSystemLogCacheAll = CanGetSystemLogCacheAll()
+ // Calling waitingForGodot, a diagnostic endpoint that delays its response on request.
+ case class CanGetWaitingForGodot(requiresBankId: Boolean = false) extends ApiRole
+ lazy val canGetWaitingForGodot = CanGetWaitingForGodot()
+
case class CanUpdateAgentStatusAtAnyBank(requiresBankId: Boolean = false) extends ApiRole
lazy val canUpdateAgentStatusAtAnyBank = CanUpdateAgentStatusAtAnyBank()
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 49ec6cf54f..1129167ae6 100644
--- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala
+++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala
@@ -56,6 +56,13 @@ object BerlinGroupCheck extends MdcLoggable {
.map(_.trim.toLowerCase)
.toList.filterNot(_.isEmpty)
+ // When true, a request carrying TPP-Redirect-URI or TPP-Nok-Redirect-URI must list that header in its
+ // Signature header, so the redirect target cannot be swapped without breaking the TPP's signature.
+ private def requireSignedTppRedirectUri: Boolean =
+ APIUtil.getPropsAsBoolValue("berlin_group_require_signed_tpp_redirect_uri", defaultValue = false)
+
+ private val tppRedirectHeaders = List(RequestHeader.`TPP-Redirect-URI`, RequestHeader.`TPP-Nok-Redirect-URI`)
+
def hasUnwantedConsentIdHeaderForBGEndpoint(path: String, reqHeaders: List[HTTPParam]): Boolean = {
val hasConsentIdId = RequestHeadersUtil.find(reqHeaders, RequestHeader.`Consent-ID`).flatMap(_.values.headOption).isDefined
@@ -151,6 +158,44 @@ object BerlinGroupCheck extends MdcLoggable {
}
+ // === TPP redirect URIs ===
+ // The PSU's browser is sent to these after authorisation, so they must meet the same redirect URL
+ // rules as a Consumer's redirect URL: a javascript: or plain-http public target is refused here.
+ val resultWithInvalidTppRedirectUriCheck: Option[(Box[User], Option[CallContext])] =
+ tppRedirectHeaders.iterator.flatMap { headerName =>
+ RequestHeadersUtil.find(reqHeaders, headerName).flatMap(_.values.headOption).flatMap { value =>
+ RedirectUrlValidation.problemWith(value.trim).map(problem => s"${ErrorMessages.InvalidRedirectUrl}$headerName '$value': $problem")
+ }
+ }.toList.headOption.map { message =>
+ (fullBoxOrException(Empty ~> APIFailureNewStyle(message, 400, forwardResult._2.map(_.toLight))), forwardResult._2)
+ }
+
+ val resultWithUnsignedTppRedirectUriCheck: Option[(Box[User], Option[CallContext])] =
+ if (!requireSignedTppRedirectUri) None
+ else {
+ val signedHeaders: List[String] = RequestHeadersUtil.find(reqHeaders, RequestHeader.Signature)
+ .flatMap(_.values.headOption)
+ .flatMap(header => BerlinGroupSignatureHeaderParser.parseSignatureHeader(header).toOption)
+ .map(_.headers.map(_.toLowerCase))
+ .getOrElse(Nil)
+ val unsignedRedirectHeaders = tppRedirectHeaders
+ .filter(RequestHeadersUtil.exists(reqHeaders, _))
+ .filterNot(headerName => signedHeaders.contains(headerName.toLowerCase))
+ if (unsignedRedirectHeaders.isEmpty) None
+ else Some(
+ (
+ fullBoxOrException(
+ Empty ~> APIFailureNewStyle(
+ s"${ErrorMessages.InvalidSignatureHeader} The signature must cover ${unsignedRedirectHeaders.mkString(", ")}.",
+ 400,
+ forwardResult._2.map(_.toLight)
+ )
+ ),
+ forwardResult._2
+ )
+ )
+ }
+
// === Signature Header Parsing ===
val resultWithInvalidSignatureHeaderCheck: Option[(Box[User], Option[CallContext])] = {
val maybeSignature: Option[String] = RequestHeadersUtil.find(reqHeaders, RequestHeader.Signature).flatMap(_.values.headOption)
@@ -217,6 +262,8 @@ object BerlinGroupCheck extends MdcLoggable {
.orElse(resultWithWrongDateHeaderCheck)
.orElse(resultWithInvalidRequestIdCheck)
.orElse(resultWithRequestIdUsedTwiceCheck)
+ .orElse(resultWithInvalidTppRedirectUriCheck)
+ .orElse(resultWithUnsignedTppRedirectUriCheck)
.orElse(resultWithInvalidSignatureHeaderCheck)
.getOrElse(forwardResult)
}
diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala
index e049b5ff6a..62124461ce 100644
--- a/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala
+++ b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala
@@ -426,7 +426,7 @@ object BerlinGroupSigning extends MdcLoggable {
val dateHeader = getCurrentDate
- val redirectUri = "www.redirect-uri.com"
+ val redirectUri = "https://tpp.example.com/redirect"
val headers = Map(
RequestHeader.Digest -> s"SHA-256=$digest",
RequestHeader.`X-Request-ID` -> xRequestId,
diff --git a/obp-api/src/main/scala/code/api/util/CommonFunctions.scala b/obp-api/src/main/scala/code/api/util/CommonFunctions.scala
index 1370e3a034..c2d5e27a18 100644
--- a/obp-api/src/main/scala/code/api/util/CommonFunctions.scala
+++ b/obp-api/src/main/scala/code/api/util/CommonFunctions.scala
@@ -54,6 +54,14 @@ object CommonFunctions extends MdcLoggable {
Nil
}
+ /**
+ * This validation applies the redirect URL rules of RedirectUrlValidation to a Mapper field, so a Consumer
+ * cannot be saved through createConsumer with a redirect URL that breaks them. The instance's host list is an
+ * endpoint-level policy (dynamic registration is exempt), so it is not applied here.
+ */
+ def validRedirectUrls[T <: MappedString[_]](field: T)(s: String): List[FieldError] =
+ RedirectUrlValidation.consumerRedirectUrlError(s, applyHostList = false).map(error => FieldError(field, error)).toList
+
private def validUrl[T <: MappedString[_]](field: T)(s: String): List[FieldError] = {
import java.net.URL
diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
index 56a05edd2d..a3f6aba03a 100644
--- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
+++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala
@@ -350,6 +350,8 @@ object ErrorMessages {
val BankAccountBalanceNotFoundById = "OBP-20105: BankAccountBalance not found. Please specify a valid value for BALANCE_ID."
val UserNotFoundByToken = "OBP-20106: User not found by token. The validation token is invalid or expired."
val UserAlreadyValidated = "OBP-20107: User email is already validated."
+ val InvalidRedirectUrl = "OBP-20108: Invalid redirect URL. Each redirect URL must use https, use http only for localhost, 127.0.0.1 or [::1], or use an app scheme in reverse-domain form such as com.example.app, and must not contain a wildcard, user information or a fragment. Rejected entry: "
+ val RedirectUrlHostNotAllowed = "OBP-20109: Redirect URL host not allowed. This instance only accepts redirect URLs that point to the hosts listed for it. Rejected entry: "
// OAuth 2
val ApplicationNotIdentified = "OBP-20200: The application cannot be identified. "
@@ -507,6 +509,7 @@ object ErrorMessages {
val CardNotFound = "OBP-30059: This Card can not be found for the user "
val CardAlreadyExists = "OBP-30060: Card already exists. Please specify different values for bankId, card_number and issueNumber."
val CardAttributeNotFound = "OBP-30061: Card Attribute not found. Please specify a valid value for CARD_ATTRIBUTE_ID."
+ val CounterpartyAttributeNotFound = "OBP-30121: Counterparty Attribute not found. Please specify a valid value for COUNTERPARTY_ATTRIBUTE_ID for this COUNTERPARTY_ID."
val ParentProductNotFoundByProductCode = "OBP-30062: Parent product not found. Please specify an existing product code for parent_product_code. Leave empty if no parent product exists."
val CannotGrantAccountAccess = "OBP-30063: Cannot grant account access."
val CannotRevokeAccountAccess = "OBP-30064: Cannot revoke account access."
@@ -514,6 +517,7 @@ object ErrorMessages {
val CannotGetAccounts = "OBP-30066: Could not get accounts."
val TransactionNotFound = "OBP-30067: Transaction not found. Please specify a valid value for TRANSACTION_ID."
val RefundedTransaction = "OBP-30068: Transaction was already refunded . Please specify a valid value for TRANSACTION_ID."
+ val RefundTransactionNotWithThisParty = "OBP-30122: The transaction to refund was not made with this party. Please specify a TRANSACTION_ID of a payment between this account and the party named in the request."
val CustomerAttributeNotFound = "OBP-30069: Customer Attribute not found. Please specify a valid value for CUSTOMER_ATTRIBUTE_ID."
val TransactionAttributeNotFound = "OBP-30070: Transaction Attribute not found. Please specify a valid value for TRANSACTION_ATTRIBUTE_ID."
val AttributeNotFound = "OBP-30071: Attribute Definition not found. Please specify a valid value for ATTRIBUTE_DEFINITION_ID."
diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala
index f29998ffd1..cb330f0a3e 100644
--- a/obp-api/src/main/scala/code/api/util/Glossary.scala
+++ b/obp-api/src/main/scala/code/api/util/Glossary.scala
@@ -3283,6 +3283,36 @@ object Glossary extends MdcLoggable {
""")
+ glossaryItems += GlossaryItem(
+ title = "Redirect URL",
+ description =
+ s"""
+|A Consumer's redirect URL is where an OIDC provider (such as OBP-OIDC) sends the User's browser, with an authorisation code, after the User signs in.
+|Because the code goes wherever the redirect URL points, OBP checks every redirect URL when a Consumer is created or its redirect URL is updated.
+|
+|The redirect URL field can hold several URLs separated by commas or spaces. Each one must:
+|
+|* use https with a host, or
+|* use http only for localhost, 127.0.0.1 or [::1] (for development), or
+|* use an app scheme in reverse-domain form, such as com.example.app:/callback
+|
+|and must not contain a wildcard (*), user information (user@host) or a fragment (#). A redirect URL that breaks these rules is refused with OBP-20108.
+|
+|## Allowed hosts
+|
+|${code.api.util.RedirectUrlValidation.allowedHostsDescription}
+|
+|An instance can optionally limit the hosts redirect URLs may point to. When it does, each https or http redirect URL must name one of the listed hosts,
+|or a host under a listed domain, and the API's own host is always allowed. A redirect URL whose host is not listed is refused with OBP-20109.
+|The host list is checked when a Consumer's redirect URL is created or updated. App schemes are not affected, and neither are
+|Berlin Group dynamic registration and TPP-Redirect-URI headers, which belong to third parties by definition, nor redirect URLs already stored.
+|
+|## Redirect URLs saved before these checks
+|
+|A Consumer saved before these checks existed may still hold a redirect URL that breaks them. Such an entry is left out when an OIDC provider reads the Consumer,
+|so it is never used as a redirect target, and the Consumer's owner should update it.
+|""")
+
glossaryItems += GlossaryItem(
title = "Berlin Group Mandatory Headers",
description =
@@ -3311,6 +3341,12 @@ object Glossary extends MdcLoggable {
|
|* **TPP-Redirect-URI** - URI to redirect the PSU to after consent authorization
|
+|Whenever TPP-Redirect-URI or TPP-Nok-Redirect-URI is sent, on any Berlin Group request, its value must meet the redirect URL rules:
+|https with a host; http only for localhost, 127.0.0.1 or [::1]; or an app scheme in reverse-domain form such as com.example.app.
+|It must not contain a wildcard, user information or a fragment. A value that breaks them is refused with HTTP 400 (OBP-20108).
+|
+|On this instance a signature that does not cover TPP-Redirect-URI and TPP-Nok-Redirect-URI is ${if (APIUtil.getPropsAsBoolValue("berlin_group_require_signed_tpp_redirect_uri", false)) "refused: when either header is sent, the Signature header's headers list must include it" else "accepted"}.
+|
|## TPP Requests Without PSU Involvement
|
|For background/batch requests where no PSU is directly involved, set:
@@ -3327,6 +3363,7 @@ object Glossary extends MdcLoggable {
|
|* `berlin_group_mandatory_headers` - Comma-separated list of mandatory header names. Set to empty to disable header checks.
|* `berlin_group_mandatory_header_consent` - Additional headers required for consent creation endpoints.
+|* `berlin_group_require_signed_tpp_redirect_uri` - When true, a request that sends TPP-Redirect-URI or TPP-Nok-Redirect-URI must list that header in its Signature header. Default false.
|
|Example Props configuration:
|
@@ -3346,10 +3383,12 @@ object Glossary extends MdcLoggable {
|2. **Date format check** - Validates the Date header conforms to RFC 7231
|3. **X-Request-ID format check** - Validates the X-Request-ID is a valid UUID
|4. **X-Request-ID uniqueness check** - Ensures the X-Request-ID has not been used in a previous successful POST (201) request
-|5. **Signature header check** - Parses the Signature header and verifies the keyId serial number matches the TPP certificate
-|6. **Consent-ID usage check** - Ensures the Consent-ID header is not sent on consent management endpoints where it is not expected
+|5. **TPP redirect URI check** - Refuses a TPP-Redirect-URI or TPP-Nok-Redirect-URI that breaks the redirect URL rules (OBP-20108)
+|6. **Signed TPP redirect URI check** - When `berlin_group_require_signed_tpp_redirect_uri` is true, refuses a request whose signature does not cover the TPP redirect headers it sends
+|7. **Signature header check** - Parses the Signature header and verifies the keyId serial number matches the TPP certificate
+|8. **Consent-ID usage check** - Ensures the Consent-ID header is not sent on consent management endpoints where it is not expected
|
-|If any check fails, OBP returns an appropriate error message (OBP-20251 through OBP-20256) with HTTP status 400.
+|If any check fails, OBP returns an appropriate error message (OBP-20251 through OBP-20256, or OBP-20108 for a TPP redirect URI) with HTTP status 400.
|
""")
diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala
index 5a4386b548..399eeec3dd 100644
--- a/obp-api/src/main/scala/code/api/util/NewStyle.scala
+++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala
@@ -79,6 +79,7 @@ import code.webhook.AccountWebhook
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.dto.{CustomerAndAttribute, GetProductsParam, ProductCollectionItemsTree}
import com.openbankproject.commons.model._
+import code.context.UserAuthContextUpdateProvider
import com.openbankproject.commons.model.enums.StrongCustomerAuthentication.SCA
import com.openbankproject.commons.model.enums.StrongCustomerAuthenticationStatus.SCAStatus
import com.openbankproject.commons.model.enums.{SuppliedAnswerType, _}
@@ -933,10 +934,18 @@ object NewStyle extends MdcLoggable{
} map { unboxFull(_) }
}
- def getCounterpartyTrait(bankId : BankId, accountId : AccountId, counterpartyId : String, callContext: Option[CallContext]): OBPReturnType[CounterpartyTrait] =
+ /**
+ * This returns the counterparty COUNTERPARTY_ID only if it belongs to the account BANK_ID / ACCOUNT_ID.
+ * A counterparty id is unique on its own, but the caller's access is checked for one account, so a
+ * counterparty of any other account must be treated as not found. The check is made here, whatever
+ * the connector returns, so that every endpoint which names a bank and account is held to them.
+ */
+ def getCounterpartyTrait(bankId : BankId, accountId : AccountId, counterpartyId : String, callContext: Option[CallContext]): OBPReturnType[CounterpartyTrait] =
{
Connector.connector.vend.getCounterpartyTrait(bankId, accountId, counterpartyId, callContext) map { i =>
- (unboxFullOrFail(i._1, callContext, s"$CounterpartyNotFoundByCounterpartyId Current counterpartyId($counterpartyId) ", 400),
+ val counterpartyOfThisAccount = i._1.filter(counterparty =>
+ counterparty.thisBankId == bankId.value && counterparty.thisAccountId == accountId.value)
+ (unboxFullOrFail(counterpartyOfThisAccount, callContext, s"$CounterpartyNotFoundByCounterpartyId Current counterpartyId($counterpartyId) ", 400),
i._2)
}
}
@@ -4706,6 +4715,44 @@ object NewStyle extends MdcLoggable{
}
}
+ /**
+ * This answers the challenge of a User Auth Context Update on behalf of the User who requested it, and is
+ * shared by the v3.1.0 and v5.0.0 answer endpoints.
+ *
+ * The update must belong to the answering User. Otherwise any logged-in User could answer, or simply use up,
+ * the challenge of someone else's update, so an update of another User is reported exactly like one that does
+ * not exist. Only an ACCEPTED answer (the right one-time password) has any effect: it creates the User Auth
+ * Context and, for the key CUSTOMER_NUMBER, links the User to the Customer with that number at the bank. A
+ * REJECTED answer changes nothing, so a wrong answer can never link a User to someone else's Customer.
+ */
+ def answerUserAuthContextUpdateChallenge(
+ bankId: BankId,
+ authContextUpdateId: String,
+ answer: String,
+ answeringUser: User,
+ callContext: Option[CallContext]
+ ): OBPReturnType[UserAuthContextUpdate] = {
+ val updatesOfTheAnsweringUser = UserAuthContextUpdateProvider.userAuthContextUpdateProvider.vend
+ .getUserAuthContextUpdatesBox(answeringUser.userId).toList.flatten
+ for {
+ _ <- Helper.booleanToFuture(UserAuthContextUpdateNotFound, cc = callContext) {
+ updatesOfTheAnsweringUser.exists(_.userAuthContextUpdateId == authContextUpdateId)
+ }
+ (userAuthContextUpdate, callContext) <- checkAnswer(authContextUpdateId, answer, callContext)
+ callContext <- if (userAuthContextUpdate.status == UserAuthContextUpdateStatus.ACCEPTED.toString) {
+ val key = userAuthContextUpdate.key.trim
+ val value = userAuthContextUpdate.value.trim
+ for {
+ (_, callContext) <- createUserAuthContext(answeringUser, key, value, callContext)
+ callContext <- key match {
+ case "CUSTOMER_NUMBER" => getOCreateUserCustomerLink(bankId, value, answeringUser.userId, callContext).map(_._2)
+ case _ => Future.successful(callContext)
+ }
+ } yield callContext
+ } else Future.successful(callContext)
+ } yield (userAuthContextUpdate, callContext)
+ }
+
def sendCustomerNotification(
scaMethod: StrongCustomerAuthentication,
recipient: String,
diff --git a/obp-api/src/main/scala/code/api/util/RedirectUrlValidation.scala b/obp-api/src/main/scala/code/api/util/RedirectUrlValidation.scala
new file mode 100644
index 0000000000..2bb02f1ea5
--- /dev/null
+++ b/obp-api/src/main/scala/code/api/util/RedirectUrlValidation.scala
@@ -0,0 +1,176 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+
+package code.api.util
+
+import java.net.URI
+
+import code.api.util.ErrorMessages.{InvalidRedirectUrl, RedirectUrlHostNotAllowed}
+import code.util.Helper
+import net.liftweb.common.Box
+
+import scala.concurrent.Future
+import scala.util.Try
+
+/**
+ * This object decides whether a redirect URL may be stored on a Consumer, or used as a Berlin Group
+ * TPP redirect.
+ *
+ * An OIDC provider (OBP-OIDC) sends the browser, together with an authorisation code, to whichever
+ * redirect URL is registered on the Consumer. A registered `javascript:` URL runs script on the
+ * provider's page, and a URL with a wildcard, user information or a plain-http public host hands the
+ * code to whoever controls that address. So every redirect URL is checked when it is written, not only
+ * when it is used.
+ *
+ * A Consumer's redirect URL field can hold several URLs separated by commas or whitespace, the same way
+ * OBP-OIDC and `getOidcClient` split it. Every entry must pass. The rules are:
+ *
+ * - `https:` with a host;
+ * - `http:` only for `localhost`, `127.0.0.1` or `[::1]`, for development;
+ * - a private-use app scheme in reverse-domain form (RFC 8252), such as `com.example.app:/callback`;
+ * - never a wildcard (`*`), user information (`user@host`) or a fragment (`#...`).
+ *
+ * An empty value is allowed: Consumers that only use DirectLogin or client credentials have no redirect.
+ *
+ * An instance can also limit which hosts a Consumer's redirect URL may point to, with the optional prop
+ * `redirect_url_allowed_hosts`. Empty (the default) puts no limit on hosts. When it is set, every https or
+ * http entry must name a listed host, where `portal.example.com` matches that host exactly and
+ * `.example.com` matches example.com and any host under it; the API's own host is always included. App
+ * schemes have no host to check and are not affected. The host list is checked when an endpoint writes a
+ * Consumer's redirect URL. It is not applied to Berlin Group dynamic registration or TPP redirect headers,
+ * which by definition belong to third parties, nor to redirect URLs already stored.
+ */
+object RedirectUrlValidation {
+
+ private val loopbackHosts = Set("localhost", "127.0.0.1", "[::1]")
+
+ // A reverse-domain scheme: at least two dot-separated labels, e.g. com.example.app or x-com.example.ios.
+ private val reverseDomainScheme = """^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+$""".r
+
+ /**
+ * The hosts a Consumer redirect URL may point to on this instance, or Nil when there is no limit.
+ * This is a def, not a val, so a change to the prop (in tests, or after a restart) is always seen.
+ */
+ def allowedHosts: List[String] = {
+ val listed = APIUtil.getPropsValue("redirect_url_allowed_hosts", "")
+ .split("[,\\s]+").map(_.trim.toLowerCase).filter(_.nonEmpty).toList
+ if (listed.isEmpty) Nil
+ else {
+ val ownHost = APIUtil.getPropsValue("hostname").toOption
+ .flatMap(hostname => Try(new URI(hostname)).toOption.flatMap(uri => Option(uri.getHost)))
+ .map(_.toLowerCase)
+ (listed ++ ownHost).distinct
+ }
+ }
+
+ private def hostIsAllowed(host: String, allowed: List[String]): Boolean = {
+ val lowerCaseHost = host.toLowerCase
+ allowed.exists { allowedHost =>
+ if (allowedHost.startsWith(".")) lowerCaseHost == allowedHost.drop(1) || lowerCaseHost.endsWith(allowedHost)
+ else lowerCaseHost == allowedHost
+ }
+ }
+
+ /**
+ * Returns why an entry that already meets the redirect URL rules points to a host this instance does not
+ * allow, or None when there is no host list, the host is listed, or the entry is an app scheme.
+ */
+ def hostProblemWith(entry: String): Option[String] = allowedHosts match {
+ case Nil => None
+ case allowed =>
+ Try(new URI(entry)).toOption
+ .filter(uri => Option(uri.getScheme).map(_.toLowerCase).exists(scheme => scheme == "https" || scheme == "http"))
+ .flatMap(uri => Option(uri.getHost))
+ .filterNot(hostIsAllowed(_, allowed))
+ .map(host => s"the host '$host' is not one of the hosts this instance allows: ${allowed.mkString(", ")}")
+ }
+
+ /** One sentence for the Glossary and the boot log saying whether this instance limits redirect URL hosts. */
+ def allowedHostsDescription: String = allowedHosts match {
+ case Nil => "On this instance a redirect URL may point to any host that meets the redirect URL rules."
+ case allowed => s"On this instance a redirect URL must point to one of these hosts: ${allowed.mkString(", ")}."
+ }
+
+ /** Splits a stored redirect URL value into its entries, the same way OBP-OIDC does. */
+ def entries(redirectUrls: String): List[String] =
+ Option(redirectUrls).toList.flatMap(_.split("[,\\s]+").toList).map(_.trim).filter(_.nonEmpty)
+
+ /** Returns why one redirect URL entry is not allowed, or None when it is allowed. */
+ def problemWith(entry: String): Option[String] = {
+ if (entry.contains("*")) Some("a wildcard is not allowed")
+ else Try(new URI(entry)).toOption match {
+ case None => Some("it is not a valid URI")
+ case Some(uri) if uri.getScheme == null => Some("it has no scheme")
+ case Some(uri) if uri.getRawFragment != null => Some("a fragment is not allowed")
+ case Some(uri) if uri.getRawUserInfo != null => Some("user information is not allowed")
+ case Some(uri) =>
+ uri.getScheme.toLowerCase match {
+ case "https" =>
+ if (Option(uri.getHost).exists(_.nonEmpty)) None
+ else Some("an https URL must name a host")
+ case "http" =>
+ if (Option(uri.getHost).map(_.toLowerCase).exists(loopbackHosts.contains)) None
+ else Some("http is only allowed for localhost, 127.0.0.1 or [::1]; use https")
+ case scheme if reverseDomainScheme.pattern.matcher(scheme).matches() =>
+ None
+ case scheme =>
+ Some(s"the scheme '$scheme' is not allowed; use https, or an app scheme in reverse-domain form such as com.example.app")
+ }
+ }
+ }
+
+ /** Describes the first entry that is not allowed, or None when every entry is allowed. */
+ def firstProblem(redirectUrls: String): Option[String] =
+ entries(redirectUrls).iterator
+ .map(entry => problemWith(entry).map(reason => s"'$entry': $reason"))
+ .collectFirst { case Some(description) => description }
+
+ def isValid(redirectUrls: String): Boolean = firstProblem(redirectUrls).isEmpty
+
+ /**
+ * The full error message for the first entry of a Consumer redirect URL value that is not allowed, or None
+ * when every entry is allowed. An entry that breaks the rules gives InvalidRedirectUrl. With
+ * `applyHostList`, an entry whose host is not on this instance's list gives RedirectUrlHostNotAllowed.
+ */
+ def consumerRedirectUrlError(redirectUrls: String, applyHostList: Boolean): Option[String] =
+ entries(redirectUrls).iterator.map { entry =>
+ problemWith(entry).map(reason => s"$InvalidRedirectUrl'$entry': $reason")
+ .orElse(if (applyHostList) hostProblemWith(entry).map(reason => s"$RedirectUrlHostNotAllowed'$entry': $reason") else None)
+ }.collectFirst { case Some(message) => message }
+
+ /** The entries that meet the rules, for readers that must skip invalid stored data. */
+ def validEntries(redirectUrls: String): List[String] = entries(redirectUrls).filter(problemWith(_).isEmpty)
+
+ /**
+ * Fails with 400, naming the offending entry, when any entry of a Consumer redirect URL value is not allowed.
+ * `applyHostList` is false only for Berlin Group dynamic registration, whose TPPs use their own domains.
+ */
+ def checkRedirectUrls(redirectUrls: String, callContext: Option[CallContext], applyHostList: Boolean = true): Future[Box[Unit]] = {
+ val error = consumerRedirectUrlError(redirectUrls, applyHostList)
+ Helper.booleanToFuture(error.getOrElse(""), 400, callContext) { error.isEmpty }
+ }
+}
diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala
index 8a7897581c..8808f99f66 100644
--- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala
+++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala
@@ -743,7 +743,9 @@ object ResourceDocMiddleware extends MdcLoggable {
case Right((cp, Some(updatedCC))) => IO.pure(Right(ctx.copy(counterparty = Some(cp), callContext = updatedCC)))
case Right((cp, None)) => IO.pure(Right(ctx.copy(counterparty = Some(cp))))
case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_))
- case Left(_) => ErrorResponseConverter.createErrorResponse(404, CounterpartyNotFound + s": counterpartyId=$counterpartyId", ctx.callContext).map(Left(_))
+ // The lookup's own failure: 400 CounterpartyNotFoundByCounterpartyId when the counterparty doesn't
+ // exist or belongs to another account, the code Lift's COUNTERPARTY_ID check answered with.
+ case Left(e) => ErrorResponseConverter.toHttp4sResponse(e, ctx.callContext).map(Left(_))
}
)
case _ => DSL.success(ctx)
diff --git a/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala b/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala
index f6b69009a1..addf318cd9 100644
--- a/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala
+++ b/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala
@@ -333,12 +333,15 @@ object Http4s140 {
"GET",
"/banks/BANK_ID/crm-events",
"Get CRM Events",
- "",
+ s"""Returns every CRM event recorded at the bank BANK_ID, for all customers. Each event includes the customer's
+ |name and number, the category, channel, scheduled and actual dates, and the detail and result notes.
+ |
+ |Authentication is Required, and the User needs the Role ${canGetCrmEvents} at the bank.""".stripMargin,
EmptyBody,
crmEventsJson,
- List(AuthenticatedUserIsRequired, BankNotFound, "No CRM Events available.", UnknownError),
+ List(AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, "No CRM Events available.", UnknownError),
List(apiTagCustomer),
- None,
+ Some(List(canGetCrmEvents)),
http4sPartialFunction = Some(getCrmEvents)
)
diff --git a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala
index b4b6e63cfd..7bce7ab09a 100644
--- a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala
+++ b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala
@@ -49,6 +49,7 @@ import code.api.v2_0_0.JSONFactory200
import code.api.v2_0_0.JSONFactory200._
import code.customer.CustomerX
import code.entitlement.Entitlement
+import code.bankconnectors.Connector
import code.model.dataAccess.{AuthUser, BankAccountCreation}
import code.model.{BankAccountX, BankExtended, UserX, _}
import code.search.{elasticsearchMetrics, elasticsearchWarehouse}
@@ -864,14 +865,19 @@ object Http4s200 {
val createAccount: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ =>
EndpointHelpers.withUserAndBankAndBody[CreateAccountJSON, CoreAccountJSON](req) { (user, bank, body, cc) =>
+ val accountId = cc.bankAccount.map(_.accountId).getOrElse(
+ AccountId(req.uri.path.segments.lastOption.map(_.encoded).getOrElse("")))
for {
_ <- code.util.Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) {
- isValidID(cc.bankAccount.map(_.accountId.value).getOrElse(
- req.uri.path.segments.lastOption.map(_.encoded).getOrElse("")))
+ isValidID(accountId.value)
}
_ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) {
isValidID(bank.bankId.value)
}
+ // An existing ACCOUNT_ID must be refused: creating it again would return the existing account
+ // and make the caller one of its holders.
+ (existingAccount, _) <- Connector.connector.vend.checkBankAccountExists(bank.bankId, accountId, Some(cc))
+ _ <- code.util.Helper.booleanToFuture(AccountIdAlreadyExists, failCode = 409, cc = Some(cc)) { existingAccount.isEmpty }
loggedInUserId = user.userId
// Implicit owner resolves to the HUMAN: under a Consent the caller is the
// per-consent shadow, and an account held by it strands when the consent dies.
@@ -895,8 +901,6 @@ object Http4s200 {
_ <- code.util.Helper.booleanToFuture(InvalidISOCurrencyCode, cc = cc2) {
isValidCurrencyISOCode(body.balance.currency)
}
- accountId = cc.bankAccount.map(_.accountId).getOrElse(
- AccountId(req.uri.path.segments.lastOption.map(_.encoded).getOrElse("")))
(bankAccount, cc3) <- NewStyle.function.createBankAccount(
bank.bankId, accountId, body.`type`, body.label, body.balance.currency,
initialBalanceAsNumber, postedOrLoggedInUser.name, "", List.empty, cc2)
@@ -926,7 +930,7 @@ object Http4s200 {
coreAccountJSON,
List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidUserId, InvalidAccountIdFormat, InvalidBankIdFormat,
UserNotFoundById, InvalidAccountBalanceAmount, InvalidAccountType, InvalidAccountInitialBalance,
- InvalidAccountBalanceCurrency, UnknownError),
+ InvalidAccountBalanceCurrency, AccountIdAlreadyExists, UnknownError),
List(apiTagAccount, apiTagOldStyle),
None,
http4sPartialFunction = Some(createAccount))
@@ -938,10 +942,15 @@ object Http4s200 {
val getTransactionTypes: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "banks" / _ / "transaction-types" =>
EndpointHelpers.withBank(req) { (bank, cc) =>
- Future {
- val types = TransactionType.TransactionTypeProvider.vend.getTransactionTypesForBank(bank.bankId)
- JSONFactory200.createTransactionTypeJSON(connectorEmptyResponse(types, Some(cc)))
- }
+ // The prop is read per request as well as at boot: the ResourceDoc (built at boot) only makes the
+ // middleware require a login when the prop was false then, and this check covers the handler itself.
+ val isPublic = APIUtil.getPropsAsBoolValue("apiOptions.getTransactionTypesIsPublic", true)
+ for {
+ _ <- if (!isPublic)
+ code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined }
+ else Future.unit
+ types <- Future(TransactionType.TransactionTypeProvider.vend.getTransactionTypesForBank(bank.bankId))
+ } yield JSONFactory200.createTransactionTypeJSON(connectorEmptyResponse(types, Some(cc)))
}
}
@@ -963,7 +972,7 @@ object Http4s200 {
|
|${userAuthenticationMessage(!getTransactionTypesIsPublic)}""".stripMargin,
EmptyBody, transactionTypesJsonV200,
- List(BankNotFound, UnknownError),
+ List(AuthenticatedUserIsRequired, BankNotFound, UnknownError),
List(apiTagBank, apiTagPSD2AIS, apiTagPsd2), None,
http4sPartialFunction = Some(getTransactionTypes))
diff --git a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala
index 61337055c8..b3e364c20d 100644
--- a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala
+++ b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala
@@ -101,8 +101,6 @@ object Http4s210 {
// and we keep the bug-for-bug compatibility so the restoration runs
// verbatim against Lift's source-of-truth.
private val createCustomeEntitlementsRequiredText: String = createCustomerEntitlementsRequiredText
- private val getTransactionTypesIsPublic =
- APIUtil.getPropsAsBoolValue("apiOptions.getTransactionTypesIsPublic", true)
object Implementations2_1_0 {
val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString
@@ -357,8 +355,8 @@ object Http4s210 {
s"${InvalidJsonFormat}, it should be $COUNTERPARTY json format", 400, Some(cc)) {
parsedJson.extract[TransactionRequestBodyCounterpartyJSON]
}
- (toCounterparty, _) <- NewStyle.function.getCounterpartyByCounterpartyId(
- CounterpartyId(body.to.counterparty_id), Some(cc))
+ (toCounterparty, _) <- NewStyle.function.getCounterpartyTrait(
+ fromAccount.bankId, fromAccount.accountId, body.to.counterparty_id, Some(cc))
(toAccount, _) <- NewStyle.function.getBankAccountFromCounterparty(toCounterparty, true, Some(cc))
_ <- code.util.Helper.booleanToFuture(s"$CounterpartyBeneficiaryPermit", cc = Some(cc)) {
toCounterparty.isBeneficiary
@@ -908,7 +906,7 @@ object Http4s210 {
| * description : A longer description
| * charge : The charge to the customer for each one of these
|
- |${userAuthenticationMessage(getTransactionTypesIsPublic)}""".stripMargin,
+ |${userAuthenticationMessage(true)}""".stripMargin,
transactionTypeJsonV200, transactionType,
List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat,
InsufficientAuthorisationToCreateTransactionType, UnknownError),
@@ -1269,13 +1267,6 @@ object Http4s210 {
case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "redirect_url" =>
EndpointHelpers.withUserAndBody[ConsumerRedirectUrlJSON, ConsumerJsonV210](req) { (user, body, cc) =>
for {
- _ <- APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false) match {
- case true => Future.unit
- case false =>
- code.util.Helper.booleanToFuture(UserHasMissingRoles + canUpdateConsumerRedirectUrl, failCode = 403, cc = Some(cc)) {
- APIUtil.hasEntitlement("", user.userId, canUpdateConsumerRedirectUrl)
- }
- }
consumerIdLong <- NewStyle.function.tryons(InvalidConsumerId, 400, Some(cc)) {
consumerId.toLong
}
@@ -1283,6 +1274,7 @@ object Http4s210 {
_ <- code.util.Helper.booleanToFuture(UserNoPermissionUpdateConsumer, failCode = 400, cc = Some(cc)) {
consumer.createdByUserId.equals(user.userId)
}
+ _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(body.redirect_url, Some(cc))
updatedConsumer <- NewStyle.function.updateConsumer(
id = consumer.id.get,
isActive = Some(APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false)),
@@ -1305,9 +1297,9 @@ object Http4s210 {
|
""".stripMargin,
consumerRedirectUrlJSON, consumerJSON,
- List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError),
List(apiTagConsumer),
- None,
+ Some(List(canUpdateConsumerRedirectUrl)),
http4sPartialFunction = Some(updateConsumerRedirectUrl))
// ─── getMetrics ───────────────────────────────────────────────────────────
diff --git a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala
index 71dc927940..436b1e70cf 100644
--- a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala
+++ b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala
@@ -683,6 +683,10 @@ object Http4s220 {
}
_ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(bank.bankId.value) }
accountId = AccountId(accountIdStr)
+ // An existing ACCOUNT_ID must be refused: creating it again would return the existing account
+ // and make the caller one of its holders.
+ (existingAccount, _) <- Connector.connector.vend.checkBankAccountExists(bank.bankId, accountId, Some(cc))
+ _ <- code.util.Helper.booleanToFuture(AccountIdAlreadyExists, failCode = 409, cc = Some(cc)) { existingAccount.isEmpty }
loggedInUserId = user.userId
userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else loggedInUserId
(postedOrLoggedInUser, _) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc))
@@ -823,6 +827,7 @@ object Http4s220 {
NewStyle.function.ownEntitlement("", user.userId, canCreateConsumer, Some(cc)),
Some(cc), UserHasMissingRoles + canCreateConsumer)
}
+ _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(body.redirect_url, Some(cc))
consumer <- Future {
Consumers.consumers.vend.createConsumer(
Some(generateUUID()), Some(generateUUID()),
@@ -852,7 +857,7 @@ object Http4s220 {
"Test",
"Description",
"some@email.com",
- "redirecturl",
+ "https://app.example.com/callback",
"createdby",
true,
new Date(),
@@ -865,7 +870,7 @@ object Http4s220 {
"App type",
"Description",
"some.email@example.com",
- "Some redirect url",
+ "https://app.example.com/callback",
"Created by UUID",
true,
new Date(),
@@ -873,7 +878,7 @@ object Http4s220 {
|client_certificate_content
|-----END CERTIFICATE-----""".stripMargin
),
- List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError),
+ List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError),
List(apiTagConsumer, apiTagOldStyle),
Some(List(canCreateConsumer)),
http4sPartialFunction = Some(createConsumer))
diff --git a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala
index f293021eba..59ebb0b506 100644
--- a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala
+++ b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala
@@ -3904,21 +3904,9 @@ object Http4s310 {
case req @ POST -> `prefixPath` / "banks" / bankIdStr / "users" / "current" / "auth-context-updates" / authContextUpdateIdStr / "challenge" =>
EndpointHelpers.executeFutureWithBody[PostUserAuthContextUpdateJsonV310, Any](req) { (postBody, cc) =>
for {
- (userAuthContextUpdate, _) <- NewStyle.function.checkAnswer(authContextUpdateIdStr, postBody.answer, Some(cc))
- (subjectUser, _) <- NewStyle.function.getUserByUserId(userAuthContextUpdate.userId, Some(cc))
- _ <- userAuthContextUpdate.status match {
- case status if status == com.openbankproject.commons.model.UserAuthContextUpdateStatus.ACCEPTED.toString =>
- NewStyle.function.createUserAuthContext(
- subjectUser, userAuthContextUpdate.key, userAuthContextUpdate.value, Some(cc)
- ).map(x => (Some(x._1), x._2))
- case _ => Future((None, Some(cc)))
- }
- _ <- userAuthContextUpdate.key match {
- case "CUSTOMER_NUMBER" =>
- NewStyle.function.getOCreateUserCustomerLink(
- BankId(bankIdStr), userAuthContextUpdate.value, subjectUser.userId, Some(cc))
- case _ => Future((None, Some(cc)))
- }
+ (userAuthContextUpdate, _) <- NewStyle.function.answerUserAuthContextUpdateChallenge(
+ BankId(bankIdStr), authContextUpdateIdStr, postBody.answer,
+ cc.user.openOrThrowException(AuthenticatedUserIsRequired), Some(cc))
} yield createUserAuthContextUpdateJson(userAuthContextUpdate)
}
}
diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala
index e405fdb17c..5e5a61d197 100644
--- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala
+++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala
@@ -2852,8 +2852,8 @@ object Http4s400 {
failMsg = s"${NoViewPermission}can_get_counterparty", failCode = 403, cc = Some(cc)) {
view.allowed_actions.exists(_ == CAN_GET_COUNTERPARTY)
}
- (counterparty, _) <- NewStyle.function.getCounterpartyByCounterpartyId(
- CounterpartyId(counterpartyIdStr), Some(cc))
+ (counterparty, _) <- NewStyle.function.getCounterpartyTrait(
+ account.bankId, account.accountId, counterpartyIdStr, Some(cc))
counterpartyMetadata <- NewStyle.function.getMetadata(
account.bankId, account.accountId, counterparty.counterpartyId, Some(cc))
} yield JSONFactory400.createCounterpartyWithMetadataJson400(counterparty, counterpartyMetadata)
@@ -2862,7 +2862,7 @@ object Http4s400 {
staticResourceDocs += ResourceDoc(
implementedInApiVersion, "getExplicitCounterpartyById", "GET",
- "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/EXPLICIT_COUNTERPARTY_ID",
+ "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID",
"Get Counterparty by Id (Explicit)",
s"""This endpoint returns a single Counterparty on an Account View specified by its COUNTERPARTY_ID:
|
@@ -2873,7 +2873,7 @@ object Http4s400 {
|""".stripMargin,
EmptyBody, counterpartyWithMetadataJson400,
List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound,
- $UserNoPermissionAccessView, UnknownError),
+ $UserNoPermissionAccessView, $CounterpartyNotFoundByCounterpartyId, UnknownError),
List(apiTagCounterparty, apiTagPSD2PIS, apiTagPsd2, apiTagCounterpartyMetaData), None,
http4sPartialFunction = Some(getExplicitCounterpartyById))
@@ -3369,6 +3369,7 @@ object Http4s400 {
InvalidTransactionRequestCurrency,
PaymentChallengeHasNoOnBehalfOfUser,
TransactionDisabled,
+ CounterpartyNotFoundByCounterpartyId, RefundTransactionNotWithThisParty, RefundedTransaction,
UnknownError
),
List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None,
@@ -3566,6 +3567,21 @@ object Http4s400 {
}
}
+ /**
+ * This says whether a transaction request involves the account BANK_ID / ACCOUNT_ID, either as the
+ * account it pays from or as the account it pays to. The pays-to side matters for a REFUND that
+ * asks another party for money back: the requester answers its challenge on their own account,
+ * which is the request's pays-to account. The pays-to account is read from the stored request,
+ * because the TransactionRequest model only exposes it for some types.
+ */
+ private def transactionRequestInvolvesAccount(transactionRequest: TransactionRequest, bankId: BankId, accountId: AccountId): Boolean = {
+ val paysFrom = transactionRequest.from.bank_id == bankId.value && transactionRequest.from.account_id == accountId.value
+ lazy val paysTo = code.transactionrequests.MappedTransactionRequest
+ .find(net.liftweb.mapper.By(code.transactionrequests.MappedTransactionRequest.mTransactionRequestId, transactionRequest.id.value))
+ .exists(stored => stored.mTo_BankId.get == bankId.value && stored.mTo_AccountId.get == accountId.value)
+ paysFrom || paysTo
+ }
+
private def answerTransactionRequestChallengeImpl(
user: User,
fromAccount: BankAccount,
@@ -3597,6 +3613,12 @@ object Http4s400 {
code.bankconnectors.DoobieTransactionRequestQueries.lockTransactionRequest(transReqId.value).isDefined
}
(existingTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(transReqId, Some(cc))
+ // The transaction request is found by its id alone, but the caller was authorised only on the
+ // account in the URL, so the request must involve that account: as the account it pays from,
+ // or (for a REFUND the caller asked for) as the account it pays to.
+ _ <- code.util.Helper.booleanToFuture(s"$InvalidTransactionRequestId Current TRANSACTION_REQUEST_ID(${transReqId.value})", cc = Some(cc)) {
+ transactionRequestInvolvesAccount(existingTransactionRequest, fromAccount.bankId, fromAccount.accountId)
+ }
_ <- code.util.Helper.booleanToFuture(
TransactionRequestStatusNotInitiatedOrPendingOrForwarded, cc = Some(cc)) {
existingTransactionRequest.status.equals(TransactionRequestStatus.INITIATED.toString) ||
@@ -3781,7 +3803,8 @@ object Http4s400 {
AllowedAttemptsUsedUp,
ChallengeNotAddressedToCaller,
TransactionDisabled,
- UnknownError
+ InvalidTransactionRequestId,
+ UnknownError
),
List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None,
http4sPartialFunction = Some(answerTransactionRequestChallenge))
@@ -3951,8 +3974,8 @@ object Http4s400 {
case req @ GET -> `prefixPath` / "management" / "banks" / _ / "accounts" / _ / _ / "counterparties" / counterpartyIdStr =>
EndpointHelpers.withBankAccount(req) { (_, account, cc) =>
for {
- (counterparty, _) <- NewStyle.function.getCounterpartyByCounterpartyId(
- CounterpartyId(counterpartyIdStr), Some(cc))
+ (counterparty, _) <- NewStyle.function.getCounterpartyTrait(
+ account.bankId, account.accountId, counterpartyIdStr, Some(cc))
counterpartyMetadata <- NewStyle.function.getMetadata(
account.bankId, account.accountId, counterpartyIdStr, Some(cc))
} yield JSONFactory400.createCounterpartyWithMetadataJson400(counterparty, counterpartyMetadata)
@@ -4008,7 +4031,7 @@ object Http4s400 {
implementedInApiVersion,
nameOf(getCounterpartyByIdForAnyAccount),
"GET",
- "/management/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID_PARAM",
+ "/management/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID",
"Get Counterparty by Id for any account (Explicit)",
s"""This is a management endpoint that gets information about any single explicitly created Counterparty on an Account / View specified by its COUNTERPARTY_ID",
|
@@ -4022,7 +4045,7 @@ object Http4s400 {
EmptyBody,
counterpartyWithMetadataJson400,
List($AuthenticatedUserIsRequired, InvalidAccountIdFormat, InvalidBankIdFormat,
- $BankNotFound, $BankAccountNotFound, InvalidJsonFormat, ViewNotFound, UnknownError),
+ $BankNotFound, $BankAccountNotFound, InvalidJsonFormat, ViewNotFound, $CounterpartyNotFoundByCounterpartyId, UnknownError),
List(apiTagCounterparty, apiTagAccount),
Some(List(canGetCounterpartyAtAnyBank, canGetCounterparty)),
http4sPartialFunction = Some(getCounterpartyByIdForAnyAccount)
@@ -4320,7 +4343,7 @@ object Http4s400 {
implementedInApiVersion,
nameOf(deleteExplicitCounterparty),
"DELETE",
- "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID_PARAM",
+ "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID",
"Delete Counterparty (Explicit)",
s"""This endpoint deletes the Counterparty on the Account / View specified by the COUNTERPARTY_ID.
|It also deletes any related Counterparty Metadata.
@@ -4341,7 +4364,7 @@ object Http4s400 {
$BankNotFound,
$BankAccountNotFound,
$UserNoPermissionAccessView,
- UnknownError
+ $CounterpartyNotFoundByCounterpartyId, UnknownError
),
List(apiTagCounterparty, apiTagAccount),
None,
@@ -4364,7 +4387,7 @@ object Http4s400 {
EmptyBody,
EmptyBody,
List($AuthenticatedUserIsRequired, $BankAccountNotFound, $BankNotFound,
- InvalidAccountIdFormat, InvalidBankIdFormat, UserHasMissingRoles, UnknownError),
+ InvalidAccountIdFormat, InvalidBankIdFormat, UserHasMissingRoles, $CounterpartyNotFoundByCounterpartyId, UnknownError),
List(apiTagCounterparty, apiTagAccount),
Some(List(canDeleteCounterparty, canDeleteCounterpartyAtAnyBank)),
http4sPartialFunction = Some(deleteCounterpartyForAnyAccount)
@@ -5561,8 +5584,8 @@ object Http4s400 {
_ <- code.users.Users.users.vend.getUserByUserIdFuture(postJson.user_id) map { box =>
unboxFullOrFail(box, Some(cc), s"$UserNotFoundByUserId Current UserId(${postJson.user_id})")
}
- (_, _) <- NewStyle.function.getCounterpartyByCounterpartyId(
- CounterpartyId(postJson.counterparty_id), Some(cc))
+ (_, _) <- NewStyle.function.getCounterpartyTrait(
+ BankId(bankIdStr), AccountId(accountIdStr), postJson.counterparty_id, Some(cc))
(directDebit, _) <- NewStyle.function.createDirectDebit(
bankIdStr, accountIdStr, postJson.customer_id, postJson.user_id,
postJson.counterparty_id,
@@ -5615,8 +5638,8 @@ object Http4s400 {
_ <- code.users.Users.users.vend.getUserByUserIdFuture(postJson.user_id) map { box =>
unboxFullOrFail(box, Some(cc), s"$UserNotFoundByUserId Current UserId(${postJson.user_id})")
}
- (_, _) <- NewStyle.function.getCounterpartyByCounterpartyId(
- CounterpartyId(postJson.counterparty_id), Some(cc))
+ (_, _) <- NewStyle.function.getCounterpartyTrait(
+ BankId(bankIdStr), AccountId(accountIdStr), postJson.counterparty_id, Some(cc))
(order, _) <- NewStyle.function.createStandingOrder(
bankIdStr, accountIdStr, postJson.customer_id, postJson.user_id,
postJson.counterparty_id, amountValue, postJson.amount.currency,
@@ -5791,7 +5814,7 @@ object Http4s400 {
CustomerNotFoundByCustomerId,
UserNotFoundByUserId,
$UserNoPermissionAccessView,
- UnknownError
+ CounterpartyNotFoundByCounterpartyId, UnknownError
),
List(apiTagStandingOrder, apiTagAccount),
None,
@@ -5815,7 +5838,7 @@ object Http4s400 {
standingOrderJsonV400,
List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound,
NoViewPermission, InvalidJsonFormat, InvalidNumber, InvalidISOCurrencyCode,
- CustomerNotFoundByCustomerId, UserNotFoundByUserId, UnknownError),
+ CustomerNotFoundByCustomerId, UserNotFoundByUserId, CounterpartyNotFoundByCounterpartyId, UnknownError),
List(apiTagStandingOrder, apiTagAccount),
Some(List(canCreateStandingOrderAtOneBank)),
http4sPartialFunction = Some(createStandingOrderManagement)
@@ -10573,6 +10596,7 @@ object Http4s400 {
}
(postedJson, appType) = postedJsonAndAppType
_ <- NewStyle.function.hasEntitlement("", u.userId, code.api.util.ApiRole.canCreateConsumer, Some(cc))
+ _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(postedJson.redirect_url, Some(cc))
(consumer, callContext) <- createConsumerNewStyle(
key = Some(LiftHelpers.randomString(40).toLowerCase),
secret = Some(LiftHelpers.randomString(40).toLowerCase),
@@ -10927,7 +10951,7 @@ object Http4s400 {
"Web",
"Description",
"some@email.com",
- "redirecturl",
+ "https://app.example.com/callback",
"createdby",
true,
new Date(),
@@ -10936,7 +10960,7 @@ object Http4s400 {
|-----END CERTIFICATE-----""".stripMargin
),
consumerJsonV400,
- List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError),
+ List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError),
List(apiTagConsumer),
Some(List(canCreateConsumer)),
http4sPartialFunction = Some(createConsumer))
diff --git a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala
index 2283aa4606..9e94a8e51f 100644
--- a/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala
+++ b/obp-api/src/main/scala/code/api/v5_0_0/Http4s500.scala
@@ -828,22 +828,9 @@ object Http4s500 {
postUserAuthContextUpdateJson <- NewStyle.function.tryons(failMsg, 400, Some(cc)) {
com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("")).extract[PostUserAuthContextUpdateJsonV310]
}
- (userAuthContextUpdate, _) <- NewStyle.function.checkAnswer(authContextUpdateId, postUserAuthContextUpdateJson.answer, Some(cc))
- (user, _) <- NewStyle.function.getUserByUserId(userAuthContextUpdate.userId, Some(cc))
- _ <- userAuthContextUpdate.status match {
- case status if status == UserAuthContextUpdateStatus.ACCEPTED.toString =>
- NewStyle.function.createUserAuthContext(
- user, userAuthContextUpdate.key.trim, userAuthContextUpdate.value.trim, Some(cc))
- .map(x => (Some(x._1), x._2))
- case _ =>
- Future.successful((None, Some(cc)))
- }
- _ <- userAuthContextUpdate.key match {
- case "CUSTOMER_NUMBER" =>
- NewStyle.function.getOCreateUserCustomerLink(bankId, userAuthContextUpdate.value, user.userId, Some(cc))
- case _ =>
- Future.successful((None, Some(cc)))
- }
+ (userAuthContextUpdate, _) <- NewStyle.function.answerUserAuthContextUpdateChallenge(
+ bankId, authContextUpdateId, postUserAuthContextUpdateJson.answer,
+ cc.user.openOrThrowException(AuthenticatedUserIsRequired), Some(cc))
} yield JSONFactory500.createUserAuthContextUpdateJson(userAuthContextUpdate)
}
}
diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala
index 0c7b9d6573..a71190b215 100644
--- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala
+++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala
@@ -658,6 +658,7 @@ object Http4s510 {
(js, appType)
}
(postedJson, appType) = tup
+ _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(postedJson.redirect_url, Some(cc))
(consumer, _) <- createConsumerNewStyle(
key = Some(Helpers.randomString(40).toLowerCase),
secret = Some(Helpers.randomString(40).toLowerCase),
@@ -677,6 +678,13 @@ object Http4s510 {
}
}
+ // For the Consumer docs that take a redirect_url: the rules it must meet and this instance's host list.
+ private val redirectUrlRulesText =
+ s"""**redirect_url** must meet the redirect URL rules (https; http only for localhost; or an app scheme in
+ |reverse-domain form; no wildcard, user information or fragment). ${code.api.util.RedirectUrlValidation.allowedHostsDescription}
+ |See ${Glossary.getGlossaryItemLink("Redirect URL")}
+ |""".stripMargin
+
// For the Create Consumer docs: how an app the installation runs itself gets the Roles its own calls need.
private val platformAppConsumerText =
s"""**If this Consumer is for an app your installation runs itself** (such as the Portal, the API Manager,
@@ -770,6 +778,8 @@ object Http4s510 {
|
|**Important**: The key and secret are only shown once in the response. Save them securely as they cannot be retrieved later.
|
+ |$redirectUrlRulesText
+ |
|$platformAppConsumerText
|
|${consumerDisabledText()}
@@ -779,7 +789,7 @@ object Http4s510 {
|""",
createConsumerRequestJsonV510,
consumerJsonOnlyForPostResponseV510,
- List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError),
+ List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError),
List(apiTagConsumer),
Some(List(canCreateConsumer)),
authMode = UserOrApplication,
@@ -1353,14 +1363,24 @@ object Http4s510 {
http4sPartialFunction = Some(logCacheAllEndpoint)
)
+ // The longest delay waitingForGodot will make. Even with the Role required, an unbounded delay would let
+ // one caller hold requests open for as long as they liked.
+ private def waitingForGodotMaxSleepInMilliseconds: Long =
+ APIUtil.getPropsAsLongValue("waiting_for_godot_max_sleep_in_milliseconds", 5000L).max(0L)
+
val waitingForGodot: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ GET -> `prefixPath` / "waiting-for-godot" =>
- EndpointHelpers.executeFuture(req) {
- val sleep = req.uri.query.params.get("sleep").getOrElse("0")
- val sleepInMillis: Long = scala.util.Try(sleep.trim.toLong).getOrElse(0L)
- for { _ <- Future(Thread.sleep(sleepInMillis)) }
- yield JSONFactory510.waitingForGodot(sleepInMillis)
- }
+ val requested = req.uri.query.params.get("sleep").getOrElse("0")
+ // Clamped to [0, max]: a negative value used to reach Thread.sleep and fail with a 500.
+ val sleepInMillis: Long = scala.util.Try(requested.trim.toLong).getOrElse(0L)
+ .max(0L).min(waitingForGodotMaxSleepInMilliseconds)
+ // IO.sleep waits on the cats-effect timer without occupying a thread. The old
+ // Future(Thread.sleep(...)) blocked a thread of the shared global pool (one thread per core)
+ // for the whole delay, so a few concurrent calls stopped every other endpoint using that pool.
+ IO.sleep(scala.concurrent.duration.Duration(sleepInMillis, java.util.concurrent.TimeUnit.MILLISECONDS)) *>
+ EndpointHelpers.executeFuture(req) {
+ Future.successful(JSONFactory510.waitingForGodot(sleepInMillis))
+ }
}
resourceDocs += ResourceDoc(
implementedInApiVersion,
@@ -1368,16 +1388,23 @@ object Http4s510 {
"GET",
"/waiting-for-godot",
"Waiting For Godot",
- """Waiting For Godot
+ s"""Waiting For Godot
+ |
+ |A diagnostic endpoint for testing how clients, proxies and gateways handle a slow response.
+ |
+ |Authentication is Required, and the User needs the Role CanGetWaitingForGodot.
|
|Uses query parameter "sleep" in milliseconds.
|For instance: .../waiting-for-godot?sleep=50 means postpone response in 50 milliseconds.
+ |
+ |On this instance the delay is at most ${waitingForGodotMaxSleepInMilliseconds} milliseconds: a larger value is reduced to that,
+ |and a negative or unreadable value counts as 0. The response reports the delay actually made.
|""".stripMargin,
EmptyBody,
WaitingForGodotJsonV510(sleep_in_milliseconds = 50),
- List(UnknownError, MandatoryPropertyIsNotSet),
+ List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
apiTagApi :: Nil,
- None,
+ Some(List(canGetWaitingForGodot)),
http4sPartialFunction = Some(waitingForGodot)
)
@@ -2864,10 +2891,6 @@ object Http4s510 {
implicit val cc: code.api.util.CallContext = req.callContext
for {
user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired))
- _ <- APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", false) match {
- case true => Future.successful(Full(()))
- case false => NewStyle.function.hasEntitlement("", user.userId, ApiRole.canUpdateConsumerRedirectUrl, Some(cc))
- }
postJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) {
com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("")).extract[ConsumerRedirectUrlJSON]
}
@@ -2875,6 +2898,7 @@ object Http4s510 {
_ <- Helper.booleanToFuture(UserNoPermissionUpdateConsumer, 400, Some(cc)) {
consumer.createdByUserId.equals(user.userId)
}
+ _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(postJson.redirect_url, Some(cc))
updatedConsumer <- NewStyle.function.updateConsumer(
id = consumer.id.get,
isActive = Some(APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", defaultValue = false)),
@@ -2897,10 +2921,11 @@ object Http4s510 {
|
| Or use the endpoint 'Get Consumers' to get it
|
+ |$redirectUrlRulesText
""".stripMargin,
consumerRedirectUrlJSON,
consumerJSON,
- List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError),
List(apiTagConsumer),
Some(List(canUpdateConsumerRedirectUrl)),
http4sPartialFunction = Some(updateConsumerRedirectURL)
@@ -3059,6 +3084,7 @@ object Http4s510 {
(js, appType)
}
(postedJson, appType) = tup
+ _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(postedJson.redirect_url, Some(cc))
(consumer, _) <- createConsumerNewStyle(
key = Some(Helpers.randomString(40).toLowerCase),
secret = Some(Helpers.randomString(40).toLowerCase),
@@ -3085,11 +3111,13 @@ object Http4s510 {
"Create a Consumer",
s"""Create a Consumer (Authenticated access).
|
+ |$redirectUrlRulesText
+ |
|$platformAppConsumerText
|""",
createConsumerRequestJsonV510,
consumerJsonV510,
- List(AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError),
+ List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidRedirectUrl, RedirectUrlHostNotAllowed, UnknownError),
List(apiTagConsumer),
None,
http4sPartialFunction = Some(createMyConsumer)
@@ -3148,6 +3176,8 @@ object Http4s510 {
postedJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) {
com.openbankproject.commons.util.JsonAliases.parse(JwtUtil.getSignedPayloadAsJson(postedJwt.jwt).getOrElse("{}")).extract[ConsumerPostJsonV510]
}
+ // TPPs register their own domains, so this instance's redirect URL host list does not apply here.
+ _ <- code.api.util.RedirectUrlValidation.checkRedirectUrls(postedJson.redirect_url.getOrElse(""), Some(cc), applyHostList = false)
certificateInfo: CertificateInfoJsonV510 <- Future(X509.getCertificateInfo(pem))
.map(unboxFullOrFail(_, Some(cc), X509GeneralError))
_ <- Helper.booleanToFuture(RegulatedEntityNotFoundByCertificate, 400, Some(cc)) {
@@ -3268,7 +3298,7 @@ object Http4s510 {
|""",
ConsumerJwtPostJsonV510("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJkZXNjcmlwdGlvbiI6IlRQUCBkZXNjcmlwdGlvbiJ9.c5gPPsyUmnVW774y7h2xyLXg0wdtu25nbU2AvOmyzcWa7JTdCKuuy3CblxueGwqYkQDDQIya1Qny4blyAvh_a1Q28LgzEKBcH7Em9FZXerhkvR9v4FWbCC5AgNLdQ7sR8-rUQdShmJcGDKdVmsZjuO4XhY2Zx0nFnkcvYfsU9bccoAvkKpVJATXzwBqdoEOuFlplnbxsMH1wWbAd3hbcPPWTdvO43xavNZTB5ybgrXVDEYjw8D-98_ZkqxS0vfvhJ4cGefHViaFzp6zXm7msdBpcE__O9rFbdl9Gvup_bsMbrHJioIrmc2d15Yc-tTNTF9J4qjD_lNxMRlx5o2TZEw"),
consumerJsonV510,
- List(InvalidJsonFormat, UnknownError),
+ List(InvalidJsonFormat, InvalidRedirectUrl, UnknownError),
List(apiTagDirectory, apiTagConsumer),
Some(Nil),
http4sPartialFunction = Some(createConsumerDynamicRegistration)
diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala
index 722f8c3093..b25db23e42 100644
--- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala
+++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala
@@ -2110,8 +2110,8 @@ object Http4s600 {
}
}
} yield {
- val redirectUris = Option(consumer.redirectURL.get).filter(_.nonEmpty)
- .map(_.split("[,\\s]+").map(_.trim).filter(_.nonEmpty).toList).getOrElse(List.empty)
+ // Stored entries that fail the redirect URL rules (written before they existed) are never handed out.
+ val redirectUris = code.api.util.RedirectUrlValidation.validEntries(consumer.redirectURL.get)
GetOidcClientResponseJsonV600(
client_id = clientId, client_name = consumer.name.get,
consumer_id = consumer.consumerId.get,
@@ -2133,8 +2133,7 @@ object Http4s600 {
} yield {
consumerBox match {
case Full(consumer) if consumer.isActive.get && consumer.secret.get == postedData.client_secret =>
- val redirectUris = Option(consumer.redirectURL.get).filter(_.nonEmpty)
- .map(_.split("[,\\s]+").map(_.trim).filter(_.nonEmpty).toList)
+ val redirectUris = Some(code.api.util.RedirectUrlValidation.validEntries(consumer.redirectURL.get)).filter(_.nonEmpty)
VerifyOidcClientResponseJsonV600(
valid = true,
client_id = Some(postedData.client_id),
@@ -2394,6 +2393,18 @@ object Http4s600 {
s"$InvalidJsonFormat The `Type` field can only accept the following field: " +
AttributeTypeDocs.typesWithExamples
+ /**
+ * This returns the attribute COUNTERPARTY_ATTRIBUTE_ID only if it belongs to the counterparty in the URL.
+ * The middleware has already checked that the counterparty belongs to the bank and account in the URL
+ * (COUNTERPARTY_ID); an attribute is found by its own id, so it could belong to any counterparty.
+ */
+ private def counterpartyAttributeOfCounterparty(counterpartyIdStr: String,
+ attributeId: String): Future[com.openbankproject.commons.model.CounterpartyAttributeTrait] =
+ for {
+ attributeBox <- code.counterpartyattribute.CounterpartyAttributeX.counterpartyAttributeProvider.vend.getCounterpartyAttributeById(attributeId)
+ } yield unboxFullOrFail(
+ attributeBox.filter(_.counterpartyId.value == counterpartyIdStr), None, CounterpartyAttributeNotFound, 404)
+
// POST /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes (201)
lazy val createCounterpartyAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] {
case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / counterpartyId / "attributes" =>
@@ -2421,18 +2432,21 @@ object Http4s600 {
// DELETE /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID
lazy val deleteCounterpartyAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] {
- case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / _ / "attributes" / attributeId =>
+ case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / counterpartyId / "attributes" / attributeId =>
EndpointHelpers.executeDelete(req) { cc =>
- code.api.util.newstyle.CounterpartyAttributeNewStyle.deleteCounterpartyAttribute(attributeId, Some(cc))
+ for {
+ _ <- counterpartyAttributeOfCounterparty(counterpartyId, attributeId)
+ deleted <- code.api.util.newstyle.CounterpartyAttributeNewStyle.deleteCounterpartyAttribute(attributeId, Some(cc))
+ } yield deleted
}
}
// GET /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID
lazy val getCounterpartyAttributeById: HttpRoutes[IO] = HttpRoutes.of[IO] {
- case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / _ / "attributes" / attributeId =>
+ case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / counterpartyId / "attributes" / attributeId =>
EndpointHelpers.withUser(req) { (_, cc) =>
for {
- (attribute, _) <- code.api.util.newstyle.CounterpartyAttributeNewStyle.getCounterpartyAttributeById(attributeId, Some(cc))
+ attribute <- counterpartyAttributeOfCounterparty(counterpartyId, attributeId)
} yield JSONFactory600.createCounterpartyAttributeJson(attribute)
}
}
@@ -2454,6 +2468,7 @@ object Http4s600 {
EndpointHelpers.executeAndRespond(req) { implicit cc =>
val rawBody = cc.httpBody.getOrElse("")
for {
+ _ <- counterpartyAttributeOfCounterparty(counterpartyId, attributeId)
postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the CounterpartyAttributeRequestJsonV600", 400, Some(cc)) {
com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CounterpartyAttributeRequestJsonV600]
}
@@ -9558,6 +9573,10 @@ object Http4s600 {
|Returns client information including name, consumer_id, redirect_uris, and enabled status.
|This endpoint does not verify the client secret - use POST /oidc/clients/verify for authentication.
|
+ |redirect_uris lists only the Consumer's redirect URLs that meet the redirect URL rules (https; http only for localhost,
+ |127.0.0.1 or [::1]; or an app scheme in reverse-domain form; no wildcard, user information or fragment). A stored entry
+ |that fails them is left out, so an OIDC provider never redirects to it. See ${Glossary.getGlossaryItemLink("Redirect URL")}
+
|${userAuthenticationMessage(true)}
|""",
EmptyBody,
@@ -9585,6 +9604,10 @@ object Http4s600 {
|Returns `valid: true` if the client_id and client_secret match an active consumer.
|Also returns the consumer_id and redirect_uris for use by the OIDC provider.
|
+ |redirect_uris lists only the Consumer's redirect URLs that meet the redirect URL rules (https; http only for localhost,
+ |127.0.0.1 or [::1]; or an app scheme in reverse-domain form; no wildcard, user information or fragment). A stored entry
+ |that fails them is left out, so an OIDC provider never redirects to it. See ${Glossary.getGlossaryItemLink("Redirect URL")}
+
|${userAuthenticationMessage(true)}
|""",
VerifyOidcClientRequestJsonV600(
@@ -9972,7 +9995,7 @@ object Http4s600 {
implementedInApiVersion,
nameOf(createCounterpartyAttribute),
"POST",
- "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes",
+ "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes",
"Create Counterparty Attribute",
s"""
| Create a new Counterparty Attribute for a given COUNTERPARTY_ID.
@@ -9983,7 +10006,7 @@ object Http4s600 {
""".stripMargin,
counterpartyAttributeRequestJsonV600,
counterpartyAttributeResponseJsonV600,
- List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError),
+ List($AuthenticatedUserIsRequired, InvalidJsonFormat, $CounterpartyNotFoundByCounterpartyId, UnknownError),
List(apiTagCounterpartyAttribute, apiTagApi),
Some(canCreateCounterpartyAttribute :: Nil),
http4sPartialFunction = Some(createCounterpartyAttribute)
@@ -9992,7 +10015,7 @@ object Http4s600 {
implementedInApiVersion,
nameOf(deleteCounterpartyAttribute),
"DELETE",
- "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes/COUNTERPARTY_ATTRIBUTE_ID",
+ "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID",
"Delete Counterparty Attribute",
s"""
| Delete a Counterparty Attribute specified by COUNTERPARTY_ATTRIBUTE_ID.
@@ -10002,7 +10025,7 @@ object Http4s600 {
""".stripMargin,
EmptyBody,
EmptyBody,
- List($AuthenticatedUserIsRequired, UnknownError),
+ List($AuthenticatedUserIsRequired, $CounterpartyNotFoundByCounterpartyId, CounterpartyAttributeNotFound, UnknownError),
List(apiTagCounterpartyAttribute, apiTagApi),
Some(canDeleteCounterpartyAttribute :: Nil),
http4sPartialFunction = Some(deleteCounterpartyAttribute)
@@ -10011,7 +10034,7 @@ object Http4s600 {
implementedInApiVersion,
nameOf(getCounterpartyAttributeById),
"GET",
- "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes/COUNTERPARTY_ATTRIBUTE_ID",
+ "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID",
"Get Counterparty Attribute By ID",
s"""
| Get a specific Counterparty Attribute by its COUNTERPARTY_ATTRIBUTE_ID.
@@ -10021,7 +10044,7 @@ object Http4s600 {
""".stripMargin,
EmptyBody,
counterpartyAttributeResponseJsonV600,
- List($AuthenticatedUserIsRequired, UnknownError),
+ List($AuthenticatedUserIsRequired, $CounterpartyNotFoundByCounterpartyId, CounterpartyAttributeNotFound, UnknownError),
List(apiTagCounterpartyAttribute, apiTagApi),
Some(canGetCounterpartyAttribute :: Nil),
http4sPartialFunction = Some(getCounterpartyAttributeById)
@@ -10030,7 +10053,7 @@ object Http4s600 {
implementedInApiVersion,
nameOf(getAllCounterpartyAttributes),
"GET",
- "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes",
+ "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes",
"Get All Counterparty Attributes",
s"""
| Get all attributes for the specified Counterparty.
@@ -10040,7 +10063,7 @@ object Http4s600 {
""".stripMargin,
EmptyBody,
counterpartyAttributesJsonV600,
- List($AuthenticatedUserIsRequired, UnknownError),
+ List($AuthenticatedUserIsRequired, $CounterpartyNotFoundByCounterpartyId, UnknownError),
List(apiTagCounterpartyAttribute, apiTagApi),
Some(canGetCounterpartyAttributes :: Nil),
http4sPartialFunction = Some(getAllCounterpartyAttributes)
@@ -10049,7 +10072,7 @@ object Http4s600 {
implementedInApiVersion,
nameOf(updateCounterpartyAttribute),
"PUT",
- "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes/COUNTERPARTY_ATTRIBUTE_ID",
+ "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID",
"Update Counterparty Attribute",
s"""
| Update an existing Counterparty Attribute specified by COUNTERPARTY_ATTRIBUTE_ID.
@@ -10059,7 +10082,7 @@ object Http4s600 {
""".stripMargin,
counterpartyAttributeRequestJsonV600,
counterpartyAttributeResponseJsonV600,
- List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError),
+ List($AuthenticatedUserIsRequired, InvalidJsonFormat, $CounterpartyNotFoundByCounterpartyId, CounterpartyAttributeNotFound, UnknownError),
List(apiTagCounterpartyAttribute, apiTagApi),
Some(canUpdateCounterpartyAttribute :: Nil),
http4sPartialFunction = Some(updateCounterpartyAttribute)
diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala
index acb76f3992..99f90e0355 100644
--- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala
+++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala
@@ -1624,7 +1624,9 @@ object LocalMappedConnector extends Connector with MdcLoggable {
}
override def getCounterpartyTrait(bankId: BankId, accountId: AccountId, counterpartyId: String, callContext: Option[CallContext]): OBPReturnType[Box[CounterpartyTrait]] = {
- getCounterpartyByCounterpartyId(CounterpartyId(counterpartyId), callContext)
+ getCounterpartyByCounterpartyId(CounterpartyId(counterpartyId), callContext).map { case (counterparty, callContext) =>
+ (counterparty.filter(found => found.thisBankId == bankId.value && found.thisAccountId == accountId.value), callContext)
+ }
}
override def getCounterpartyByCounterpartyId(counterpartyId: CounterpartyId, callContext: Option[CallContext]): OBPReturnType[Box[CounterpartyTrait]] = Future {
@@ -5550,6 +5552,31 @@ object LocalMappedConnector extends Connector with MdcLoggable {
for {
(fromCounterparty, callContext) <- NewStyle.function.getCounterpartyByIbanAndBankAccountId(fromCounterpartyIban, toAccount.bankId, toAccount.accountId, callContext)
(fromAccount, callContext) <- NewStyle.function.getBankAccountFromCounterparty(fromCounterparty, false, callContext)
+ // Here the caller is the party being refunded, so the money comes from the other party.
+ // The caller was authorised only on their own account, so the other party may not be an
+ // account on this instance: OBP-API can't debit an account the caller has no access to.
+ // The other party is an external account, and the payment goes through an adapter
+ // (for example a SEPA recall that the other bank has accepted).
+ (otherPartyAccountOnThisInstance, callContext) <- checkBankAccountExists(fromAccount.bankId, fromAccount.accountId, callContext)
+ _ <- Helper.booleanToFuture(
+ s"$InsufficientAuthorisationToCreateTransactionRequest A REFUND can't debit the account BANK_ID(${fromAccount.bankId.value}) ACCOUNT_ID(${fromAccount.accountId.value}), because the caller is not authorised on it.",
+ cc = callContext) {
+ otherPartyAccountOnThisInstance.isEmpty
+ }
+ // Money from an external account can only arrive through an adapter that really collects
+ // it (the SEPA adapter routes makePaymentv210 to itself). When this connector would make
+ // the payment, it would credit the account from a stand-in for the external account with
+ // no money behind it, so the refund is refused.
+ paymentConnectorName = code.api.Constant.CONNECTOR.openOr("mapped") match {
+ case "star" => code.bankconnectors.getConnectorNameAndMethodRouting(
+ "makePaymentv210", Array[(String, AnyRef)]("bankId" -> toAccount.bankId.value))._2
+ case connectorName => connectorName
+ }
+ _ <- Helper.booleanToFuture(
+ s"$InvalidTransactionRequestType A REFUND that takes money from an external account needs an adapter that makes the payment (makePaymentv210); on this instance it would be made by the $paymentConnectorName connector.",
+ cc = callContext) {
+ paymentConnectorName != "mapped"
+ }
} yield (fromAccount, toAccount, callContext)
}
}
diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala
index dcc45a9ecb..e1d4b272fc 100644
--- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala
+++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala
@@ -816,7 +816,7 @@ object LocalMappedConnectorInternal extends MdcLoggable {
case Some(refundRequestTo) if refundRequestTo.counterparty_id.isDefined =>
val toCounterpartyId = CounterpartyId(refundRequestTo.counterparty_id.get)
for {
- (toCounterparty, callContext) <- NewStyle.function.getCounterpartyByCounterpartyId(toCounterpartyId, callContext)
+ (toCounterparty, callContext) <- NewStyle.function.getCounterpartyTrait(fromAccount.bankId, fromAccount.accountId, toCounterpartyId.value, callContext)
(toAccount, callContext) <- NewStyle.function.getBankAccountFromCounterparty(toCounterparty, isOutgoingAccount = true, callContext)
_ <- Helper.booleanToFuture(s"$CounterpartyBeneficiaryPermit", cc=callContext) {
toCounterparty.isBeneficiary
@@ -828,7 +828,7 @@ object LocalMappedConnectorInternal extends MdcLoggable {
val fromCounterpartyId = CounterpartyId(transactionRequestBodyRefundJson.from.get.counterparty_id)
val toAccount = fromAccount
for {
- (fromCounterparty, callContext) <- NewStyle.function.getCounterpartyByCounterpartyId(fromCounterpartyId, callContext)
+ (fromCounterparty, callContext) <- NewStyle.function.getCounterpartyTrait(toAccount.bankId, toAccount.accountId, fromCounterpartyId.value, callContext)
(fromAccount, callContext) <- NewStyle.function.getBankAccountFromCounterparty(fromCounterparty, isOutgoingAccount = false, callContext)
_ <- Helper.booleanToFuture(s"$CounterpartyBeneficiaryPermit", cc=callContext) {
fromCounterparty.isBeneficiary
@@ -844,10 +844,48 @@ object LocalMappedConnectorInternal extends MdcLoggable {
_ <- Helper.booleanToFuture(s"${RefundedTransaction} Current input amount is: '${transDetailsJson.value.amount}'. It can not be more than the original amount(${(transaction.amount).abs})", cc=callContext) {
(transaction.amount).abs >= transactionAmountNumber
}
- //TODO, we need additional field to guarantee the transaction is refunded...
- // _ <- Helper.booleanToFuture(s"${RefundedTransaction}") {
- // !((transaction.description.toString contains(" Refund to ")) && (transaction.description.toString contains(" and transaction_id(")))
- // }
+ // A refund must be for a payment between the account in the URL and the party named in the
+ // request. With `to`, the caller asks for money they paid out to be sent back, so the original
+ // transaction is a debit on their account. With `from`, the caller pays back money they
+ // received, so it is a credit. Either way its other party has to be the named party.
+ requestsMoneyBack = transactionRequestBodyRefundJson.to.isDefined
+ otherParty = if (requestsMoneyBack) toAccount else fromAccount
+ (otherPartyOnThisInstance, callContext) <- Connector.connector.vend.checkBankAccountExists(otherParty.bankId, otherParty.accountId, callContext)
+ _ <- Helper.booleanToFuture(s"$RefundTransactionNotWithThisParty Current TRANSACTION_ID(${transactionId.value})", cc=callContext) {
+ val wasPaidInTheRightDirection = if (requestsMoneyBack) transaction.amount < 0 else transaction.amount > 0
+ // The transaction records one routing of its other party (scheme and address) and that party's
+ // bank routing. The routing must be one of the named party's routings. Only an IBAN identifies
+ // an account on its own; any other routing (an account id, an account number) is unique only
+ // within its bank, so the bank must match too, and for an account on this instance an empty
+ // bank routing can't tell banks apart, so it is refused.
+ val originalScheme = transaction.otherAccount.otherAccountRoutingScheme.trim.toUpperCase
+ val originalAddress = transaction.otherAccount.otherAccountRoutingAddress.map(_.trim).getOrElse("")
+ val originalBank = transaction.otherAccount.otherBankRoutingAddress.map(_.trim).getOrElse("")
+ val otherPartyBank = otherParty.bankRoutingAddress.trim
+ val sameAccountRouting = originalAddress.nonEmpty && otherParty.accountRoutings.exists(routing =>
+ routing.scheme.trim.toUpperCase == originalScheme && routing.address.trim == originalAddress)
+ val sameBank =
+ if (originalScheme == AccountRoutingScheme.IBAN.toString) true
+ else if (otherPartyOnThisInstance.isDefined) originalBank.nonEmpty && originalBank == otherPartyBank
+ else originalBank == otherPartyBank
+ wasPaidInTheRightDirection && sameAccountRouting && sameBank
+ }
+
+ // A transaction can be refunded only once. Each refund records the transaction it refunds in its
+ // `original_transaction_id` attribute; a refund that was rejected, failed or cancelled doesn't count.
+ earlierRefundIds <- Future {
+ code.transactionRequestAttribute.TransactionRequestAttribute.findAll(
+ By(code.transactionRequestAttribute.TransactionRequestAttribute.BankId, bankId.value),
+ By(code.transactionRequestAttribute.TransactionRequestAttribute.Name, "original_transaction_id"),
+ By(code.transactionRequestAttribute.TransactionRequestAttribute.`Value`, transactionId.value)
+ ).map(_.transactionRequestId)
+ }
+ _ <- Helper.booleanToFuture(s"$RefundedTransaction Current TRANSACTION_ID(${transactionId.value})", cc=callContext) {
+ val endedWithoutPaying = Set(TransactionRequestStatus.REJECTED, TransactionRequestStatus.FAILED, TransactionRequestStatus.CANCELLED).map(_.toString)
+ earlierRefundIds
+ .flatMap(id => Connector.connector.vend.getTransactionRequestImpl(id, callContext).map(_._1).toList)
+ .forall(earlierRefund => endedWithoutPaying.contains(earlierRefund.status))
+ }
//we add the extra info (counterparty name + transaction_id) for this special Refund endpoint.
newDescription = s"${transactionRequestBodyRefundJson.description} - Refund for transaction_id: (${transactionId.value}) to ${transaction.otherAccount.counterpartyName}"
@@ -963,7 +1001,7 @@ object LocalMappedConnectorInternal extends MdcLoggable {
}
toCounterpartyId = transactionRequestBodyCounterparty.to.counterparty_id
_ <- Future { logger.debug(s"After extracting counterparty id: $toCounterpartyId") }
- (toCounterparty, callContext) <- NewStyle.function.getCounterpartyByCounterpartyId(CounterpartyId(toCounterpartyId), callContext)
+ (toCounterparty, callContext) <- NewStyle.function.getCounterpartyTrait(fromAccount.bankId, fromAccount.accountId, toCounterpartyId, callContext)
transactionRequestAttributes <- if(transactionRequestBodyCounterparty.attributes.isDefined && transactionRequestBodyCounterparty.attributes.head.length > 0 ) {
diff --git a/obp-api/src/main/scala/code/model/BankingData.scala b/obp-api/src/main/scala/code/model/BankingData.scala
index cec96a5e94..b6b1e7b4f4 100644
--- a/obp-api/src/main/scala/code/model/BankingData.scala
+++ b/obp-api/src/main/scala/code/model/BankingData.scala
@@ -432,7 +432,11 @@ case class BankAccountExtended(val bankAccount: BankAccount) extends MdcLoggable
APIUtil.hasAccountAccess(view, bankIdAccountId, user, callContext) match {
case Full(true) =>
for{
- (counterparty, callContext) <- Connector.connector.vend.getCounterpartyByCounterpartyId(CounterpartyId(counterpartyID), callContext)
+ (counterpartyFoundById, callContext) <- Connector.connector.vend.getCounterpartyByCounterpartyId(CounterpartyId(counterpartyID), callContext)
+ // The lookup by id searches every account, but the caller's access was checked for this account only,
+ // so a counterparty that belongs to another account is treated as not found.
+ counterparty = counterpartyFoundById.filter(found =>
+ found.thisBankId == bankIdAccountId.bankId.value && found.thisAccountId == bankIdAccountId.accountId.value)
moderateOtherAccount <- if(counterparty.isDefined) {
Future{
counterparty.map(BankAccountX.toInternalCounterparty).flatten.map(view.moderateOtherAccount).flatten
diff --git a/obp-api/src/main/scala/code/model/OAuth.scala b/obp-api/src/main/scala/code/model/OAuth.scala
index 6bcf38ac45..fd19f32b09 100644
--- a/obp-api/src/main/scala/code/model/OAuth.scala
+++ b/obp-api/src/main/scala/code/model/OAuth.scala
@@ -25,7 +25,7 @@ TESOBE (http://www.tesobe.com/)
*/
package code.model
-import code.api.util.CommonFunctions.validUri
+import code.api.util.CommonFunctions.{validRedirectUrls, validUri}
import code.api.util.migration.Migration.DbFunction
import code.api.util._
import code.consumer.{Consumers, ConsumersProvider}
@@ -279,7 +279,10 @@ object MappedConsumersProvider extends ConsumersProvider with MdcLoggable {
case None =>
}
redirectURL match {
- case Some(v) => c.redirectURL(v)
+ case Some(v) =>
+ // updateConsumer does not run c.validate, so the redirect URL rules are applied here.
+ RedirectUrlValidation.consumerRedirectUrlError(v, applyHostList = false).foreach(error => throw new Error(error))
+ c.redirectURL(v)
case None =>
}
logoURL match {
@@ -612,7 +615,7 @@ class Consumer extends LongKeyedMapper[Consumer] with CreatedUpdated{
}
object redirectURL extends MappedString(this, 250){
override def displayName = "Redirect URL:"
- override def validations = validUri(this) _ :: super.validations
+ override def validations = validRedirectUrls(this) _ :: super.validations
}
object logoUrl extends MappedString(this, 250){
@@ -658,21 +661,6 @@ class Consumer extends LongKeyedMapper[Consumer] with CreatedUpdated{
object Consumer extends Consumer with MdcLoggable with LongKeyedMetaMapper[Consumer] {
override def dbIndexes = UniqueIndex(key) :: UniqueIndex(azp, sub) :: super.dbIndexes
-
- def getRedirectURLByConsumerKey(consumerKey: String): String = {
- logger.debug("hello from getRedirectURLByConsumerKey")
- val consumer: Consumer = Consumers.consumers.vend.getConsumerByConsumerKey(consumerKey).openOrThrowException(s"OBP Consumer not found by consumerKey. You looked for $consumerKey Please check the database")
- logger.debug(s"getRedirectURLByConsumerKey found consumer with id: ${consumer.id}, name is: ${consumer.name}, isActive is ${consumer.isActive}")
- consumer.redirectURL.toString()
- }
-
- /**
- * match the flow style, it can be http, https, or Private-Use URI Scheme Redirection for app:
- * http://some.domain.com/path
- * https://some.domain.com/path
- * com.example.app:/oauth2redirect/example-provider
- */
- val redirectURLRegex = """^([.\w]+:|(http|https):/)/(www.)?\S+?(:\d{2,6})?\S*$""".r
}
object MappedNonceProvider extends NoncesProvider {
diff --git a/obp-api/src/main/scala/code/util/Helper.scala b/obp-api/src/main/scala/code/util/Helper.scala
index 9d149685ce..b24f696721 100644
--- a/obp-api/src/main/scala/code/util/Helper.scala
+++ b/obp-api/src/main/scala/code/util/Helper.scala
@@ -30,7 +30,7 @@ package code.util
import org.json4s._
import code.api.cache.{Redis, RedisLogger}
-import java.net.{Socket, SocketException, URL}
+import java.net.{Socket, SocketException}
import java.util.UUID.randomUUID
import java.util.Date
import code.api.util.{APIUtil, CallContext, CallContextLight, CustomJsonFormats}
@@ -41,7 +41,6 @@ import code.model.dataAccess.internalMapping.MappedAccountIdMappingProvider
import code.transaction.internalMapping.MappedTransactionIdMappingProvider
import net.liftweb.common._
import org.json4s.Extraction._
-import org.apache.commons.lang3.StringUtils
import com.openbankproject.commons.ExecutionContext.Implicits.global
import com.openbankproject.commons.model.{AccountBalance, AccountBalances, AccountHeld, AccountId, CoreAccount, Customer, CustomerId, Transaction, TransactionCore, TransactionId}
import com.openbankproject.commons.util.{ReflectUtils, RequiredFieldValidation, RequiredInfo}
@@ -198,50 +197,6 @@ object Helper extends Loggable {
}
- /**
- * @param redirectUrl eg: http://localhost:8082/callback?foo=bar
- * @return http://localhost:8082/callback
- */
- def getStaticPortionOfRedirectURL(redirectUrl: String): Box[String] = {
- tryo(redirectUrl.split("\\?")(0)) //return everything before the "?"
- }
-
- /**
- * extract the host-only portion of a redirect URL.
- *
- * @param redirectUrl -> http://localhost:8082/callback?foo=bar
- * @return hostOnlyOfRedirectURL -> http://localhost:8082
- */
- @deprecated("We can not only use hostname as the redirectUrl, now add new method `getStaticPortionOfRedirectURL` ","05.12.2023")
- def getHostOnlyOfRedirectURL(redirectUrl: String): Box[String] = {
- val url = new URL(redirectUrl)
- val protocol = url.getProtocol() // http
- val authority = url.getAuthority()// localhost:8082, this will contain the port.
- tryo(s"$protocol://$authority") // http://localhost:8082
- }
-
- /**
- * check the redirect url is valid with default values.
- */
- def isValidInternalRedirectUrl(url: String) : Boolean = {
- val internalRedirectUrlsWhiteList = List(
- "/",
- "/dummy-user-tokens","/create-sandbox-account",
- "/add-user-auth-context-update-request","/otp",
- "/terms-and-conditions", "/privacy-policy",
- "/confirm-bg-consent-request",
- "/confirm-bg-consent-request-sca",
- "/confirm-vrp-consent-request",
- "/confirm-vrp-consent",
- "/consent-screen",
- "/consent",
- )
-
- val extractCleanURL = StringUtils.substringBefore(url, "?")
-
- internalRedirectUrlsWhiteList.contains(extractCleanURL)
- }
-
/**
* Used for version extraction from props string
*/
diff --git a/obp-api/src/test/scala/code/api/berlin/group/signing/PSD2RequestSigner.scala b/obp-api/src/test/scala/code/api/berlin/group/signing/PSD2RequestSigner.scala
index edd61cc788..91b304d001 100644
--- a/obp-api/src/test/scala/code/api/berlin/group/signing/PSD2RequestSigner.scala
+++ b/obp-api/src/test/scala/code/api/berlin/group/signing/PSD2RequestSigner.scala
@@ -69,7 +69,7 @@ class PSD2RequestSigner(
* @param psuDeviceId Optional PSU device ID (default: "device-1234567890")
* @param psuDeviceName Optional PSU device name (default: "Kalina-PC")
* @param psuIpAddress Optional PSU IP address (default: "psu-service.local")
- * @param tppRedirectUri Optional TPP redirect URI (default: "tppapp://example.com/redirect")
+ * @param tppRedirectUri Optional TPP redirect URI (default: "com.example.tppapp://redirect", an app scheme in reverse-domain form)
* @param tppNokRedirectUri Optional TPP error redirect URI (default: "https://example.com/redirect")
* @return Map of HTTP headers for the signed request
*/
@@ -78,7 +78,7 @@ class PSD2RequestSigner(
psuDeviceId: String = "device-1234567890",
psuDeviceName: String = "Kalina-PC",
psuIpAddress: String = "psu-service.local", // Use DNS/hostname instead of raw IP
- tppRedirectUri: String = "tppapp://example.com/redirect",
+ tppRedirectUri: String = "com.example.tppapp://redirect",
tppNokRedirectUri: String = "https://example.com/redirect"
): Map[String, String] = {
@@ -180,7 +180,7 @@ trait PSD2SigningSupport {
psuDeviceId: String,
psuDeviceName: String,
psuIpAddress: String,
- tppRedirectUri: String = "tppapp://example.com/redirect",
+ tppRedirectUri: String = "com.example.tppapp://redirect",
tppNokRedirectUri: String = "https://example.com/redirect"
): Map[String, String] = {
psd2Signer.signRequest(requestBody, psuDeviceId, psuDeviceName, psuIpAddress, tppRedirectUri, tppNokRedirectUri)
diff --git a/obp-api/src/test/scala/code/api/util/BerlinGroupMandatoryHeadersTest.scala b/obp-api/src/test/scala/code/api/util/BerlinGroupMandatoryHeadersTest.scala
index f209fc2ff7..246b91ecd7 100644
--- a/obp-api/src/test/scala/code/api/util/BerlinGroupMandatoryHeadersTest.scala
+++ b/obp-api/src/test/scala/code/api/util/BerlinGroupMandatoryHeadersTest.scala
@@ -182,6 +182,78 @@ class BerlinGroupMandatoryHeadersTest extends BerlinGroupServerSetupV1_3 {
}
}
+ // ─── TPP redirect URI rules ──────────────────────────────────────────────
+
+ feature("BG TPP redirect URIs must meet the redirect URL rules") {
+
+ scenario("A javascript: TPP-Redirect-URI is rejected", MandatoryHeaders) {
+ val result = callValidate(bgConsentUrl, verb = "POST", headers = Map(
+ "X-Request-ID" -> UUID.randomUUID().toString,
+ "TPP-Redirect-URI" -> "javascript:alert(1)"
+ ))
+ result shouldBe a[Failure]
+ result.asInstanceOf[Failure].msg should include(ErrorMessages.InvalidRedirectUrl)
+ result.asInstanceOf[Failure].msg should include("TPP-Redirect-URI")
+ }
+
+ scenario("A plain-http public TPP-Nok-Redirect-URI is rejected, on any Berlin Group request", MandatoryHeaders) {
+ val result = callValidate(bgUrl, headers = Map(
+ "X-Request-ID" -> UUID.randomUUID().toString,
+ "TPP-Nok-Redirect-URI" -> "http://tpp.example.com/nok"
+ ))
+ result shouldBe a[Failure]
+ result.asInstanceOf[Failure].msg should include(ErrorMessages.InvalidRedirectUrl)
+ result.asInstanceOf[Failure].msg should include("TPP-Nok-Redirect-URI")
+ }
+
+ scenario("https and reverse-domain app scheme TPP redirect URIs are accepted", MandatoryHeaders) {
+ val result = callValidate(bgConsentUrl, verb = "POST", headers = Map(
+ "X-Request-ID" -> UUID.randomUUID().toString,
+ "TPP-Redirect-URI" -> "com.example.tppapp://redirect",
+ "TPP-Nok-Redirect-URI" -> "https://tpp.example.com/nok"
+ ))
+ result should not be a[Failure]
+ }
+ }
+
+ feature("BG TPP redirect URIs must be signed when berlin_group_require_signed_tpp_redirect_uri is true") {
+
+ scenario("A TPP-Redirect-URI that the signature does not cover is rejected", MandatoryHeaders) {
+ setPropsValues("berlin_group_require_signed_tpp_redirect_uri" -> "true")
+ val result = callValidate(bgConsentUrl, verb = "POST", headers = Map(
+ "X-Request-ID" -> UUID.randomUUID().toString,
+ "TPP-Redirect-URI" -> "https://tpp.example.com/redirect",
+ "Signature" -> """keyId="CA=CN=Test CA, SN=43A, O=Test", algorithm="rsa-sha256", headers="digest date x-request-id", signature="abc123==""""
+ ))
+ result shouldBe a[Failure]
+ result.asInstanceOf[Failure].msg should include(ErrorMessages.InvalidSignatureHeader)
+ result.asInstanceOf[Failure].msg should include("must cover TPP-Redirect-URI")
+ }
+
+ scenario("A TPP-Redirect-URI that the signature covers passes this check", MandatoryHeaders) {
+ setPropsValues("berlin_group_require_signed_tpp_redirect_uri" -> "true")
+ val result = callValidate(bgConsentUrl, verb = "POST", headers = Map(
+ "X-Request-ID" -> UUID.randomUUID().toString,
+ "TPP-Redirect-URI" -> "https://tpp.example.com/redirect",
+ "Signature" -> """keyId="CA=CN=Test CA, SN=43A, O=Test", algorithm="rsa-sha256", headers="digest date x-request-id tpp-redirect-uri", signature="abc123==""""
+ ))
+ // The signature itself is not verifiable here (no certificate), so a later check may still refuse it,
+ // but never for leaving the redirect header unsigned.
+ result match {
+ case failure: Failure => failure.msg should not include ("must cover")
+ case _ =>
+ }
+ }
+
+ scenario("An unsigned TPP-Redirect-URI is accepted when the prop is false", MandatoryHeaders) {
+ val result = callValidate(bgConsentUrl, verb = "POST", headers = Map(
+ "X-Request-ID" -> UUID.randomUUID().toString,
+ "TPP-Redirect-URI" -> "https://tpp.example.com/redirect"
+ ))
+ result should not be a[Failure]
+ }
+ }
+
// ─── Disabled check ───────────────────────────────────────────────────────
feature("BG mandatory headers - disabled when list is empty") {
diff --git a/obp-api/src/test/scala/code/api/util/RedirectUrlValidationTest.scala b/obp-api/src/test/scala/code/api/util/RedirectUrlValidationTest.scala
new file mode 100644
index 0000000000..224a2797fd
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/util/RedirectUrlValidationTest.scala
@@ -0,0 +1,140 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+
+package code.api.util
+
+import code.setup.PropsReset
+import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers}
+
+/**
+ * This suite checks the redirect URL rules that every Consumer redirect URL must meet before it is stored.
+ */
+class RedirectUrlValidationTest extends FeatureSpec with Matchers with GivenWhenThen with PropsReset {
+
+ feature("RedirectUrlValidation accepts the allowed forms") {
+ scenario("https, loopback http, reverse-domain app schemes, several entries and an empty value") {
+ List(
+ "https://a.com/cb",
+ "https://a.com:8443/cb?x=1",
+ "http://localhost:5173/cb",
+ "http://127.0.0.1:8080/cb",
+ "http://[::1]:8080/cb",
+ "com.example.app:/cb",
+ "x-com.tesobe.helloobp.ios://callback",
+ "https://a.com/cb https://b.com/cb",
+ "https://a.com/cb,http://localhost:3000/cb",
+ "",
+ " "
+ ).foreach { value =>
+ withClue(value) { RedirectUrlValidation.firstProblem(value) shouldBe None }
+ }
+ }
+ }
+
+ feature("RedirectUrlValidation rejects everything else") {
+ scenario("script schemes, wildcards, user information, fragments, public http and missing schemes") {
+ List(
+ "javascript:alert(1)",
+ "data:text/html;base64,PHNjcmlwdD4=",
+ "vbscript:x",
+ "file:///etc/passwd",
+ "https://a.com,javascript:x",
+ "https://*.a.com",
+ "https://user@evil.com",
+ "https://a.com/cb#fragment",
+ "http://example.com",
+ "myapp://callback",
+ "www.openbankproject.com",
+ "https:///cb"
+ ).foreach { value =>
+ withClue(value) { RedirectUrlValidation.firstProblem(value) should not be None }
+ }
+ }
+
+ scenario("the problem names the rejected entry") {
+ RedirectUrlValidation.firstProblem("https://a.com/cb javascript:alert(1)").get should startWith("'javascript:alert(1)'")
+ }
+ }
+
+ feature("RedirectUrlValidation.validEntries") {
+ scenario("keeps only the allowed entries of a stored value") {
+ RedirectUrlValidation.validEntries("https://a.com/cb,javascript:x http://example.com http://localhost/cb") shouldBe
+ List("https://a.com/cb", "http://localhost/cb")
+ }
+ }
+
+ feature("redirect_url_allowed_hosts") {
+ scenario("unset or empty, it puts no limit on hosts") {
+ setPropsValues("redirect_url_allowed_hosts" -> "")
+ RedirectUrlValidation.allowedHosts shouldBe Nil
+ RedirectUrlValidation.consumerRedirectUrlError("https://any-third-party.example/cb", applyHostList = true) shouldBe None
+ RedirectUrlValidation.allowedHostsDescription should include("any host")
+ }
+
+ scenario("set, it allows listed hosts, hosts under a listed domain, app schemes and the API's own host") {
+ setPropsValues(
+ "redirect_url_allowed_hosts" -> "portal.example.com, .bank.example",
+ "hostname" -> "https://api.example.org"
+ )
+ RedirectUrlValidation.allowedHosts should contain allOf ("portal.example.com", ".bank.example", "api.example.org")
+ List(
+ "https://portal.example.com/cb",
+ "https://PORTAL.example.com/cb",
+ "https://bank.example/cb",
+ "https://app.bank.example/cb",
+ "https://api.example.org/cb",
+ "com.example.app:/cb"
+ ).foreach { value =>
+ withClue(value) { RedirectUrlValidation.consumerRedirectUrlError(value, applyHostList = true) shouldBe None }
+ }
+ RedirectUrlValidation.allowedHostsDescription should include("portal.example.com")
+ }
+
+ scenario("set, it refuses other hosts with RedirectUrlHostNotAllowed") {
+ setPropsValues(
+ "redirect_url_allowed_hosts" -> "portal.example.com, .bank.example",
+ "hostname" -> "https://api.example.org"
+ )
+ List(
+ "https://evil.example/cb",
+ "https://portal.example.com.evil.example/cb",
+ "https://notbank.example/cb",
+ "http://localhost:5173/cb"
+ ).foreach { value =>
+ withClue(value) {
+ RedirectUrlValidation.consumerRedirectUrlError(value, applyHostList = true).get should startWith(ErrorMessages.RedirectUrlHostNotAllowed)
+ }
+ }
+ }
+
+ scenario("set, it is not applied where applyHostList is false, and a rule break still wins") {
+ setPropsValues("redirect_url_allowed_hosts" -> "portal.example.com")
+ RedirectUrlValidation.consumerRedirectUrlError("https://tpp.example/cb", applyHostList = false) shouldBe None
+ RedirectUrlValidation.consumerRedirectUrlError("javascript:alert(1)", applyHostList = true).get should startWith(ErrorMessages.InvalidRedirectUrl)
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v1_4_0/CrmEventsTest.scala b/obp-api/src/test/scala/code/api/v1_4_0/CrmEventsTest.scala
new file mode 100644
index 0000000000..f634ef2044
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v1_4_0/CrmEventsTest.scala
@@ -0,0 +1,109 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+
+package code.api.v1_4_0
+
+import java.util.Date
+
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ApiRole.CanGetCrmEvents
+import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles}
+import code.crm.MappedCrmEvent
+import code.entitlement.Entitlement
+import code.setup.{APIResponse, DefaultUsers}
+import org.json4s._
+
+/**
+ * This suite checks that getCrmEvents, which returns every CRM event at a bank for all customers, needs an
+ * authenticated User with the Role CanGetCrmEvents at that bank, and returns only that bank's events.
+ */
+class CrmEventsTest extends V140ServerSetup with DefaultUsers {
+
+ override def beforeEach(): Unit = {
+ super.beforeEach()
+ MappedCrmEvent.bulkDelete_!!()
+ }
+
+ override def afterEach(): Unit = {
+ super.afterEach()
+ MappedCrmEvent.bulkDelete_!!()
+ }
+
+ private def crmEventsAt(bankId: String) = (v1_4Request / "banks" / bankId / "crm-events").GET
+
+ private def messageOf(response: APIResponse): String = (response.body \ "message").extractOpt[String].getOrElse("")
+
+ private def createCrmEvent(crmEventId: String, bankId: String, customerName: String) =
+ MappedCrmEvent.create
+ .mCrmEventId(crmEventId)
+ .mBankId(bankId)
+ .mUserId(resourceUser2)
+ .mCustomerName(customerName)
+ .mCustomerNumber("1234")
+ .mScheduledDate(new Date(12340000))
+ .mActualDate(new Date(12340000))
+ .mChannel("PHONE")
+ .mDetail("Call about mortgage")
+ .mResult("No answer")
+ .mCategory("Category X")
+ .saveMe()
+
+ feature("getCrmEvents needs authentication and the Role CanGetCrmEvents") {
+
+ scenario("an anonymous caller is refused with 401") {
+ createCrmEvent("CRM-EVENT-1", testBankId1.value, "Customer One")
+ val response = makeGetRequest(crmEventsAt(testBankId1.value))
+ response.code should equal(401)
+ messageOf(response) should include(AuthenticatedUserIsRequired)
+ }
+
+ scenario("a User without the Role is refused with 403") {
+ createCrmEvent("CRM-EVENT-1", testBankId1.value, "Customer One")
+ val response = makeGetRequest(crmEventsAt(testBankId1.value) <@ (user1))
+ response.code should equal(403)
+ messageOf(response) should include(UserHasMissingRoles)
+ messageOf(response) should include(CanGetCrmEvents.toString)
+ }
+
+ scenario("a User with the Role at another bank is refused with 403") {
+ Entitlement.entitlement.vend.addEntitlement(testBankId2.value, resourceUser1.userId, CanGetCrmEvents.toString)
+ val response = makeGetRequest(crmEventsAt(testBankId1.value) <@ (user1))
+ response.code should equal(403)
+ }
+
+ scenario("a User with the Role at the bank gets that bank's events only") {
+ createCrmEvent("CRM-EVENT-1", testBankId1.value, "Customer One")
+ createCrmEvent("CRM-EVENT-2", testBankId2.value, "Customer Two")
+ Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanGetCrmEvents.toString)
+ val response = makeGetRequest(crmEventsAt(testBankId1.value) <@ (user1))
+ response.code should equal(200)
+ val events = (response.body \ "crm_events").children
+ events.map(event => (event \ "id").extract[String]) should equal(List("CRM-EVENT-1"))
+ (events.head \ "customer_name").extract[String] should equal("Customer One")
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v2_0_0/AccountTest.scala b/obp-api/src/test/scala/code/api/v2_0_0/AccountTest.scala
index 84a9997dcb..54168aabe0 100644
--- a/obp-api/src/test/scala/code/api/v2_0_0/AccountTest.scala
+++ b/obp-api/src/test/scala/code/api/v2_0_0/AccountTest.scala
@@ -27,6 +27,8 @@ TESOBE (http://www.tesobe.com/)
package code.api.v2_0_0
+import code.accountholders.AccountHolders
+import com.openbankproject.commons.model.AccountId
import org.json4s._
import code.api.util.APIUtil.OAuth._
import code.api.util.ErrorMessages
@@ -177,4 +179,28 @@ class AccountTest extends V200ServerSetup with DefaultUsers with PrivateUser2Acc
}
}
+ private def createAccountBodyFor(userId: String) =
+ CreateAccountJSON(userId, "CURRENT", mockAccountLabel1, AmountOfMoneyJSON121("EUR", "0"))
+
+ feature("Create Account refuses an ACCOUNT_ID that already exists - v2.0.0") {
+
+ scenario("a second User PUTting an existing ACCOUNT_ID gets 409 AccountIdAlreadyExists and does not become a holder") {
+ val existingAccountId = "EXISTING_ACCOUNT_ID_V200"
+ val createdByUser1 = makePutRequest(
+ (v2_0Request / "banks" / testBankId1.value / "accounts" / existingAccountId).PUT <@ (user1),
+ write(createAccountBodyFor(resourceUser1.userId)))
+ createdByUser1.code should equal(200)
+
+ val attemptByUser2 = makePutRequest(
+ (v2_0Request / "banks" / testBankId1.value / "accounts" / existingAccountId).PUT <@ (user2),
+ write(createAccountBodyFor(resourceUser2.userId)))
+ attemptByUser2.code should equal(409)
+ (attemptByUser2.body \ "message").extract[String] should include(ErrorMessages.AccountIdAlreadyExists)
+
+ val holderUserIds = AccountHolders.accountHolders.vend
+ .getAccountHolders(testBankId1, AccountId(existingAccountId)).map(_.userId)
+ holderUserIds should contain(resourceUser1.userId)
+ holderUserIds should not contain(resourceUser2.userId)
+ }
+ }
}
diff --git a/obp-api/src/test/scala/code/api/v2_0_0/TransactionTypesIsPublicTest.scala b/obp-api/src/test/scala/code/api/v2_0_0/TransactionTypesIsPublicTest.scala
new file mode 100644
index 0000000000..d47c98fbfa
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v2_0_0/TransactionTypesIsPublicTest.scala
@@ -0,0 +1,67 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+
+package code.api.v2_0_0
+
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ErrorMessages.AuthenticatedUserIsRequired
+import code.setup.{APIResponse, DefaultUsers}
+import org.json4s._
+
+/**
+ * This suite checks that getTransactionTypes (GET /banks/BANK_ID/transaction-types) honours the prop
+ * apiOptions.getTransactionTypesIsPublic: anyone may call it while the prop is true (the default), and only a
+ * logged-in User may call it when the prop is false.
+ */
+class TransactionTypesIsPublicTest extends V200ServerSetup with DefaultUsers {
+
+ private def transactionTypesAt(bankId: String) = (v2_0Request / "banks" / bankId / "transaction-types").GET
+
+ private def messageOf(response: APIResponse): String = (response.body \ "message").extractOpt[String].getOrElse("")
+
+ feature("getTransactionTypes honours apiOptions.getTransactionTypesIsPublic") {
+
+ scenario("an anonymous caller gets the transaction types while the prop is true") {
+ setPropsValues("apiOptions.getTransactionTypesIsPublic" -> "true")
+ val response = makeGetRequest(transactionTypesAt(testBankId1.value))
+ response.code should equal(200)
+ }
+
+ scenario("an anonymous caller is refused with 401 when the prop is false") {
+ setPropsValues("apiOptions.getTransactionTypesIsPublic" -> "false")
+ val response = makeGetRequest(transactionTypesAt(testBankId1.value))
+ response.code should equal(401)
+ messageOf(response) should include(AuthenticatedUserIsRequired)
+ }
+
+ scenario("a logged-in User gets the transaction types when the prop is false") {
+ setPropsValues("apiOptions.getTransactionTypesIsPublic" -> "false")
+ val response = makeGetRequest(transactionTypesAt(testBankId1.value) <@ (user1))
+ response.code should equal(200)
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v2_1_0/UpdateConsumerRedirectUrlTest.scala b/obp-api/src/test/scala/code/api/v2_1_0/UpdateConsumerRedirectUrlTest.scala
index 18659dc731..12db38cb23 100644
--- a/obp-api/src/test/scala/code/api/v2_1_0/UpdateConsumerRedirectUrlTest.scala
+++ b/obp-api/src/test/scala/code/api/v2_1_0/UpdateConsumerRedirectUrlTest.scala
@@ -68,6 +68,25 @@ class UpdateConsumerRedirectUrlTest extends V210ServerSetup with DefaultUsers {
error should equal(UserHasMissingRoles + CanUpdateConsumerRedirectUrl)
}
+ scenario("Try to Update Redirect Url without proper role when consumers are enabled by default") {
+ // The role used to be checked only when consumers_enabled_by_default was false,
+ // so on an instance with the prop set to true the creator could change it without the role.
+ setPropsValues("consumers_enabled_by_default" -> "true")
+
+ When("We make the request Update Redirect Url for a Consumer")
+ val requestPut = (v2_1Request / "management" / "consumers" / testConsumer.id.get / "consumer" / "redirect_url" ).PUT <@ (user1)
+ val responsePut = makePutRequest(requestPut, write(consumerRedirectUrlJSON))
+
+ Then("We should get a 403")
+ responsePut.code should equal(403)
+
+ val error = (responsePut.body \ "message" ) match {
+ case JString(i) => i
+ case _ => ""
+ }
+ error should equal(UserHasMissingRoles + CanUpdateConsumerRedirectUrl)
+ }
+
scenario("Try to Update Redirect Url created by other user ") {
Then("We add entitlement to user2")
diff --git a/obp-api/src/test/scala/code/api/v2_2_0/AccountTest.scala b/obp-api/src/test/scala/code/api/v2_2_0/AccountTest.scala
index 62261c6395..3eb6046d92 100644
--- a/obp-api/src/test/scala/code/api/v2_2_0/AccountTest.scala
+++ b/obp-api/src/test/scala/code/api/v2_2_0/AccountTest.scala
@@ -27,6 +27,8 @@ TESOBE (http://www.tesobe.com/)
package code.api.v2_2_0
+import code.accountholders.AccountHolders
+import com.openbankproject.commons.model.AccountId
import org.json4s._
import code.api.Constant
import code.api.util.APIUtil.OAuth._
@@ -224,4 +226,34 @@ class AccountTest extends V220ServerSetup with DefaultUsers {
}
}
+ private def createAccountBodyFor(userId: String) =
+ CreateAccountJSONV220(
+ user_id = userId,
+ label = "Label",
+ `type` = "CURRENT",
+ balance = AmountOfMoneyJsonV121("EUR", "0"),
+ branch_id = "1234",
+ account_routing = AccountRoutingJsonV121(scheme = "OBP", address = "UK123456"))
+
+ feature("Create Account refuses an ACCOUNT_ID that already exists - v2.2.0") {
+
+ scenario("a second User PUTting an existing ACCOUNT_ID gets 409 AccountIdAlreadyExists and does not become a holder") {
+ val existingAccountId = "EXISTING_ACCOUNT_ID_V220"
+ val createdByUser1 = makePutRequest(
+ (v2_2Request / "banks" / testBankId1.value / "accounts" / existingAccountId).PUT <@ (user1),
+ write(createAccountBodyFor(resourceUser1.userId)))
+ createdByUser1.code should equal(200)
+
+ val attemptByUser2 = makePutRequest(
+ (v2_2Request / "banks" / testBankId1.value / "accounts" / existingAccountId).PUT <@ (user2),
+ write(createAccountBodyFor(resourceUser2.userId)))
+ attemptByUser2.code should equal(409)
+ (attemptByUser2.body \ "message").extract[String] should include(ErrorMessages.AccountIdAlreadyExists)
+
+ val holderUserIds = AccountHolders.accountHolders.vend
+ .getAccountHolders(testBankId1, AccountId(existingAccountId)).map(_.userId)
+ holderUserIds should contain(resourceUser1.userId)
+ holderUserIds should not contain(resourceUser2.userId)
+ }
+ }
}
diff --git a/obp-api/src/test/scala/code/api/v3_0_0/CounterpartyTest.scala b/obp-api/src/test/scala/code/api/v3_0_0/CounterpartyTest.scala
index d395bfac12..4c6a35745f 100644
--- a/obp-api/src/test/scala/code/api/v3_0_0/CounterpartyTest.scala
+++ b/obp-api/src/test/scala/code/api/v3_0_0/CounterpartyTest.scala
@@ -31,6 +31,10 @@ import code.api.Constant._
import com.openbankproject.commons.util.ApiVersion
import code.api.v3_0_0.Http4s300.Implementations3_0_0
import com.github.dwickern.macros.NameOf.nameOf
+import code.api.util.ErrorMessages.CounterpartyNotFound
+import code.metadata.counterparties.Counterparties
+import code.api.v2_0_0.BasicAccountsJSON
+import com.openbankproject.commons.model.{AccountId, BankId, ErrorMessage}
import org.scalatest.Tag
class CounterpartyTest extends V300ServerSetup {
@@ -74,4 +78,41 @@ class CounterpartyTest extends V300ServerSetup {
}
}
+ feature("Get Other Account by Id only returns counterparties of the account in the URL - V300") {
+ scenario("A counterparty of the account in the URL is returned", VersionOfApi, ApiEndpoint2) {
+ Given("A counterparty that belongs to the account in the URL")
+ val bankId = randomBankId
+ val accountId = randomPrivateAccountId(bankId)
+ val counterparty = createCounterparty(bankId, accountId, accountId, true, resourceUser1.userId)
+ Counterparties.counterparties.vend.getOrCreateMetadata(
+ BankId(bankId), AccountId(accountId), counterparty.counterpartyId, counterparty.name)
+
+ When("We ask for it through that account")
+ val response = getOtherAccountByIdForBankAccount(bankId, accountId, SYSTEM_OWNER_VIEW_ID, counterparty.counterpartyId, user1)
+
+ Then("We should get a 200 with that counterparty")
+ response.code should equal(200)
+ response.body.extract[OtherAccountJsonV300].id should equal(counterparty.counterpartyId)
+ }
+
+ scenario("A counterparty of a different account is not found", VersionOfApi, ApiEndpoint2) {
+ Given("A counterparty that belongs to a different account than the one in the URL")
+ val bankId = randomBankId
+ val accountIds = getPrivateAccounts(bankId, user1).body.extract[BasicAccountsJSON].accounts.map(_.id)
+ accountIds.size should be >= 2
+ val accountInUrl = accountIds(0)
+ val otherAccount = accountIds(1)
+ val counterparty = createCounterparty(bankId, otherAccount, otherAccount, true, resourceUser1.userId)
+ Counterparties.counterparties.vend.getOrCreateMetadata(
+ BankId(bankId), AccountId(otherAccount), counterparty.counterpartyId, counterparty.name)
+
+ When("We ask for it through the account in the URL")
+ val response = getOtherAccountByIdForBankAccount(bankId, accountInUrl, SYSTEM_OWNER_VIEW_ID, counterparty.counterpartyId, user1)
+
+ Then("We should get a 400 CounterpartyNotFound, even though the caller can also see the other account")
+ response.code should equal(400)
+ response.body.extract[ErrorMessage].message should startWith(CounterpartyNotFound)
+ }
+ }
+
}
diff --git a/obp-api/src/test/scala/code/api/v3_1_0/UserAuthContextUpdateTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/UserAuthContextUpdateTest.scala
index 2e4664fac1..10c372edc1 100644
--- a/obp-api/src/test/scala/code/api/v3_1_0/UserAuthContextUpdateTest.scala
+++ b/obp-api/src/test/scala/code/api/v3_1_0/UserAuthContextUpdateTest.scala
@@ -36,6 +36,8 @@ import code.consumer.Consumers
import code.context.UserAuthContextUpdateProvider
import code.entitlement.Entitlement
import code.scope.Scope
+import code.usercustomerlinks.UserCustomerLink
+import code.api.util.ErrorMessages
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.model.UserAuthContextUpdateStatus
import com.openbankproject.commons.model.enums.StrongCustomerAuthentication
@@ -110,6 +112,42 @@ class UserAuthContextUpdateTest extends V310ServerSetup {
responseUserAuthContextUpdate310.code should equal(200)
val status = responseUserAuthContextUpdate310.body.extract[UserAuthContextUpdateJson].status
status should equal(UserAuthContextUpdateStatus.REJECTED.toString)
+
+ And("A wrong answer must not link the User to the Customer")
+ UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser1.userId, infoPost.customer_id).isDefined should equal(false)
+ }
+ scenario("Another User cannot answer, or use up, the challenge of a User's update", ApiEndpoint1, ApiEndpoint2, VersionOfApi) {
+ val bankId = randomBankId
+ val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(user1.get._1.key).map(_.id.get.toString).getOrElse("")
+ Scope.scope.vend.addScope(bankId, consumerId, ApiRole.canCreateUserAuthContextUpdate.toString())
+ Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateCustomer.toString)
+ val response310 = makePostRequest((v3_1_0_Request / "banks" / bankId / "customers").POST <@(user1), write(postCustomerJson))
+ response310.code should equal(201)
+ val infoPost = response310.body.extract[CustomerJsonV310]
+
+ val scaMethod = StrongCustomerAuthentication.SMS.toString
+ val createResponse = makePostRequest(
+ (v3_1_0_Request / "banks" / bankId / "users" / "current" / "auth-context-updates" / scaMethod).POST <@(user1),
+ write(postUserAuthContextJson.copy(value = infoPost.customer_number)))
+ createResponse.code should equal(201)
+ val authContextUpdateId = createResponse.body.extract[UserAuthContextUpdateJson].user_auth_context_update_id
+ val challenge = UserAuthContextUpdateProvider.userAuthContextUpdateProvider.vend.getUserAuthContextUpdatesBox(resourceUser1.userId)
+ .toList.flatten.filter(_.userAuthContextUpdateId == authContextUpdateId).map(_.challenge).head
+
+ When("User2 answers User1's update, even with the right answer")
+ val answerPath = v3_1_0_Request / "banks" / bankId / "users" / "current" / "auth-context-updates" / authContextUpdateId / "challenge"
+ val responseOfUser2 = makePostRequest(answerPath.POST <@(user2), write(PostUserAuthContextUpdateJsonV310(answer = challenge)))
+ Then("It is refused as not found, and nobody is linked to the Customer")
+ responseOfUser2.code should equal(400)
+ (responseOfUser2.body \ "message").extract[String] should include(ErrorMessages.UserAuthContextUpdateNotFound)
+ UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser2.userId, infoPost.customer_id).isDefined should equal(false)
+ UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser1.userId, infoPost.customer_id).isDefined should equal(false)
+
+ And("User1 can still answer it, because the challenge was not used up")
+ val responseOfUser1 = makePostRequest(answerPath.POST <@(user1), write(PostUserAuthContextUpdateJsonV310(answer = challenge)))
+ responseOfUser1.code should equal(200)
+ responseOfUser1.body.extract[UserAuthContextUpdateJson].status should equal(UserAuthContextUpdateStatus.ACCEPTED.toString)
+ UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser1.userId, infoPost.customer_id).isDefined should equal(true)
}
scenario("We will call the Answer endpoint with user credentials and right challenge answer", ApiEndpoint1, ApiEndpoint2, VersionOfApi) {
When("We try to answer the User Auth Context Update v3.1.0")
@@ -150,7 +188,7 @@ class UserAuthContextUpdateTest extends V310ServerSetup {
When("We try to make the GET request v3.1.0")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetUserAuthContext.toString)
- val successGetReq = (v3_1_0_Request / "users" / userId1.value / "auth-context").GET <@(user1)
+ val successGetReq = (v3_1_0_Request / "users" / resourceUser1.userId / "auth-context").GET <@(user1)
val successGetRes = makeGetRequest(successGetReq)
Then("We should get a 200")
successGetRes.code should equal(200)
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/CounterpartyTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/CounterpartyTest.scala
index 1b44715d72..461502c946 100644
--- a/obp-api/src/test/scala/code/api/v4_0_0/CounterpartyTest.scala
+++ b/obp-api/src/test/scala/code/api/v4_0_0/CounterpartyTest.scala
@@ -35,6 +35,7 @@ import code.api.util.ApiRole._
import code.api.util.ErrorMessages
import code.api.v4_0_0.Http4s400.Implementations4_0_0
import code.entitlement.Entitlement
+import code.metadata.counterparties.Counterparties
import code.views.Views
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.model.{AccountId, ErrorMessage, ViewId, BankIdAccountIdViewId}
@@ -417,7 +418,9 @@ class CounterpartyTest extends V400ServerSetup {
}
Then(s"we can test the $ApiEndpoint7")
- val requestDelete = (v4_0_0_Request / "banks" / bankId.value / "accounts" / accountId.value / viewId.value / "counterparties" / "xxx" ).DELETE <@ (user1)
+ // A real counterparty of this account, so that the view's missing permission is what refuses the request.
+ val existingCounterpartyId = createCounterparty(bankId.value, accountId.value, accountId.value, true, resourceUser1.userId).counterpartyId
+ val requestDelete = (v4_0_0_Request / "banks" / bankId.value / "accounts" / accountId.value / viewId.value / "counterparties" / existingCounterpartyId ).DELETE <@ (user1)
val responseDelete = makeDeleteRequest(requestDelete)
responseDelete.code should equal(403)
responseDelete.body.toString contains(s"${ErrorMessages.NoViewPermission}")
@@ -427,4 +430,63 @@ class CounterpartyTest extends V400ServerSetup {
}
+ feature("A counterparty is only reachable through its own account") {
+
+ // A counterparty of testAccount0, addressed through testAccount1. user1 has the owner view on both,
+ // so only the rule that a counterparty belongs to one account can refuse these requests.
+ def counterpartyOfTestAccount0(): String = {
+ val counterparty = createCounterparty(testBankId1.value, testAccountId0.value, testAccountId0.value, true, resourceUser1.userId)
+ Counterparties.counterparties.vend.getOrCreateMetadata(
+ testBankId1, testAccountId0, counterparty.counterpartyId, counterparty.name)
+ counterparty.counterpartyId
+ }
+ def stillExists(counterpartyId: String): Boolean =
+ Counterparties.counterparties.vend.getCounterparty(counterpartyId).isDefined
+ val accountUrl = v4_0_0_Request / "banks" / testBankId1.value / "accounts" / testAccountId1.value / SYSTEM_OWNER_VIEW_ID
+ val managementUrl = v4_0_0_Request / "management" / "banks" / testBankId1.value / "accounts" / testAccountId1.value / SYSTEM_OWNER_VIEW_ID
+
+ scenario("Get and Delete through a view refuse a counterparty of another account", VersionOfApi, ApiEndpoint6, ApiEndpoint7) {
+ val counterpartyId = counterpartyOfTestAccount0()
+
+ val getResponse = makeGetRequest((accountUrl / "counterparties" / counterpartyId).GET <@ user1)
+ getResponse.code should equal(400)
+ getResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId)
+
+ val deleteResponse = makeDeleteRequest((accountUrl / "counterparties" / counterpartyId).DELETE <@ user1)
+ deleteResponse.code should equal(400)
+ deleteResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId)
+ stillExists(counterpartyId) should equal(true)
+ }
+
+ scenario("The management Get and Delete refuse a counterparty of another account", VersionOfApi, ApiEndpoint3, ApiEndpoint4) {
+ val counterpartyId = counterpartyOfTestAccount0()
+ val entitlements = List(CanGetCounterparty, CanDeleteCounterparty).map(role =>
+ Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, role.toString))
+
+ val getResponse = makeGetRequest((managementUrl / "counterparties" / counterpartyId).GET <@ user1)
+ getResponse.code should equal(400)
+ getResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId)
+
+ val deleteResponse = makeDeleteRequest((managementUrl / "counterparties" / counterpartyId).DELETE <@ user1)
+ deleteResponse.code should equal(400)
+ deleteResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId)
+ stillExists(counterpartyId) should equal(true)
+
+ entitlements.foreach(Entitlement.entitlement.vend.deleteEntitlement(_))
+ }
+
+ scenario("A COUNTERPARTY transaction request refuses a counterparty of another account", VersionOfApi) {
+ val counterpartyId = counterpartyOfTestAccount0()
+ val body = code.api.v2_1_0.TransactionRequestBodyCounterpartyJSON(
+ code.api.v2_1_0.CounterpartyIdJson(counterpartyId),
+ com.openbankproject.commons.model.AmountOfMoneyJsonV121("EUR", "1"),
+ "Pay a counterparty of another account",
+ "SHARED")
+
+ val response = makePostRequest(
+ (accountUrl / "transaction-request-types" / "COUNTERPARTY" / "transaction-requests").POST <@ user1, write(body))
+ response.code should equal(400)
+ response.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId)
+ }
+ }
}
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/RefundTransactionRequestTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/RefundTransactionRequestTest.scala
new file mode 100644
index 0000000000..5509c1edc2
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v4_0_0/RefundTransactionRequestTest.scala
@@ -0,0 +1,264 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+
+package code.api.v4_0_0
+
+import code.api.Constant.SYSTEM_OWNER_VIEW_ID
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ErrorMessages
+import code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140
+import code.api.v2_0_0.TransactionRequestBodyJsonV200
+import code.metadata.counterparties.Counterparties
+import code.model.BankAccountX
+import com.openbankproject.commons.model.{AccountId, AmountOfMoneyJsonV121, BankId, ErrorMessage}
+import com.openbankproject.commons.util.ApiVersion
+import org.json4s.native.Serialization.write
+import org.scalatest.Tag
+
+/**
+ * This suite covers the checks on a REFUND transaction request. A refund either pays back money the
+ * account received (`from`) or asks for money the account paid out to be sent back (`to`). In both
+ * cases OBP-API may only debit the account the caller is authorised on, the refund must be for a
+ * payment between the account and the named party, and a payment can be refunded only once.
+ */
+class RefundTransactionRequestTest extends V400ServerSetup {
+
+ object VersionOfApi extends Tag(ApiVersion.v4_0_0.toString)
+
+ // The caller's account (A) and the other party (B), both at the same bank. user1 has the owner view
+ // on both, so only the refund rules themselves can refuse the requests below.
+ val bankId = testBankId1
+ val callerAccountId = testAccountId1
+ val otherPartyAccountId = testAccountId0
+ // An account that has nothing to do with the payment being refunded.
+ val unrelatedBankId = testBankId2
+ val unrelatedAccountId = testAccountId0
+
+ def transactionRequestsUrl(bank: BankId, account: AccountId, transactionRequestType: String) =
+ v4_0_0_Request / "banks" / bank.value / "accounts" / account.value / SYSTEM_OWNER_VIEW_ID /
+ "transaction-request-types" / transactionRequestType / "transaction-requests"
+
+ override def beforeEach(): Unit = {
+ super.beforeEach()
+ setPropsValues(
+ "transactionRequests_supported_types" -> "SANDBOX_TAN,COUNTERPARTY,SEPA,ACCOUNT_OTP,ACCOUNT,SIMPLE,REFUND",
+ "REFUND_OTP_INSTRUCTION_TRANSPORT" -> "DUMMY")
+ }
+
+ /** This pays 1 EUR from the caller's account to the other party and returns the transaction id. */
+ def payTheOtherParty(): String = {
+ val body = TransactionRequestBodyJsonV200(
+ TransactionRequestAccountJsonV140(bankId.value, otherPartyAccountId.value),
+ AmountOfMoneyJsonV121("EUR", "1"),
+ "A payment that may later be refunded")
+ val response = makePostRequest(transactionRequestsUrl(bankId, callerAccountId, "ACCOUNT").POST <@ user1, write(body))
+ response.code should equal(201)
+ val created = response.body.extract[TransactionRequestWithChargeJSON400]
+ created.transaction_ids.headOption.getOrElse(fail(s"The payment did not complete: ${created.status}"))
+ }
+
+ def refundBody(transactionId: String, to: Option[TransactionRequestRefundTo], from: Option[TransactionRequestRefundFrom]) =
+ TransactionRequestBodyRefundJsonV400(
+ to = to,
+ from = from,
+ value = AmountOfMoneyJsonV121("EUR", "1"),
+ description = "Refund",
+ refund = RefundJson(transaction_id = transactionId, reason_code = "CUST"))
+
+ def askForMoneyBack(transactionId: String, fromBank: BankId, fromAccount: AccountId) =
+ makePostRequest(
+ transactionRequestsUrl(bankId, callerAccountId, "REFUND").POST <@ user1,
+ write(refundBody(transactionId, Some(TransactionRequestRefundTo(Some(fromBank.value), Some(fromAccount.value), None)), None)))
+
+ /**
+ * This creates a beneficiary counterparty on the caller's account that points at the other party's
+ * account, with that account's id as its IBAN: the shape the challenge-answer step looks for when it
+ * works out which account a `to` refund debits.
+ */
+ def counterpartyOnCallerAccountPointingAtOtherParty(): String =
+ Counterparties.counterparties.vend.createCounterparty(
+ createdByUserId = resourceUser1.userId,
+ thisBankId = bankId.value,
+ thisAccountId = callerAccountId.value,
+ thisViewId = "",
+ name = java.util.UUID.randomUUID.toString,
+ otherAccountRoutingScheme = "OBP",
+ otherAccountRoutingAddress = otherPartyAccountId.value,
+ otherBankRoutingScheme = "OBP",
+ otherBankRoutingAddress = bankId.value,
+ otherBranchRoutingScheme = "OBP",
+ otherBranchRoutingAddress = "Berlin",
+ isBeneficiary = true,
+ otherAccountSecondaryRoutingScheme = "IBAN",
+ otherAccountSecondaryRoutingAddress = otherPartyAccountId.value,
+ description = "",
+ currency = "EUR",
+ bespoke = Nil
+ ).map(_.counterpartyId).openOrThrowException("The counterparty could not be created")
+
+ /**
+ * This creates a beneficiary counterparty on the caller's account for an account at another bank, which
+ * OBP-API represents by a stand-in account built from the counterparty's routings, and pays it 1 EUR.
+ * It returns the counterparty id and the transaction id.
+ */
+ def payAnExternalParty(): (String, String) = {
+ // On the local connector, money to or from another bank passes through the default bank's
+ // settlement accounts, so the test bank needs them.
+ List(code.api.Constant.INCOMING_SETTLEMENT_ACCOUNT_ID, code.api.Constant.OUTGOING_SETTLEMENT_ACCOUNT_ID).foreach { settlementAccountId =>
+ if (code.model.dataAccess.MappedBankAccount.find(
+ net.liftweb.mapper.By(code.model.dataAccess.MappedBankAccount.bank, code.api.util.APIUtil.defaultBankId),
+ net.liftweb.mapper.By(code.model.dataAccess.MappedBankAccount.theAccountId, settlementAccountId)).isEmpty)
+ code.model.dataAccess.MappedBankAccount.create
+ .bank(code.api.util.APIUtil.defaultBankId).theAccountId(settlementAccountId).accountCurrency("EUR").saveMe()
+ }
+ val externalAccountReference = "EXTERNAL-" + java.util.UUID.randomUUID.toString.take(8)
+ val counterpartyId = Counterparties.counterparties.vend.createCounterparty(
+ createdByUserId = resourceUser1.userId,
+ thisBankId = bankId.value,
+ thisAccountId = callerAccountId.value,
+ thisViewId = "",
+ name = java.util.UUID.randomUUID.toString,
+ otherAccountRoutingScheme = "BBAN",
+ otherAccountRoutingAddress = externalAccountReference,
+ otherBankRoutingScheme = "BIC",
+ otherBankRoutingAddress = "EXTERNALXXX",
+ otherBranchRoutingScheme = "",
+ otherBranchRoutingAddress = "",
+ isBeneficiary = true,
+ otherAccountSecondaryRoutingScheme = "IBAN",
+ otherAccountSecondaryRoutingAddress = externalAccountReference,
+ description = "",
+ currency = "EUR",
+ bespoke = Nil
+ ).map(_.counterpartyId).openOrThrowException("The counterparty could not be created")
+ val body = code.api.v2_1_0.TransactionRequestBodyCounterpartyJSON(
+ code.api.v2_1_0.CounterpartyIdJson(counterpartyId), AmountOfMoneyJsonV121("EUR", "1"), "A payment to another bank", "SHARED")
+ val response = makePostRequest(transactionRequestsUrl(bankId, callerAccountId, "COUNTERPARTY").POST <@ user1, write(body))
+ response.code should equal(201)
+ val created = response.body.extract[TransactionRequestWithChargeJSON400]
+ (counterpartyId, created.transaction_ids.headOption.getOrElse(fail(s"The payment did not complete: ${created.status}")))
+ }
+
+ def balanceOf(account: AccountId): BigDecimal =
+ BankAccountX(bankId, account).map(_.balance).openOrThrowException("Account not found")
+
+ feature("A REFUND only debits the account the caller is authorised on") {
+
+ scenario("Answering the challenge of a `to` refund does not debit another account on this instance", VersionOfApi) {
+ Given("A payment from the caller to the other party, and a refund request asking for it back")
+ val transactionId = payTheOtherParty()
+ counterpartyOnCallerAccountPointingAtOtherParty()
+ val refundResponse = askForMoneyBack(transactionId, bankId, otherPartyAccountId)
+ refundResponse.code should equal(201)
+ val refund = refundResponse.body.extract[TransactionRequestWithChargeJSON400]
+ val challenge = refund.challenges.find(_.user_id == resourceUser1.userId).getOrElse(fail("No challenge for the caller"))
+ val otherPartyBalanceBefore = balanceOf(otherPartyAccountId)
+
+ When("The caller answers the challenge on their own account")
+ val answerResponse = makePostRequest(
+ (transactionRequestsUrl(bankId, callerAccountId, "REFUND") / refund.id / "challenge").POST <@ user1,
+ write(ChallengeAnswerJson400(id = challenge.id, answer = "123")))
+
+ Then("The payment is refused and the other party's account is not debited")
+ answerResponse.code should equal(400)
+ answerResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.InsufficientAuthorisationToCreateTransactionRequest)
+ balanceOf(otherPartyAccountId) should equal(otherPartyBalanceBefore)
+ }
+
+ scenario("Without an adapter making the payment, a `to` refund from an external party is not paid", VersionOfApi) {
+ Given("A payment to an account at another bank, and a refund request asking for it back")
+ val (counterpartyId, transactionId) = payAnExternalParty()
+ val refundResponse = makePostRequest(
+ transactionRequestsUrl(bankId, callerAccountId, "REFUND").POST <@ user1,
+ write(refundBody(transactionId, Some(TransactionRequestRefundTo(None, None, Some(counterpartyId))), None)))
+ refundResponse.code should equal(201)
+ val refund = refundResponse.body.extract[TransactionRequestWithChargeJSON400]
+ val challenge = refund.challenges.find(_.user_id == resourceUser1.userId).getOrElse(fail("No challenge for the caller"))
+ val callerBalanceBefore = balanceOf(callerAccountId)
+
+ When("The caller answers the challenge, on an instance where the local connector would make the payment")
+ val answerResponse = makePostRequest(
+ (transactionRequestsUrl(bankId, callerAccountId, "REFUND") / refund.id / "challenge").POST <@ user1,
+ write(ChallengeAnswerJson400(id = challenge.id, answer = "123")))
+
+ Then("The payment is refused and the caller's account is not credited")
+ answerResponse.code should equal(400)
+ answerResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.InvalidTransactionRequestType)
+ balanceOf(callerAccountId) should equal(callerBalanceBefore)
+ }
+
+ scenario("A challenge can't be answered through an account the transaction request does not involve", VersionOfApi) {
+ Given("A refund request between the caller's account and the other party")
+ val transactionId = payTheOtherParty()
+ val refund = askForMoneyBack(transactionId, bankId, otherPartyAccountId).body.extract[TransactionRequestWithChargeJSON400]
+ val challenge = refund.challenges.find(_.user_id == resourceUser1.userId).getOrElse(fail("No challenge for the caller"))
+
+ When("The challenge is answered through an unrelated account")
+ val answerResponse = makePostRequest(
+ (transactionRequestsUrl(unrelatedBankId, unrelatedAccountId, "REFUND") / refund.id / "challenge").POST <@ user1,
+ write(ChallengeAnswerJson400(id = challenge.id, answer = "123")))
+
+ Then("The transaction request is not found for that account")
+ answerResponse.code should equal(400)
+ answerResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.InvalidTransactionRequestId)
+ }
+ }
+
+ feature("A REFUND must be for a payment between the account and the named party") {
+
+ scenario("A `to` refund naming a party the payment was not made with is refused", VersionOfApi) {
+ val transactionId = payTheOtherParty()
+ val response = askForMoneyBack(transactionId, unrelatedBankId, unrelatedAccountId)
+ response.code should equal(400)
+ response.body.extract[ErrorMessage].message should startWith(ErrorMessages.RefundTransactionNotWithThisParty)
+ }
+
+ scenario("A `from` refund of a payment the account made, not received, is refused", VersionOfApi) {
+ Given("A payment the caller made to the other party")
+ val transactionId = payTheOtherParty()
+ val counterpartyId = counterpartyOnCallerAccountPointingAtOtherParty()
+
+ When("The caller tries to pay it back to the other party as if they had received it")
+ val response = makePostRequest(
+ transactionRequestsUrl(bankId, callerAccountId, "REFUND").POST <@ user1,
+ write(refundBody(transactionId, None, Some(TransactionRequestRefundFrom(counterpartyId)))))
+
+ Then("The refund is refused")
+ response.code should equal(400)
+ response.body.extract[ErrorMessage].message should startWith(ErrorMessages.RefundTransactionNotWithThisParty)
+ }
+
+ scenario("A payment can be refunded only once", VersionOfApi) {
+ val transactionId = payTheOtherParty()
+ askForMoneyBack(transactionId, bankId, otherPartyAccountId).code should equal(201)
+
+ val secondResponse = askForMoneyBack(transactionId, bankId, otherPartyAccountId)
+ secondResponse.code should equal(400)
+ secondResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.RefundedTransaction)
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v5_0_0/UserAuthContextTest.scala b/obp-api/src/test/scala/code/api/v5_0_0/UserAuthContextTest.scala
index b72910da9b..a9e4aa3da5 100644
--- a/obp-api/src/test/scala/code/api/v5_0_0/UserAuthContextTest.scala
+++ b/obp-api/src/test/scala/code/api/v5_0_0/UserAuthContextTest.scala
@@ -36,6 +36,8 @@ import code.api.util.ErrorMessages._
import code.api.v3_1_0.CustomerJsonV310
import code.api.v5_0_0.Http4s500.Implementations5_0_0
import code.entitlement.Entitlement
+import code.usercustomerlinks.UserCustomerLink
+import com.openbankproject.commons.model.UserAuthContextUpdateStatus
import com.github.dwickern.macros.NameOf.nameOf
import org.json4s.native.Serialization.write
import org.scalatest.Tag
@@ -63,7 +65,7 @@ class UserAuthContextTest extends V500ServerSetup {
feature("Add/Get Auth Context v5.0.0") {
scenario("We will call the Add endpoint without a user credentials", ApiEndpoint1, VersionOfApi) {
When("We make a request v5.0.0")
- val request500 = (v5_0_0_Request / "users" / userId1.value / "auth-context").POST
+ val request500 = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").POST
val response500 = makePostRequest(request500, write(postUserAuthContextJsonV310))
Then("We should get a 401")
response500.code should equal(401)
@@ -72,7 +74,7 @@ class UserAuthContextTest extends V500ServerSetup {
}
scenario("We will call the Add endpoint without a proper role", ApiEndpoint1, VersionOfApi) {
When("We make a request v5.0.0")
- val request500 = (v5_0_0_Request / "users" / userId1.value / "auth-context").POST <@(user1)
+ val request500 = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").POST <@(user1)
val response500 = makePostRequest(request500, write(postUserAuthContextJsonV310))
Then("We should get a 403")
response500.code should equal(403)
@@ -82,7 +84,7 @@ class UserAuthContextTest extends V500ServerSetup {
scenario("We will call the Get endpoint without a user credentials", ApiEndpoint2, VersionOfApi) {
When("We make a request v5.0.0")
- val request500 = (v5_0_0_Request / "users" / userId1.value / "auth-context").GET
+ val request500 = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").GET
val response500 = makeGetRequest(request500)
Then("We should get a 401")
response500.code should equal(401)
@@ -91,7 +93,7 @@ class UserAuthContextTest extends V500ServerSetup {
}
scenario("We will call the Get endpoint without a proper role", ApiEndpoint2, VersionOfApi) {
When("We make a request v5.0.0")
- val request500 = (v5_0_0_Request / "users" / userId1.value / "auth-context").GET <@(user1)
+ val request500 = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").GET <@(user1)
val response500 = makeGetRequest(request500)
Then("We should get a 403")
response500.code should equal(403)
@@ -103,14 +105,14 @@ class UserAuthContextTest extends V500ServerSetup {
scenario("We will call the Add, Get and Delete endpoints with user credentials and role", ApiEndpoint1, ApiEndpoint2, VersionOfApi) {
When("We try to create the UserAuthContext v5.0.0")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateUserAuthContext.toString)
- val requestUserAuthContext500 = (v5_0_0_Request / "users" / userId1.value / "auth-context").POST <@(user1)
+ val requestUserAuthContext500 = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").POST <@(user1)
val responseUserAuthContext500 = makePostRequest(requestUserAuthContext500, write(postUserAuthContextJsonV310))
Then("We should get a 201")
responseUserAuthContext500.code should equal(201)
val customerJson = responseUserAuthContext500.body.extract[UserAuthContextJsonV500]
When("We try to create the UserAuthContext v5.0.0")
- val successReq = (v5_0_0_Request / "users" / userId1.value / "auth-context").POST <@(user1)
+ val successReq = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").POST <@(user1)
val successRes = makePostRequest(successReq, write(postUserAuthContextJsonV5002))
Then("We should get a 201")
successRes.code should equal(201)
@@ -118,12 +120,12 @@ class UserAuthContextTest extends V500ServerSetup {
When("We try to make the GET request v5.0.0")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetUserAuthContext.toString)
- val successGetReq = (v5_0_0_Request / "users" / userId1.value / "auth-context").GET <@(user1)
+ val successGetReq = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").GET <@(user1)
val successGetRes = makeGetRequest(successGetReq)
Then("We should get a 200")
successGetRes.code should equal(200)
val userAuthContexts = successGetRes.body.extract[UserAuthContextsJsonV500]
- userAuthContexts.user_auth_contexts.map(_.user_id).forall(userId1.value ==) shouldBe (true)
+ userAuthContexts.user_auth_contexts.map(_.user_id).forall(resourceUser1.userId ==) shouldBe (true)
userAuthContexts.user_auth_contexts.map(_.consumer_id).forall(testConsumer.consumerId.get ==) shouldBe (true)
}
@@ -177,16 +179,19 @@ class UserAuthContextTest extends V500ServerSetup {
Then("We should get a 200")
successRes.code should equal(200)
val result = successRes.body.extract[UserAuthContextUpdateJsonV500]
+ And("The example answer is wrong, so the update is rejected and the User is not linked to the Customer")
+ result.status should equal(UserAuthContextUpdateStatus.REJECTED.toString)
+ UserCustomerLink.userCustomerLink.vend.getUserCustomerLink(resourceUser1.userId, infoPost.customer_id).isDefined should equal(false)
When("We try to make the GET request v5.0.0")
Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetUserAuthContext.toString)
- val successGetReq = (v5_0_0_Request / "users" / userId1.value / "auth-context").GET <@(user1)
+ val successGetReq = (v5_0_0_Request / "users" / resourceUser1.userId / "auth-context").GET <@(user1)
val successGetRes = makeGetRequest(successGetReq)
Then("We should get a 200")
successGetRes.code should equal(200)
val userAuthContexts = successGetRes.body.extract[UserAuthContextsJsonV500]
- userAuthContexts.user_auth_contexts.map(_.user_id).forall(userId1.value ==) shouldBe (true)
+ userAuthContexts.user_auth_contexts.map(_.user_id).forall(resourceUser1.userId ==) shouldBe (true)
userAuthContexts.user_auth_contexts.map(_.consumer_id).forall(testConsumer.consumerId.get ==) shouldBe (true)
}
diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerRedirectUrlValidationTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerRedirectUrlValidationTest.scala
new file mode 100644
index 0000000000..569f480e38
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerRedirectUrlValidationTest.scala
@@ -0,0 +1,171 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+
+package code.api.v5_1_0
+
+import java.util.Date
+
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ApiRole._
+import code.api.util.ErrorMessages.{InvalidRedirectUrl, RedirectUrlHostNotAllowed}
+import code.api.v2_1_0.{ConsumerPostJSON, ConsumerRedirectUrlJSON}
+import code.consumer.Consumers
+import code.entitlement.Entitlement
+import code.model.Consumer
+import code.setup.APIResponse
+import net.liftweb.util.Helpers.randomString
+import org.json4s._
+import org.json4s.native.Serialization.write
+
+/**
+ * This suite checks that every endpoint that writes a Consumer's redirect URL rejects one that breaks the
+ * redirect URL rules (code.api.util.RedirectUrlValidation), and that the OIDC client endpoints never hand
+ * out a stored entry that breaks them.
+ */
+class ConsumerRedirectUrlValidationTest extends V510ServerSetup {
+
+ private val v2_1_0 = baseRequest / "obp" / "v2.1.0"
+ private val v2_2_0 = baseRequest / "obp" / "v2.2.0"
+ private val v4_0_0 = baseRequest / "obp" / "v4.0.0"
+ private val v6_0_0 = baseRequest / "obp" / "v6.0.0"
+
+ private val badRedirectUrl = "https://app.example.com/cb,javascript:alert(1)"
+
+ private def createConsumerRequestJson(redirectUrl: String) = CreateConsumerRequestJsonV510(
+ app_name = "redirect-test-" + randomString(8), app_type = "Confidential", description = "redirect test",
+ developer_email = "developer@example.com", company = "Example", redirect_url = redirectUrl,
+ enabled = true, client_certificate = None, logo_url = None)
+
+ private def consumerPostJson(redirectUrl: String) = ConsumerPostJSON(
+ "redirect-test-" + randomString(8), "Confidential", "redirect test", "developer@example.com",
+ redirectUrl, "", true, new Date(), "")
+
+ private def messageOf(body: JValue): String = (body \ "message") match {
+ case JString(message) => message
+ case _ => ""
+ }
+
+ private def shouldBeRejected(response: APIResponse): Unit = {
+ response.code should equal(400)
+ messageOf(response.body) should startWith(InvalidRedirectUrl)
+ messageOf(response.body) should include("javascript:alert(1)")
+ }
+
+ private def createConsumerOwnedByUser1(redirectUrl: String): String = {
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString)
+ val response = makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1), write(createConsumerRequestJson(redirectUrl)))
+ response.code should equal(201)
+ (response.body \ "consumer_id").extract[String]
+ }
+
+ feature("Creating a Consumer with a redirect URL that breaks the rules is rejected") {
+
+ scenario("v5.1.0 createConsumer") {
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString)
+ shouldBeRejected(makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1), write(createConsumerRequestJson(badRedirectUrl))))
+ }
+
+ scenario("v5.1.0 createMyConsumer") {
+ shouldBeRejected(makePostRequest((v5_1_0_Request / "my" / "consumers").POST <@ (user1), write(createConsumerRequestJson(badRedirectUrl))))
+ }
+
+ scenario("v4.0.0 createConsumer") {
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString)
+ shouldBeRejected(makePostRequest((v4_0_0 / "management" / "consumers").POST <@ (user1), write(consumerPostJson(badRedirectUrl))))
+ }
+
+ scenario("v2.2.0 createConsumer") {
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString)
+ shouldBeRejected(makePostRequest((v2_2_0 / "management" / "consumers").POST <@ (user1), write(consumerPostJson(badRedirectUrl))))
+ }
+
+ scenario("an allowed redirect URL is still accepted") {
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString)
+ val response = makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1),
+ write(createConsumerRequestJson("https://app.example.com/cb http://localhost:5173/cb com.example.app:/cb")))
+ response.code should equal(201)
+ }
+ }
+
+ feature("redirect_url_allowed_hosts limits the hosts a Consumer redirect URL may point to") {
+
+ scenario("with the prop unset, any host that meets the rules is accepted") {
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString)
+ val response = makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1),
+ write(createConsumerRequestJson("https://any-third-party.example/cb")))
+ response.code should equal(201)
+ }
+
+ scenario("with the prop set, an unlisted host is refused with RedirectUrlHostNotAllowed and a listed one is accepted") {
+ setPropsValues("redirect_url_allowed_hosts" -> ".bank.example")
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateConsumer.toString)
+
+ val refused = makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1),
+ write(createConsumerRequestJson("https://evil.example/cb")))
+ refused.code should equal(400)
+ messageOf(refused.body) should startWith(RedirectUrlHostNotAllowed)
+
+ val accepted = makePostRequest((v5_1_0_Request / "management" / "consumers").POST <@ (user1),
+ write(createConsumerRequestJson("https://portal.bank.example/cb")))
+ accepted.code should equal(201)
+ }
+ }
+
+ feature("Updating a Consumer's redirect URL to one that breaks the rules is rejected") {
+
+ scenario("v5.1.0 updateConsumerRedirectURL") {
+ val consumerId = createConsumerOwnedByUser1("https://app.example.com/cb")
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerRedirectUrl.toString)
+ shouldBeRejected(makePutRequest((v5_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "redirect_url").PUT <@ (user1),
+ write(ConsumerRedirectUrlJSON(badRedirectUrl))))
+ }
+
+ scenario("v2.1.0 updateConsumerRedirectUrl") {
+ val consumerId = createConsumerOwnedByUser1("https://app.example.com/cb")
+ val primaryId = Consumers.consumers.vend.getConsumerByConsumerId(consumerId).map(_.id.get).openOrThrowException("consumer just created")
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateConsumerRedirectUrl.toString)
+ shouldBeRejected(makePutRequest((v2_1_0 / "management" / "consumers" / primaryId.toString / "consumer" / "redirect_url").PUT <@ (user1),
+ write(ConsumerRedirectUrlJSON(badRedirectUrl))))
+ }
+ }
+
+ feature("The OIDC client endpoints leave out stored entries that break the rules") {
+
+ scenario("getOidcClient returns only the allowed entries of a redirect URL saved before the rules existed") {
+ // Saved without validation, as a Consumer written before the redirect URL rules existed would be.
+ val key = randomString(40).toLowerCase
+ Consumer.create.key(key).secret(randomString(40).toLowerCase).name("legacy-" + randomString(8))
+ .description("legacy").isActive(true)
+ .redirectURL("https://app.example.com/cb,javascript:alert(1) http://public.example.com/cb").saveMe()
+
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canGetOidcClient.toString)
+ val response = makeGetRequest((v6_0_0 / "oidc" / "clients" / key).GET <@ (user1))
+ response.code should equal(200)
+ (response.body \ "redirect_uris").extract[List[String]] should equal(List("https://app.example.com/cb"))
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala
index 0beecdd1fa..0ae1a9fd5e 100644
--- a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala
+++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala
@@ -259,10 +259,13 @@ class ConsumerTest extends V510ServerSetup {
}
+ // A Consumer name that is already taken gets a random suffix, and the Consumer table survives
+ // between runs, so this Consumer gets a name no earlier run can have used.
+ val myConsumerRequestJson = createConsumerRequestJsonV510.copy(app_name = "myConsumer" + java.util.UUID.randomUUID.toString)
val requestApiEndpoint6 = (v5_1_0_Request / "my" / "consumers").POST<@ (user1)
- val responseApiEndpoint6 = makePostRequest(requestApiEndpoint6, write(createConsumerRequestJsonV510))
+ val responseApiEndpoint6 = makePostRequest(requestApiEndpoint6, write(myConsumerRequestJson))
val consumerJson6 = responseApiEndpoint6.body.extract[ConsumerJsonOnlyForPostResponseV510]
- consumerJson6.app_name shouldBe createConsumerRequestJsonV510.app_name
+ consumerJson6.app_name shouldBe myConsumerRequestJson.app_name
consumerJson6.redirect_url shouldBe createConsumerRequestJsonV510.redirect_url
consumerJson6.logo_url.headOption shouldBe createConsumerRequestJsonV510.logo_url.headOption
consumerJson6.description shouldBe createConsumerRequestJsonV510.description
diff --git a/obp-api/src/test/scala/code/api/v5_1_0/WaitingForGodotTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/WaitingForGodotTest.scala
new file mode 100644
index 0000000000..2a88b13875
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v5_1_0/WaitingForGodotTest.scala
@@ -0,0 +1,99 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2026, TESOBE GmbH.
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+
+package code.api.v5_1_0
+
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ApiRole.CanGetWaitingForGodot
+import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UserHasMissingRoles}
+import code.entitlement.Entitlement
+import code.setup.APIResponse
+import org.json4s._
+
+/**
+ * This suite checks that waitingForGodot needs an authenticated User with the Role CanGetWaitingForGodot,
+ * and that it only ever delays for between zero and the instance's maximum, however large or invalid the
+ * requested delay is.
+ */
+class WaitingForGodotTest extends V510ServerSetup {
+
+ private def waitingForGodot(sleep: String) = (v5_1_0_Request / "waiting-for-godot").GET < List("sleep" -> sleep)
+
+ private def sleepReported(response: APIResponse): Long = (response.body \ "sleep_in_milliseconds").extract[Long]
+
+ private def messageOf(response: APIResponse): String = (response.body \ "message").extractOpt[String].getOrElse("")
+
+ private def grantRoleToUser1(): Unit =
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetWaitingForGodot.toString)
+
+ feature("waitingForGodot needs authentication and a Role") {
+
+ scenario("an anonymous caller is refused with 401") {
+ val response = makeGetRequest(waitingForGodot("20"))
+ response.code should equal(401)
+ messageOf(response) should include(AuthenticatedUserIsRequired)
+ }
+
+ scenario("a User without the Role is refused with 403") {
+ val response = makeGetRequest(waitingForGodot("20") <@ (user2))
+ response.code should equal(403)
+ messageOf(response) should equal(UserHasMissingRoles + CanGetWaitingForGodot)
+ }
+ }
+
+ feature("waitingForGodot delays only within bounds") {
+
+ scenario("a User with the Role gets the delay it asks for when it is within the maximum") {
+ grantRoleToUser1()
+ val response = makeGetRequest(waitingForGodot("20") <@ (user1))
+ response.code should equal(200)
+ sleepReported(response) should equal(20L)
+ }
+
+ scenario("a delay above the maximum is reduced to the maximum") {
+ grantRoleToUser1()
+ setPropsValues("waiting_for_godot_max_sleep_in_milliseconds" -> "50")
+ val started = System.currentTimeMillis()
+ val response = makeGetRequest(waitingForGodot(Long.MaxValue.toString) <@ (user1))
+ val elapsed = System.currentTimeMillis() - started
+ response.code should equal(200)
+ sleepReported(response) should equal(50L)
+ elapsed should be < 10000L
+ }
+
+ scenario("a negative or unreadable delay counts as zero instead of failing") {
+ grantRoleToUser1()
+ List("-1", "not-a-number").foreach { requested =>
+ val response = makeGetRequest(waitingForGodot(requested) <@ (user1))
+ withClue(requested) {
+ response.code should equal(200)
+ sleepReported(response) should equal(0L)
+ }
+ }
+ }
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala
index 57be013044..6091cf343d 100644
--- a/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala
+++ b/obp-api/src/test/scala/code/api/v6_0_0/CounterpartyAttributeTest.scala
@@ -53,8 +53,25 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers {
val bankId = testBankId1.value
val accountId = testAccountId1.value
- lazy val counterpartyId = createMockCounterparty()
- lazy val attributeId = createMockAttribute(counterpartyId)
+ // The test data, counterparties included, is wiped before every scenario, so each scenario gets its
+ // own counterparty and attribute, created on first use after that wipe.
+ private var counterpartyOfThisScenario: Option[String] = None
+ private var attributeOfThisScenario: Option[String] = None
+ def counterpartyId: String = counterpartyOfThisScenario.getOrElse {
+ val created = createMockCounterparty()
+ counterpartyOfThisScenario = Some(created)
+ created
+ }
+ def attributeId: String = attributeOfThisScenario.getOrElse {
+ val created = createMockAttribute(counterpartyId)
+ attributeOfThisScenario = Some(created)
+ created
+ }
+ override def beforeEach(): Unit = {
+ super.beforeEach()
+ counterpartyOfThisScenario = None
+ attributeOfThisScenario = None
+ }
def createMockCounterparty(): String = {
val counterparty = createCounterparty(bankId, accountId, accountId, true, UUID.randomUUID.toString)
@@ -148,8 +165,6 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers {
}
feature("Delete Counterparty Attribute") {
- lazy val counterpartyId = createMockCounterparty()
- lazy val attributeId = createMockAttribute(counterpartyId)
scenario("401 Unauthorized", Delete, VersionOfApi) {
val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).DELETE
val response = makeDeleteRequest(request)
@@ -175,8 +190,6 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers {
}
feature("Get All Counterparty Attributes") {
- lazy val counterpartyId = createMockCounterparty()
- lazy val attributeId = createMockAttribute(counterpartyId)
scenario("401 Unauthorized", GetAll, VersionOfApi) {
val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes").GET
val response = makeGetRequest(request)
@@ -200,7 +213,6 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers {
}
feature("Get Counterparty Attribute by ID") {
- lazy val counterpartyId = createMockCounterparty()
scenario("401 Unauthorized", GetOne, VersionOfApi) {
val request = (v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyId / "attributes" / attributeId).GET
@@ -224,4 +236,55 @@ class CounterpartyAttributeTest extends V600ServerSetup with DefaultUsers {
Entitlement.entitlement.vend.deleteEntitlement(entitlement)
}
}
+
+ feature("Counterparty Attributes are only reachable through the counterparty's own account") {
+
+ scenario("400 when the counterparty belongs to a different account", Create, GetAll, VersionOfApi) {
+ Given("A counterparty of testAccount0, addressed through testAccount1")
+ val otherAccountId = testAccountId0.value
+ val counterpartyOfOtherAccount = createCounterparty(bankId, otherAccountId, otherAccountId, true, UUID.randomUUID.toString).counterpartyId
+ val createEntitlement = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanCreateCounterpartyAttribute.toString)
+ val getEntitlement = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanGetCounterpartyAttributes.toString)
+ val attributesUrl = v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / counterpartyOfOtherAccount / "attributes"
+
+ Then("Creating an attribute is refused and nothing is created")
+ val createResponse = makePostRequest(attributesUrl.POST <@ user1, write(counterpartyAttributeRequestJsonV600))
+ createResponse.code should equal(400)
+ createResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId)
+
+ And("Listing its attributes is refused")
+ val listResponse = makeGetRequest(attributesUrl.GET <@ user1)
+ listResponse.code should equal(400)
+ listResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyNotFoundByCounterpartyId)
+
+ Entitlement.entitlement.vend.deleteEntitlement(createEntitlement)
+ Entitlement.entitlement.vend.deleteEntitlement(getEntitlement)
+ }
+
+ scenario("404 when the attribute belongs to a different counterparty", GetOne, Update, Delete, VersionOfApi) {
+ Given("An attribute of one counterparty, addressed through another counterparty of the same account")
+ val attributeOfFirstCounterparty = createMockAttribute(createMockCounterparty())
+ val secondCounterparty = createMockCounterparty()
+ val entitlements = List(CanGetCounterpartyAttribute, CanUpdateCounterpartyAttribute, CanDeleteCounterpartyAttribute).map(role =>
+ Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, role.toString))
+ val attributeUrl = v6_0_0_Request / "banks" / bankId / "accounts" / accountId / "counterparties" / secondCounterparty / "attributes" / attributeOfFirstCounterparty
+
+ Then("Getting, updating and deleting it are all refused")
+ val getResponse = makeGetRequest(attributeUrl.GET <@ user1)
+ getResponse.code should equal(404)
+ getResponse.body.extract[ErrorMessage].message should startWith(ErrorMessages.CounterpartyAttributeNotFound)
+ val updateResponse = makePutRequest(attributeUrl.PUT <@ user1, write(counterpartyAttributeRequestJsonV600))
+ updateResponse.code should equal(404)
+ val deleteResponse = makeDeleteRequest(attributeUrl.DELETE <@ user1)
+ deleteResponse.code should equal(404)
+
+ And("The attribute is untouched")
+ scala.concurrent.Await.result(
+ code.counterpartyattribute.CounterpartyAttributeX.counterpartyAttributeProvider.vend
+ .getCounterpartyAttributeById(attributeOfFirstCounterparty),
+ scala.concurrent.duration.Duration(10, "seconds")).isDefined should equal(true)
+
+ entitlements.foreach(Entitlement.entitlement.vend.deleteEntitlement(_))
+ }
+ }
}
diff --git a/obp-api/src/test/scala/code/setup/DefaultUsers.scala b/obp-api/src/test/scala/code/setup/DefaultUsers.scala
index 78bdd82478..5b0d2e196a 100644
--- a/obp-api/src/test/scala/code/setup/DefaultUsers.scala
+++ b/obp-api/src/test/scala/code/setup/DefaultUsers.scala
@@ -71,7 +71,9 @@ trait DefaultUsers {
description = Some("test1 description"),
developerEmail = Some("eveline@example.com"),
redirectURL = None,
- createdByUserId = userId1,
+ // The ResourceUser can survive from an earlier run with a different user id (the reset keeps that
+ // table, while userId1 is new on every JVM start), so take the owner from the user actually used.
+ createdByUserId = Some(resourceUser1.userId),
None,
None,
None
@@ -87,7 +89,7 @@ trait DefaultUsers {
description = Some("test2 description"),
developerEmail = Some("eveline@example.com"),
redirectURL = None,
- createdByUserId = userId2,
+ createdByUserId = Some(resourceUser2.userId),
None,
None,
None,
@@ -103,7 +105,7 @@ trait DefaultUsers {
description = Some("test3 description"),
developerEmail = Some("eveline@example.com"),
redirectURL = None,
- createdByUserId = userId3,
+ createdByUserId = Some(resourceUser3.userId),
None,
None,
None
@@ -119,7 +121,7 @@ trait DefaultUsers {
description = Some("test4 description"),
developerEmail = Some("eveline@example.com"),
redirectURL = None,
- createdByUserId = userId4,
+ createdByUserId = Some(resourceUser4.userId),
None,
None,
None
diff --git a/obp-api/src/test/scala/code/util/HelperTest.scala b/obp-api/src/test/scala/code/util/HelperTest.scala
index 724dad94de..ab9bea7098 100644
--- a/obp-api/src/test/scala/code/util/HelperTest.scala
+++ b/obp-api/src/test/scala/code/util/HelperTest.scala
@@ -35,34 +35,6 @@ import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers}
class HelperTest extends FeatureSpec with Matchers with GivenWhenThen with PropsReset {
- feature("test Helper.getStaticPortionOfRedirectURL method") {
- // The redirectURl is `http://localhost:8082/oauthcallback`
- val testString1 = "http://localhost:8082/oauthcallback?oauth_token=G5AEA2U1WG404EGHTIGBHKRR4YJZAPPHWKOMNEEV&oauth_verifier=53018"
- val testString2 = "http://localhost:8082?oauth_token=G5AEA2U1WG404EGHTIGBHKRR4YJZAPPHWKOMNEEV&oauth_verifier=53018"
- val testString3 = "myapp://callback?oauth_token=%3DEBRZBMOPDXEUGGJP421FPFGK01IY2DGM5O3TLVSK%26oauth_verifier%3D63461"
- val testString4 = "fb00000000:://callback?oauth_token=%3DEBRZBMOPDXEUGGJP421FPFGK01IY2DGM5O3TLVSK%26oauth_verifier%3D63461"
- val testString5 = "http://127.0.0.1:8000/oauth/authorize?next=/en/metrics/api/&oauth_token=TN0124OCPRCL4KUJRF5LNLVMRNHTVZPJDBS2PNWU&oauth_verifier=10470"
-
- Helper.getStaticPortionOfRedirectURL(testString1).head should be("http://localhost:8082/oauthcallback")
- Helper.getStaticPortionOfRedirectURL(testString2).head should be("http://localhost:8082")
- Helper.getStaticPortionOfRedirectURL(testString3).head should be("myapp://callback")
- Helper.getStaticPortionOfRedirectURL(testString4).head should be("fb00000000:://callback")
- Helper.getStaticPortionOfRedirectURL(testString5).head should be("http://127.0.0.1:8000/oauth/authorize")
- }
-
- feature("test Helper.getHostOnlyOfRedirectURL method") {
- // The redirectURl is `http://localhost:8082/oauthcallback`
- val testString1 = "http://localhost:8082/oauthcallback?oauth_token=G5AEA2U1WG404EGHTIGBHKRR4YJZAPPHWKOMNEEV&oauth_verifier=53018"
- val testString2 = "http://localhost:8082/oauthcallback"
- val testString3 = "http://localhost:8082?oauth_token=G5AEA2U1WG404EGHTIGBHKRR4YJZAPPHWKOMNEEV&oauth_verifier=53018"
- val testString4 = "http://localhost:8082"
-
- Helper.getHostOnlyOfRedirectURL(testString1).head should be("http://localhost:8082")
- Helper.getHostOnlyOfRedirectURL(testString2).head should be("http://localhost:8082")
- Helper.getHostOnlyOfRedirectURL(testString3).head should be("http://localhost:8082")
- Helper.getHostOnlyOfRedirectURL(testString4).head should be("http://localhost:8082")
- }
-
feature(s"test Helper.getIfNotExistsAddedColumLengthForMsSqlServer method") {
scenario(s"test case addColumnIfNotExists") {
diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json
index 3a1e7f30c8..e371131ee3 100644
--- a/scripts/resource_doc_baseline/parity_allowlist.json
+++ b/scripts/resource_doc_baseline/parity_allowlist.json
@@ -763,14 +763,6 @@
"lift_digest": "1121e061bb57d86b7b28c331cfd46382f6bc01b163301a62871d5c95b0240eab",
"http4s_digest": "1216685612894e081ee8c9fa73e16bec14351fa08675b4c15c7f5043a3578c03"
},
- {
- "version": "v4_0_0",
- "endpoint": "deleteExplicitCounterparty",
- "field": "requestUrl",
- "reason": "COUNTERPARTY_ID -> COUNTERPARTY_ID_PARAM rename, same shape.",
- "lift_digest": "921d75d9e3f3f8829417e01b547c83bb66305f39e6125d07641d70228ba12762",
- "http4s_digest": "f17aeac76f848755b702722badaf8024590ced0c796cdf6467cc8f3ca0cabf2e"
- },
{
"version": "v4_0_0",
"endpoint": "deleteUser",
@@ -803,14 +795,6 @@
"lift_digest": "88d3999f8d4a15b904b69b597c770539efcee05cf149c0eb47cba88e4134bce8",
"http4s_digest": "ab7e41da4bb607ed2e1f5008352a6914ce993c1bb231d21e183bb53e78988da7"
},
- {
- "version": "v4_0_0",
- "endpoint": "getCounterpartyByIdForAnyAccount",
- "field": "requestUrl",
- "reason": "COUNTERPARTY_ID_PARAM rename.",
- "lift_digest": "10cc23745c9f2e446f7128aebff01d4cd79e7390299d1ff9d53782c8e199a0c8",
- "http4s_digest": "ca07ee2eaee48c958711ac6e697032ac41035a9fdfe4ee4cdc71ff6ea4412cb9"
- },
{
"version": "v4_0_0",
"endpoint": "getDynamicResourceDoc",
@@ -819,14 +803,6 @@
"lift_digest": "1121e061bb57d86b7b28c331cfd46382f6bc01b163301a62871d5c95b0240eab",
"http4s_digest": "1216685612894e081ee8c9fa73e16bec14351fa08675b4c15c7f5043a3578c03"
},
- {
- "version": "v4_0_0",
- "endpoint": "getExplicitCounterpartyById",
- "field": "requestUrl",
- "reason": "EXPLICIT_COUNTERPARTY_ID rename.",
- "lift_digest": "921d75d9e3f3f8829417e01b547c83bb66305f39e6125d07641d70228ba12762",
- "http4s_digest": "51b5c4487bf594005cbd5895f0fa7f8f2ef4ed246386e9cd2fb154c386d5289b"
- },
{
"version": "v4_0_0",
"endpoint": "getFirehoseAccountsAtOneBank",
@@ -939,14 +915,6 @@
"lift_digest": "172ecd180e7c87fd9080d231736f8ea1e5d130fdc9224adb017d69494edba066",
"http4s_digest": "5f61128bd66efed0f37498698f7819d75cc2dcbb4501d09e96fd74769eeb4c55"
},
- {
- "version": "v6_0_0",
- "endpoint": "createCounterpartyAttribute",
- "field": "requestUrl",
- "reason": "COUNTERPARTY_ID_PARAM rename.",
- "lift_digest": "4afc1baca0551e1cb89611a42c0d7652e3040468728f465d9061052414e6e1a9",
- "http4s_digest": "5f2aedc26f7688fd913b6f9dffe12c6858fb3814c7515530861a1613afb8a5b5"
- },
{
"version": "v6_0_0",
"endpoint": "createPersonalDataField",
@@ -971,14 +939,6 @@
"lift_digest": "f171234e229c0a8c5636773ff0eef555a86cc86cbce6dd89854d72157d56c76a",
"http4s_digest": "4f6ba60ae9bfce501646d1311bfb1d544d3ecbf02b4b5e522e8da293d3407c04"
},
- {
- "version": "v6_0_0",
- "endpoint": "deleteCounterpartyAttribute",
- "field": "requestUrl",
- "reason": "COUNTERPARTY_ID_PARAM rename.",
- "lift_digest": "6be19ba2409dbba4ae9f6ed783f5a2068645dd189761c83af8cd0d9f107a22f4",
- "http4s_digest": "2cb204a9cd0b15471f6fdbe274209d3f4cce2a4d016f0fd8526b0b24fa640d8a"
- },
{
"version": "v6_0_0",
"endpoint": "deletePersonalDataField",
@@ -1059,14 +1019,6 @@
"lift_digest": "9940ac8e6f7dbf5a4e4e5d8a65635722e8ce8839f7954ad39f99c1eee3f12865",
"http4s_digest": "b8f6c80d7d9aab5c1954d93d78e4e9caaf55bbba86cedc57b614c7350f5d8fda"
},
- {
- "version": "v6_0_0",
- "endpoint": "getAllCounterpartyAttributes",
- "field": "requestUrl",
- "reason": "COUNTERPARTY_ID_PARAM rename.",
- "lift_digest": "4afc1baca0551e1cb89611a42c0d7652e3040468728f465d9061052414e6e1a9",
- "http4s_digest": "5f2aedc26f7688fd913b6f9dffe12c6858fb3814c7515530861a1613afb8a5b5"
- },
{
"version": "v6_0_0",
"endpoint": "getAllProductsV600",
@@ -1099,14 +1051,6 @@
"lift_digest": "f41cdd1dbacdfd52a114a2dc76487367b4b20746184d32d3b0709bd858b8363d",
"http4s_digest": "835e4890e3775490f00f47ca65a16e63acc63d6cf1961976712b585b643e54e2"
},
- {
- "version": "v6_0_0",
- "endpoint": "getCounterpartyAttributeById",
- "field": "requestUrl",
- "reason": "COUNTERPARTY_ID_PARAM rename.",
- "lift_digest": "6be19ba2409dbba4ae9f6ed783f5a2068645dd189761c83af8cd0d9f107a22f4",
- "http4s_digest": "2cb204a9cd0b15471f6fdbe274209d3f4cce2a4d016f0fd8526b0b24fa640d8a"
- },
{
"version": "v6_0_0",
"endpoint": "getPersonalDataFieldById",
@@ -1187,14 +1131,6 @@
"lift_digest": "dca0922381fb4c5f1fb34ab5a531d440b068f2170fd933c3c2eeddb4c52eaf94",
"http4s_digest": "f0bea922b31c331bae138d58990bf31a6bc5ca0099055f4cff4fdaf62c0a6263"
},
- {
- "version": "v6_0_0",
- "endpoint": "updateCounterpartyAttribute",
- "field": "requestUrl",
- "reason": "COUNTERPARTY_ID_PARAM rename.",
- "lift_digest": "6be19ba2409dbba4ae9f6ed783f5a2068645dd189761c83af8cd0d9f107a22f4",
- "http4s_digest": "2cb204a9cd0b15471f6fdbe274209d3f4cce2a4d016f0fd8526b0b24fa640d8a"
- },
{
"version": "v6_0_0",
"endpoint": "updatePersonalDataField",
@@ -1639,9 +1575,9 @@
"version": "v4_0_0",
"endpoint": "answerTransactionRequestChallenge",
"field": "errorResponseBodies",
- "reason": "Adds ChallengeNotAddressedToCaller (OBP-40063): a payment challenge is answered by the person the payment is for, so an agent acting under a Consent is refused rather than allowed to relay a code it was never sent. See ON_BEHALF_OF_USER_ID_PLAN.md Decision 9.",
+ "reason": "Adds ChallengeNotAddressedToCaller (OBP-40063): a payment challenge is answered by the person the payment is for, so an agent acting under a Consent is refused rather than allowed to relay a code it was never sent. See ON_BEHALF_OF_USER_ID_PLAN.md Decision 9. Lists InvalidTransactionRequestId, returned when the transaction request does not involve the BANK_ID / ACCOUNT_ID in the URL.",
"lift_digest": "13648f17562c1654881963b4978024f82e1edcdb92729397a2b015b291939ddb",
- "http4s_digest": "c584fa1579db6b30ed7c21cf3dc7da57f50dcad7abba332ea1a58dd4c37808c4"
+ "http4s_digest": "82c6ce31f384147541e77f70a52e17ab45e7230c8543a92eaf9df446070317b2"
},
{
"version": "v4_0_0",
@@ -1695,9 +1631,9 @@
"version": "v4_0_0",
"endpoint": "createTransactionRequestRefund",
"field": "errorResponseBodies",
- "reason": "Adds PaymentChallengeHasNoOnBehalfOfUser (OBP-40064): a payment needing Strong Customer Authentication is refused when no person can be determined to address the challenge to, instead of being parked behind a challenge nobody can answer. See ON_BEHALF_OF_USER_ID_PLAN.md Decision 9.",
+ "reason": "Adds PaymentChallengeHasNoOnBehalfOfUser (OBP-40064): a payment needing Strong Customer Authentication is refused when no person can be determined to address the challenge to, instead of being parked behind a challenge nobody can answer. See ON_BEHALF_OF_USER_ID_PLAN.md Decision 9. Lists CounterpartyNotFoundByCounterpartyId (the counterparty does not belong to the account in the URL), RefundTransactionNotWithThisParty (the refunded transaction was not a payment between this account and the named party, in the right direction) and RefundedTransaction (the transaction was already refunded).",
"lift_digest": "4eac30b6b708ad9f30e688ef1327a01ff7f4280f982f77063851c9483e3179c2",
- "http4s_digest": "36b31730de78f93aa41f09da7fe44eff78fe76dac497497e7562b84454a2e3b0"
+ "http4s_digest": "d60c90fc7e51d9f5b050dd3ca31d0c4176292041c962433830f47f8767270800"
},
{
"version": "v4_0_0",
@@ -1815,17 +1751,17 @@
"version": "v5_1_0",
"endpoint": "createConsumer",
"field": "description",
- "reason": "Adds a paragraph pointing the developer of an app the installation runs itself (Portal, API Manager, Opey, OBP-Sentinel) to Platform Apps, so its Consumer can be granted Roles as Scopes.",
+ "reason": "Adds a paragraph pointing the developer of an app the installation runs itself (Portal, API Manager, Opey, OBP-Sentinel) to Platform Apps, so its Consumer can be granted Roles as Scopes. Adds the redirect URL rules, this instance's redirect URL host list, and a link to the Redirect URL glossary entry.",
"lift_digest": "7e639520852912f5fbb9938d9d3f65943054eb8a158dfa7105416a43870d3ce4",
- "http4s_digest": "983ad12ac6c61fc5637ee32d4fbc95592ed259c2588b8b50feab0376fec554a6"
+ "http4s_digest": "0dc9ec5c88cccd750dc082dad3a38334251bfcd2c4bdb1e1b48ac3076f91aed8"
},
{
"version": "v5_1_0",
"endpoint": "createMyConsumer",
"field": "description",
- "reason": "Adds a paragraph pointing the developer of an app the installation runs itself (Portal, API Manager, Opey, OBP-Sentinel) to Platform Apps, so its Consumer can be granted Roles as Scopes.",
+ "reason": "Adds a paragraph pointing the developer of an app the installation runs itself (Portal, API Manager, Opey, OBP-Sentinel) to Platform Apps, so its Consumer can be granted Roles as Scopes. Adds the redirect URL rules, this instance's redirect URL host list, and a link to the Redirect URL glossary entry.",
"lift_digest": "bba5099d62c01e4fd03073a2c54ef3f5669d0cb0f756d8c2556dd48c9dc8cbc2",
- "http4s_digest": "fb85fa76f84dab2e7a328d65e38b2d1ecf9eabe90ca99efdd7e8d24ff9bcd152"
+ "http4s_digest": "ff968d07d614393257e9f40efd3293e0c4f1872e86ec07955e1429756b8b364c"
},
{
"version": "v3_1_0",
@@ -1946,6 +1882,254 @@
"reason": "Lists the accepted attribute types from AttributeTypeDocs.typeFieldDescription, which adds DECIMAL and BOOLEAN (new enum values) to the four the Lift text listed and says when to use DECIMAL.",
"lift_digest": "d2f72f200b8c01cb57f1e07c608a160c125d3e210f9282fbbfa532c72f802248",
"http4s_digest": "789c5598a7af5691282a4192dc5d38199bb068928a392b08314aab3b96d5b6af"
+ },
+ {
+ "version": "v2_1_0",
+ "endpoint": "updateConsumerRedirectUrl",
+ "field": "errorResponseBodies",
+ "reason": "Adds RedirectUrlHostNotAllowed (OBP-20109) for instances that set redirect_url_allowed_hosts, alongside InvalidRedirectUrl (OBP-20108): the redirect URL is checked against the redirect URL rules before it is stored.",
+ "lift_digest": "f06cd9a2114625e9b6a37b8e57fa91e7bdb6586b9899fcd9265713aed3e873a4",
+ "http4s_digest": "b1e42b699ef095f7b2a523062366197eee3538c96068563f1bd25f0197559e33"
+ },
+ {
+ "version": "v2_2_0",
+ "endpoint": "createConsumer",
+ "field": "errorResponseBodies",
+ "reason": "Adds RedirectUrlHostNotAllowed (OBP-20109) for instances that set redirect_url_allowed_hosts, alongside InvalidRedirectUrl (OBP-20108): the redirect URL is checked against the redirect URL rules before it is stored.",
+ "lift_digest": "647479a642a49ee3a80a4bfc28ab2ae3413796d26a3eecbc48411d6e05620ad5",
+ "http4s_digest": "f1a3eab881d039a14b768fca036adc44759cc99d416fc176c8eea09829037ccf"
+ },
+ {
+ "version": "v2_2_0",
+ "endpoint": "createConsumer",
+ "field": "exampleRequestBody",
+ "reason": "The example redirect URL is now a valid https callback; the old placeholder would be rejected by the redirect URL rules.",
+ "lift_digest": "7b5796ab8e816b80f886a5d44a8414394170d7e2d61ab2ed6edb6ae4a58841e0",
+ "http4s_digest": "28fbe0a0ed9a7eb4e229b285c1715bc32b77a0485d6db6c9a3661ea037859a8b"
+ },
+ {
+ "version": "v2_2_0",
+ "endpoint": "createConsumer",
+ "field": "successResponseBody",
+ "reason": "The example redirect URL is now a valid https callback; the old placeholder would be rejected by the redirect URL rules.",
+ "lift_digest": "4cb0d642b4a86b86be87cad67a311c77f1d68aacafb957b2e4e83d23ae9695fb",
+ "http4s_digest": "5914176f0c140e0e56de59f39988dd4bd8312a0c873aa816729a12d5037ae936"
+ },
+ {
+ "version": "v4_0_0",
+ "endpoint": "createConsumer",
+ "field": "errorResponseBodies",
+ "reason": "Adds RedirectUrlHostNotAllowed (OBP-20109) for instances that set redirect_url_allowed_hosts, alongside InvalidRedirectUrl (OBP-20108): the redirect URL is checked against the redirect URL rules before it is stored.",
+ "lift_digest": "647479a642a49ee3a80a4bfc28ab2ae3413796d26a3eecbc48411d6e05620ad5",
+ "http4s_digest": "f1a3eab881d039a14b768fca036adc44759cc99d416fc176c8eea09829037ccf"
+ },
+ {
+ "version": "v4_0_0",
+ "endpoint": "createConsumer",
+ "field": "exampleRequestBody",
+ "reason": "The example redirect URL is now a valid https callback; the old placeholder would be rejected by the redirect URL rules.",
+ "lift_digest": "bedf35738b2bdbadfd0cf52925b59047394f1479aaf041401e689921a15d5da5",
+ "http4s_digest": "64bb412d6630509cccfda9d1cba36da072ace8e189a6798811648b1c2267aaa7"
+ },
+ {
+ "version": "v5_1_0",
+ "endpoint": "createConsumer",
+ "field": "errorResponseBodies",
+ "reason": "Adds RedirectUrlHostNotAllowed (OBP-20109) for instances that set redirect_url_allowed_hosts, alongside InvalidRedirectUrl (OBP-20108): the redirect URL is checked against the redirect URL rules before it is stored.",
+ "lift_digest": "647479a642a49ee3a80a4bfc28ab2ae3413796d26a3eecbc48411d6e05620ad5",
+ "http4s_digest": "f1a3eab881d039a14b768fca036adc44759cc99d416fc176c8eea09829037ccf"
+ },
+ {
+ "version": "v5_1_0",
+ "endpoint": "createMyConsumer",
+ "field": "errorResponseBodies",
+ "reason": "Adds RedirectUrlHostNotAllowed (OBP-20109) for instances that set redirect_url_allowed_hosts, alongside InvalidRedirectUrl (OBP-20108): the redirect URL is checked against the redirect URL rules before it is stored.",
+ "lift_digest": "ab6b9a504c9dafbcc134976d9f0b976cbe222c9054b9c811c5570adfb79ae6ce",
+ "http4s_digest": "8aea0f733a9dab1db64f8bcdc5e240c17467c6eee80d468b48c76dd8f7be81d3"
+ },
+ {
+ "version": "v5_1_0",
+ "endpoint": "createConsumerDynamicRegistration",
+ "field": "errorResponseBodies",
+ "reason": "Adds InvalidRedirectUrl (OBP-20108): the redirect URL is now checked against the redirect URL rules (https, http only for loopback, reverse-domain app schemes; no wildcard, user information or fragment) before it is stored.",
+ "lift_digest": "4e74e64b929f5a2a290113b4f8bfabd520ea39ad6fa4fe1a498fe76bbb805631",
+ "http4s_digest": "64c74c1413b68ad5553717d6ee442ed730ff08a383c419d597c5ddda9b711b72"
+ },
+ {
+ "version": "v5_1_0",
+ "endpoint": "updateConsumerRedirectURL",
+ "field": "errorResponseBodies",
+ "reason": "Adds RedirectUrlHostNotAllowed (OBP-20109) for instances that set redirect_url_allowed_hosts, alongside InvalidRedirectUrl (OBP-20108): the redirect URL is checked against the redirect URL rules before it is stored.",
+ "lift_digest": "f06cd9a2114625e9b6a37b8e57fa91e7bdb6586b9899fcd9265713aed3e873a4",
+ "http4s_digest": "b1e42b699ef095f7b2a523062366197eee3538c96068563f1bd25f0197559e33"
+ },
+ {
+ "version": "v6_0_0",
+ "endpoint": "getOidcClient",
+ "field": "description",
+ "reason": "States that redirect_uris leaves out stored entries that break the redirect URL rules, which the endpoint now filters, and links the Redirect URL glossary entry.",
+ "lift_digest": "49ddd5f6ec985292ee48802cd18e479c5a1b46fee368ce0d06a653e07eb65e90",
+ "http4s_digest": "afb1da33f039efe8cff6de2d718c11e552434fb08f7e41a66d82a26d07be0de6"
+ },
+ {
+ "version": "v6_0_0",
+ "endpoint": "verifyOidcClient",
+ "field": "description",
+ "reason": "States that redirect_uris leaves out stored entries that break the redirect URL rules, which the endpoint now filters, and links the Redirect URL glossary entry.",
+ "lift_digest": "120f4c6ed1083567d078a7afbb5c2e97d8de69e68e7314166c05e3afcdf50ecd",
+ "http4s_digest": "670c0479be7d2aa3c8beb18d2e1708c3ec7e67b2a1b625018b07a89c770fe962"
+ },
+ {
+ "version": "v5_1_0",
+ "endpoint": "updateConsumerRedirectURL",
+ "field": "description",
+ "reason": "Adds the redirect URL rules, this instance's redirect URL host list, and a link to the Redirect URL glossary entry.",
+ "lift_digest": "d2319e98d44314c5142a17c76318478bd3f2e88a41c560f30fd99ebde75a6b9b",
+ "http4s_digest": "6ae0abfeccdb9959fc3efe679b4a279e6aefb89f584a3d8d0f025620f03329f9"
+ },
+ {
+ "version": "v5_1_0",
+ "endpoint": "waitingForGodot",
+ "field": "description",
+ "reason": "States that authentication and the Role CanGetWaitingForGodot are required, and the delay cap on this instance (waiting_for_godot_max_sleep_in_milliseconds); negative or unreadable values count as 0.",
+ "lift_digest": "ecc556ba40db508705a57cb4f877b21bbb12a30a3fb48193c2f93da9ea4295ea",
+ "http4s_digest": "4aa1ed75cf6fb788d6f268f1693820ab5eb3dd20ea771cfb36d798abbb5371f9"
+ },
+ {
+ "version": "v5_1_0",
+ "endpoint": "waitingForGodot",
+ "field": "errorResponseBodies",
+ "reason": "The endpoint now requires authentication and the Role CanGetWaitingForGodot (it was anonymous and could tie up the shared thread pool); MandatoryPropertyIsNotSet was never raised.",
+ "lift_digest": "6ba561b556cbd54b3708ddc50db90e03301fda0b10101c168c80e590bcbdeb8c",
+ "http4s_digest": "cb07b8a8074c71a21a64f9030e412d57858383e4f503c2444c6c7000be5f4030"
+ },
+ {
+ "version": "v1_4_0",
+ "endpoint": "getCrmEvents",
+ "field": "description",
+ "reason": "The description was empty; it now says the endpoint returns every customer's CRM events at the bank and needs authentication and the Role CanGetCrmEvents.",
+ "lift_digest": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126",
+ "http4s_digest": "dec619a40558e96f890969e02eb8f036f8c2427d43139f8f02fec670b5762957"
+ },
+ {
+ "version": "v1_4_0",
+ "endpoint": "getCrmEvents",
+ "field": "errorResponseBodies",
+ "reason": "The endpoint now requires the bank-level Role CanGetCrmEvents; it returned every customer's CRM events to any logged-in User (the 2015 code was marked TODO Require Role).",
+ "lift_digest": "17b115e7d973045ce988c14715fa934e8dbb506b02316d0736dbc19c82ce75b3",
+ "http4s_digest": "26b2f283fe3019f4c20bec2d8666686efcc376f0250fcfe43933a1c4236c2747"
+ },
+ {
+ "version": "v2_0_0",
+ "endpoint": "getTransactionTypes",
+ "field": "errorResponseBodies",
+ "reason": "Lists AuthenticatedUserIsRequired so the middleware requires a login when apiOptions.getTransactionTypesIsPublic is false, as the Lift version did; the ResourceDoc drops it again when the prop is true.",
+ "lift_digest": "3bdcd0c958443de6b8a40314fb0e25bb976e6a734c73b4877302491756cb2ab9",
+ "http4s_digest": "266858573788363b20c268a76fb993e29a82e884667ec1a8f2ec8d27a9b88700"
+ },
+ {
+ "version": "v4_0_0",
+ "endpoint": "createStandingOrder",
+ "field": "errorResponseBodies",
+ "reason": "Lists CounterpartyNotFoundByCounterpartyId, returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.",
+ "lift_digest": "06a05f5082af05f86fb2d03f146a4a0d5f509405599781682198421383d24b1d",
+ "http4s_digest": "00a4a41b6dd6180c22589c39c7f504812330325232d1500fa4e1de0db4d649f1"
+ },
+ {
+ "version": "v4_0_0",
+ "endpoint": "createStandingOrderManagement",
+ "field": "errorResponseBodies",
+ "reason": "Lists CounterpartyNotFoundByCounterpartyId, returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.",
+ "lift_digest": "56a8a987f7348394c50e8f41e8cd0490b0ce68b266f898088c19334fa6b9e3a1",
+ "http4s_digest": "b716ea78d9cd1c02ef8aadef73ae10bb8870c0072b6156de4bb4017d04b2cedb"
+ },
+ {
+ "version": "v4_0_0",
+ "endpoint": "deleteCounterpartyForAnyAccount",
+ "field": "errorResponseBodies",
+ "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check), returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.",
+ "lift_digest": "74e48268b3b2dcc8120085167677f93e918c82246f2974bf90dbe4e8fa1ee761",
+ "http4s_digest": "5b86622b4bf6256e1840f581347312adad4de3cc6715d9f1c89a876827ca8026"
+ },
+ {
+ "version": "v4_0_0",
+ "endpoint": "deleteExplicitCounterparty",
+ "field": "errorResponseBodies",
+ "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check), returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.",
+ "lift_digest": "ad616f0980b99d4e23758a08c4983d6e05fd48e3ee6160832a4058bf0cba0ac5",
+ "http4s_digest": "dec64daf6eb1d796825733dc5a90de2abcb947d14fbc83d6ce8d8409daf83501"
+ },
+ {
+ "version": "v4_0_0",
+ "endpoint": "getCounterpartyByIdForAnyAccount",
+ "field": "errorResponseBodies",
+ "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check), returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.",
+ "lift_digest": "049483db4c37fe0fed677d64c700f3120cc5683a749b8316c47cec7f7918409b",
+ "http4s_digest": "0237a2b1b407cb784cb0c9f09c5cbf503210e00ebe9866a351f41d18e77b2a2a"
+ },
+ {
+ "version": "v4_0_0",
+ "endpoint": "getExplicitCounterpartyById",
+ "field": "errorResponseBodies",
+ "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check), returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.",
+ "lift_digest": "86bc4572566bcf1dd26b36429bd2e18bcea94b288b76100f9c842a383f6ca8a6",
+ "http4s_digest": "1227ef34c7cabd8837842965f7b4997fa41219f5bd6b41d8503b277c12d24d3a"
+ },
+ {
+ "version": "v6_0_0",
+ "endpoint": "createCounterpartyAttribute",
+ "field": "errorResponseBodies",
+ "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check), returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.",
+ "lift_digest": "8b9e9294a47baff632975336cec3da4c29f3ca3b8398ec5875e9b5d1712f0140",
+ "http4s_digest": "8688a59895b39a1fa023ea1dd7713a6f32154a76d772736dbef5d05d970ab5e4"
+ },
+ {
+ "version": "v6_0_0",
+ "endpoint": "getAllCounterpartyAttributes",
+ "field": "errorResponseBodies",
+ "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check), returned when the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL.",
+ "lift_digest": "12ad62cb53de1ed64537da486e94cc24fab901e301123e5f601fcd17fade09b3",
+ "http4s_digest": "956f30cff3ef2d5eeb625620e71e67ff08f6d9934d9d7e36aeb5c7e9f8f465fe"
+ },
+ {
+ "version": "v6_0_0",
+ "endpoint": "deleteCounterpartyAttribute",
+ "field": "errorResponseBodies",
+ "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check: the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL) and CounterpartyAttributeNotFound (the attribute does not belong to the COUNTERPARTY_ID in the URL).",
+ "lift_digest": "12ad62cb53de1ed64537da486e94cc24fab901e301123e5f601fcd17fade09b3",
+ "http4s_digest": "ad3a9a07f9f61f02744348beab43a6723b64032ad869dfefd4e2c8f8cd92b752"
+ },
+ {
+ "version": "v6_0_0",
+ "endpoint": "getCounterpartyAttributeById",
+ "field": "errorResponseBodies",
+ "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check: the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL) and CounterpartyAttributeNotFound (the attribute does not belong to the COUNTERPARTY_ID in the URL).",
+ "lift_digest": "12ad62cb53de1ed64537da486e94cc24fab901e301123e5f601fcd17fade09b3",
+ "http4s_digest": "ad3a9a07f9f61f02744348beab43a6723b64032ad869dfefd4e2c8f8cd92b752"
+ },
+ {
+ "version": "v6_0_0",
+ "endpoint": "updateCounterpartyAttribute",
+ "field": "errorResponseBodies",
+ "reason": "Lists CounterpartyNotFoundByCounterpartyId (400, from the middleware's COUNTERPARTY_ID check: the counterparty does not belong to the BANK_ID / ACCOUNT_ID in the URL) and CounterpartyAttributeNotFound (the attribute does not belong to the COUNTERPARTY_ID in the URL).",
+ "lift_digest": "8b9e9294a47baff632975336cec3da4c29f3ca3b8398ec5875e9b5d1712f0140",
+ "http4s_digest": "4776e3f23e3c01fc6a4a433289d9d2a1241b60e92eb44145cf4218ed5b6d1198"
+ },
+ {
+ "version": "v2_1_0",
+ "endpoint": "createTransactionType",
+ "field": "description",
+ "reason": "Always says User Authentication is Required; it used apiOptions.getTransactionTypesIsPublic (the read setting) un-negated, so with that prop false the docs called login optional for this create endpoint.",
+ "lift_digest": "4da376be772a5df5ca18eb378056a8b6cd06359b9e1b716e39590cddc035bd6b",
+ "http4s_digest": "d11d7022ff409e6f65b2d8fe4f56b8dd54bfd7744dd946806347c91d3ed355b6"
+ },
+ {
+ "version": "v2_0_0",
+ "endpoint": "createAccount",
+ "field": "errorResponseBodies",
+ "reason": "Adds AccountIdAlreadyExists: an existing ACCOUNT_ID is now refused, where it used to return the existing account and make the caller one of its holders.",
+ "lift_digest": "30b24bdbcdb523f00b5e4655fbdb91d8648ab84ee92f1f2292008816b8e31012",
+ "http4s_digest": "c48cc1204d4ea66a5f131c599ace7f8d502c91ce16a36bf0e507e06b915b8055"
}
]
}