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/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..e0049c7723 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/http4s/StatusPage.scala @@ -0,0 +1,113 @@ +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.{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 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

    + | + | + | + | + |""".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..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,17 +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: 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. 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..22948d52f9 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/EndpointAuthModeTest.scala @@ -0,0 +1,56 @@ +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 operationId = buildOperationId(ApiVersion.v6_0_0, "verifyUserCredentials") + val docs = ResourceDoc.getResourceDocs(List(operationId)) + + 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) { + val operationId = buildOperationId(ApiVersion.v6_0_0, "root") + val docs = ResourceDoc.getResourceDocs(List(operationId)) + + docs should not be empty + docs.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..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 @@ -79,8 +81,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) { @@ -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) 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 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/:$//'