Merge pull request #2516 from constantine2nd/develop

A few tweaks
This commit is contained in:
Simon Redfern 2025-03-27 14:36:37 +01:00 committed by GitHub
commit e75e190bf7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 258 additions and 16 deletions

View File

@ -0,0 +1,65 @@
# OAuth 2.0 Client Credentials Flow Manual
## Overview
OAuth 2.0 Client Credentials Flow is used when a client application (such as a backend service) needs to authenticate and request access to resources without user interaction. This flow is typically used for machine-to-machine (M2M) authentication.
## Prerequisites / Assumptions
Before making requests, ensure you have:
- A valid **client_id** and **client_secret**.
- This example assumes the authorization server (Keycloak) is running on **localhost:7070**. Replace this with the actual auth server URL.
- A realm needs to been configured (e.g. 'master') and respective endpoint available: `/realms/master/protocol/openid-connect/token`.
## 1. Requesting an Access Token
To obtain an access token, send a **POST** request to the token endpoint with the following details.
### **Request**
```
POST /realms/master/protocol/openid-connect/token HTTP/1.1
Host: localhost:7070
Content-Type: application/x-www-form-urlencoded
Authorization: Basic Og==
Content-Length: 104
client_id=<client_id>&client_secret=<client_secret>&grant_type=client_credentials
```
### **Explanation of Parameters**
| Parameter | Description |
|------------------|-------------|
| `client_id` | The unique identifier for your client application. |
| `client_secret` | The secret key assigned to your client. |
| `grant_type` | Must be set to `client_credentials` to indicate this authentication flow. |
### **Example cURL Command**
```sh
curl -X POST "http://localhost:7070/realms/master/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-u "open-bank-project:WWJ04UzMhWmLEqW2KIgBHwD4UNEotzXz" \
-d "grant_type=client_credentials"
```
## 2. Expected Response
A successful request will return a JSON response containing the access token:
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600
}
```
### **Response Fields**
| Field | Description |
|----------------|-------------|
| `access_token` | The token required to authenticate API requests. |
| `token_type` | Usually `Bearer`, meaning it should be included in the Authorization header. |
| `expires_in` | The token expiration time in seconds. |
## 3. Using the Access Token
Once you obtain the access token, include it in the `Authorization` header of your subsequent API requests:
### **Example API Request with Token**
```sh
curl -X GET "http://localhost:7070/protected/resource" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```

View File

@ -165,6 +165,9 @@ jwt.use.ssl=false
# Bypass TPP signature validation
# bypass_tpp_signature_validation = false
## Reject Berlin Group Consents in status "received" after defined time
# berlin_group_outdated_consents_interval = 5
## Enable writing API metrics (which APIs are called) to RDBMS
write_metrics=true

View File

@ -107,7 +107,7 @@ import code.productfee.ProductFee
import code.products.MappedProduct
import code.ratelimiting.RateLimiting
import code.regulatedentities.MappedRegulatedEntity
import code.scheduler.{DataBaseCleanerScheduler, DatabaseDriverScheduler, JobScheduler, MetricsArchiveScheduler}
import code.scheduler.{ConsentScheduler, DataBaseCleanerScheduler, DatabaseDriverScheduler, JobScheduler, MetricsArchiveScheduler}
import code.scope.{MappedScope, MappedUserScope}
import code.signingbaskets.{MappedSigningBasket, MappedSigningBasketConsent, MappedSigningBasketPayment}
import code.snippet.{OAuthAuthorisation, OAuthWorkedThanks}
@ -728,6 +728,8 @@ class Boot extends MdcLoggable {
case Full(i) => DatabaseDriverScheduler.start(i)
case _ => // Do not start it
}
ConsentScheduler.startAll()
APIUtil.getPropsAsBoolValue("enable_metrics_scheduler", true) match {
case true =>

View File

@ -1281,9 +1281,9 @@ Maybe in a later version the access path will change.
)
consent <- challenge.scaStatus match {
case Some(status) if status == StrongCustomerAuthenticationStatus.finalised => // finalised
Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.VALID))
Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.valid))
case Some(status) if status == StrongCustomerAuthenticationStatus.failed => // failed
Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.REJECTED))
Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.rejected))
case _ => // all other cases
Future(Consents.consentProvider.vend.getConsentByConsentId(consentId))
}

View File

@ -548,7 +548,8 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats {
_links = ConsentLinksV13(
scaRedirect = Some(Href(s"$scaRedirectUrl")),
status = Some(Href(s"/v1.3/consents/${consent.consentId}/status")),
scaStatus = Some(Href(s"/v1.3/consents/${consent.consentId}/authorisations/AUTHORISATIONID")),
// TODO Introduce a working link
// scaStatus = Some(Href(s"/v1.3/consents/${consent.consentId}/authorisations/AUTHORISATIONID")),
)
)
case Full("redirection_with_dedicated_start_of_authorization") =>

View File

@ -732,7 +732,8 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
callContextLight.map(_.url)
else
None
val errorMessagesBG = ErrorMessagesBG(tppMessages = List(ErrorMessageBG(category = "ERROR", code = code.toString, path = path, text = message)))
val codeText = BerlinGroupError.translateToBerlinGroupError(code.toString, message)
val errorMessagesBG = ErrorMessagesBG(tppMessages = List(ErrorMessageBG(category = "ERROR", code = codeText, path = path, text = message)))
Extraction.decompose(errorMessagesBG)
} else {
Extraction.decompose(ErrorMessage(message = message, code = code))

View File

@ -0,0 +1,84 @@
package code.api.util
object BerlinGroupError {
/*
+---------------------------+---------------------------------------------+------------------------------------------------------------------------------------------------+---------------------------------+
| Code | HTTP Code | Description | Endpoint Method |
+---------------------------+---------------------------------------------+------------------------------------------------------------------------------------------------+---------------------------------+
| FORMAT_ERROR | 400 | The format of certain fields in the request does not meet the requirements. | /consents, /accounts, /payments |
| PARAMETER_NOT_CONSISTENT | 400 | The parameters sent by TPP are inconsistent (only for query parameters). | /consents, /accounts, /payments |
| PARAMETER_NOT_SUPPORTED | 400 | The parameter is not supported by the ASPSP API. | /consents, /accounts |
| SERVICE_INVALID | 400 (if payload) / 405 (if HTTP method) | The requested service is not valid for the requested resources. | /consents, /accounts, /payments |
| RESOURCE_UNKNOWN | 400 (if payload) / 403 / 404 | The requested resource cannot be found with respect to the TPP. | /consents, /accounts, /payments |
| RESOURCE_EXPIRED | 400 (if payload) / 403 | The requested resource has expired and is no longer accessible. | /consents, /accounts, /payments |
| RESOURCE_BLOCKED | 400 | The requested resource cannot be accessed because it is blocked. | /consents, /accounts, /payments |
| TIMESTAMP_INVALID | 400 | The time is not within an accepted period. | /consents, /accounts, /payments |
| PERIOD_INVALID | 400 | The requested time period is out of range. | /consents, /accounts, /payments |
| SCA_METHOD_UNKNOWN | 400 | The SCA method selected is unknown or cannot be compared by ASPSP with PSU. | /consents, /accounts, /payments |
| CONSENT_UNKNOWN | 400 (if header) / 403 (if path) | Consent-ID cannot be found by ASPSP with respect to TPP. | /consents, /accounts, /payments |
| SESSIONS_NOT_SUPPORTED | 400 | The combined service indicator cannot be used with this ASPSP. | /consents, /accounts, /payments |
| PAYMENT_FAILED | 400 | The POST request for initiating the payment failed. | /payments |
| EXECUTION_DATE_INVALID | 400 | The requested execution date is invalid for ASPSP. | /payments |
| CERTIFICATE_INVALID | 401 | The signature certificate content is invalid. | /consents, /accounts, /payments |
| ROLE_INVALID | 403 | The TPP does not have the necessary role. | /consents, /accounts |
| CERTIFICATE_EXPIRED | 401 | The signature certificate has expired. | /consents, /accounts, /payments |
| CERTIFICATE_BLOCKED | 401 | The signature certificate has been blocked. | /consents, /accounts, /payments |
| CERTIFICATE_REVOKED | 401 | The signature certificate has been revoked. | /consents, /accounts, /payments |
| CERTIFICATE_MISSING | 401 | The signature certificate was not available in the request but is required. | /consents, /accounts, /payments |
| SIGNATURE_INVALID | 401 | The signature applied for TPP authentication is invalid. | /consents, /accounts, /payments |
| SIGNATURE_MISSING | 401 | The signature applied for TPP authentication is missing. | /consents, /accounts, /payments |
| CORPORATE_ID_INVALID | 401 | PSU-Corporate-ID cannot be found by ASPSP. | /consents, /accounts, /payments |
| PSU_CREDENTIALS_INVALID | 401 | PSU-ID cannot be found by ASPSP, or it is blocked, or the password/OTP is incorrect. | /consents, /accounts, /payments |
| CONSENT_INVALID | 401 | The consent created by TPP is not valid for the requested service/resource. | /consents, /accounts, /payments |
| CONSENT_EXPIRED | 401 | The consent created by TPP has expired and needs to be renewed. | /consents, /accounts, /payments |
| TOKEN_UNKNOWN | 401 | The OAuth2 token cannot be found by ASPSP with respect to TPP. | /consents, /accounts, /payments |
| TOKEN_INVALID | 401 | The OAuth2 token is associated with the TPP but is not valid for the requested service. | /consents, /accounts, /payments |
| TOKEN_EXPIRED | 401 | The OAuth2 token has expired and needs to be renewed. | /consents, /accounts, /payments |
| SERVICE_BLOCKED | 403 | The service is not accessible to PSU due to a block by ASPSP. | /consents, /accounts, /payments |
| PRODUCT_INVALID | 403 | The requested payment product is not available for PSU. | /payments |
| PRODUCT_UNKNOWN | 404 | The requested payment product is not supported by ASPSP. | /payments |
| CANCELLATION_INVALID | 405 | Payments cannot be cancelled due to a time limit or legal restrictions. | /payments |
| REQUESTED_FORMATS_INVALID | 406 | The formats requested in the Accept header do not match the formats offered by ASPSP. | /consents, /accounts |
| STATUS_INVALID | 409 | The requested resource does not allow additional authorizations. | /consents, /accounts, /payments |
| ACCESS_EXCEEDED | 429 | Access to the account has exceeded the consented frequency per day. | /consents, /accounts |
+---------------------------+---------------------------------------------+------------------------------------------------------------------------------------------------+---------------------------------+
*/
def translateToBerlinGroupError(code: String, message: String): String = {
code match {
case "401" if message.contains("OBP-20001") => "PSU_CREDENTIALS_INVALID"
case "401" if message.contains("OBP-20201") => "PSU_CREDENTIALS_INVALID"
case "401" if message.contains("OBP-20214") => "PSU_CREDENTIALS_INVALID"
case "401" if message.contains("OBP-20013") => "PSU_CREDENTIALS_INVALID"
case "401" if message.contains("OBP-20202") => "PSU_CREDENTIALS_INVALID"
case "401" if message.contains("OBP-20203") => "PSU_CREDENTIALS_INVALID"
case "401" if message.contains("OBP-20206") => "PSU_CREDENTIALS_INVALID"
case "401" if message.contains("OBP-20207") => "PSU_CREDENTIALS_INVALID"
case "401" if message.contains("OBP-20204") => "TOKEN_EXPIRED"
case "401" if message.contains("OBP-35003") => "CONSENT_EXPIRED"
case "401" if message.contains("OBP-35004") => "CONSENT_INVALID"
case "401" if message.contains("OBP-35015") => "CONSENT_INVALID"
case "401" if message.contains("OBP-35017") => "CONSENT_INVALID"
case "401" if message.contains("OBP-35019") => "CONSENT_INVALID"
case "401" if message.contains("OBP-35018") => "CONSENT_INVALID"
case "401" if message.contains("OBP-20060") => "ROLE_INVALID"
case "400" if message.contains("OBP-35018") => "CONSENT_UNKNOWN"
case "400" if message.contains("OBP-35001") => "CONSENT_UNKNOWN"
case "404" if message.contains("OBP-30076") => "RESOURCE_UNKNOWN"
case "400" if message.contains("OBP-10001") => "FORMAT_ERROR"
case "400" if message.contains("OBP-20062") => "FORMAT_ERROR"
case "400" if message.contains("OBP-20063") => "FORMAT_ERROR"
case "429" if message.contains("OBP-10018") => "ACCESS_EXCEEDED"
case _ => code
}
}
}

View File

@ -202,7 +202,7 @@ object Consent extends MdcLoggable {
val consentBox = Consents.consentProvider.vend.getConsentByConsentId(consent.jti)
logger.debug(s"code.api.util.Consent.checkConsent.getConsentByConsentId: consentBox($consentBox)")
val result = consentBox match {
case Full(c) if c.mStatus.toString().toUpperCase == ConsentStatus.ACCEPTED.toString | c.mStatus.toString().toUpperCase() == ConsentStatus.VALID.toString =>
case Full(c) if c.mStatus.toString().toUpperCase == ConsentStatus.ACCEPTED.toString | c.mStatus.toString().toLowerCase() == ConsentStatus.valid.toString =>
verifyHmacSignedJwt(consentIdAsJwt, c) match {
case true =>
(System.currentTimeMillis / 1000) match {

View File

@ -3514,10 +3514,8 @@ object Glossary extends MdcLoggable {
}
private def getListOfFiles():List[File] = {
val d = new File("src/main/docs/glossary").exists() match {
case true => new File("src/main/docs/glossary")
case false => new File("obp-api/src/main/docs/glossary")
}
val currentDir = new File(".").getCanonicalPath
val d = new File(currentDir + "/obp-api/src/main/docs/glossary")
if (d.exists && d.isDirectory) {
d.listFiles.filter(_.isFile).filter(_.getName.endsWith(".md")).toList
} else {

View File

@ -191,7 +191,7 @@ object ConsentStatus extends Enumeration {
type ConsentStatus = Value
val INITIATED, ACCEPTED, REJECTED, rejected, REVOKED,
// The following one only exist in case of BerlinGroup
RECEIVED, received, VALID, valid, REVOKEDBYPSU, revokedByPsu, EXPIRED, expired, TERMINATEDBYTPP, terminatedByTpp,
received, valid, REVOKEDBYPSU, revokedByPsu, EXPIRED, expired, TERMINATEDBYTPP, terminatedByTpp,
//these added for UK Open Banking
AUTHORISED, AWAITINGAUTHORISATION = Value
}

View File

@ -130,7 +130,7 @@ object MappedConsentProvider extends ConsentProvider {
.create
.mUserId(user.map(_.userId).getOrElse(null))
.mConsumerId(consumer.map(_.consumerId.get).getOrElse(null))
.mStatus(ConsentStatus.RECEIVED.toString)
.mStatus(ConsentStatus.received.toString)
.mRecurringIndicator(recurringIndicator)
.mValidUntil(validUntil)
.mFrequencyPerDay(frequencyPerDay)

View File

@ -0,0 +1,73 @@
package code.scheduler
import code.actorsystem.ObpLookupSystem
import code.api.util.APIUtil
import code.consent.{ConsentStatus, MappedConsent}
import code.util.Helper.MdcLoggable
import com.openbankproject.commons.util.ApiVersion
import net.liftweb.mapper.{By, By_<}
import java.util.concurrent.TimeUnit
import java.util.{Calendar, Date}
import scala.concurrent.duration._
import scala.util.{Failure, Success, Try}
object ConsentScheduler extends MdcLoggable {
private lazy val actorSystem = ObpLookupSystem.obpLookupSystem
implicit lazy val executor = actorSystem.dispatcher
private lazy val scheduler = actorSystem.scheduler
// Starts multiple scheduled tasks with different intervals
def startAll(): Unit = {
startTask(interval = 60, () => unfinishedBerlinGroupConsents()) // Runs every 60 sec
}
// Generic method to schedule a task
private def startTask(interval: Long, task: () => Unit): Unit = {
scheduler.schedule(
initialDelay = Duration(interval, TimeUnit.SECONDS),
interval = Duration(interval, TimeUnit.SECONDS),
runnable = new Runnable {
def run(): Unit = task()
}
)
}
// Calculate the timestamp 5 minutes ago
private val someMinutesAgo: Date = {
val minutes = APIUtil.getPropsAsIntValue("berlin_group_outdated_consents_interval", 5)
val cal = Calendar.getInstance()
cal.add(Calendar.MINUTE, -minutes)
cal.getTime
}
private def unfinishedBerlinGroupConsents(): Unit = {
Try {
logger.debug("|---> Checking for outdated Berlin Group consents...")
val outdatedConsents = MappedConsent.findAll(
By(MappedConsent.mStatus, ConsentStatus.received.toString),
By(MappedConsent.mApiStandard, ApiVersion.berlinGroupV13.apiStandard),
By_<(MappedConsent.updatedAt, someMinutesAgo)
)
logger.debug(s"|---> Found ${outdatedConsents.size} outdated consents")
outdatedConsents.foreach { consent =>
Try {
consent.mStatus(ConsentStatus.rejected.toString).save
logger.warn(s"|---> Changed status to ${ConsentStatus.rejected.toString} for consent ID: ${consent.id}")
} match {
case Failure(ex) => logger.error(s"Failed to update consent ID: ${consent.id}", ex)
case Success(_) => // Already logged
}
}
} match {
case Failure(ex) => logger.error("Error in unfinishedBerlinGroupConsents!", ex)
case Success(_) => logger.debug("|---> Task executed successfully")
}
}
}

View File

@ -284,11 +284,26 @@ class BerlinGroupConsent extends MdcLoggable with RestHelper with APIMethods510
</p>
<div>
<p><strong>Allowed actions:</strong></p>
<p style="padding-left: 20px">Read account details</p>
<p style={if (accessBalancesDefinedVar.is) "padding-left: 20px;" else "padding-left: 20px; display: none;"}>Read account balances</p>
<p style={if (accessTransactionsDefinedVar.is) "padding-left: 20px;" else "padding-left: 20px; display: none;"}>Read transactions</p>
<p style="padding-left: 20px">
Read account details
<div style={if (!updateConsentPayloadValue.is) "padding-left: 40px; display: block;" else "display: none;"}>
{scala.xml.Unparsed(canReadAccountsIbans.map(iban => s"- $iban").mkString("<br/>"))}
</div>
</p>
<p style={if (accessBalancesDefinedVar.is) "padding-left: 20px;" else "padding-left: 20px; display: none;"}>
Read account balances
<div style={if (!updateConsentPayloadValue.is) "padding-left: 40px; display: block;" else "display: none;"}>
{scala.xml.Unparsed(canReadBalancesIbans.map(iban => s"- $iban").mkString("<br/>"))}
</div>
</p>
<p style={if (accessTransactionsDefinedVar.is) "padding-left: 20px;" else "padding-left: 20px; display: none;"}>
Read transactions
<div style={if (!updateConsentPayloadValue.is) "padding-left: 40px; display: block;" else "display: none;"}>
{scala.xml.Unparsed(canReadTransactionsIbans.map(iban => s"- $iban").mkString("<br/>"))}
</div>
</p>
</div>
<div>
<div style={if (updateConsentPayloadValue.is) "display: block;" else "display: none;"}>
<p><strong>Accounts</strong>:</p>
<div style="padding-left: 20px">
{generateCheckboxes("canReadAccountsIbans", userIbans.toList, selectedAccountsIbansValue.is, selectedAccountsIbansValue, ibansFromGetConsentResponseJson)}