Skip to content

Berlin Group alias #2545

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 23, 2025
6 changes: 6 additions & 0 deletions obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -1160,12 +1160,18 @@ default_auth_context_update_request_key=CUSTOMER_NUMBER
## Berlin Group Create Consent Frequency per Day Upper Limit
#berlin_group_frequency_per_day_upper_limit = 4

## Berlin Group Create Consent ASPSP-SCA-Approach response header value
#berlin_group_aspsp_sca_approach = redirect

# Support multiple brands on one instance. Note this needs checking on a clustered environment
#brands_enabled=false

# Support removing the app type checkbox during consumer registration
#consumer_registration.display_app_type=true

# Default logo URL during of consumer
#consumer_default_logo_url=

# if set this props, we can automatically grant the Entitlements required to use all the Dynamic Endpoint roles belonging
# to the bank_ids (Spaces) the User has access to via their validated email domain. Entitlements are generated /refreshed
# both following manual login and Direct Login token generation (POST).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package code.api.builder.AccountInformationServiceAISApi

import java.text.SimpleDateFormat
import code.api.APIFailureNewStyle
import code.api.Constant.{SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID}
import code.api.berlin.group.ConstantsBG
Expand All @@ -13,7 +12,7 @@ import code.api.util.ApiTag._
import code.api.util.ErrorMessages._
import code.api.util.NewStyle.HttpCode
import code.api.util.newstyle.BalanceNewStyle
import code.api.util.{APIUtil, ApiTag, CallContext, Consent, ExampleValue, NewStyle}
import code.api.util._
import code.bankconnectors.Connector
import code.consent.{ConsentStatus, Consents}
import code.context.{ConsentAuthContextProvider, UserAuthContextProvider}
Expand All @@ -24,16 +23,14 @@ import code.views.Views
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.ExecutionContext.Implicits.global
import com.openbankproject.commons.model._
import com.openbankproject.commons.model.enums.{ChallengeType, StrongCustomerAuthentication, StrongCustomerAuthenticationStatus, SuppliedAnswerType}
import com.openbankproject.commons.util.ApiVersion
import com.openbankproject.commons.model.enums.{ChallengeType, StrongCustomerAuthenticationStatus, SuppliedAnswerType}
import net.liftweb
import net.liftweb.common.{Empty, Full}
import net.liftweb.http.js.JE.JsRaw
import net.liftweb.http.rest.RestHelper
import net.liftweb
import net.liftweb.json
import net.liftweb.json._

import scala.collection.immutable.Nil
import scala.collection.mutable.ArrayBuffer
import scala.concurrent.Future

