From 1d05c9fda6fcb04632c6dfd7704382f79e5dc4b5 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 9 Oct 2026 07:29:10 +0200 Subject: [PATCH 1/4] fix: sanitiseHtml: sanitise rendered description HTML with a jsoup allowlist + send X-Content-Type-Options: nosniff on every response. --- README.md | 2 +- obp-api/pom.xml | 7 ++ .../scala/code/api/util/PegdownOptions.scala | 65 +++++++++++++++--- .../util/http4s/Http4sStandardHeaders.scala | 5 ++ .../code/api/v7_0_0/Http4s700RoutesTest.scala | 19 ++++++ .../scala/code/util/PegdownOptionsTest.scala | 67 +++++++++++++++++++ 6 files changed, 155 insertions(+), 10 deletions(-) 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/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 ("""") + stringToNodeSeq(descriptionHtml) + descriptionHtml should include ("src=\"https://example.com/a.png\"") + descriptionHtml should not include ("onerror") + } + + "a javascript link" should "lose its href" taggedAs FunctionsTag in { + val markdownLink = convertPegdownToHtmlTweaked("[click](javascript:alert(1))") + val htmlLink = convertPegdownToHtmlTweaked("""click""") + markdownLink should not include ("javascript:") + htmlLink should not include ("javascript:") + htmlLink should include ("click") + } + + "a sized image whose url contains a double quote" should "not break out of the src attribute" taggedAs FunctionsTag in { + val descriptionHtml = convertPegdownToHtmlTweaked("""![logo](https://example.com/a.png" onload="alert(1) =10x20)""") + val image = stringToNodeSeq(descriptionHtml) \\ "img" + image.size should be (1) + // The quote is escaped, so onload stays text inside src rather than becoming an attribute. + image.head.attribute("onload") should be (None) + image.head \@ "src" should be ("https://example.com/a.png\" onload=\"alert(1)") + image.head \@ "width" should be ("10") + } + + "formatting that descriptions use on purpose" should "survive sanitising" taggedAs FunctionsTag in { + val descriptionHtml = convertPegdownToHtmlTweaked( + """
More + | + |

Inside a link

+ | + |
+ | + |[BANK_ID](/glossary#Bank.bank_id) and [Contact](#Contact) + | + |```json + |{ + | "indented": true + |} + |``` + | + || a | b | + ||---|---| + || 1 | 2 | + | + |~~gone~~ and line
break + |""".stripMargin) + stringToNodeSeq(descriptionHtml) + descriptionHtml should include ("
") + descriptionHtml should include ("More") + descriptionHtml should include ("a link") + descriptionHtml should include ("BANK_ID") + descriptionHtml should include ("Contact") + descriptionHtml should include ("\n \"indented\": true\n") + descriptionHtml should include ("") + descriptionHtml should include ("gone") + descriptionHtml should include ("
") + } } From e34e6024555ec74a0d54ce3b6cee4d83ec38d114 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 10 Oct 2026 09:37:09 +0200 Subject: [PATCH 2/4] feature: add GET /obp/v7.0.0/oidc/consents/CONSENT_ID so an OIDC provider can read a consent's status, consent request, consumer and user from OBP-API before issuing a code, gated by the new role CanGetOidcConsent, which is added to the OIDC operator virtual roles and granted to the bootstrap OIDC operator user and consumer. --- .../docs/introductory_system_documentation.md | 2 +- .../main/scala/bootstrap/liftweb/Boot.scala | 7 +- .../main/scala/code/api/util/APIUtil.scala | 2 +- .../main/scala/code/api/util/ApiRole.scala | 3 + .../scala/code/api/v7_0_0/Http4s700.scala | 3 + .../api/v7_0_0/Http4s700OpenIdConnect.scala | 124 ++++++++++++++++++ .../v7_0_0/JSONFactory700OpenIdConnect.scala | 49 +++++++ .../Http4sServerIntegrationTest.scala | 60 +++++++++ .../code/api/v7_0_0/GetOidcConsentTest.scala | 115 ++++++++++++++++ 9 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/v7_0_0/Http4s700OpenIdConnect.scala create mode 100644 obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700OpenIdConnect.scala create mode 100644 obp-api/src/test/scala/code/api/v7_0_0/GetOidcConsentTest.scala 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/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/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 135a3c57b3..87d397322a 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -7512,6 +7512,9 @@ object Http4s700 { // The asset registry: the currencies, metals, accounting units, crypto assets and bank-issued assets amounts are held in. resourceDocs ++= Http4s700Assets.resourceDocs + // What an OIDC provider reads from OBP-API to finish signing a User in for a Consumer. + resourceDocs ++= Http4s700OpenIdConnect.resourceDocs + val allRoutes: HttpRoutes[IO] = { val sorted = resourceDocs .sortBy(rd => -rd.requestUrl.split("/").count(_.nonEmpty)) diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700OpenIdConnect.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700OpenIdConnect.scala new file mode 100644 index 0000000000..85c7cb6c95 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700OpenIdConnect.scala @@ -0,0 +1,124 @@ +/** +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.v7_0_0 + +import cats.effect.IO +import code.api.Constant.ApiPathZero +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, UserOrApplication, unboxFullOrFail, userAuthenticationMessage} +import code.api.util.ApiRole._ +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers +import code.api.util.{CallContext, CustomJsonFormats, NewStyle} +import code.consent.Consents +import code.consumer.Consumers +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.util.ApiVersion +import org.http4s._ +import org.http4s.dsl.io._ +import org.json4s.Formats + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +/** + * This object holds the v7.0.0 endpoints an OIDC provider (such as OBP-OIDC) calls while it signs Users in + * for Consumers. The older ones, which look up and verify OIDC clients, are in v6.0.0. + * + * It is declared in its own object to keep Http4s700's initialiser under the JVM's 64KB method limit. + */ +object Http4s700OpenIdConnect { + + implicit val formats: Formats = CustomJsonFormats.formats + + private val implementedInApiVersion = ApiVersion.v7_0_0 + private val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + val resourceDocs = ArrayBuffer[ResourceDoc]() + + // Route: GET /obp/v7.0.0/oidc/consents/CONSENT_ID + // EndpointHelpers.executeAndRespond rather than withUser, so an OIDC provider can call this as an + // application holding the Scope, without a User. + lazy val getOidcConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "oidc" / "consents" / consentId => + EndpointHelpers.executeAndRespond(req) { implicit cc: CallContext => + for { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + .map(unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)", 404)) + (user, _) <- NewStyle.function.getUserByUserId(consent.userId, Some(cc)) + consumerBox <- Future(Consumers.consumers.vend.getConsumerByConsumerId(consent.consumerId)) + } yield OpenIdConnectConsentJsonV700( + consent_id = consent.consentId, + status = consent.status, + consent_request_id = Option(consent.consentRequestId).filter(_.nonEmpty), + consumer_id = consent.consumerId, + client_id = consumerBox.toOption.map(_.key.get), + user_id = user.userId, + username = user.name, + provider = user.provider + ) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getOidcConsent), + "GET", + "/oidc/consents/CONSENT_ID", + "Get OIDC Consent", + s"""Gets the facts an OIDC provider needs to finish a consent flow, read from the Consent stored in OBP-API. + | + |After a User accepts a Consent on the consent page, the OIDC provider issues an authorization code for that User. + |It must take the User, the Consent's status and its Consumer from this record, never from parameters on the browser's + |callback URL, because anyone can edit those. The provider then checks that `status` is an accepted one, that + |`consent_request_id` is the Consent Request it started the flow for, and that `client_id` is the client it is about + |to issue the code to. + | + |`client_id` is the Consumer Key of the Consumer the Consent belongs to; it is absent if that Consumer no longer exists. + |`user_id`, `username` and `provider` identify the User who gave the Consent. + | + |${userAuthenticationMessage(true)} + |""".stripMargin, + EmptyBody, + OpenIdConnectConsentJsonV700( + consent_id = "9d429899-24f5-42c8-8565-943ffa6a7945", + status = "ACCEPTED", + consent_request_id = Some("8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0"), + consumer_id = "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh", + client_id = Some("abc123def456"), + user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", + username = "felixsmith", + provider = "https://my-obp-oidc.example.com" + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, ConsentNotFound, UserNotFoundByUserId, UnknownError), + apiTagOIDC :: apiTagConsent :: apiTagOAuth :: Nil, + Some(canGetOidcConsent :: Nil), + authMode = UserOrApplication, + http4sPartialFunction = Some(getOidcConsent) + ) +} diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700OpenIdConnect.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700OpenIdConnect.scala new file mode 100644 index 0000000000..27a15bdd9b --- /dev/null +++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700OpenIdConnect.scala @@ -0,0 +1,49 @@ +/** +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.v7_0_0 + +/* + * The JSON of the v7.0.0 endpoints an OIDC provider (such as OBP-OIDC) calls on OBP-API. + * + * Declared at package level, in a file of their own, for the reason given in JSONFactory700Operations.scala. + */ + +/** + * This class is what an OIDC provider reads to finish a consent flow: the Consent's status, the Consent Request + * it came from, the Consumer it belongs to (as both consumer_id and client_id, the Consumer Key), and the User who + * gave it. The provider takes these from OBP-API's record instead of from the browser's callback URL. + */ +case class OpenIdConnectConsentJsonV700( + consent_id: String, + status: String, + consent_request_id: Option[String], + consumer_id: String, + client_id: Option[String], + user_id: String, + username: String, + provider: String +) diff --git a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala b/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala index 9852f32fe9..dfb38a0897 100644 --- a/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala +++ b/obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala @@ -343,4 +343,64 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser } } + + // ─── Standard response headers ─────────────────────────────────────────────── + + // Http4sApp.httpApp adds these to every response it sends. Http4s700RoutesTest applies + // Http4sStandardHeaders itself, so only a request through the running server shows that + // httpApp really does, whichever route or error path produced the response. + private val standardResponseHeaders: List[(String, String)] = List( + "X-Frame-Options" -> "DENY", + "X-Content-Type-Options" -> "nosniff", + "Cache-Control" -> "no-cache, private, no-store", + "Pragma" -> "no-cache" + ) + + private def headerValue(headers: Map[String, String], name: String): Option[String] = + headers.find { case (key, _) => key.equalsIgnoreCase(name) }.map(_._2) + + feature("HTTP4S standard response headers") { + + val requests = List( + ("an old API version", "/obp/v1.2.1/root", 200), + ("a current API version", "/obp/v6.0.0/root", 200), + ("an unknown path (JSON 404)", "/obp/v6.0.0/this-does-not-exist", 404), + ("an unauthenticated call to an endpoint that needs a User (401)", "/obp/v6.0.0/users/current", 401), + ("the Kubernetes probe /health", "/health", 200), + ("the Kubernetes probe /alive", "/alive", 200) + ) + + requests.foreach { case (description, path, expectedStatus) => + scenario(s"Every standard header is sent for $description", Http4sServerIntegrationTag) { + When(s"GET $path") + val (status, _, headers) = execOkHttp(buildHttp4sReq(path, "GET")) + + Then(s"The status is $expectedStatus") + status should equal(expectedStatus) + + And("Every standard header is present with its value") + standardResponseHeaders.foreach { case (name, value) => + withClue(s"$name on $path: ") { headerValue(headers, name) should equal(Some(value)) } + } + headerValue(headers, "Correlation-Id").getOrElse("") should not be empty + } + } + + // Kubernetes decides whether a pod is alive from these two responses, so pin them exactly. + scenario("The Kubernetes probes keep their status, body and content type", Http4sServerIntegrationTag) { + When("GET /health") + val (healthStatus, healthBody, healthHeaders) = execOkHttp(buildHttp4sReq("/health", "GET")) + Then("It answers 200 {\"status\":\"ok\"} as JSON") + healthStatus should equal(200) + healthBody should equal("""{"status":"ok"}""") + headerValue(healthHeaders, "Content-Type").getOrElse("") should startWith("application/json") + + When("GET /alive") + val (aliveStatus, aliveBody, aliveHeaders) = execOkHttp(buildHttp4sReq("/alive", "GET")) + Then("It answers 200 true as JSON") + aliveStatus should equal(200) + aliveBody should equal("true") + headerValue(aliveHeaders, "Content-Type").getOrElse("") should startWith("application/json") + } + } } diff --git a/obp-api/src/test/scala/code/api/v7_0_0/GetOidcConsentTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/GetOidcConsentTest.scala new file mode 100644 index 0000000000..a03b697511 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v7_0_0/GetOidcConsentTest.scala @@ -0,0 +1,115 @@ +/** +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.v7_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanGetOidcConsent +import code.api.util.ErrorMessages +import code.api.util.ErrorMessages.{ConsentNotFound, UserHasMissingRoles} +import code.api.v6_0_0.V600ServerSetup +import code.consent.Consents +import code.entitlement.Entitlement +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +/** + * This suite covers GET /obp/v7.0.0/oidc/consents/CONSENT_ID, which an OIDC provider calls to + * finish a consent flow from OBP-API's own record of the Consent rather than from the + * user, status and consumer carried on the browser's callback URL. + */ +class GetOidcConsentTest extends V600ServerSetup { + + def v7_0_0_Request = baseRequest / "obp" / "v7.0.0" + + object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString) + object ApiEndpoint extends Tag(nameOf(Http4s700OpenIdConnect.getOidcConsent)) + + private def withRole[T](block: => T): T = { + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetOidcConsent.toString) + try block finally Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } + + feature(s"Get OIDC Consent - GET /obp/v7.0.0/oidc/consents/CONSENT_ID - $VersionOfApi") { + + scenario("Anonymous access should fail with 401", ApiEndpoint, VersionOfApi) { + When("We make the request without authentication") + val request = (v7_0_0_Request / "oidc" / "consents" / "nonexistent_consent_id").GET + val response = makeGetRequest(request) + + Then("We should get a 401") + response.code should equal(401) + response.body.extract[ErrorMessage].message should equal(ErrorMessages.ApplicationNotIdentified) + } + + scenario("Authenticated user without role should fail with 403", ApiEndpoint, VersionOfApi) { + When("We make the request as an authenticated user without the required role") + val request = (v7_0_0_Request / "oidc" / "consents" / "nonexistent_consent_id").GET <@ (user1) + val response = makeGetRequest(request) + + Then("We should get a 403") + response.code should equal(403) + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanGetOidcConsent) + } + + scenario("An unknown CONSENT_ID should fail with 404", ApiEndpoint, VersionOfApi) { + When("We request a consent that does not exist") + val request = (v7_0_0_Request / "oidc" / "consents" / "nonexistent_consent_id").GET <@ (user1) + val response = withRole(makeGetRequest(request)) + + Then("We should get a 404") + response.code should equal(404) + response.body.extract[ErrorMessage].message should startWith(ConsentNotFound) + } + + scenario("An existing consent returns its own user, consumer and consent request", ApiEndpoint, VersionOfApi) { + Given("A consent created by user2 for testConsumer2 from a consent request") + val consentRequestId = java.util.UUID.randomUUID().toString + val consent = Consents.consentProvider.vend + .createObpConsent(resourceUser2, "12345678", Some(consentRequestId), Some(testConsumer2)) + .openOrThrowException("test consent could not be created") + + When("A caller holding CanGetOidcConsent reads it") + val request = (v7_0_0_Request / "oidc" / "consents" / consent.consentId).GET <@ (user1) + val response = withRole(makeGetRequest(request)) + + Then("We get the facts from the stored consent, not from the caller") + response.code should equal(200) + val body = response.body.extract[OpenIdConnectConsentJsonV700] + body.consent_id should equal(consent.consentId) + body.status should equal(consent.status) + body.consent_request_id should equal(Some(consentRequestId)) + body.consumer_id should equal(testConsumer2.consumerId.get) + body.client_id should equal(Some(testConsumer2.key.get)) + body.user_id should equal(resourceUser2.userId) + body.username should equal(resourceUser2.name) + body.provider should equal(resourceUser2.provider) + } + } +} From 305ce3f2fb62652e6ec79d8eb2bd43606d1a9736 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 10 Oct 2026 09:43:26 +0200 Subject: [PATCH 3/4] fix: OAuth2 app tokens get their own user namespace; provider claim and federated sub only trusted from OBP-OIDC/Keycloak --- obp-api/src/main/scala/code/api/OAuth2.scala | 41 +++++- .../code/api/OAuth2UserResolutionTest.scala | 128 ++++++++++++++++++ 2 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/OAuth2UserResolutionTest.scala 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/test/scala/code/api/OAuth2UserResolutionTest.scala b/obp-api/src/test/scala/code/api/OAuth2UserResolutionTest.scala new file mode 100644 index 0000000000..ceb01bd26a --- /dev/null +++ b/obp-api/src/test/scala/code/api/OAuth2UserResolutionTest.scala @@ -0,0 +1,128 @@ +/** +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 + +import code.api.util.APIUtil +import code.model.dataAccess.AuthUser +import code.setup.{DefaultUsers, ServerSetup, TestPasswordConfig} +import code.users.Users +import com.nimbusds.jose.crypto.MACSigner +import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} +import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} +import com.openbankproject.commons.model.User +import net.liftweb.util.Helpers.randomString + +import java.net.URI + +/** + * How a token's user is found (OAuth2Login.OAuth2Util.getOrCreateResourceUser), where one identity could + * otherwise land on another's user: + * - an OBP-OIDC client-credentials token (sub = client id, no provider claim) gets a user no person can + * sign up as, while an app that already has its user under the local provider keeps it + * - only OBP's own identity providers may name the provider; only Keycloak may use the federated sub form + */ +class OAuth2UserResolutionTest extends ServerSetup with DefaultUsers { + + private val obpOidcIssuer = "http://localhost:9000/obp-oidc" + private val otherIssuer = "https://idp.example.com" + + private object oidcProvider extends OAuth2Login.OAuth2Util { + override def wellKnownOpenidConfiguration: URI = new URI(s"$obpOidcIssuer/.well-known/openid-configuration") + } + + // getOrCreateResourceUser only parses claims (no signature verification), as in OAuth2ConsumerResolutionTest. + private def token(iss: String, sub: String, claims: (String, String)*): String = { + val builder = new JWTClaimsSet.Builder().issuer(iss).subject(sub) + claims.foreach { case (k, v) => builder.claim(k, v) } + val jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), builder.build()) + jwt.sign(new MACSigner("0123456789abcdef0123456789abcdef")) + jwt.serialize() + } + + /** As OBP-OIDC issues it for client_credentials. */ + private def appToken(clientId: String) = + token(obpOidcIssuer, clientId, "azp" -> clientId, "client_id" -> clientId, "grant_type" -> "client_credentials") + + /** Like the keys OBP-API generates for Consumers: 40 lowercase letters and digits, also a valid username. */ + private def freshClientId() = randomString(40).toLowerCase + + private def resolve(jwt: String): User = + oidcProvider.getOrCreateResourceUser(jwt).openOrThrowException("getOrCreateResourceUser must return a user") + + private val local = Constant.localIdentityProvider + + feature("an OBP-OIDC client-credentials token never lands on a person's user") { + + scenario("a new app gets a user under a provider no person can sign up under") { + val clientId = freshClientId() + val user = resolve(appToken(clientId)) + user.provider should equal(OAuth2Login.OBPOIDC.clientCredentialsProvider) + user.idGivenByProvider should equal(clientId) + resolve(appToken(clientId)).userId should equal(user.userId) + } + + scenario("an app that already has its user under the local provider keeps it") { + val clientId = freshClientId() + val existing = Users.users.vend.createResourceUser( + provider = local, providerId = Some(clientId), createdByConsentId = None, name = Some(clientId), email = None, + userId = None, createdByUserInvitationId = None, company = None, lastMarketingAgreementSignedDate = None + ).openOrThrowException("could not create the app's existing user") + resolve(appToken(clientId)).userId should equal(existing.userId) + } + + scenario("a person who signed up with the client id as username does not become the app's user") { + val clientId = freshClientId() + AuthUser.create.email(s"$clientId@example.com").username(clientId).password(TestPasswordConfig.VALID_PASSWORD) + .validated(true).firstName("x").lastName("y").saveMe() + val person = Users.users.vend.getUserByProviderId(local, clientId).openOrThrowException("sign-up made no user") + val user = resolve(appToken(clientId)) + user.userId should not equal person.userId + user.provider should equal(OAuth2Login.OBPOIDC.clientCredentialsProvider) + } + } + + feature("only OBP's identity providers may say which user a token is") { + + scenario("an OBP-OIDC user token naming the local provider is that local user") { + val user = resolve(token(obpOidcIssuer, resourceUser1.idGivenByProvider, "azp" -> "some-app", "provider" -> resourceUser1.provider)) + user.userId should equal(resourceUser1.userId) + } + + scenario("another issuer's provider claim is ignored") { + val user = resolve(token(otherIssuer, resourceUser1.idGivenByProvider, "azp" -> "some-app", "provider" -> resourceUser1.provider)) + user.userId should not equal resourceUser1.userId + user.provider should equal(otherIssuer) + } + + scenario("another issuer's federated sub does not name a user_id") { + val sub = s"f:${APIUtil.generateUUID()}:${resourceUser1.userId}" + val user = resolve(token(otherIssuer, sub, "azp" -> "some-app")) + user.userId should not equal resourceUser1.userId + } + } +} From 7c53eed78f7b9cb2a39fce9b98b90abb5c13221c Mon Sep 17 00:00:00 2001 From: simonredfern Date: Sat, 10 Oct 2026 09:43:26 +0200 Subject: [PATCH 4/4] feature: GET /obp/v7.0.0/banks/BANK_ID/my/roles/ROLE_NAME (SYS for system Roles) --- .../scala/code/api/v7_0_0/Http4s700.scala | 3 + .../code/api/v7_0_0/Http4s700MyRoles.scala | 172 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 obp-api/src/main/scala/code/api/v7_0_0/Http4s700MyRoles.scala diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 135a3c57b3..03c7566dc2 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -7491,6 +7491,9 @@ object Http4s700 { // What has been granted across all Users and Consumers: every Scope, and the Role names anyone holds. resourceDocs ++= Http4s700Grants.resourceDocs + // One Role as the caller holds it, for services that guard their own data with OBP Roles. + resourceDocs ++= Http4s700MyRoles.resourceDocs + // IP penalties: an operator's temporary per-minute limit on one address. resourceDocs ++= Http4s700IpPenalties.resourceDocs diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700MyRoles.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700MyRoles.scala new file mode 100644 index 0000000000..c02cd40daa --- /dev/null +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700MyRoles.scala @@ -0,0 +1,172 @@ +/** +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.v7_0_0 + +import cats.effect.IO +import code.api.Constant.{ApiPathZero, DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID} +import code.api.util.APIUtil.{EmptyBody, _} +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.{ApiPropsWithAlias, ApiRole, CallContext, CustomJsonFormats, NewStyle} +import code.util.Helper +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.common.{Empty, Full} +import org.http4s._ +import org.http4s.dsl.io._ +import org.json4s.Formats + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +case class MyRoleCallerJsonV700(user_id: String, consumer_id: String, application_only: Boolean) +case class MyRoleHeldByJsonV700(user: Boolean, consumer: Boolean) +case class MyRoleJsonV700( + role_name: String, + bank_id: String, + caller: MyRoleCallerJsonV700, + held_by: MyRoleHeldByJsonV700, + scopes_required: Boolean, + allowed_for_auth_modes: List[String] +) + +/** + * One Role, as the caller holds it: whether the calling User holds it, whether the calling Consumer holds it + * as a Scope, and so for which endpoint auth modes an endpoint that needs this Role would let the caller in. + * + * A service in front of OBP-API (API Explorer II, the Portal, API Manager) forwards the token it was given + * and gets OBP-API's own answer, instead of copying OBP-API's access rules. + * + * Declared in its own object to keep Http4s700's initialiser under the JVM's 64KB method limit. + */ +object Http4s700MyRoles { + + implicit val formats: Formats = CustomJsonFormats.formats + + private val implementedInApiVersion = ApiVersion.v7_0_0 + private val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + val resourceDocs = ArrayBuffer[ResourceDoc]() + + private val authModes: List[EndpointAuthMode] = List(UserOnly, ApplicationOnly, UserOrApplication, UserAndApplication) + + /** The same decision as handleAccessControlWithAuthMode, for each auth mode, without granting + * just-in-time Entitlements: a GET must not create them. */ + private def myRole(roleName: String, bankId: String, cc: CallContext): Future[MyRoleJsonV700] = + for { + consumer <- Future(cc.consumer match { + case Full(c) => Full(c) + case _ => Empty + }).map(unboxFullOrFail(_, Some(cc), ApplicationNotIdentified, 401)) + role <- NewStyle.function.tryons(s"$IncorrectRoleName $roleName", 404, Some(cc)) { ApiRole.valueOf(roleName) } + // A system Role is asked for at SYS, which for it means no bank. A bank Role at SYS is held in the system space. + _ <- Helper.booleanToFuture(failMsg = s"$EntitlementIsSystemRole Use $DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID as BANK_ID.", + cc = Some(cc))(role.requiresBankId || bankId == DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID) + } yield { + val heldAt = if (role.requiresBankId) bankId else "" + val user = cc.user.toOption + val userId = user.map(_.userId).getOrElse("") + // A client-credentials token still brings a pseudo-user, named after the Consumer's client id. + val applicationOnly = user.forall(u => Set(Option(consumer.key.get), Option(consumer.azp.get)).flatten.contains(u.idGivenByProvider)) + val virtualRoles = if (userId.isEmpty) Nil + else if (isSuperAdmin(userId)) superAdminVirtualRoles + else if (isOidcOperator(userId)) oidcOperatorVirtualRoles + else Nil + val virtual = virtualRoles.contains(role.toString) + val heldByUser = virtual || (userId.nonEmpty && hasEntitlement(heldAt, userId, role)) + val heldByConsumer = hasScope(heldAt, consumer.id.get.toString, role) + val listed = getPropsValue("require_scopes_for_listed_roles", "").split(",").contains(role.toString) + val scopesRequired = ApiPropsWithAlias.requireScopesForAllRoles || listed + val allowed = authModes.filter { mode => + virtual || (if (scopesRequired) heldByUser && heldByConsumer else mode match { + case UserOnly => heldByUser + case ApplicationOnly => heldByConsumer + case UserOrApplication => heldByUser || heldByConsumer + case UserAndApplication => heldByUser && heldByConsumer + }) + } + MyRoleJsonV700(role.toString, bankId, MyRoleCallerJsonV700(userId, consumer.consumerId.get, applicationOnly), + MyRoleHeldByJsonV700(heldByUser, heldByConsumer), scopesRequired, allowed.map(_.toString)) + } + + // Route: GET /obp/v7.0.0/banks/BANK_ID/my/roles/ROLE_NAME (BANK_ID SYS for system Roles) + lazy val getMyRole: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankId / "my" / "roles" / roleName => + EndpointHelpers.executeFuture(req) { myRole(roleName, bankId, req.callContext) } + } + + private val example = MyRoleJsonV700( + role_name = "CanGetSystemLogCacheError", + bank_id = DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID, + caller = MyRoleCallerJsonV700(user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", consumer_id = "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh", application_only = true), + held_by = MyRoleHeldByJsonV700(user = false, consumer = true), + scopes_required = false, + allowed_for_auth_modes = List("ApplicationOnly", "UserOrApplication") + ) + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getMyRole), + "GET", + "/banks/BANK_ID/my/roles/ROLE_NAME", + "Get My Role", + s"""Returns how the caller holds one Role, and so whether an endpoint that needs it would let the caller in. + | + |For a system Role, use `$DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID` as BANK_ID: it then means no bank. A bank Role is looked up at + |BANK_ID, where `$DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID` is the system space of Dynamic Entities. + | + |Use it from a service that receives an OBP-API token (for example API Explorer II, the Portal or API Manager) + |and must decide, as OBP-API would, whether that token may see something the service guards with an OBP Role. + | + |* `caller`: the User and Consumer of the token. `application_only` is true when no person is behind it + | (a client-credentials token, whose User is a pseudo-user named after the Consumer's client id). + |* `held_by.user`: the User holds the Role as an Entitlement, or as a virtual Role from `super_admin_user_ids` or + | `oidc_operator_user_ids`. + |* `held_by.consumer`: the Consumer holds the Role as a Scope. + |* `scopes_required`: the props `require_scopes_for_all_roles` or `require_scopes_for_listed_roles` make + | every endpoint need both. + |* `allowed_for_auth_modes`: the endpoint auth modes (`UserOnly`, `ApplicationOnly`, `UserOrApplication`, + | `UserAndApplication`) under which an endpoint that needs this Role would let the caller in. + | An endpoint that accepts several Roles lets the caller in if any one of them is allowed. + | + |Just-in-time Entitlements (`create_just_in_time_entitlements`) are not granted here: an endpoint may still + |grant one when it is called. + | + |No Role is required: a caller may always learn what it holds. The caller must be identifiable as a Consumer. + |An unknown Role name gets 404. + |""".stripMargin, + EmptyBody, + example, + List(ApplicationNotIdentified, BankNotFound, IncorrectRoleName, EntitlementIsSystemRole, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagScope), + None, + authMode = UserOrApplication, + http4sPartialFunction = Some(getMyRole) + ).allowSystemSpace() +}