diff --git a/README.md b/README.md
index 1cec985add..8bde28bc04 100644
--- a/README.md
+++ b/README.md
@@ -372,7 +372,7 @@ To populate the OBP database with sandbox data:
## Production Options
-OBP-API runs on http4s Ember. Standard security headers (Cache-Control, X-Frame-Options, Correlation-Id, etc.) are applied automatically by `Http4sStandardHeaders` (wired into `Http4sApp.httpApp`) to all responses. Cookie flags and other session-related settings can be configured via the props file.
+OBP-API runs on http4s Ember. Standard security headers (Cache-Control, X-Frame-Options, X-Content-Type-Options, Correlation-Id, etc.) are applied automatically by `Http4sStandardHeaders` (wired into `Http4sApp.httpApp`) to all responses. Cookie flags and other session-related settings can be configured via the props file.
## Server Mode Configuration (Removed)
diff --git a/obp-api/pom.xml b/obp-api/pom.xml
index 2d5203ced5..fc8fcaac90 100644
--- a/obp-api/pom.xml
+++ b/obp-api/pom.xml
@@ -357,6 +357,13 @@
1.17.1
+
+
+ org.jsoup
+ jsoup
+ 1.18.3
+
org.reflections
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 3d66491005..e86d38b42e 100644
--- a/obp-api/src/main/resources/docs/introductory_system_documentation.md
+++ b/obp-api/src/main/resources/docs/introductory_system_documentation.md
@@ -2697,7 +2697,7 @@ super_admin_user_ids=uuid-1,uuid-2
OBP can bootstrap a Consumer (Application) for OBP-OIDC at startup. This allows OBP-OIDC to authenticate as an application (without a User) and manage consumers via the API, eliminating the need for direct database access.
-The bootstrap consumer is granted the following Scopes: `CanGetConsumers`, `CanCreateConsumer`, `CanVerifyOidcClient`, `CanGetOidcClient`.
+The bootstrap consumer is granted the following Scopes: `CanGetConsumers`, `CanCreateConsumer`, `CanVerifyOidcClient`, `CanGetOidcClient`, `CanGetOidcConsent`.
These endpoints use `authMode = UserOrApplication`, meaning they can be accessed either by a logged-in User with Entitlements, or by an Application using a Consumer Key with Scopes.
diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
index aad033ee35..162debf7bb 100644
--- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
+++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
@@ -872,7 +872,7 @@ class Boot extends MdcLoggable {
/**
* Bootstrap OIDC Operator User
* Given the following credentials, OBP will create a user *if it does not exist already*.
- * This user will be granted: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient, CanGetConsumers, CanCreateConsumer
+ * This user will be granted: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient, CanGetOidcConsent, CanGetConsumers, CanCreateConsumer
*/
private def createBootstrapOidcOperatorUser() = {
@@ -912,6 +912,7 @@ class Boot extends MdcLoggable {
CanVerifyUserCredentials,
CanVerifyOidcClient,
CanGetOidcClient,
+ CanGetOidcConsent,
CanGetConsumers,
CanCreateConsumer
)
@@ -934,7 +935,7 @@ class Boot extends MdcLoggable {
/**
* Bootstrap OIDC Operator Consumer
* Given the following key and secret, OBP will create a consumer *if it does not exist already*.
- * This consumer will be granted scopes: CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient
+ * This consumer will be granted scopes: CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient, CanGetOidcConsent
* This allows OBP-OIDC to authenticate as an application (without a user) and manage consumers via the API.
*/
private def createBootstrapOidcOperatorConsumer() = {
@@ -982,7 +983,7 @@ class Boot extends MdcLoggable {
consumerBox match {
case Full(consumer) =>
logger.info(s"createBootstrapOidcOperatorConsumer says: Consumer created successfully with consumer_id: ${consumer.consumerId.get}")
- val scopes = List(CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient)
+ val scopes = List(CanGetConsumers, CanCreateConsumer, CanVerifyOidcClient, CanGetOidcClient, CanGetOidcConsent)
scopes.foreach { role =>
val resultBox = Scope.scope.vend.addScope("", consumer.id.get.toString, role.toString)
if (resultBox.isEmpty) {
diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala
index 80b8e452ba..0ee0e4e564 100644
--- a/obp-api/src/main/scala/code/api/OAuth2.scala
+++ b/obp-api/src/main/scala/code/api/OAuth2.scala
@@ -32,6 +32,7 @@ import code.consumer.Consumers
import code.consumer.Consumers.consumers
import code.loginattempts.LoginAttempt
import code.model.{AppType, Consumer}
+import code.model.dataAccess.AuthUser
import code.scope.Scope
import code.users.Users
import code.util.Helper.MdcLoggable
@@ -41,6 +42,7 @@ import com.openbankproject.commons.ExecutionContext.Implicits.global
import com.openbankproject.commons.model.User
import net.liftweb.common.Box.tryo
import net.liftweb.common._
+import net.liftweb.mapper.By
import net.liftweb.util.Helpers
import org.apache.commons.lang3.StringUtils
@@ -428,7 +430,11 @@ object OAuth2Login extends MdcLoggable {
def getOrCreateResourceUser(jwtToken: String): Box[User] = {
val uniqueIdGivenByProvider = JwtUtil.getSubject(jwtToken).getOrElse("")
val provider = resolveProvider(jwtToken)
- KeycloakFederatedUserReference.parse(uniqueIdGivenByProvider) match {
+ // Only Keycloak issues the federated form. From any other issuer it would name an arbitrary user_id.
+ val federatedRef =
+ if (Keycloak.isIssuer(jwtToken)) KeycloakFederatedUserReference.parse(uniqueIdGivenByProvider)
+ else Left("not issued by Keycloak")
+ federatedRef match {
case Right(fedRef) => // Users log on via Keycloak, which uses User Federation to access the external OBP database.
logger.debug(s"External ID = ${fedRef.externalId}")
logger.debug(s"Storage Provider ID = ${fedRef.storageProviderId}")
@@ -452,13 +458,17 @@ object OAuth2Login extends MdcLoggable {
}
def resolveProvider(jwtToken: String) = {
- // First try to get provider from token's provider claim
+ // First try to get provider from token's provider claim. Only OBP's own identity providers, which read
+ // OBP's user store, may name it: from any other issuer it could point a token at someone else's user.
val providerFromToken = JwtUtil.getProvider(jwtToken)
-
+ .filter(_ => OBPOIDC.isIssuer(jwtToken) || Keycloak.isIssuer(jwtToken))
+
providerFromToken.filter(_.trim.nonEmpty) match {
case Some(provider) =>
logger.debug(s"resolveProvider says: using provider from token claim: $provider")
provider
+ case None if OBPOIDC.isIssuer(jwtToken) && isClientCredentials(jwtToken) =>
+ clientCredentialsProvider(jwtToken)
case None if OBPOIDC.isIssuer(jwtToken) =>
// OBP-OIDC authenticates against the OBP-API user store, so its users ARE local users —
// use the local provider to avoid creating a duplicate user during the OIDC flow.
@@ -471,6 +481,29 @@ object OAuth2Login extends MdcLoggable {
}
}
+ /** A token with no person behind it: OBP-OIDC puts the client id in sub and names no provider. */
+ def isClientCredentials(jwtToken: String): Boolean =
+ getClaim(name = "grant_type", jwtToken = jwtToken).contains("client_credentials") ||
+ JwtUtil.getSubject(jwtToken).exists(sub => sub.nonEmpty && getClaim(name = "azp", jwtToken = jwtToken).contains(sub))
+
+ /** Where an app's own user lives. Not the local provider, where anyone may sign up with a username equal
+ * to a client id and then be the user everything the app does is recorded as. An app that already has a
+ * user there keeps it, unless that user is a person who signed up with the client id as username. */
+ private def clientCredentialsProvider(jwtToken: String): String = {
+ val clientId = JwtUtil.getSubject(jwtToken).getOrElse("")
+ val local = Constant.localIdentityProvider
+ lazy val localUser = Users.users.vend.getUserByProviderId(provider = local, idGivenByProvider = clientId)
+ lazy val signedUp = localUser.isDefined &&
+ AuthUser.find(By(AuthUser.username, clientId), By(AuthUser.provider, local)).isDefined
+ if (Users.users.vend.getUserByProviderId(provider = OBPOIDC.clientCredentialsProvider, idGivenByProvider = clientId).isDefined)
+ OBPOIDC.clientCredentialsProvider
+ else if (localUser.isDefined && !signedUp) local
+ else {
+ if (signedUp) logger.warn(s"resolveProvider says: a person signed up with the client id $clientId as username; the client gets a user of its own")
+ OBPOIDC.clientCredentialsProvider
+ }
+ }
+
/**
* This function resolves the token to a consumer based on the "azp", "aud", "iss", "sub",
* "name" and "email" claims. Please note that a user must be created before consumer.
@@ -768,6 +801,8 @@ object OAuth2Login extends MdcLoggable {
object OBPOIDC extends OAuth2Util {
val obpOidcHost = APIUtil.getPropsValue(nameOfProperty = "oauth2.obp_oidc.host", "http://localhost:9000")
val obpOidcIssuer = "obp-oidc"
+ // The provider of the users OBP-OIDC's client-credentials tokens resolve to: one no person can sign up under.
+ val clientCredentialsProvider = "obp-oidc#client"
/**
* OBP-OIDC (Open Bank Project OIDC Provider)
* OBP-OIDC exposes OpenID Connect discovery documents at /.well-known/openid-configuration
diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala
index 14d1e2309e..b93dabc9ed 100644
--- a/obp-api/src/main/scala/code/api/util/APIUtil.scala
+++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala
@@ -2252,7 +2252,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
// Virtual roles granted by super_admin_user_ids prop
val superAdminVirtualRoles: List[String] = List("CanCreateEntitlementAtOneBank", "CanCreateEntitlementAtAnyBank", "CanGetAnyUser")
// Virtual roles granted by oidc_operator_user_ids prop
- val oidcOperatorVirtualRoles: List[String] = List("CanGetAnyUser", "CanVerifyUserCredentials", "CanVerifyOidcClient", "CanGetOidcClient", "CanGetConsumers", "CanCreateConsumer")
+ val oidcOperatorVirtualRoles: List[String] = List("CanGetAnyUser", "CanVerifyUserCredentials", "CanVerifyOidcClient", "CanGetOidcClient", "CanGetOidcConsent", "CanGetConsumers", "CanCreateConsumer")
def hasScope(bankId: String, consumerId: String, role: ApiRole): Boolean = {
!Scope.scope.vend.getScope(bankId, consumerId, role.toString).isEmpty
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 b68b73f314..3e61f5037b 100644
--- a/obp-api/src/main/scala/code/api/util/ApiRole.scala
+++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala
@@ -345,6 +345,9 @@ object ApiRole extends MdcLoggable{
case class CanGetOidcClient(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetOidcClient = CanGetOidcClient()
+ case class CanGetOidcConsent(requiresBankId: Boolean = false) extends ApiRole
+ lazy val canGetOidcConsent = CanGetOidcConsent()
+
case class CanCreateTestEmail(requiresBankId: Boolean = false) extends ApiRole
lazy val canCreateTestEmail = CanCreateTestEmail()
diff --git a/obp-api/src/main/scala/code/api/util/PegdownOptions.scala b/obp-api/src/main/scala/code/api/util/PegdownOptions.scala
index 48f918958c..29405db5d2 100644
--- a/obp-api/src/main/scala/code/api/util/PegdownOptions.scala
+++ b/obp-api/src/main/scala/code/api/util/PegdownOptions.scala
@@ -32,6 +32,11 @@ import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.profiles.pegdown.Extensions
import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter
import com.vladsch.flexmark.util.options.{DataHolder, MutableDataSet}
+import org.jsoup.Jsoup
+import org.jsoup.nodes.{Document, Entities}
+import org.jsoup.safety.Safelist
+
+import scala.util.matching.Regex
object PegdownOptions {
@@ -41,20 +46,62 @@ object PegdownOptions {
private val OPTIONS: DataHolder = PegdownOptionsAdapter.flexmarkOptions(Extensions.ALL & ~Extensions.SMARTYPANTS)
private val PARSER: Parser = Parser.builder(OPTIONS).build
private val RENDERER: HtmlRenderer = HtmlRenderer.builder(OPTIONS).build
-
+
+ // Descriptions of Dynamic Entities, Dynamic Resource Docs, Dynamic Endpoints and Glossary Items are
+ // written through the API, and the HTML rendered from them is shown to every API Explorer visitor.
+ // Markdown passes raw HTML through, so without this a description could carry a \n\nAfter")
+ stringToNodeSeq(descriptionHtml)
+ descriptionHtml should not include ("