Expand Down Expand Up @@ -160,11 +157,28 @@ recurringIndicator:
consentJson <- NewStyle.function.tryons(failMsg, 400, callContext) {
json.extract[PostConsentJson]
}
_ <- Helper.booleanToFuture(failMsg = BerlinGroupConsentAccessIsEmpty, cc=callContext) {
consentJson.access.accounts.isDefined ||
consentJson.access.balances.isDefined ||
consentJson.access.transactions.isDefined

_ <- if (consentJson.access.availableAccounts.isDefined) {
for {
_ <- Helper.booleanToFuture(failMsg = BerlinGroupConsentAccessAvailableAccounts, cc = callContext) {
consentJson.access.availableAccounts.contains("allAccounts")
}
_ <- Helper.booleanToFuture(failMsg = BerlinGroupConsentAccessRecurringIndicator, cc = callContext) {
!consentJson.recurringIndicator
}
_ <- Helper.booleanToFuture(failMsg = BerlinGroupConsentAccessFrequencyPerDay, cc = callContext) {
consentJson.frequencyPerDay == 1
}
} yield Full(())
} else {
Helper.booleanToFuture(
failMsg = BerlinGroupConsentAccessIsEmpty, cc = callContext) {
consentJson.access.accounts.isDefined ||
consentJson.access.balances.isDefined ||
consentJson.access.transactions.isDefined
}
}

upperLimit = APIUtil.getPropsAsIntValue("berlin_group_frequency_per_day_upper_limit", 4)
_ <- Helper.booleanToFuture(failMsg = FrequencyPerDayError, cc=callContext) {
consentJson.frequencyPerDay > 0 && consentJson.frequencyPerDay <= upperLimit
Expand Down Expand Up @@ -246,11 +260,16 @@ recurringIndicator:
case "consents" :: consentId :: Nil JsonDelete _ => {
cc =>
for {
(Full(user), callContext) <- authenticatedAccess(cc)
(_, callContext) <- applicationAccess(cc)
_ <- passesPsd2Aisp(callContext)
_ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map {
consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map {
unboxFullOrFail(_, callContext, ConsentNotFound, 403)
}
consumerIdFromConsent = consent.mConsumerId.get
consumerIdFromCurrentCall = callContext.map(_.consumer.map(_.consumerId.get).getOrElse("None")).getOrElse("None")
_ <- Helper.booleanToFuture(failMsg = s"$ConsentNotFound $consumerIdFromConsent != $consumerIdFromCurrentCall", failCode = 403, cc = cc.callContext) {
consumerIdFromConsent == consumerIdFromCurrentCall
}
_ <- Future(Consents.consentProvider.vend.revokeBerlinGroupConsent(consentId)) map {
i => connectorEmptyResponse(i, callContext)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ object BgSpecValidation {

if (date.isBefore(today)) {
Left(s"$InvalidDateFormat The `validUntil` date ($dateStr) cannot be in the past!")
} else if (date.isAfter(MaxValidDays)) {
} else if (date.isEqual(MaxValidDays) || date.isAfter(MaxValidDays)) {
Left(s"$InvalidDateFormat The `validUntil` date ($dateStr) exceeds the maximum allowed period of 180 days (until $MaxValidDays).")
} else {
Right(date) // Valid date
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ object OBP_BERLIN_GROUP_1_3_Alias extends OBPRestHelper with MdcLoggable with Sc

override val allResourceDocs: ArrayBuffer[ResourceDoc] = if(berlinGroupV13AliasPath.nonEmpty){
OBP_BERLIN_GROUP_1_3.allResourceDocs.map(resourceDoc => resourceDoc.copy(
implementedInApiVersion = apiVersion,
implementedInApiVersion = apiVersion.copy(apiStandard = resourceDoc.implementedInApiVersion.apiStandard),
))
} else ArrayBuffer.empty[ResourceDoc]

Expand Down
1 change: 1 addition & 0 deletions obp-api/src/main/scala/code/api/constant/constant.scala
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ object RequestHeader {
final lazy val `If-Modified-Since` = "If-Modified-Since"
}
object ResponseHeader {
final lazy val `ASPSP-SCA-Approach` = "ASPSP-SCA-Approach" // Berlin Group
final lazy val `Correlation-Id` = "Correlation-Id"
final lazy val `WWW-Authenticate` = "WWW-Authenticate"
final lazy val ETag = "ETag"
Expand Down
17 changes: 15 additions & 2 deletions obp-api/src/main/scala/code/api/util/APIUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -449,8 +449,9 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{

private def getHeadersNewStyle(cc: Option[CallContextLight]) = {
CustomResponseHeaders(
getGatewayLoginHeader(cc).list :::
getRateLimitHeadersNewStyle(cc).list :::
getGatewayLoginHeader(cc).list :::
getRequestHeadersBerlinGroup(cc).list :::
getRateLimitHeadersNewStyle(cc).list :::
getPaginationHeadersNewStyle(cc).list :::
getRequestHeadersToMirror(cc).list :::
getRequestHeadersToEcho(cc).list
Expand Down Expand Up @@ -556,6 +557,18 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
CustomResponseHeaders(Nil)
}
}

def getRequestHeadersBerlinGroup(callContext: Option[CallContextLight]): CustomResponseHeaders = {
val aspspScaApproach = getPropsValue("berlin_group_aspsp_sca_approach", defaultValue = "redirect")
callContext match {
case Some(cc) if cc.url.contains(ConstantsBG.berlinGroupVersion1.urlPrefix) && cc.url.endsWith("/consents") =>
CustomResponseHeaders(List(
(ResponseHeader.`ASPSP-SCA-Approach`, aspspScaApproach)
))
case _ =>
CustomResponseHeaders(Nil)
}
}
/**
*
* @param jwt is a JWT value extracted from GatewayLogin Authorization Header.
Expand Down
3 changes: 3 additions & 0 deletions obp-api/src/main/scala/code/api/util/BerlinGroupError.scala
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ object BerlinGroupError {
case "400" if message.contains("OBP-20252") => "FORMAT_ERROR"
case "400" if message.contains("OBP-20251") => "FORMAT_ERROR"
case "400" if message.contains("OBP-20088") => "FORMAT_ERROR"
case "400" if message.contains("OBP-20089") => "FORMAT_ERROR"
case "400" if message.contains("OBP-20090") => "FORMAT_ERROR"
case "400" if message.contains("OBP-20091") => "FORMAT_ERROR"

case "429" if message.contains("OBP-10018") => "ACCESS_EXCEEDED"
case _ => code
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,8 @@ object BerlinGroupSigning extends MdcLoggable {
developerEmail = extractedEmail,
redirectURL = None,
createdByUserId = None,
certificate = None
certificate = None,
logoUrl = APIUtil.getPropsValue("consumer_default_logo_url")
)

// Set or update certificate
Expand Down
62 changes: 62 additions & 0 deletions obp-api/src/main/scala/code/api/util/ConsentUtil.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package code.api.util

import code.accountholders.AccountHolders
import code.api.berlin.group.ConstantsBG

import java.text.SimpleDateFormat
Expand All @@ -20,6 +21,7 @@ import code.consumer.Consumers
import code.context.{ConsentAuthContextProvider, UserAuthContextProvider}
import code.entitlement.Entitlement
import code.model.Consumer
import code.model.dataAccess.BankAccountRouting
import code.scheduler.ConsentScheduler.logger
import code.users.Users
import code.util.Helper.MdcLoggable
Expand Down Expand Up @@ -873,6 +875,66 @@ object Consent extends MdcLoggable {
}
}
}
def updateViewsOfBerlinGroupConsentJWT(user: User,
consent: MappedConsent,
callContext: Option[CallContext]): Future[Box[MappedConsent]] = {
implicit val dateFormats = CustomJsonFormats.formats
val payloadToUpdate: Box[ConsentJWT] = JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken) // Payload as JSON string
.map(net.liftweb.json.parse(_).extract[ConsentJWT]) // Extract case class

val availableAccountsUserIbans: List[String] = payloadToUpdate match {
case Full(consentJwt) =>
val availableAccountsUserIbans: List[String] =
if (consentJwt.access.map(_.availableAccounts.contains("allAccounts")).isDefined) {
// Get all accounts held by the current user
val userAccounts: List[BankIdAccountId] =
AccountHolders.accountHolders.vend.getAccountsHeldByUser(user, Some(null)).toList
userAccounts.flatMap { acc =>
BankAccountRouting.find(
By(BankAccountRouting.BankId, acc.bankId.value),
By(BankAccountRouting.AccountId, acc.accountId.value),
By(BankAccountRouting.AccountRoutingScheme, "IBAN")
).map(_.AccountRoutingAddress.get)
}
} else {
val emptyList: List[String] = Nil
emptyList
}
availableAccountsUserIbans
case _ =>
val emptyList: List[String] = Nil
emptyList
}


// 1. Add access
val availableAccounts: List[Future[ConsentView]] = availableAccountsUserIbans.distinct map { iban =>
Connector.connector.vend.getBankAccountByIban(iban, callContext) map { bankAccount =>
logger.debug(s"createBerlinGroupConsentJWT.accounts.bankAccount: $bankAccount")
val error = s"${InvalidConnectorResponse} IBAN: ${iban} ${handleBox(bankAccount._1)}"
ConsentView(
bank_id = bankAccount._1.map(_.bankId.value).getOrElse(""),
account_id = bankAccount._1.map(_.accountId.value).openOrThrowException(error),
view_id = Constant.SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID,
None
)
}
}

Future.sequence(availableAccounts) map { views =>
if(views.isEmpty) {
Empty
} else {
val updatedPayload = payloadToUpdate.map(i =>
i.copy(views = views) // Update the field "views"
)
val jwtPayloadAsJson = compactRender(Extraction.decompose(updatedPayload))
val jwtClaims: JWTClaimsSet = JWTClaimsSet.parse(jwtPayloadAsJson)
val jwt = CertificateUtil.jwtWithHmacProtection(jwtClaims, consent.secret)
Consents.consentProvider.vend.setJsonWebToken(consent.consentId, jwt)
}
}
}

def updateUserIdOfBerlinGroupConsentJWT(createdByUserId: String,
consent: MappedConsent,
Expand Down
5 changes: 4 additions & 1 deletion obp-api/src/main/scala/code/api/util/ErrorMessages.scala
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,10 @@ object ErrorMessages {
s"OBP-20087: The current source view.can_revoke_access_to_custom_views is false."

val BerlinGroupConsentAccessIsEmpty = s"OBP-20088: An access must be requested."

val BerlinGroupConsentAccessRecurringIndicator = s"OBP-20089: Recurring indicator must be false when availableAccounts is used."
val BerlinGroupConsentAccessFrequencyPerDay = s"OBP-20090: Frequency per day must be 1 when availableAccounts is used."
val BerlinGroupConsentAccessAvailableAccounts = s"OBP-20091: availableAccounts must be exactly 'allAccounts'."

val UserNotSuperAdminOrMissRole = "OBP-20101: Current User is not super admin or is missing entitlements:"
val CannotGetOrCreateUser = "OBP-20102: Cannot get or create user."
val InvalidUserProvider = "OBP-20103: Invalid DAuth User Provider."
Expand Down
4 changes: 3 additions & 1 deletion obp-api/src/main/scala/code/consumer/ConsumerProvider.scala
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ trait ConsumersProvider {
developerEmail: Option[String],
redirectURL: Option[String],
createdByUserId: Option[String],
certificate: Option[String] = None): Box[Consumer]
certificate: Option[String] = None,
logoUrl: Option[String] = None
): Box[Consumer]
def populateMissingUUIDs(): Boolean

}
8 changes: 7 additions & 1 deletion obp-api/src/main/scala/code/model/OAuth.scala
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,9 @@ object MappedConsumersProvider extends ConsumersProvider with MdcLoggable {
developerEmail: Option[String],
redirectURL: Option[String],
createdByUserId: Option[String],
certificate: Option[String]): Box[Consumer] = {
certificate: Option[String],
logoUrl: Option[String],
): Box[Consumer] = {

val consumer: Box[Consumer] =
// 1st try to find via UUID issued by OBP-API back end
Expand Down Expand Up @@ -473,6 +475,10 @@ object MappedConsumersProvider extends ConsumersProvider with MdcLoggable {
case Some(v) => c.clientCertificate(v)
case None =>
}
logoUrl match {
case Some(v) => c.logoUrl(v)
case None =>
}
consumerId match {
case Some(v) => c.consumerId(v)
case None =>
Expand Down
27 changes: 24 additions & 3 deletions obp-api/src/main/scala/code/snippet/BerlinGroupConsent.scala
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,19 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
// Select all IBANs
selectedAccountsIbansValue.set(userIbans)

var canReadAccountsIbansAvailableAccounts: List[String] = List()
if(json.access.availableAccounts.contains("allAccounts")) { //
/*
Access is requested via:
"access":
{
"availableAccounts": "allAccounts"
}
*/
accessAccountsDefinedVar.set(true)
canReadAccountsIbansAvailableAccounts = userIbans.toList
}

// Determine which IBANs the user can access for accounts, balances, and transactions
val canReadAccountsIbans: List[String] = json.access.accounts match {
case Some(accounts) if accounts.isEmpty => // Access is requested via "accounts": []
Expand Down Expand Up @@ -226,7 +239,7 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
}

// all Selected IBANs
val ibansFromGetConsentResponseJson = (canReadAccountsIbans ::: canReadBalancesIbans ::: canReadTransactionsIbans).distinct
val ibansFromGetConsentResponseJson = (canReadAccountsIbansAvailableAccounts ::: canReadAccountsIbans ::: canReadBalancesIbans ::: canReadTransactionsIbans).distinct

/**
* Generates toggle switches for IBAN lists.
Expand Down Expand Up @@ -388,7 +401,9 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
Consents.consentProvider.vend.getConsentByConsentId(consentId) match {
case Full(consent) if otpValue.is == consent.challenge =>
updateConsentUser(consent)
Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.rejected)
updateConsentJwt(consent) map { i =>
Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.rejected)
}
S.redirectTo(
s"$redirectUriValue?CONSENT_ID=${consentId}"
)
Expand All @@ -406,7 +421,9 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
Consents.consentProvider.vend.getConsentByConsentId(consentId) match {
case Full(consent) if otpValue.is == consent.challenge =>
updateConsentUser(consent)
Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.valid)
updateConsentJwt(consent) map { i =>
Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.valid)
}
S.redirectTo(
s"/confirm-bg-consent-request-redirect-uri?CONSENT_ID=${consentId}"
)
Expand All @@ -421,6 +438,10 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
val jwt = Consent.updateUserIdOfBerlinGroupConsentJWT(loggedInUser.userId, consent, None).openOrThrowException(ErrorMessages.InvalidConnectorResponse)
Consents.consentProvider.vend.setJsonWebToken(consent.consentId, jwt)
}
private def updateConsentJwt(consent: MappedConsent) = {
val loggedInUser = AuthUser.currentUser.flatMap(_.user.foreign).openOrThrowException(ErrorMessages.UserNotLoggedIn)
Consent.updateViewsOfBerlinGroupConsentJWT(loggedInUser, consent, None)
}

private def getTppRedirectUri() = {
val consentId = ObpS.param("CONSENT_ID") openOr ("")
Expand Down
Loading