From 1b35285434368f62045cdfe3305e17fab0cc93fe Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 5 Mar 2026 04:49:20 +0100 Subject: [PATCH 1/6] App Directory and other links html page at / --- .../code/api/util/http4s/Http4sApp.scala | 3 +- .../code/api/util/http4s/StatusPage.scala | 57 +++++++++++++++++++ .../scala/code/api/v6_0_0/APIMethods600.scala | 34 ++++++++++- 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index 460f2981fa..823bb1c9b5 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala @@ -28,7 +28,8 @@ object Http4sApp { * Build the base HTTP4S routes with priority-based routing */ private def baseServices: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => - code.api.v5_0_0.Http4s500.wrappedRoutesV500Services.run(req) + StatusPage.routes.run(req) + .orElse(code.api.v5_0_0.Http4s500.wrappedRoutesV500Services.run(req)) .orElse(code.api.v7_0_0.Http4s700.wrappedRoutesV700Services.run(req)) .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) .orElse(Http4sLiftWebBridge.routes.run(req)) diff --git a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala new file mode 100644 index 0000000000..703879d268 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala @@ -0,0 +1,57 @@ +package code.api.util.http4s + +import cats.effect.IO +import code.api.util.APIUtil +import org.http4s._ +import org.http4s.dsl.io._ +import org.http4s.headers.`Content-Type` + +object StatusPage { + + val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case GET -> Root => + val appDiscoveryLinks = APIUtil.getAppDiscoveryPairs.map { case (name, url) => + val displayName = name + .stripPrefix("public_") + .stripSuffix("_url") + .replace("_", " ") + .split(" ") + .map(_.capitalize) + .mkString(" ") + s"""
  • $displayName ($name)
  • """ + }.mkString("\n") + + val html = + s""" + | + | + | OBP API - Status Page + | + | + | + |

    Welcome to the OBP API

    + | + |

    App Directory

    + | + | + |

    API Endpoints

    + | + | + |""".stripMargin + + Ok(html).map(_.withContentType(`Content-Type`(MediaType.text.html))) + } +} diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 2daffc7682..18aefa7be5 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -8749,9 +8749,37 @@ trait APIMethods600 { lazy val verifyUserCredentials: OBPEndpoint = { case "users" :: "verify-credentials" :: Nil JsonPost json -> _ => { cc => implicit val ec = EndpointContext(Some(cc)) - // TODO: Consider allowing Client Credentials (app-level) auth instead of user-level auth, - // so the caller doesn't need to be logged in as a user (which is circular for credential verification). - // TODO: Add rate limiting / anti-DOS protection for this endpoint to prevent credential enumeration/spamming. + // TODO: Implement per-endpoint auth mode via a new EndpointAuthMode field on ResourceDoc. + // + // Proposed design: + // sealed trait EndpointAuthMode + // case object UserOnly extends EndpointAuthMode // current default — user entitlement required + // case object ApplicationOnly extends EndpointAuthMode // consumer scope required, no user needed + // case object UserOrApplication extends EndpointAuthMode // consumer scope OR user entitlement + // case object UserAndApplication extends EndpointAuthMode // consumer scope AND user entitlement (PSD2) + // + // Add to ResourceDoc as: authMode: EndpointAuthMode = UserOnly + // Only endpoints needing non-default behaviour need to specify it — no changes to + // existing ResourceDocs required. This endpoint would use UserOrApplication. + // + // Implementation points: + // - wrappedWithAuthCheck.checkAuth: use applicationAccess for ApplicationOnly/UserOrApplication + // (it still resolves user if present), authenticatedAccess for UserOnly/UserAndApplication. + // - wrappedWithAuthCheck.checkRoles: match on authMode to decide whether to check + // consumer scopes, user entitlements, or both — instead of relying on the global + // allow_entitlements_or_scopes config flag. + // - The global allow_entitlements_or_scopes flag should be deprecated/removed as it has + // large security implications, defaults to false (so is under-tested), and can silently + // override per-endpoint auth policy. + // - ResourceDoc constructor (lines 1586-1611 in APIUtil.scala): auto-add appropriate + // error messages based on authMode (e.g. ApplicationNotIdentified for app modes + // instead of AuthenticatedUserIsRequired). This also fixes the current design issue + // where security policy is derived from error message strings. + // - Note: AuthenticationTypeValidation is a separate concern — it controls which auth + // protocols (DirectLogin, OAuth2, etc.) an endpoint accepts, not what level of access + // (user vs app) is required. The two are orthogonal. + // TODO: Additionally consider endpoint-specific anti-abuse protection beyond standard rate limiting, + // e.g. LoginAttempt-style lockout tracking per target username to prevent credential enumeration/brute-force. for { (Full(u), callContext) <- authenticatedAccess(cc) postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostVerifyUserCredentialsJsonV600", 400, callContext) { From 0e022857ce2998b9e38a6018249286d334e2da70 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 5 Mar 2026 05:41:54 +0100 Subject: [PATCH 2/6] =?UTF-8?q?Removed=20allow=5Fentitlements=5For=5Fscope?= =?UTF-8?q?s=20config=20flag.=20This=20global=20flag=20allowed=20consumer?= =?UTF-8?q?=20scopes=20as=20an=20alternative=20to=20user=20entitlements=20?= =?UTF-8?q?for=20ALL=20endpoints.=20It=20has=20been=20replaced=20by=20per-?= =?UTF-8?q?endpoint=20EndpointAuthMode=20on=20ResourceDoc=20with=20four=20?= =?UTF-8?q?modes:=20-=20UserOnly=20(default)=20=E2=80=94=20user=20entitlem?= =?UTF-8?q?ent=20required=20-=20ApplicationOnly=20=E2=80=94=20consumer=20s?= =?UTF-8?q?cope=20required,=20no=20user=20needed=20-=20UserOrApplication?= =?UTF-8?q?=20=E2=80=94=20consumer=20scope=20OR=20user=20entitlement=20-?= =?UTF-8?q?=20UserAndApplication=20=E2=80=94=20consumer=20scope=20AND=20us?= =?UTF-8?q?er=20entitlement=20=20=20Migration:=20add=20authMode=20=3D=20Us?= =?UTF-8?q?erOrApplication=20to=20individual=20ResourceDoc=20=20=20instanc?= =?UTF-8?q?es=20that=20previously=20relied=20on=20allow=5Fentitlements=5Fo?= =?UTF-8?q?r=5Fscopes=3Dtrue.=20=20=20The=20verifyUserCredentials=20endpoi?= =?UTF-8?q?nt=20is=20the=20first=20to=20use=20=20=20UserOrApplication.=20?= =?UTF-8?q?=20=20require=5Fscopes=5Ffor=5Fall=5Froles=20and=20require=5Fsc?= =?UTF-8?q?opes=5Ffor=5Flisted=5Froles=20are=20=20=20unaffected=20and=20co?= =?UTF-8?q?ntinue=20to=20work.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/props/sample.props.template | 6 +- .../main/scala/bootstrap/liftweb/Boot.scala | 17 +- .../OpenAPI31JSONFactory.scala | 4 +- .../main/scala/code/api/util/APIUtil.scala | 115 ++++++++++++-- .../code/api/util/http4s/StatusPage.scala | 145 ++++++++++++------ .../scala/code/api/v6_0_0/APIMethods600.scala | 39 +---- .../scala/code/api/v4_0_0/ScopesTest.scala | 42 +---- .../api/v6_0_0/EndpointAuthModeTest.scala | 59 +++++++ .../v6_0_0/VerifyUserCredentialsTest.scala | 4 +- release_notes.md | 13 ++ 10 files changed, 292 insertions(+), 152 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 825c3cb523..8c51d388fd 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1114,9 +1114,9 @@ featured_apis=elasticSearchWarehouseV300 # In case isn't defined default value is "false" # require_scopes_for_all_roles=false # require_scopes_for_listed_roles=CanCreateUserAuthContext,CanGetCustomer -# Scopes can also be used as an alternative to User Entitlements -# i.e. instead of asking every user to have a Role, you can give the Role(s) to a Consumer in the form of a Scope -# allow_entitlements_or_scopes=false +# Scopes can also be used as an alternative to User Entitlements on a per-endpoint basis. +# Set authMode = UserOrApplication on individual ResourceDoc instances to allow +# consumer scopes OR user entitlements for that endpoint. # --------------------------------------------------------------- # -- Just in Time Entitlements ------------------------------- diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index fc47a9413b..bac7995200 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -496,9 +496,9 @@ class Boot extends MdcLoggable { //add management apis LiftRules.statelessDispatch.append(ImporterAPI) } - + enableAPIs - + //LiftRules.statelessDispatch.append(AccountsAPI) @@ -693,8 +693,6 @@ class Boot extends MdcLoggable { LiftSession.onSessionActivate = UsernameLockedChecker.onSessionActivate _ :: LiftSession.onSessionActivate LiftSession.onSessionPassivate = UsernameLockedChecker.onSessionPassivate _ :: LiftSession.onSessionPassivate - // Sanity check for incompatible Props values for Scopes. - sanityCheckOPropertiesRegardingScopes() // export one Connector's methods as endpoints, it is just for develop APIUtil.getPropsValue("connector.name.export.as.endpoints").foreach { connectorName => // validate whether "connector.name.export.as.endpoints" have set a correct value @@ -731,17 +729,6 @@ class Boot extends MdcLoggable { } - private def sanityCheckOPropertiesRegardingScopes() = { - if (propertiesRegardingScopesAreValid()) { - throw new Exception(s"Incompatible Props values for Scopes.") - } - } - - def propertiesRegardingScopesAreValid() = { - (ApiPropsWithAlias.requireScopesForAllRoles || !getPropsValue("require_scopes_for_listed_roles").toList.map(_.split(",")).isEmpty) && - APIUtil.getPropsAsBoolValue("allow_entitlements_or_scopes", false) - } - // create Hydra client if exists active consumer but missing Hydra client def createHydraClients() = { try { diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala index f2e7391663..4281431319 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala @@ -653,9 +653,11 @@ object OpenAPI31JSONFactory extends MdcLoggable { */ private def requiresAuthentication(doc: ResourceDocJson): Boolean = { doc.error_response_bodies.exists(_.contains("AuthenticatedUserIsRequired")) || + doc.error_response_bodies.exists(_.contains("ApplicationNotIdentified")) || doc.roles.nonEmpty || doc.description.toLowerCase.contains("authentication is required") || - doc.description.toLowerCase.contains("user must be logged in") + doc.description.toLowerCase.contains("user must be logged in") || + doc.description.toLowerCase.contains("application access is required") } /** 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 bb02be7955..9ddd1fba04 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -1553,6 +1553,12 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ .filter(isPathVariable(_)) } + sealed trait EndpointAuthMode + case object UserOnly extends EndpointAuthMode + case object ApplicationOnly extends EndpointAuthMode + case object UserOrApplication extends EndpointAuthMode + case object UserAndApplication extends EndpointAuthMode + // Used to document the API calls case class ResourceDoc( partialFunction: OBPEndpoint, // PartialFunction[Req, Box[User] => Box[JsonResponse]], @@ -1576,6 +1582,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ specialInstructions: Option[String] = None, var specifiedUrl: Option[String] = None, // A derived value: Contains the called version (added at run time). See the resource doc for resource doc! createdByBankId: Option[String] = None, //we need to filter the resource Doc by BankId + authMode: EndpointAuthMode = UserOnly, // Per-endpoint auth mode: UserOnly, ApplicationOnly, UserOrApplication, UserAndApplication http4sPartialFunction: Http4sEndpoint = None // http4s endpoint handler ) { // this code block will be merged to constructor. @@ -1609,6 +1616,19 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ |$authenticationIsOptional |""" } + + // Auth-mode-aware error message adjustments + authMode match { + case ApplicationOnly | UserOrApplication => + errorResponseBodies ?+= ApplicationNotIdentified + if (authMode == ApplicationOnly) { + errorResponseBodies ?-= AuthenticatedUserIsRequired + } + case UserAndApplication => + errorResponseBodies ?+= AuthenticatedUserIsRequired + errorResponseBodies ?+= ApplicationNotIdentified + case UserOnly => // existing logic already handles this + } } val operationId = buildOperationId(implementedInApiVersion, partialFunctionName) @@ -1692,7 +1712,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ private val requestUrlPartPath: Array[String] = StringUtils.split(requestUrl, '/') - private val isNeedCheckAuth = errorResponseBodies.contains($AuthenticatedUserIsRequired) + private val AuthCheckIsRequired = errorResponseBodies.contains($AuthenticatedUserIsRequired) private val isNeedCheckRoles = _autoValidateRoles && rolesForCheck.nonEmpty private val isNeedCheckBank = errorResponseBodies.contains($BankNotFound) && requestUrlPartPath.contains("BANK_ID") private val isNeedCheckAccount = errorResponseBodies.contains($BankAccountNotFound) && @@ -1724,8 +1744,11 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def wrappedWithAuthCheck(obpEndpoint: OBPEndpoint): OBPEndpoint = { _isEndpointAuthCheck = true - def checkAuth(cc: CallContext): Future[(Box[User], Option[CallContext])] = { - if (isNeedCheckAuth) authenticatedAccessFun(cc) else anonymousAccessFun(cc) + def checkAuth(cc: CallContext): Future[(Box[User], Option[CallContext])] = authMode match { + case UserOnly | UserAndApplication => + if (AuthCheckIsRequired) authenticatedAccessFun(cc) else anonymousAccessFun(cc) + case ApplicationOnly | UserOrApplication => + applicationAccessFun(cc) } def checkObpIds(obpKeyValuePairs: List[(String, String)], callContext: Option[CallContext]): Future[Option[CallContext]] = { @@ -1747,7 +1770,14 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ if (isNeedCheckRoles) { val bankIdStr = bankId.map(_.value).getOrElse("") val userIdStr = user.map(_.userId).openOr("") - checkRolesFun(bankIdStr)(userIdStr, rolesForCheck, cc) + val consumerId = APIUtil.getConsumerPrimaryKey(cc) + val errorMessage = if (rolesForCheck.filter(_.requiresBankId).isEmpty) + UserHasMissingRoles + rolesForCheck.mkString(" or ") + else + UserHasMissingRoles + rolesForCheck.mkString(" or ") + s" for BankId($bankIdStr)." + Helper.booleanToFuture(errorMessage, cc = cc) { + APIUtil.handleAccessControlWithAuthMode(bankIdStr, userIdStr, consumerId, rolesForCheck, authMode) + } } else { Future.successful(Full(Unit)) } @@ -1792,10 +1822,12 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ // reset connectorMethods { val checkerFunctions = mutable.ListBuffer[PartialFunction[_, _]]() - if (isNeedCheckAuth) { - checkerFunctions += authenticatedAccessFun - } else { - checkerFunctions += anonymousAccessFun + authMode match { + case UserOnly | UserAndApplication => + if (AuthCheckIsRequired) checkerFunctions += authenticatedAccessFun + else checkerFunctions += anonymousAccessFun + case ApplicationOnly | UserOrApplication => + checkerFunctions += applicationAccessFun } if (isNeedCheckRoles) { checkerFunctions += checkRolesFun @@ -2403,6 +2435,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ // Function checks does a user specified by a parameter userId has at least one role provided by a parameter roles at a bank specified by a parameter bankId // i.e. does user has assigned at least one role from the list // when roles is empty, that means no access control, treat as pass auth check + @deprecated("Use handleAccessControlWithAuthMode instead. It uses per-endpoint EndpointAuthMode rather than global config flags.", "OBP v6.0.0") def handleAccessControlRegardingEntitlementsAndScopes(bankId: String, userId: String, consumerId: String, roles: List[ApiRole]): Boolean = { if (roles.isEmpty) { // No access control, treat as pass auth check true @@ -2443,10 +2476,6 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ if (ApiPropsWithAlias.requireScopesForAllRoles || requireScopesForRoles.nonEmpty) { userHasTheRoles && roles.exists(hasScope(bankId, consumerId, _)) } - // Consumer OR User has the Role - else if (getPropsAsBoolValue("allow_entitlements_or_scopes", false)) { - roles.exists(role => hasScope(if (role.requiresBankId) bankId else "", consumerId, role)) || userHasTheRoles - } // User has the Role else { userHasTheRoles @@ -2457,6 +2486,65 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ + /** + * Per-endpoint auth mode access control. Checks virtual roles first, then matches on authMode: + * - UserOnly: user entitlements only (includes just-in-time entitlements) + * - ApplicationOnly: consumer scopes only + * - UserOrApplication: scopes OR entitlements + * - UserAndApplication: scopes AND entitlements + * Global overrides (require_scopes_for_all_roles, require_scopes_for_listed_roles) force UserAndApplication behavior. + */ + def handleAccessControlWithAuthMode(bankId: String, userId: String, consumerId: String, roles: List[ApiRole], authMode: EndpointAuthMode): Boolean = { + if (roles.isEmpty) { + true + } else { + // Check virtual roles granted by config (super_admin_user_ids, oidc_operator_user_ids) + val virtualRoles = if (isSuperAdmin(userId)) superAdminVirtualRoles + else if (isOidcOperator(userId)) oidcOperatorVirtualRoles + else List.empty + if (roles.exists(role => virtualRoles.contains(role.toString))) { + true + } else { + // Global overrides that force UserAndApplication (scopes AND entitlements) + val requireScopesForListedRoles = getPropsValue("require_scopes_for_listed_roles", "").split(",").toSet + val requireScopesForRoles = roles.map(_.toString).toSet.intersect(requireScopesForListedRoles) + val globalOverrideToUserAndApp = ApiPropsWithAlias.requireScopesForAllRoles || requireScopesForRoles.nonEmpty + + def userHasTheRoles: Boolean = { + val userHasTheRole: Boolean = roles.exists(hasEntitlement(bankId, userId, _)) + userHasTheRole || { + getPropsAsBoolValue("create_just_in_time_entitlements", false) && { + (hasEntitlement(bankId, userId, ApiRole.canCreateEntitlementAtOneBank) || + hasEntitlement("", userId, ApiRole.canCreateEntitlementAtAnyBank)) && + roles.forall { role => + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement( + bankId, userId, role.toString, "create_just_in_time_entitlements" + ) + logger.info(s"Just in Time Entitlements: $addedEntitlement") + addedEntitlement.isDefined + } + } + } + } + + def consumerHasTheScopes: Boolean = + roles.exists(role => hasScope(if (role.requiresBankId) bankId else "", consumerId, role)) + + if (globalOverrideToUserAndApp) { + // Global config forces both scopes AND entitlements + userHasTheRoles && roles.exists(hasScope(bankId, consumerId, _)) + } else { + authMode match { + case UserOnly => userHasTheRoles + case ApplicationOnly => consumerHasTheScopes + case UserOrApplication => consumerHasTheScopes || userHasTheRoles + case UserAndApplication => userHasTheRoles && consumerHasTheScopes + } + } + } + } + } + // Function checks does a user specified by a parameter userId has all roles provided by a parameter roles at a bank specified by a parameter bankId // i.e. does user has assigned all roles from the list // when roles is empty, that means no access control, treat as pass auth check @@ -4531,6 +4619,9 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ private val anonymousAccessFun: PartialFunction[CallContext, OBPReturnType[Box[User]]] = { case x => anonymousAccess(x) } + private val applicationAccessFun: PartialFunction[CallContext, Future[(Box[User], Option[CallContext])]] = { + case x => applicationAccess(x) + } private val checkRolesFun: PartialFunction[String, (String, List[ApiRole], Option[CallContext]) => Future[Box[Unit]]] = { case x => NewStyle.function.handleEntitlementsAndScopes(x, _, _, _) } diff --git a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala index 703879d268..f4454dd0d5 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala @@ -4,54 +4,109 @@ import cats.effect.IO import code.api.util.APIUtil import org.http4s._ import org.http4s.dsl.io._ -import org.http4s.headers.`Content-Type` +import org.http4s.headers.{Accept, `Content-Type`} object StatusPage { + private def appDiscoveryPairs = APIUtil.getAppDiscoveryPairs + + private def humanName(key: String): String = + key.stripPrefix("public_") + .stripSuffix("_url") + .replace("_", " ") + .split(" ") + .map(_.capitalize) + .mkString(" ") + + private def prefersJson(req: Request[IO]): Boolean = + req.headers.get[Accept].exists { accept => + accept.values.toList.exists { mediaRange => + mediaRange.mediaRange.satisfiedBy(MediaType.application.json) + } + } + val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { - case GET -> Root => - val appDiscoveryLinks = APIUtil.getAppDiscoveryPairs.map { case (name, url) => - val displayName = name - .stripPrefix("public_") - .stripSuffix("_url") - .replace("_", " ") - .split(" ") - .map(_.capitalize) - .mkString(" ") - s"""
  • $displayName ($name)
  • """ - }.mkString("\n") - - val html = - s""" - | - | - | OBP API - Status Page - | - | - | - |

    Welcome to the OBP API

    - | - |

    App Directory

    - | - | - |

    API Endpoints

    - | - | - |""".stripMargin - - Ok(html).map(_.withContentType(`Content-Type`(MediaType.text.html))) + case req @ GET -> Root => + if (prefersJson(req)) jsonResponse else htmlResponse + } + + private def jsonResponse: IO[Response[IO]] = { + val pairs = appDiscoveryPairs + val appDirectory = pairs.map { case (name, url) => + s""" {"name": "${humanName(name)}", "key": "$name", "url": "$url"}""" + }.mkString(",\n") + + val json = + s"""{ + | "app_directory": [ + |$appDirectory + | ], + | "discovery_endpoints": { + | "api_info": "/obp/v6.0.0/root", + | "resource_docs": "/obp/v6.0.0/resource-docs/v6.0.0/obp", + | "well_known": "/obp/v5.1.0/well-known", + | "banks": "/obp/v6.0.0/banks" + | }, + | "links": { + | "github": "https://github.com/OpenBankProject/OBP-API", + | "tesobe": "https://www.tesobe.com", + | "open_bank_project": "https://www.openbankproject.com" + | }, + | "copyright": "Copyright TESOBE GmbH 2010-2026" + |}""".stripMargin + + Ok(json).map(_.withContentType(`Content-Type`(MediaType.application.json))) + } + + private def htmlResponse: IO[Response[IO]] = { + val appDiscoveryLinks = appDiscoveryPairs.map { case (name, url) => + s"""
  • ${humanName(name)} ($name)
  • """ + }.mkString("\n") + + val html = + s""" + | + | + | OBP API - Status Page + | + | + | + |

    Welcome to the OBP API technical discovery page

    + |

    OBP API is a headless open source Open Banking API stack. Navigate to the Apps below to interact with the APIs or see the Discovery Endpoints.

    + | + |

    App Directory

    + | + | + |

    Discovery Endpoints (See also API Explorer, Portal or MCP Server above)

    + | + | + |

    Links

    + | + | + |
    + | Copyright TESOBE GmbH 2010-2026 + |
    + | + |""".stripMargin + + Ok(html).map(_.withContentType(`Content-Type`(MediaType.text.html))) } } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 18aefa7be5..3272b4912f 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -8725,7 +8725,7 @@ trait APIMethods600 { |This endpoint validates the provided credentials without creating a token or session. |It can be used to verify user credentials in external systems. | - |${userAuthenticationMessage(true)} + |${applicationAccessMessage(true)} | |""", PostVerifyUserCredentialsJsonV600( @@ -8735,7 +8735,6 @@ trait APIMethods600 { ), userJsonV200, List( - $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, InvalidLoginCredentials, @@ -8743,45 +8742,15 @@ trait APIMethods600 { UnknownError ), List(apiTagUser), - Some(List(canVerifyUserCredentials)) + Some(List(canVerifyUserCredentials)), + authMode = UserOrApplication ) lazy val verifyUserCredentials: OBPEndpoint = { case "users" :: "verify-credentials" :: Nil JsonPost json -> _ => { cc => implicit val ec = EndpointContext(Some(cc)) - // TODO: Implement per-endpoint auth mode via a new EndpointAuthMode field on ResourceDoc. - // - // Proposed design: - // sealed trait EndpointAuthMode - // case object UserOnly extends EndpointAuthMode // current default — user entitlement required - // case object ApplicationOnly extends EndpointAuthMode // consumer scope required, no user needed - // case object UserOrApplication extends EndpointAuthMode // consumer scope OR user entitlement - // case object UserAndApplication extends EndpointAuthMode // consumer scope AND user entitlement (PSD2) - // - // Add to ResourceDoc as: authMode: EndpointAuthMode = UserOnly - // Only endpoints needing non-default behaviour need to specify it — no changes to - // existing ResourceDocs required. This endpoint would use UserOrApplication. - // - // Implementation points: - // - wrappedWithAuthCheck.checkAuth: use applicationAccess for ApplicationOnly/UserOrApplication - // (it still resolves user if present), authenticatedAccess for UserOnly/UserAndApplication. - // - wrappedWithAuthCheck.checkRoles: match on authMode to decide whether to check - // consumer scopes, user entitlements, or both — instead of relying on the global - // allow_entitlements_or_scopes config flag. - // - The global allow_entitlements_or_scopes flag should be deprecated/removed as it has - // large security implications, defaults to false (so is under-tested), and can silently - // override per-endpoint auth policy. - // - ResourceDoc constructor (lines 1586-1611 in APIUtil.scala): auto-add appropriate - // error messages based on authMode (e.g. ApplicationNotIdentified for app modes - // instead of AuthenticatedUserIsRequired). This also fixes the current design issue - // where security policy is derived from error message strings. - // - Note: AuthenticationTypeValidation is a separate concern — it controls which auth - // protocols (DirectLogin, OAuth2, etc.) an endpoint accepts, not what level of access - // (user vs app) is required. The two are orthogonal. - // TODO: Additionally consider endpoint-specific anti-abuse protection beyond standard rate limiting, - // e.g. LoginAttempt-style lockout tracking per target username to prevent credential enumeration/brute-force. for { - (Full(u), callContext) <- authenticatedAccess(cc) + callContext <- Future.successful(Some(cc)) postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostVerifyUserCredentialsJsonV600", 400, callContext) { json.extract[PostVerifyUserCredentialsJsonV600] } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/ScopesTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/ScopesTest.scala index 0373366bc9..be9f7f106d 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/ScopesTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/ScopesTest.scala @@ -18,9 +18,8 @@ import org.scalatest.Tag class ScopesTest extends V400ServerSetup { override def beforeEach() = { - // Default props values + // Default props values setPropsValues("require_scopes_for_all_roles"-> "false") - setPropsValues("allow_entitlements_or_scopes"-> "false") setPropsValues("require_scopes_for_listed_roles"-> "") } @@ -62,8 +61,7 @@ class ScopesTest extends V400ServerSetup { * Those tests needs to check the app behaviour regarding next properties: * - require_scopes_for_all_roles=false * - require_scopes_for_listed_roles=CanCreateUserAuthContext,CanGetCustomersAtOneBank - * - allow_entitlements_or_scopes=false - * + * */ feature(s"test $ApiEndpoint1 version $VersionOfApi") { @@ -156,42 +154,8 @@ class ScopesTest extends V400ServerSetup { response400.code should equal(403) } - - // Consumer OR User has the Role - scenario("We will call the endpoint with allow_entitlements_or_scopes=true and scope", ApiEndpoint1, VersionOfApi) { - setPropsValues("allow_entitlements_or_scopes"-> "true") - //Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) - Scope.scope.vend.addScope("", testConsumer.id.get.toString, ApiRole.CanGetAnyUser.toString) - When("We make a request v4.0.0") - val request400 = (v4_0_0_Request / "users" / "user_id" / resourceUser3.userId).GET <@(user1) - val response400 = makeGetRequest(request400) - Then("We get successful response") - response400.code should equal(200) - response400.body.extract[UserJsonV400].user_id should equal(resourceUser3.userId) - } - scenario("We will call the endpoint with allow_entitlements_or_scopes=true and user entitlement", ApiEndpoint1, VersionOfApi) { - setPropsValues("allow_entitlements_or_scopes"-> "true") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) - // Scope.scope.vend.addScope("", testConsumer.id.get.toString, ApiRole.CanGetAnyUser.toString) - When("We make a request v4.0.0") - val request400 = (v4_0_0_Request / "users" / "user_id" / resourceUser3.userId).GET <@(user1) - val response400 = makeGetRequest(request400) - Then("We get successful response") - response400.code should equal(200) - response400.body.extract[UserJsonV400].user_id should equal(resourceUser3.userId) - } - scenario("We will call the endpoint with allow_entitlements_or_scopes=true but without entitlement or scope", ApiEndpoint1, VersionOfApi) { - setPropsValues("allow_entitlements_or_scopes"-> "true") - // Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) - // Scope.scope.vend.addScope("", testConsumer.id.get.toString, ApiRole.CanGetAnyUser.toString) - When("We make a request v4.0.0") - val request400 = (v4_0_0_Request / "users" / "user_id" / resourceUser3.userId).GET <@(user1) - val response400 = makeGetRequest(request400) - Then("We get successful response") - response400.code should equal(403) - } - // Consumer has he Scope but this is not enough + // Consumer has the Scope but this is not enough scenario("We will call the endpoint without user entitlement but with scope", ApiEndpoint1, VersionOfApi) { // Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) Scope.scope.vend.addScope("", testConsumer.id.get.toString, ApiRole.CanGetAnyUser.toString) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala new file mode 100644 index 0000000000..4719325713 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala @@ -0,0 +1,59 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil._ +import code.api.util.ErrorMessages._ +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +/** + * Unit tests for EndpointAuthMode sealed trait and its integration with ResourceDoc. + * Tests that the auth mode values are correctly defined and accessible. + */ +class EndpointAuthModeTest extends V600ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + + feature("EndpointAuthMode sealed trait") { + + scenario("All four auth modes should be defined", VersionOfApi) { + val userOnly: EndpointAuthMode = UserOnly + val appOnly: EndpointAuthMode = ApplicationOnly + val userOrApp: EndpointAuthMode = UserOrApplication + val userAndApp: EndpointAuthMode = UserAndApplication + + userOnly shouldBe a[EndpointAuthMode] + appOnly shouldBe a[EndpointAuthMode] + userOrApp shouldBe a[EndpointAuthMode] + userAndApp shouldBe a[EndpointAuthMode] + } + + scenario("verifyUserCredentials ResourceDoc should have UserOrApplication authMode", VersionOfApi) { + val verifyCredsDocs = ResourceDoc.operationIdToResourceDoc.values().toArray + .collect { case rd: ResourceDoc => rd } + .filter(_.partialFunctionName == "verifyUserCredentials") + + verifyCredsDocs should not be empty + verifyCredsDocs.foreach { doc => + doc.authMode should equal(UserOrApplication) + doc.errorResponseBodies should contain(ApplicationNotIdentified) + } + } + + scenario("Default authMode should be UserOnly for existing endpoints", VersionOfApi) { + // Pick a known existing endpoint that doesn't set authMode explicitly + val getApiInfoDocs = ResourceDoc.operationIdToResourceDoc.values().toArray + .collect { case rd: ResourceDoc => rd } + .filter(_.partialFunctionName == "getApiInfo") + + getApiInfoDocs should not be empty + getApiInfoDocs.foreach { doc => + doc.authMode should equal(UserOnly) + } + } + + scenario("handleAccessControlWithAuthMode should pass for empty roles", VersionOfApi) { + val result = handleAccessControlWithAuthMode("", "", "", Nil, UserOnly) + result should equal(true) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala index 394815ac9b..3c780db410 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala @@ -79,8 +79,8 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { Then("We should get a 401") response.code should equal(401) - And("The error message should indicate authentication is required") - response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) + And("The error message should indicate application not identified (UserOrApplication mode requires at least app auth)") + response.body.extract[ErrorMessage].message should include("OBP-20200") } scenario("Authenticated user without role should fail with 403", ApiEndpoint, VersionOfApi) { diff --git a/release_notes.md b/release_notes.md index 0652d39f43..28aa398a33 100644 --- a/release_notes.md +++ b/release_notes.md @@ -3,6 +3,19 @@ ### Most recent changes at top of file ``` Date Commit Action +05/03/2026 TBD BREAKING CHANGE: Removed allow_entitlements_or_scopes config flag. + This global flag allowed consumer scopes as an alternative to user + entitlements for ALL endpoints. It has been replaced by per-endpoint + EndpointAuthMode on ResourceDoc with four modes: + - UserOnly (default) — user entitlement required + - ApplicationOnly — consumer scope required, no user needed + - UserOrApplication — consumer scope OR user entitlement + - UserAndApplication — consumer scope AND user entitlement + Migration: add authMode = UserOrApplication to individual ResourceDoc + instances that previously relied on allow_entitlements_or_scopes=true. + The verifyUserCredentials endpoint is the first to use UserOrApplication. + require_scopes_for_all_roles and require_scopes_for_listed_roles are + unaffected and continue to work. 27/02/2026 24035862 Http4s server bind address configuration Added bind_address property for http4s server configuration: - bind_address: Optional property to specify the network binding address From 0f1f01078c933c8986857cdf85fe0f3ce2271158 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 5 Mar 2026 05:51:30 +0100 Subject: [PATCH 3/6] added test scenario: Successfully verify valid credentials with consumer scope --- .../v6_0_0/VerifyUserCredentialsTest.scala | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala index 3c780db410..643184ba41 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/VerifyUserCredentialsTest.scala @@ -2,6 +2,7 @@ package code.api.v6_0_0 import code.api.Constant import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole import code.api.util.ApiRole.CanVerifyUserCredentials import code.api.util.ErrorMessages import code.api.util.ErrorMessages.{InvalidLoginCredentials, UserHasMissingRoles, UsernameHasBeenLocked} @@ -9,6 +10,7 @@ import code.api.v6_0_0.APIMethods600.Implementations6_0_0 import code.entitlement.Entitlement import code.loginattempts.LoginAttempt import code.model.dataAccess.AuthUser +import code.scope.Scope import code.setup.DefaultUsers import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.ErrorMessage @@ -99,6 +101,31 @@ class VerifyUserCredentialsTest extends V600ServerSetup with DefaultUsers { response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanVerifyUserCredentials) } + scenario("Successfully verify valid credentials with consumer scope (no user entitlement)", ApiEndpoint, VersionOfApi) { + // Add scope to consumer instead of entitlement to user — UserOrApplication should accept this + val addedScope = Scope.scope.vend.addScope("", testConsumer.id.get.toString, ApiRole.CanVerifyUserCredentials.toString) + + When("We verify valid credentials using consumer with scope") + val postJson = Map( + "username" -> testUsername, + "password" -> testPassword, + "provider" -> Constant.localIdentityProvider + ) + val request = (v6_0_0_Request / "users" / "verify-credentials").POST <@ (user1) + val response = try { + makePostRequest(request, write(postJson)) + } finally { + Scope.scope.vend.deleteScope(addedScope) + } + + Then("We should get a 200") + response.code should equal(200) + + And("The response should contain user details") + val json = response.body + (json \ "username").extract[String] should equal(testUsername) + } + scenario("Successfully verify valid credentials", ApiEndpoint, VersionOfApi) { // Add the required entitlement val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyUserCredentials.toString) From b759f517b2abbe39ca253e019d387248a078f626 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 5 Mar 2026 06:12:17 +0100 Subject: [PATCH 4/6] fixtest: Endpoint Auth Mode Test --- .../api/v6_0_0/EndpointAuthModeTest.scala | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala index 4719325713..83b2af07ec 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala @@ -28,25 +28,22 @@ class EndpointAuthModeTest extends V600ServerSetup { } scenario("verifyUserCredentials ResourceDoc should have UserOrApplication authMode", VersionOfApi) { - val verifyCredsDocs = ResourceDoc.operationIdToResourceDoc.values().toArray - .collect { case rd: ResourceDoc => rd } - .filter(_.partialFunctionName == "verifyUserCredentials") + val operationId = s"${ApiVersion.v6_0_0.toString}-verifyUserCredentials" + val docs = ResourceDoc.getResourceDocs(List(operationId)) - verifyCredsDocs should not be empty - verifyCredsDocs.foreach { doc => + docs should not be empty + docs.foreach { doc => doc.authMode should equal(UserOrApplication) doc.errorResponseBodies should contain(ApplicationNotIdentified) } } scenario("Default authMode should be UserOnly for existing endpoints", VersionOfApi) { - // Pick a known existing endpoint that doesn't set authMode explicitly - val getApiInfoDocs = ResourceDoc.operationIdToResourceDoc.values().toArray - .collect { case rd: ResourceDoc => rd } - .filter(_.partialFunctionName == "getApiInfo") + val operationId = s"${ApiVersion.v6_0_0.toString}-getApiInfo" + val docs = ResourceDoc.getResourceDocs(List(operationId)) - getApiInfoDocs should not be empty - getApiInfoDocs.foreach { doc => + docs should not be empty + docs.foreach { doc => doc.authMode should equal(UserOnly) } } From 71ce769286f4f9324df490da2c4f21059f256a2b Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 5 Mar 2026 06:14:46 +0100 Subject: [PATCH 5/6] index page --- obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala index f4454dd0d5..e0049c7723 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala @@ -86,7 +86,8 @@ object StatusPage { |$appDiscoveryLinks | | - |

    Discovery Endpoints (See also API Explorer, Portal or MCP Server above)

    + |

    Discovery Endpoints

    + |

    See also API Explorer, Portal or MCP Server above.

    |
      |
    • API Info
    • |
    • API Documentation
    • From f64198a4905153e5ab9479d16c5314c4e9af59dc Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 5 Mar 2026 08:37:37 +0100 Subject: [PATCH 6/6] fixtest using buildOperationId --- .../src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala | 4 ++-- run_all_tests.sh | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala index 83b2af07ec..22948d52f9 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala @@ -28,7 +28,7 @@ class EndpointAuthModeTest extends V600ServerSetup { } scenario("verifyUserCredentials ResourceDoc should have UserOrApplication authMode", VersionOfApi) { - val operationId = s"${ApiVersion.v6_0_0.toString}-verifyUserCredentials" + val operationId = buildOperationId(ApiVersion.v6_0_0, "verifyUserCredentials") val docs = ResourceDoc.getResourceDocs(List(operationId)) docs should not be empty @@ -39,7 +39,7 @@ class EndpointAuthModeTest extends V600ServerSetup { } scenario("Default authMode should be UserOnly for existing endpoints", VersionOfApi) { - val operationId = s"${ApiVersion.v6_0_0.toString}-getApiInfo" + val operationId = buildOperationId(ApiVersion.v6_0_0, "root") val docs = ResourceDoc.getResourceDocs(List(operationId)) docs should not be empty diff --git a/run_all_tests.sh b/run_all_tests.sh index cd5a59d622..c04f586b83 100755 --- a/run_all_tests.sh +++ b/run_all_tests.sh @@ -590,7 +590,8 @@ HEADER # Strategy 2: For failures without file references, find the test class header # ScalaTest prints "ClassName:" before its scenarios - grep -n "\*\*\* FAILED \*\*\*" "${stripped_log}" | cut -d: -f1 | while read failure_line; do + # Exclude summary lines like "*** 2 TESTS FAILED ***" which aren't individual test failures + grep -n "\*\*\* FAILED \*\*\*" "${stripped_log}" | grep -v "TESTS FAILED" | cut -d: -f1 | while read failure_line; do head -n "$failure_line" "${stripped_log}" | \ grep -E '^[A-Z][a-zA-Z0-9_]*(Test|Tests|Suite):$' | \ tail -1 | sed 's/:$//'