diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 97b266b36f..bd079e8f96 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -280,6 +280,10 @@ db.url=jdbc:h2:./lift_proto.db;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE ## (this needs to be a URL) hostname=http://127.0.0.1:8080 +# Used as the base URL for password reset and email validation links sent via email. +# Set this to your frontend/portal URL so that emails contain the correct link. +portal_external_url=http://localhost:5174 + ## This is only useful for running the api locally via RunWebApp ## If you use it, make sure this matches your hostname port! ## If you want to change the port when running via the command line, use "mvn -Djetty.port=8080 jetty:run" instead @@ -693,22 +697,28 @@ defaultBank.bank_id=OBP ################################################################################ ## Super Admin Users are used to boot-strap User Entitlements (access to Roles). -## Super Admins listed below can grant them selves the following entitlements: +## Super Admins listed below automatically have the following virtual roles: ## CanCreateEntitlementAtAnyBank -## and ## CanCreateEntitlementAtOneBank +## CanGetAnyUser ## After they have granted these roles, they can grant further roles and remove their # user_id from the super_admin_user_ids list because its redundant. ## Once you have the roles above you can grant any other system or bank related roles to yourself. ## ## THUS, probably the first thing a Super Admin will do is to grant themselves or other users a number of Roles -## For instance, a Super Admin defined by their user_id in super_admin_user_ids CANNOT carry out actions unless they first give themselves an actual Entitlment to a Role. +## For instance, a Super Admin defined by their user_id in super_admin_user_ids CANNOT carry out actions unless they first give themselves an actual Entitlement to a Role. ## List the Users here, with their user_id(s), that should be Super Admins super_admin_user_ids=USER_ID1,USER_ID2, ################################################################################ +################################################################################## +# List of Users that should automatically have roles needed to call endpoints used by OBP-OIDC or OBP Keycloak Provider. +# The following users will automatically have: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient +# oidc_operator_user_ids=USER_ID1,USER_ID2, +#################################################################################### + ######## Enable / Disable Versions and individual endpoints. ######## # In OBP, endpoints are defined in various files but made available under a *version* # e.g. in v3_0_0 (aka v3.0.0) we have endpoints from various versions. @@ -813,7 +823,13 @@ autocomplete_at_login_form_enabled=false # This involves this OBP-API sending an email to the newly registered email provided by the User and the User clicking on a link in that email # which results in a field being changed in the database. # To BYPASS this security features (for local development only), set this property to true to skip the email address validation. -#authUser.skipEmailValidation=false +authUser.skipEmailValidation=false + +# Expiry time in minutes for email validation JWT tokens (default: 1440 = 24 hours) +email_validation_token_expiry_minutes=1440 + +# Expiry time in minutes for password reset JWT tokens (default: 120 = 2 hours) +password_reset_token_expiry_minutes=120 # control the create and access to public views. # allow_public_views=false @@ -1598,6 +1614,14 @@ regulated_entities = [] # super_admin_inital_password=681aeeb9f681aeeb9f681aeeb9 # super_admin_email=tom@tesobe.com +# Bootstrap OIDC Operator User +# Given the following credentials, OBP will create a user if they do not already exist. +# This user will be granted: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient, CanGetConsumers +# If you want to use this feature, please set up all three values properly at the same time. +# oidc_operator_username=... +# oidc_operator_initial_password=... +# oidc_operator_email=... + ## Ethereum Connector Configuration ## ================================ @@ -1697,6 +1721,13 @@ securelogging_mask_credit_card=true securelogging_mask_email=true +############################################ +# Signal Channels (Redis-backed ephemeral channels for AI agent coordination) +############################################ +# messaging.channel.ttl.seconds=3600 +# messaging.channel.max.messages=1000 + + ############################################ # http4s server configuration ############################################ diff --git a/obp-api/src/main/resources/props/test.default.props.template b/obp-api/src/main/resources/props/test.default.props.template index c72d0ec8bc..511bf99803 100644 --- a/obp-api/src/main/resources/props/test.default.props.template +++ b/obp-api/src/main/resources/props/test.default.props.template @@ -39,6 +39,23 @@ write_metrics = false # --for tests don't set it to 127.0.0.1, for some reason hostname=http://localhost:8016 +# Used as the base URL for password reset and email validation links sent via email. +# Set this to your frontend/portal URL so that emails contain the correct link. +portal_external_url=http://localhost:5174 + +# Set to true to skip email validation on user signup (default: false) +authUser.skipEmailValidation=false + +# Expiry time in minutes for email validation JWT tokens (default: 1440 = 24 hours) +email_validation_token_expiry_minutes=1440 + +# Expiry time in minutes for password reset JWT tokens (default: 120 = 2 hours) +password_reset_token_expiry_minutes=120 + +# List of Users that should automatically have roles needed to call endpoints used by OBP-OIDC or OBP Keycloak Provider. +# The following users will automatically have: CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient +# oidc_operator_user_ids=USER_ID1,USER_ID2, + #this is only useful for running the api locally via RunWebApp #if you use it, make sure this matches your hostname port! #if you want to change the port when running via the command line, use "mvn -Djetty.port=8089 jetty:run" instead diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 276cf685fd..1bf33a4525 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -43,7 +43,7 @@ import code.api.attributedefinition.AttributeDefinition import code.api.berlin.group.ConstantsBG import code.api.cache.Redis import code.api.util.APIUtil.{enableVersionIfAllowed, errorJsonResponse, getPropsValue} -import code.api.util.ApiRole.CanCreateEntitlementAtAnyBank +import code.api.util.ApiRole.{CanCreateEntitlementAtAnyBank, CanGetAnyUser, CanVerifyUserCredentials, CanVerifyOidcClient, CanGetOidcClient, CanGetConsumers} import code.api.util.ErrorMessages.MandatoryPropertyIsNotSet import code.api.util._ import code.api.util.migration.Migration @@ -334,6 +334,8 @@ class Boot extends MdcLoggable { warnAboutSuperAdminUsers() + createBootstrapOidcOperatorUser() + //launch the scheduler to clean the database from the expired tokens and nonces, 1 hour DataBaseCleanerScheduler.start(intervalInSeconds = 60*60) @@ -1059,6 +1061,67 @@ 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 + */ + private def createBootstrapOidcOperatorUser() = { + + val oidcOperatorUsername = APIUtil.getPropsValue("oidc_operator_username", "") + val oidcOperatorInitialPassword = APIUtil.getPropsValue("oidc_operator_initial_password", "") + val oidcOperatorEmail = APIUtil.getPropsValue("oidc_operator_email", "") + + val isPropsNotSetProperly = oidcOperatorUsername == "" || oidcOperatorInitialPassword == "" || oidcOperatorEmail == "" + + val existingAuthUser = AuthUser.find(By(AuthUser.username, oidcOperatorUsername)) + + if (isPropsNotSetProperly) { + //Nothing happens, props is not set + } else if (existingAuthUser.isDefined) { + logger.error(s"createBootstrapOidcOperatorUser- Errors: Existing AuthUser with username ${oidcOperatorUsername} detected in data import where no ResourceUser was found") + } else { + val authUser = AuthUser.create + .email(oidcOperatorEmail) + .firstName(oidcOperatorUsername) + .lastName(oidcOperatorUsername) + .username(oidcOperatorUsername) + .password(oidcOperatorInitialPassword) + .passwordShouldBeChanged(false) + .validated(true) + + val validationErrors = authUser.validate + + if (!validationErrors.isEmpty) + logger.error(s"createBootstrapOidcOperatorUser- Errors: ${validationErrors.map(_.msg)}") + else { + Full(authUser.save()) + + val userBox = Users.users.vend.getUserByProviderAndUsername(authUser.getProvider(), authUser.username.get) + + val oidcOperatorRoles = List( + CanGetAnyUser, + CanVerifyUserCredentials, + CanVerifyOidcClient, + CanGetOidcClient, + CanGetConsumers + ) + + userBox match { + case Full(user) => + oidcOperatorRoles.foreach { role => + val resultBox = Entitlement.entitlement.vend.addEntitlement("", user.userId, role.toString) + if (resultBox.isEmpty) { + logger.error(s"createBootstrapOidcOperatorUser- Error granting ${role}: ${resultBox}") + } + } + case _ => + logger.error(s"createBootstrapOidcOperatorUser- Error: Could not find user after creation") + } + } + } + } + LiftRules.statelessDispatch.append(aliveCheck) } diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index c27548ee8a..1745d5313f 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -2135,8 +2135,7 @@ object SwaggerDefinitionsJSON { username = usernameExample.value, password = "String", first_name = "Simon", - last_name = "Redfern", - validating_application = Some("OBP-Portal") + last_name = "Redfern" ) @@ -6189,6 +6188,59 @@ object SwaggerDefinitionsJSON { account_access_requests = List(accountAccessRequestJsonV600) ) + // Signal Channels swagger examples + lazy val postSignalMessageJsonV600 = PostSignalMessageJsonV600( + payload = net.liftweb.json.parse("""{"agent_name": "my-agent", "capabilities": ["summarize", "search"]}"""), + message_type = Some("announce"), + to_user_id = None + ) + + lazy val signalMessageJsonV600 = SignalMessageJsonV600( + message_id = "d8839721-2e41-4c60-9bba-42c5a7164027", + channel_name = "discovery", + sender_consumer_id = "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh", + sender_user_id = "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1", + to_user_id = None, + timestamp = "2026-02-20T10:30:00Z", + message_type = "announce", + payload = net.liftweb.json.parse("""{"agent_name": "my-agent", "capabilities": ["summarize", "search"]}""") + ) + + lazy val signalMessagesJsonV600 = SignalMessagesJsonV600( + channel_name = "discovery", + messages = List(signalMessageJsonV600), + total_count = 1, + has_more = false + ) + + lazy val signalMessagePublishedJsonV600 = SignalMessagePublishedJsonV600( + message_id = "d8839721-2e41-4c60-9bba-42c5a7164027", + channel_name = "discovery", + timestamp = "2026-02-20T10:30:00Z", + channel_message_count = 1 + ) + + lazy val signalChannelInfoJsonV600 = SignalChannelInfoJsonV600( + channel_name = "discovery", + message_count = 5, + ttl_seconds = 3500 + ) + + lazy val signalChannelsJsonV600 = SignalChannelsJsonV600( + channels = List(signalChannelInfoJsonV600) + ) + + lazy val signalStatsJsonV600 = SignalStatsJsonV600( + total_channels = 3, + total_messages = 12, + channels = List(signalChannelInfoJsonV600) + ) + + lazy val signalChannelDeletedJsonV600 = SignalChannelDeletedJsonV600( + channel_name = "discovery", + deleted = true + ) + //The common error or success format. //Just some helper format to use in Json case class NotSupportedYet() diff --git a/obp-api/src/main/scala/code/api/cache/RedisMessaging.scala b/obp-api/src/main/scala/code/api/cache/RedisMessaging.scala new file mode 100644 index 0000000000..d6f0234366 --- /dev/null +++ b/obp-api/src/main/scala/code/api/cache/RedisMessaging.scala @@ -0,0 +1,150 @@ +package code.api.cache + +import code.api.Constant +import code.api.util.APIUtil +import code.util.Helper.MdcLoggable +import redis.clients.jedis.Jedis + +import scala.collection.JavaConverters._ + +object RedisMessaging extends MdcLoggable { + + val channelTtlSeconds: Int = APIUtil.getPropsAsIntValue("messaging.channel.ttl.seconds", 3600) + val channelMaxMessages: Int = APIUtil.getPropsAsIntValue("messaging.channel.max.messages", 1000) + + private def keyPrefix: String = s"${Constant.getGlobalCacheNamespacePrefix}msg_channel_" + + private def channelKey(channelName: String): String = s"${keyPrefix}${channelName}" + + def validateChannelName(name: String): Boolean = { + name.nonEmpty && + name.length <= 128 && + name.matches("^[a-zA-Z0-9._\\-]+$") + } + + /** + * Publish a message to a channel. + * Uses RPUSH so messages are ordered oldest-first (index 0 = oldest). + * LTRIM caps the list at max messages. EXPIRE refreshes the TTL. + * + * @return the length of the list after push + */ + def publishMessage(channelName: String, messageJson: String): Long = { + var jedisConnection: Option[Jedis] = None + try { + jedisConnection = Some(Redis.jedisPool.getResource()) + val jedis = jedisConnection.get + val key = channelKey(channelName) + + val length = jedis.rpush(key, messageJson) + // Cap the list: keep only the last N messages + jedis.ltrim(key, -channelMaxMessages.toLong, -1) + // Refresh TTL on every publish + jedis.expire(key, channelTtlSeconds) + length + } catch { + case e: Throwable => + logger.error(s"RedisMessaging.publishMessage error for channel $channelName: ${e.getMessage}") + throw new RuntimeException(e) + } finally { + jedisConnection.foreach(_.close()) + } + } + + /** + * Fetch messages from a channel with offset/limit pagination. + * Messages are ordered oldest-first (index 0 = oldest). + * + * @return (list of message JSON strings, total count in channel) + */ + def fetchMessages(channelName: String, offset: Int, limit: Int): (List[String], Long) = { + var jedisConnection: Option[Jedis] = None + try { + jedisConnection = Some(Redis.jedisPool.getResource()) + val jedis = jedisConnection.get + val key = channelKey(channelName) + + val totalCount = jedis.llen(key) + val messages = jedis.lrange(key, offset.toLong, (offset + limit - 1).toLong) + (messages.asScala.toList, totalCount) + } catch { + case e: Throwable => + logger.error(s"RedisMessaging.fetchMessages error for channel $channelName: ${e.getMessage}") + throw new RuntimeException(e) + } finally { + jedisConnection.foreach(_.close()) + } + } + + /** + * List all active channel names by scanning for the key prefix. + * + * @return list of channel names (prefix stripped) + */ + def listChannels(): List[String] = { + var jedisConnection: Option[Jedis] = None + try { + jedisConnection = Some(Redis.jedisPool.getResource()) + val jedis = jedisConnection.get + val pattern = s"${keyPrefix}*" + val keys = jedis.keys(pattern).asScala.toList + keys.map(_.stripPrefix(keyPrefix)) + } catch { + case e: Throwable => + logger.error(s"RedisMessaging.listChannels error: ${e.getMessage}") + List.empty + } finally { + jedisConnection.foreach(_.close()) + } + } + + /** + * Delete a channel. + * + * @return true if the key was deleted + */ + def deleteChannel(channelName: String): Boolean = { + var jedisConnection: Option[Jedis] = None + try { + jedisConnection = Some(Redis.jedisPool.getResource()) + val jedis = jedisConnection.get + val key = channelKey(channelName) + jedis.del(key) > 0 + } catch { + case e: Throwable => + logger.error(s"RedisMessaging.deleteChannel error for channel $channelName: ${e.getMessage}") + false + } finally { + jedisConnection.foreach(_.close()) + } + } + + /** + * Get channel info: message count and remaining TTL. + * + * @return Some((messageCount, ttlSeconds)) or None if channel doesn't exist + */ + def channelInfo(channelName: String): Option[(Long, Long)] = { + var jedisConnection: Option[Jedis] = None + try { + jedisConnection = Some(Redis.jedisPool.getResource()) + val jedis = jedisConnection.get + val key = channelKey(channelName) + + val exists = jedis.exists(key) + if (exists) { + val count = jedis.llen(key) + val ttl = jedis.ttl(key) + Some((count, ttl)) + } else { + None + } + } catch { + case e: Throwable => + logger.error(s"RedisMessaging.channelInfo error for channel $channelName: ${e.getMessage}") + None + } finally { + jedisConnection.foreach(_.close()) + } + } +} 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 925fcfdc14..a3a7a10632 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2401,6 +2401,21 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ user_ids.filter(_ == user_id).length > 0 } + def isOidcOperator(user_id: String): Boolean = { + val user_ids = APIUtil.getPropsValue("oidc_operator_user_ids") match { + case Full(v) => + v.split(",").map(_.trim).toList + case _ => + List() + } + user_ids.filter(_ == user_id).length > 0 + } + + // 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") + def hasScope(bankId: String, consumerId: String, role: ApiRole): Boolean = { !Scope.scope.vend.getScope(bankId, consumerId, role.toString).isEmpty } @@ -2419,6 +2434,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ } } + @deprecated("Use handleAccessControlRegardingEntitlementsAndScopes instead. It checks virtual roles (super_admin, oidc_operator), Scopes, and just-in-time entitlements in addition to Entitlements.", "OBP v6.0.0") def hasEntitlement(bankId: String, userId: String, apiRole: ApiRole): Boolean = apiRole match { case RoleCombination(roles) => roles.forall(hasEntitlement(bankId, userId, _)) case role => @@ -2440,6 +2456,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ } } + @deprecated("Use handleAccessControlRegardingEntitlementsAndScopes instead. It checks virtual roles (super_admin, oidc_operator), Scopes, and just-in-time entitlements in addition to Entitlements.", "OBP v6.0.0") def hasEntitlementAndScope(bankId: String, userId: String, consumerId: String, role: ApiRole): Box[EntitlementAndScopeStatus]= { for{ hasEntitlement <- tryo{ !Entitlement.entitlement.vend.getEntitlement(bankId, userId, role.toString).isEmpty} ?~! s"$UnknownError" @@ -2462,6 +2479,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 handleAccessControlRegardingEntitlementsAndScopes instead. It checks virtual roles (super_admin, oidc_operator), Scopes, and just-in-time entitlements in addition to Entitlements.", "OBP v6.0.0") def hasAtLeastOneEntitlement(bankId: String, userId: String, roles: List[ApiRole]): Boolean = roles.isEmpty || roles.exists(hasEntitlement(bankId, userId, _)) @@ -2472,6 +2490,13 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ if (roles.isEmpty) { // No access control, treat as pass auth check 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 { val requireScopesForListedRoles = getPropsValue("require_scopes_for_listed_roles", "").split(",").toSet val requireScopesForRoles = roles.map(_.toString).toSet.intersect(requireScopesForListedRoles) @@ -2509,6 +2534,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ else { userHasTheRoles } + } // end of virtual roles else } } @@ -2518,6 +2544,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ // 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 // TODO Should we accept Option[BankId] for bankId instead of String ? + @deprecated("Use handleAccessControlRegardingEntitlementsAndScopes instead. It checks virtual roles (super_admin, oidc_operator), Scopes, and just-in-time entitlements in addition to Entitlements.", "OBP v6.0.0") def hasAllEntitlements(bankId: String, userId: String, roles: List[ApiRole]): Boolean = roles.forall(hasEntitlement(bankId, userId, _)) @@ -3917,24 +3944,27 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ //eg: List(("webui_get_started_text","Get started building your application using this sandbox now"), // ("webui_post_consumer_registration_more_info_text"," Please tell us more your Application and / or Startup using this link")) def getWebUIPropsPairs: List[(String, String)] = { - val filepath = this.getClass.getResource("/props/sample.props.template").getPath - val bufferedSource: BufferedSource = scala.io.Source.fromFile(filepath) - - val proPairs: List[(String, String)] = for{ - line <- bufferedSource.getLines.toList if(line.startsWith("webui_") || line.startsWith("#webui_")) - webuiProps = line.toString.split("=", 2) - } yield { - val webuiPropsKey = webuiProps(0).trim.replaceAll("#","") //Remove the whitespace - val webuiPropsValue = if (webuiProps.length > 1) webuiProps(1).trim else "" - (webuiPropsKey, webuiPropsValue) + val stream = this.getClass.getResourceAsStream("/props/sample.props.template") + val bufferedSource: BufferedSource = scala.io.Source.fromInputStream(stream, "utf-8") + try { + val proPairs: List[(String, String)] = for{ + line <- bufferedSource.getLines.toList if(line.startsWith("webui_") || line.startsWith("#webui_")) + webuiProps = line.toString.split("=", 2) + } yield { + val webuiPropsKey = webuiProps(0).trim.replaceAll("#","") //Remove the whitespace + val webuiPropsValue = if (webuiProps.length > 1) webuiProps(1).trim else "" + (webuiPropsKey, webuiPropsValue) + } + proPairs + } finally { + bufferedSource.close() + stream.close() } - bufferedSource.close() - proPairs } def getConfigPropsPairs: List[(String, String)] = { - val filepath = this.getClass.getResource("/props/sample.props.template").getPath - val bufferedSource: BufferedSource = scala.io.Source.fromFile(filepath) + val stream = this.getClass.getResourceAsStream("/props/sample.props.template") + val bufferedSource: BufferedSource = scala.io.Source.fromInputStream(stream, "utf-8") try { val keys: List[String] = (for { line <- bufferedSource.getLines.toList @@ -3952,6 +3982,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ } } finally { bufferedSource.close() + stream.close() } } 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 d3a2b8ae73..b56579cf7f 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -462,6 +462,9 @@ object ApiRole extends MdcLoggable{ case class CanGetConfigProps(requiresBankId: Boolean = false) extends ApiRole lazy val canGetConfigProps = CanGetConfigProps() + case class CanGetSignalStats(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetSignalStats = CanGetSignalStats() + case class CanDeleteEntitlementRequestsAtAnyBank(requiresBankId: Boolean = false) extends ApiRole lazy val canDeleteEntitlementRequestsAtAnyBank = CanDeleteEntitlementRequestsAtAnyBank() diff --git a/obp-api/src/main/scala/code/api/util/ApiTag.scala b/obp-api/src/main/scala/code/api/util/ApiTag.scala index b9ffbc87d7..2bcd2b91b3 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -158,6 +158,11 @@ object ApiTag { //Note: the followings are for the code generator -- AUOpenBankingV1.0.0 val apiTagBanking = ResourceDocTag("AU-Banking") + val apiTagAiAgent = ResourceDocTag("AI-Agent") + val apiTagSignal = ResourceDocTag("Signal") + val apiTagSignalling = ResourceDocTag("Signalling") + val apiTagChannel = ResourceDocTag("Channel") + private[this] val tagNameSymbolMapTag: MutableMap[String, ResourceDocTag] = MutableMap() /** diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 387f271c87..7d6cca9785 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -135,6 +135,7 @@ object ErrorMessages { val InvalidTagsParameter = "OBP-10053: Invalid tags parameter. Tags cannot be empty when provided" val InvalidFunctionsParameter = "OBP-10054: Invalid functions parameter. Functions cannot be empty when provided" val InvalidApiCollectionIdParameter = "OBP-10055: Invalid api-collection-id parameter. API collection ID cannot be empty when provided" + val IncompleteServerConfiguration = "OBP-10056: A required server configuration property is missing. " diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index 7b7553eba4..7a691e90c1 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -1114,7 +1114,7 @@ object Glossary extends MdcLoggable { glossaryItems += GlossaryItem( - title = "Consent_OBP_Flow_Example", + title = "Authentication: Consent OBP Flow Example", description = s""" |#### 1) Call endpoint Create Consent Request using application access (Client Credentials) @@ -1355,7 +1355,7 @@ object Glossary extends MdcLoggable { glossaryItems += GlossaryItem( - title = "Direct Login", + title = "Authentication: Direct Login", description = s""" |Direct Login is a simple authentication process to be used at hackathons and trusted environments: @@ -2136,7 +2136,7 @@ object Glossary extends MdcLoggable { """) glossaryItems += GlossaryItem( - title = "OAuth 1.0a", + title = "Authentication: OAuth 1.0a", description = s""" |The following steps will explain how to connect an instance of the Open Bank Project OAuth Server 1.0a. This authentication mechanism is necessary so a third party application can consume the Open Bank project API securely. @@ -2372,7 +2372,7 @@ object Glossary extends MdcLoggable { {"OAuth2 is allowed on this instance."} else {"Note: *OAuth2 is NOT allowed on this instance!*"} glossaryItems += GlossaryItem( - title = "OAuth 2", + title = "Authentication: OAuth 2", description = s""" | @@ -2581,7 +2581,7 @@ object Glossary extends MdcLoggable { glossaryItems += GlossaryItem( - title = "Gateway Login", + title = "Authentication: Gateway Login", description = s""" |### Introduction @@ -4151,7 +4151,7 @@ object Glossary extends MdcLoggable { | """.stripMargin) glossaryItems += GlossaryItem( - title = "OAuth 2.0", + title = "Authentication: OAuth 2.0", description = s"""OAuth 2.0, is a framework, specified by the IETF in RFCs 6749 and 6750 (published in 2012) designed to support the development of authentication and authorization protocols. It provides a variety of standardized message flows based on JSON and HTTP.""".stripMargin) @@ -5037,6 +5037,105 @@ object Glossary extends MdcLoggable { ) ) + glossaryItems += GlossaryItem( + title = "Email Validation for OBP Local Users", + description = + s""" + |### Overview + | + |When a new OBP local user is created, they may be required to validate their email address before they can log in. + |This is controlled by the `authUser.skipEmailValidation` property (default: `false`). + | + |When email validation is enabled, the user receives an email containing a signed JWT token with a validation link. + |The user clicks the link, and the App (portal) extracts the token and calls the API to complete the validation. + | + |### Props + | + |The following properties are involved: + | + |- `authUser.skipEmailValidation` — Set to `true` to skip email validation entirely (default: `false`). Currently: `${APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", false)}` + |- `portal_external_url` — **Required.** The base URL of your frontend/portal application. Used to construct the validation link in the email. For example: `portal_external_url=https://your-portal.example.com`. Currently: `${APIUtil.getPropsValue("portal_external_url", "not set")}` + |- `email_validation_token_expiry_minutes` — Expiry time for the validation JWT token in minutes (default: `1440` i.e. 24 hours). Currently: `${APIUtil.getPropsAsIntValue("email_validation_token_expiry_minutes", 1440)}` + | + |### Step 1: User Creation + | + |A user can be created via: + | + |**POST /obp/v6.0.0/users** (no authentication required) + | + |Request body: + | + | { + | "username": "user@example.com", + | "password": "Str0ng!Password", + | "first_name": "Jane", + | "last_name": "Doe", + | "email": "user@example.com" + | } + | + |If `authUser.skipEmailValidation=false`, the API will: + | + |1. Create the user with `validated=false` + |2. Generate a signed JWT token containing the user's unique ID as the subject, with a configurable expiry + |3. Construct a validation link: `{portal_external_url}/user-validation?token={JWT}` + |4. Send an email to the user with the validation link + | + |The user or the legacy Lift signup form can also trigger validation emails. In all cases, the same JWT-based token is used. + | + |### Step 2: Email Validation + | + |**POST /obp/v6.0.0/users/email-validation** (no authentication required) + | + |Request body: + | + | { + | "token": "eyJhbGciOiJIUzI1NiJ9..." + | } + | + |Response (201): + | + | { + | "user_id": "5995d6a2-01b3-423c-a173-5481df49bdaf", + | "email": "user@example.com", + | "username": "user@example.com", + | "provider": "https://your-api.example.com", + | "validated": true, + | "message": "Email validated successfully" + | } + | + |Error responses: + | + |- **400** — Invalid JSON format or empty token + |- **404** — Invalid or expired JWT token (bad signature, expired, or user not found) + |- **400** — User email is already validated + | + |This endpoint: + | + |1. Verifies the JWT signature (HMAC) and checks the expiry time + |2. Extracts the unique ID from the JWT subject + |3. Looks up the user by unique ID + |4. Sets the user's validated status to `true` + |5. Resets the unique ID (invalidating the token — it is single-use) + |6. Grants default entitlements to the user + | + |### Token Security + | + |- The token is a **signed JWT** (HMAC-SHA256) — it cannot be forged without the server's shared secret. + |- The token has a **configurable expiry** (default: 24 hours) set via `email_validation_token_expiry_minutes`. + |- The token is **single-use** — after validation, the unique ID is reset, so the same token cannot be used again. + | + |### Typical App Flow + | + |1. User submits registration form + |2. App calls POST /obp/v6.0.0/users + |3. App shows "Check your email for a validation link" + |4. User clicks link in email, App opens at `/user-validation?token={JWT}` + |5. App extracts the token from the URL query parameter + |6. App calls POST /obp/v6.0.0/users/email-validation with the token + |7. App shows "Email validated successfully. Please log in." + | + |""") + glossaryItems += GlossaryItem( title = "Password Reset for OBP Local Users", description = @@ -5073,6 +5172,7 @@ object Glossary extends MdcLoggable { | |- The response is always the same whether or not the user exists. This prevents user enumeration. |- If the user exists, is validated, and the email matches, a reset email is sent containing a link with a reset token. + |- The reset link base URL is constructed from the `portal_external_url` props value (currently: `${APIUtil.getPropsValue("portal_external_url", "not set")}`). This must be set to your frontend/portal URL so that reset emails contain the correct link. |- The App should present a form asking for username and email, call this endpoint, and then show a message saying "Check your email for a reset link." | |### Step 2: Complete Password Reset @@ -5101,7 +5201,7 @@ object Glossary extends MdcLoggable { | |Notes: | - |- The token is a signed JWT with a configurable expiry (default: 120 minutes). The server-side expiry can be configured with the `password_reset_token_expiry_minutes` property. + |- The token is a signed JWT with a configurable expiry (default: 120 minutes). The server-side expiry can be configured with the `password_reset_token_expiry_minutes` property (currently: `${APIUtil.getPropsAsIntValue("password_reset_token_expiry_minutes", 120)}` minutes). |- The token comes from the reset email URL. The App should extract the token from the URL path (everything after `/user_mgt/reset_password/`) and URL-decode it before sending it to this endpoint. |- The token is single-use. Once the password is reset, the token is invalidated. An expired token will also be rejected. | @@ -5148,7 +5248,7 @@ object Glossary extends MdcLoggable { """) glossaryItems += GlossaryItem( - title = "Credential Checking Flow", + title = "Authentication: Credential Checking Flow", description = s""" |### Overview diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index d21ef5d8b8..e9e8d9ea2b 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -869,6 +869,7 @@ object NewStyle extends MdcLoggable{ } } + @deprecated("Use handleEntitlementsAndScopes instead. It checks virtual roles (super_admin, oidc_operator), Scopes, and just-in-time entitlements in addition to Entitlements.", "OBP v6.0.0") def hasEntitlement(bankId: String, userId: String, role: ApiRole, callContext: Option[CallContext], errorMsg: String = ""): Future[Box[Unit]] = { val errorInfo = if(StringUtils.isBlank(errorMsg)&& !bankId.isEmpty) UserHasMissingRoles + role.toString() + s" at Bank($bankId)" @@ -880,6 +881,7 @@ object NewStyle extends MdcLoggable{ } map validateRequestPayload(callContext) } // scala not allow overload method both have default parameter, so this method name is just in order avoid the same name with hasEntitlement + @deprecated("Use handleEntitlementsAndScopes instead. It checks virtual roles (super_admin, oidc_operator), Scopes, and just-in-time entitlements in addition to Entitlements.", "OBP v6.0.0") def ownEntitlement(bankId: String, userId: String, role: ApiRole,callContext: Option[CallContext], errorMsg: String = ""): Box[Unit] = { val errorInfo = if(StringUtils.isBlank(errorMsg)) UserHasMissingRoles + role.toString() else errorMsg @@ -887,16 +889,18 @@ object NewStyle extends MdcLoggable{ validateRequestPayload(callContext)(boxResult) } + @deprecated("Use handleEntitlementsAndScopes instead. It checks virtual roles (super_admin, oidc_operator), Scopes, and just-in-time entitlements in addition to Entitlements.", "OBP v6.0.0") def hasAtLeastOneEntitlement(failMsg: => String)(bankId: String, userId: String, roles: List[ApiRole], callContext: Option[CallContext]): Future[Box[Unit]] = Helper.booleanToFuture(failMsg, cc=callContext) { APIUtil.hasAtLeastOneEntitlement(bankId, userId, roles) - } map validateRequestPayload(callContext) - + } map validateRequestPayload(callContext) + def handleEntitlementsAndScopes(failMsg: => String)(bankId: String, userId: String, roles: List[ApiRole], callContext: Option[CallContext]): Future[Box[Unit]] = Helper.booleanToFuture(failMsg, cc=callContext) { APIUtil.handleAccessControlRegardingEntitlementsAndScopes(bankId, userId, APIUtil.getConsumerPrimaryKey(callContext),roles) } map validateRequestPayload(callContext) + @deprecated("Use handleEntitlementsAndScopes instead. It checks virtual roles (super_admin, oidc_operator), Scopes, and just-in-time entitlements in addition to Entitlements.", "OBP v6.0.0") def hasAtLeastOneEntitlement(bankId: String, userId: String, roles: List[ApiRole], callContext: Option[CallContext]): Future[Box[Unit]] = { val errorMessage = if (roles.filter(_.requiresBankId).isEmpty) UserHasMissingRoles + roles.mkString(" or ") else UserHasMissingRoles + roles.mkString(" or ") + s" for BankId($bankId)." hasAtLeastOneEntitlement(errorMessage)(bankId, userId, roles, callContext) @@ -906,16 +910,18 @@ object NewStyle extends MdcLoggable{ handleEntitlementsAndScopes(errorMessage)(bankId, userId, roles, callContext) } + @deprecated("Use handleEntitlementsAndScopes instead. It checks virtual roles (super_admin, oidc_operator), Scopes, and just-in-time entitlements in addition to Entitlements.", "OBP v6.0.0") def hasAllEntitlements(bankId: String, userId: String, roles: List[ApiRole], callContext: Option[CallContext]): Box[Unit] = { - val errorMessage = if (roles.filter(_.requiresBankId).isEmpty) - s"$UserHasMissingRoles${roles.mkString(" and ")} entitlements are required." - else + val errorMessage = if (roles.filter(_.requiresBankId).isEmpty) + s"$UserHasMissingRoles${roles.mkString(" and ")} entitlements are required." + else s"$UserHasMissingRoles${roles.mkString(" and ")} entitlements are required for BankId($bankId)." - + val boxResult = Helper.booleanToBox(APIUtil.hasAllEntitlements(bankId, userId, roles), errorMessage) validateRequestPayload(callContext)(boxResult) } + @deprecated("Use handleEntitlementsAndScopes instead. It checks virtual roles (super_admin, oidc_operator), Scopes, and just-in-time entitlements in addition to Entitlements.", "OBP v6.0.0") def hasAllEntitlements(bankId: String, userId: String, specificBankRoles: List[ApiRole], anyBankRoles: List[ApiRole], callContext: Option[CallContext]): Box[Unit] = { val errorMsg = UserHasMissingRoles + specificBankRoles.mkString(" and ") + " OR " + anyBankRoles.mkString(" and ") + " entitlements are required." val boxResult = Helper.booleanToBox( @@ -924,6 +930,7 @@ object NewStyle extends MdcLoggable{ validateRequestPayload(callContext)(boxResult) } + @deprecated("Use handleEntitlementsAndScopes instead. It checks virtual roles (super_admin, oidc_operator), Scopes, and just-in-time entitlements in addition to Entitlements.", "OBP v6.0.0") def hasEntitlementAndScope(bankId: String, userId: String, consumerId: String, role: ApiRole, callContext: Option[CallContext]): Box[EntitlementAndScopeStatus] = { val boxResult = APIUtil.hasEntitlementAndScope(bankId, userId, consumerId, role) validateRequestPayload(callContext)(boxResult) diff --git a/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala b/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala index 06e72fbe8e..0e702593f7 100644 --- a/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala +++ b/obp-api/src/main/scala/code/api/v2_0_0/APIMethods200.scala @@ -1875,16 +1875,14 @@ trait APIMethods200 { entitlements <- Entitlement.entitlement.vend.getEntitlementsByUserId(userId) } yield { - var json = EntitlementJSONs(Nil) - // Format the data as V2.0.0 json - if (isSuperAdmin(userId)) { - // If the user is SuperAdmin add it to the list - json = addedSuperAdminEntitlementJson(entitlements) - successJsonResponse(Extraction.decompose(json)) + // Add virtual entitlements for super_admin_user_ids or oidc_operator_user_ids + val json = if (isSuperAdmin(userId)) { + JSONFactory200.withVirtualEntitlements(entitlements, JSONFactory200.superAdminVirtualRoles) + } else if (isOidcOperator(userId)) { + JSONFactory200.withVirtualEntitlements(entitlements, JSONFactory200.oidcOperatorVirtualRoles) } else { - json = JSONFactory200.createEntitlementJSONs(entitlements) + JSONFactory200.createEntitlementJSONs(entitlements) } - // Return successJsonResponse(Extraction.decompose(json)) } } diff --git a/obp-api/src/main/scala/code/api/v2_0_0/JSONFactory2.0.0.scala b/obp-api/src/main/scala/code/api/v2_0_0/JSONFactory2.0.0.scala index dcc2e2b2d3..d649a206a1 100644 --- a/obp-api/src/main/scala/code/api/v2_0_0/JSONFactory2.0.0.scala +++ b/obp-api/src/main/scala/code/api/v2_0_0/JSONFactory2.0.0.scala @@ -29,6 +29,7 @@ package code.api.v2_0_0 import java.util.Date import code.TransactionTypes.TransactionType.TransactionType +import code.api.util.APIUtil import code.api.util.CustomJsonFormats import code.api.v1_2_1.{JSONFactory => JSONFactory121, MinimalBankJSON => MinimalBankJSON121, ThisAccountJSON => ThisAccountJSON121, UserJSONV121 => UserJSON121} import code.api.v1_4_0.JSONFactory1_4_0.{ChallengeJsonV140, CustomerFaceImageJson, TransactionRequestAccountJsonV140} @@ -837,8 +838,22 @@ def createTransactionTypeJSON(transactionType : TransactionType) : TransactionTy )) ))) - def addedSuperAdminEntitlementJson(entitlements: List[Entitlement]) = { - EntitlementJSONs(JSONFactory200.createEntitlementJSONs(entitlements).list ::: List(EntitlementJSON("", "SuperAdmin", ""))) + // Virtual roles granted by super_admin_user_ids prop + val superAdminVirtualRoles = APIUtil.superAdminVirtualRoles + // Virtual roles granted by oidc_operator_user_ids prop + val oidcOperatorVirtualRoles = APIUtil.oidcOperatorVirtualRoles + + /** + * Add virtual entitlements to an entitlement list. + * Virtual entitlements represent roles granted by config (e.g. super_admin_user_ids, oidc_operator_user_ids) + * rather than stored in the database. + */ + def withVirtualEntitlements(entitlements: List[Entitlement], virtualRoles: List[String]): EntitlementJSONs = { + val existingRoleNames = entitlements.map(_.roleName).toSet + val virtualEntitlementJsons = virtualRoles.filterNot(existingRoleNames.contains).map { role => + EntitlementJSON(entitlement_id = "", role_name = role, bank_id = "") + } + EntitlementJSONs(JSONFactory200.createEntitlementJSONs(entitlements).list ::: virtualEntitlementJsons) } diff --git a/obp-api/src/main/scala/code/api/v2_1_0/APIMethods210.scala b/obp-api/src/main/scala/code/api/v2_1_0/APIMethods210.scala index 798ff76130..c655926ca9 100644 --- a/obp-api/src/main/scala/code/api/v2_1_0/APIMethods210.scala +++ b/obp-api/src/main/scala/code/api/v2_1_0/APIMethods210.scala @@ -832,16 +832,15 @@ trait APIMethods210 { } yield { val filteredEntitlements = entitlements.filter(_.bankId == bankId.value) - // Format the data as V2.1.0 json - if (isSuperAdmin(userId)) { - // If the user is SuperAdmin add it to the list - val json = JSONFactory200.addedSuperAdminEntitlementJson(filteredEntitlements) - successJsonResponse(Extraction.decompose(json)) - (json, HttpCode.`200`(callContext)) + // Add virtual entitlements for super_admin_user_ids or oidc_operator_user_ids + val json = if (isSuperAdmin(userId)) { + JSONFactory200.withVirtualEntitlements(filteredEntitlements, JSONFactory200.superAdminVirtualRoles) + } else if (isOidcOperator(userId)) { + JSONFactory200.withVirtualEntitlements(filteredEntitlements, JSONFactory200.oidcOperatorVirtualRoles) } else { - val json = JSONFactory200.createEntitlementJSONs(filteredEntitlements) - (json, HttpCode.`200`(callContext)) + JSONFactory200.createEntitlementJSONs(filteredEntitlements) } + (json, HttpCode.`200`(callContext)) } } } diff --git a/obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala b/obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala index 8e7693af3a..39026cc285 100644 --- a/obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala +++ b/obp-api/src/main/scala/code/api/v3_0_0/APIMethods300.scala @@ -21,6 +21,7 @@ import code.api.v4_0_0.{AtmJsonV400, JSONFactory400} import code.api.{APIFailureNewStyle, Constant} import code.bankconnectors._ import code.consumer.Consumers +import code.entitlement.Entitlement import code.entitlementrequest.EntitlementRequest import code.metrics.APIMetrics import code.model._ @@ -2087,7 +2088,15 @@ trait APIMethods300 { (Full(u), callContext) <- authenticatedAccess(cc) entitlements <- NewStyle.function.getEntitlementsByUserId(u.userId, callContext) } yield { - (JSONFactory200.createEntitlementJSONs(entitlements), HttpCode.`200`(callContext)) + // Add virtual entitlements for super_admin_user_ids or oidc_operator_user_ids + val json = if (isSuperAdmin(u.userId)) { + JSONFactory200.withVirtualEntitlements(entitlements, JSONFactory200.superAdminVirtualRoles) + } else if (isOidcOperator(u.userId)) { + JSONFactory200.withVirtualEntitlements(entitlements, JSONFactory200.oidcOperatorVirtualRoles) + } else { + JSONFactory200.createEntitlementJSONs(entitlements) + } + (json, HttpCode.`200`(callContext)) } } } diff --git a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala index 28208c793f..97f488d459 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala @@ -3743,13 +3743,13 @@ trait APIMethods400 extends MdcLoggable { cc.callContext ) } yield { - var json = EntitlementJSONs(Nil) - // Format the data as V2.0.0 json - if (isSuperAdmin(userId)) { - // If the user is SuperAdmin add it to the list - json = JSONFactory200.addedSuperAdminEntitlementJson(entitlements) + // Add virtual entitlements for super_admin_user_ids or oidc_operator_user_ids + val json = if (isSuperAdmin(userId)) { + JSONFactory200.withVirtualEntitlements(entitlements, JSONFactory200.superAdminVirtualRoles) + } else if (isOidcOperator(userId)) { + JSONFactory200.withVirtualEntitlements(entitlements, JSONFactory200.oidcOperatorVirtualRoles) } else { - json = JSONFactory200.createEntitlementJSONs(entitlements) + JSONFactory200.createEntitlementJSONs(entitlements) } (json, HttpCode.`200`(cc.callContext)) } 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 2fcde5e0b4..2be01ca280 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 @@ -5,7 +5,7 @@ import code.accountattribute.AccountAttributeX import code.api.Constant._ import code.api.{Constant, DirectLogin, ObpApiFailure} import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ -import code.api.cache.{Caching, Redis} +import code.api.cache.{Caching, Redis, RedisMessaging} import code.api.util.APIUtil._ import code.api.util.ApiRole import code.api.util.ApiRole._ @@ -15,7 +15,7 @@ import code.api.util.FutureUtil.EndpointContext import code.api.util.{CertificateUtil, Glossary} import code.api.util.JsonSchemaGenerator import code.api.util.NewStyle.HttpCode -import code.api.util.{APIUtil, ApiVersionUtils, CallContext, DiagnosticDynamicEntityCheck, ErrorMessages, NewStyle, OBPLimit, RateLimitingUtil} +import code.api.util.{APIUtil, ApiVersionUtils, CallContext, DiagnosticDynamicEntityCheck, ErrorMessages, NewStyle, OBPLimit, OBPOffset, RateLimitingUtil} import net.liftweb.json import code.api.util.NewStyle.function.extractQueryParams import code.api.util.newstyle.ViewNewStyle @@ -1682,23 +1682,28 @@ trait APIMethods600 { entitlements <- NewStyle.function.getEntitlementsByUserId(u.userId, callContext) } yield { val permissions: Option[Permission] = Views.views.vend.getPermissionForUser(u).toOption - // Add SuperAdmin virtual entitlement if user is super admin - val finalEntitlements = if (APIUtil.isSuperAdmin(u.userId)) { - // Create a virtual SuperAdmin entitlement - val superAdminEntitlement: Entitlement = new Entitlement { + // Add virtual entitlements for super_admin_user_ids or oidc_operator_user_ids + val virtualRoleNames = if (APIUtil.isSuperAdmin(u.userId)) { + JSONFactory200.superAdminVirtualRoles + } else if (APIUtil.isOidcOperator(u.userId)) { + JSONFactory200.oidcOperatorVirtualRoles + } else { + List.empty + } + val existingRoleNames = entitlements.map(_.roleName).toSet + val virtualEntitlements = virtualRoleNames.filterNot(existingRoleNames.contains).map { role => + new Entitlement { def entitlementId: String = "" def bankId: String = "" def userId: String = u.userId - def roleName: String = "SuperAdmin" - def createdByProcess: String = "System" + def roleName: String = role + def createdByProcess: String = if (APIUtil.isSuperAdmin(u.userId)) "super_admin_user_ids" else "oidc_operator_user_ids" def entitlementRequestId: Option[String] = None def groupId: Option[String] = None def process: Option[String] = None } - entitlements ::: List(superAdminEntitlement) - } else { - entitlements } + val finalEntitlements = entitlements ::: virtualEntitlements val currentUser = UserV600(u, finalEntitlements, permissions) val onBehalfOfUser = if(cc.onBehalfOfUser.isDefined) { val user = cc.onBehalfOfUser.toOption.get @@ -1790,7 +1795,6 @@ trait APIMethods600 { implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- NewStyle.function.hasEntitlement("", u.userId, canGetAnyUser, callContext) user <- Users.users.vend.getUserByUserIdFuture(userId) map { x => unboxFullOrFail(x, callContext, s"$UserNotFoundByUserId Current UserId($userId)") } @@ -3811,15 +3815,16 @@ trait APIMethods600 { "POST", "/users/email-validation", "Validate User Email", - s"""Validate a user's email address using the token sent via email. + s"""Validate a user's email address using the JWT token sent via email. | |This endpoint is called anonymously (no authentication required). | |When a user signs up and email validation is enabled (authUser.skipEmailValidation=false), - |they receive an email with a validation link containing a unique token. + |they receive an email with a validation link containing a signed JWT token. | |This endpoint: - |- Validates the token + |- Verifies the JWT signature and checks expiry + |- Extracts the unique ID from the JWT subject |- Sets the user's validated status to true |- Resets the unique ID token (invalidating the link) |- Grants default entitlements to the user @@ -3827,16 +3832,12 @@ trait APIMethods600 { |**Important: This is a single-use token.** Once the email is validated, the token is invalidated. |Any subsequent attempts to use the same token will return a 404 error (UserNotFoundByToken or UserAlreadyValidated). | - |The token is a unique identifier (UUID) that was generated when the user was created. - | - |Example token from validation email URL: - |https://your-obp-instance.com/user_mgt/validate_user/a1b2c3d4-e5f6-7890-abcd-ef1234567890 - | - |In this case, the token would be: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + |The token is a signed JWT with a configurable expiry (default: 1440 minutes / 24 hours). + |The server-side expiry can be configured with the `email_validation_token_expiry_minutes` property. | |""".stripMargin, JSONFactory600.ValidateUserEmailJsonV600( - token = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + token = "eyJhbGciOiJIUzI1NiJ9..." ), JSONFactory600.ValidateUserEmailResponseJsonV600( user_id = "5995d6a2-01b3-423c-a173-5481df49bdaf", @@ -3867,9 +3868,25 @@ trait APIMethods600 { _ <- Helper.booleanToFuture(s"$InvalidJsonFormat Token cannot be empty", cc = cc.callContext) { token.nonEmpty } - // Find user by unique ID (the validation token) + // Verify JWT signature and extract uniqueId from subject + uniqueId <- NewStyle.function.tryons( + s"$UserNotFoundByToken Invalid or expired validation token", + 404, + cc.callContext + ) { + val signedJWT = com.nimbusds.jwt.SignedJWT.parse(token) + val expiration = signedJWT.getJWTClaimsSet.getExpirationTime + if (expiration == null || expiration.before(new java.util.Date())) { + throw new Exception("Token has expired") + } + if (!CertificateUtil.verifywtWithHmacProtection(token)) { + throw new Exception("Invalid token signature") + } + signedJWT.getJWTClaimsSet.getSubject + } + // Find user by unique ID from JWT authUser <- Future { - code.model.dataAccess.AuthUser.findUserByValidationToken(token) match { + code.model.dataAccess.AuthUser.findUserByValidationToken(uniqueId) match { case Full(user) => Full(user) case Empty => Empty case f: net.liftweb.common.Failure => f @@ -4243,11 +4260,6 @@ trait APIMethods600 { | | Requires username(email), password, first_name, last_name, and email. | - | Optional fields: - | - validating_application: Optional application name that will validate the user's email (e.g., "LEGACY_PORTAL") - | When set to "LEGACY_PORTAL", the validation link will use the API hostname property - | When set to any other value or not provided, the validation link will use the portal_external_url property (default behavior) - | | Validation checks performed: | - Password must meet strong password requirements (InvalidStrongPasswordFormat error if not) | - Username must be unique (409 error if username already exists) @@ -4256,9 +4268,7 @@ trait APIMethods600 { | Email validation behavior: | - Controlled by property 'authUser.skipEmailValidation' (default: false) | - When false: User is created with validated=false and a validation email is sent to the user's email address - | - Validation link domain is determined by validating_application: - | * "LEGACY_PORTAL": Uses API hostname property (e.g., https://api.example.com) - | * Other/None (default): Uses portal_external_url property (e.g., https://external-portal.example.com) + | - The validation link is constructed using the `portal_external_url` property which must be set | - When true: User is created with validated=true and no validation email is sent | - Default entitlements are granted immediately regardless of validation status | @@ -4319,40 +4329,36 @@ trait APIMethods600 { // STEP 8: Send validation email (if required) val skipEmailValidation = APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", defaultValue = false) if (!skipEmailValidation) { - // Construct validation link based on validating_application and portal_external_url - val portalExternalUrl = APIUtil.getPropsValue("portal_external_url") + APIUtil.getPropsValue("portal_external_url") match { + case Full(portalUrl) => + // Create a JWT token with the uniqueId as subject and configurable expiry + val expiryMinutes = APIUtil.getPropsAsIntValue("email_validation_token_expiry_minutes", 1440) + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .subject(savedUser.uniqueId.get) + .expirationTime(new java.util.Date(System.currentTimeMillis() + expiryMinutes * 60L * 1000L)) + .issueTime(new java.util.Date()) + .build() + val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) + + val emailValidationLink = portalUrl + "/user-validation?token=" + java.net.URLEncoder.encode(jwtToken, "UTF-8") + + val textContent = Some(s"Welcome! Please validate your account by clicking the following link: $emailValidationLink") + val htmlContent = Some(s"
Welcome! Please validate your account by clicking the following link:
") + val subjectContent = "Sign up confirmation" + + val emailContent = code.api.util.CommonsEmailWrapper.EmailContent( + from = code.model.dataAccess.AuthUser.emailFrom, + to = List(savedUser.email.get), + bcc = code.model.dataAccess.AuthUser.bccEmail.toList, + subject = subjectContent, + textContent = textContent, + htmlContent = htmlContent + ) - val emailValidationLink = postedData.validating_application match { - case Some("LEGACY_PORTAL") => - // Use API hostname with legacy path - Constant.HostName + "/" + code.model.dataAccess.AuthUser.validateUserPath.mkString("/") + "/" + java.net.URLEncoder.encode(savedUser.uniqueId.get, "UTF-8") + code.api.util.CommonsEmailWrapper.sendHtmlEmail(emailContent) case _ => - // If portal_external_url is set, use modern portal path - // Otherwise fall back to API hostname with legacy path - portalExternalUrl match { - case Full(portalUrl) => - // Portal is configured - use modern frontend route - portalUrl + "/user-validation?token=" + java.net.URLEncoder.encode(savedUser.uniqueId.get, "UTF-8") - case _ => - // No portal configured - fall back to API hostname with legacy path - Constant.HostName + "/" + code.model.dataAccess.AuthUser.validateUserPath.mkString("/") + "/" + java.net.URLEncoder.encode(savedUser.uniqueId.get, "UTF-8") - } + logger.error("portal_external_url is not set in props. Cannot send validation email.") } - - val textContent = Some(s"Welcome! Please validate your account by clicking the following link: $emailValidationLink") - val htmlContent = Some(s"Welcome! Please validate your account by clicking the following link:
") - val subjectContent = "Sign up confirmation" - - val emailContent = code.api.util.CommonsEmailWrapper.EmailContent( - from = code.model.dataAccess.AuthUser.emailFrom, - to = List(savedUser.email.get), - bcc = code.model.dataAccess.AuthUser.bccEmail.toList, - subject = subjectContent, - textContent = textContent, - htmlContent = htmlContent - ) - - code.api.util.CommonsEmailWrapper.sendHtmlEmail(emailContent) } // STEP 9: Grant default entitlements @@ -5435,6 +5441,10 @@ trait APIMethods600 { case _ => throw new Exception("User not found, not validated, or email mismatch") } } + portalUrl <- APIUtil.getPropsValue("portal_external_url") match { + case Full(url) => Future.successful(url) + case _ => Future.failed(new Exception(s"$IncompleteServerConfiguration portal_external_url is not set in props. It is required to construct the password reset link.")) + } } yield { // Explicitly type the user to ensure proper method resolution val user: code.model.dataAccess.AuthUser = authUser @@ -5453,7 +5463,7 @@ trait APIMethods600 { val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) // Construct reset URL using portal_external_url - val resetPasswordLink = APIUtil.getPropsValue("portal_external_url", Constant.HostName) + + val resetPasswordLink = portalUrl + "/reset-password/" + java.net.URLEncoder.encode(jwtToken, "UTF-8") @@ -5545,8 +5555,8 @@ trait APIMethods600 { net.liftweb.mapper.By(code.model.dataAccess.AuthUser.username, postedData.username) ) - authUserBox match { - case Full(user) if user.validated.get && user.email.get == postedData.email => + (authUserBox, APIUtil.getPropsValue("portal_external_url")) match { + case (Full(user), Full(portalUrl)) if user.validated.get && user.email.get == postedData.email => // Generate new reset token user.uniqueId.set(java.util.UUID.randomUUID().toString.replace("-", "")) user.save @@ -5561,7 +5571,7 @@ trait APIMethods600 { val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) // Construct reset URL - val resetPasswordLink = APIUtil.getPropsValue("portal_external_url", Constant.HostName) + + val resetPasswordLink = portalUrl + "/reset-password/" + java.net.URLEncoder.encode(jwtToken, "UTF-8") @@ -5581,6 +5591,9 @@ trait APIMethods600 { code.api.util.CommonsEmailWrapper.sendHtmlEmail(emailContent) + case (_, Empty) => + logger.error("portal_external_url is not set in props. Cannot send password reset email.") + case _ => // Do nothing - return same response to prevent user enumeration } @@ -5640,12 +5653,6 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) for { (_, callContext) <- anonymousAccess(cc) - _ <- Helper.booleanToFuture( - failMsg = ErrorMessages.NotAllowedEndpoint, - cc = callContext - ) { - APIUtil.getPropsAsBoolValue("ResetPasswordUrlEnabled", false) - } postedData <- NewStyle.function.tryons( s"$InvalidJsonFormat The Json body should be the ${classOf[PostResetPasswordCompleteJsonV600]}", 400, @@ -8702,8 +8709,6 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- if(isSuperAdmin(u.userId)) Future.successful(Full(Unit)) - else NewStyle.function.hasEntitlement("", u.userId, canVerifyUserCredentials, callContext) postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostVerifyUserCredentialsJsonV600", 400, callContext) { json.extract[PostVerifyUserCredentialsJsonV600] } @@ -8774,8 +8779,6 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- if(isSuperAdmin(u.userId)) Future.successful(Full(Unit)) - else NewStyle.function.hasEntitlement("", u.userId, canVerifyOidcClient, callContext) postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the VerifyOidcClientRequestJsonV600", 400, callContext) { json.extract[VerifyOidcClientRequestJsonV600] } @@ -8794,6 +8797,9 @@ trait APIMethods600 { consumer_id = Some(consumer.consumerId.get), redirect_uris = redirectUris ), HttpCode.`200`(callContext)) + case Full(consumer) if !consumer.isActive.get => + logger.warn(s"verifyOidcClient: client_id ${postedData.client_id} exists but is not active (consumer_id: ${consumer.consumerId.get})") + (VerifyOidcClientResponseJsonV600(valid = false), HttpCode.`200`(callContext)) case _ => (VerifyOidcClientResponseJsonV600(valid = false), HttpCode.`200`(callContext)) } @@ -8837,8 +8843,6 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- if(isSuperAdmin(u.userId)) Future.successful(Full(Unit)) - else NewStyle.function.hasEntitlement("", u.userId, canGetOidcClient, callContext) consumerBox <- Future { Consumers.consumers.vend.getConsumerByConsumerKey(clientId) } @@ -10401,6 +10405,376 @@ trait APIMethods600 { } } + + // ---- Signal Channels (Redis-backed short-lived messaging for AI agents and other consumers) ---- + + staticResourceDocs += ResourceDoc( + publishSignalMessage, + implementedInApiVersion, + nameOf(publishSignalMessage), + "POST", + "/signal/channels/CHANNEL_NAME/messages", + "Publish Signal Message", + s"""Publish a message to a signal channel. + | + |Signal channels provide short-lived, Redis-backed messaging for lightweight coordination between + |AI agents and other OBP consumers. Messages are not persisted to a database. + | + |Channels are auto-created on first publish and expire after a configurable TTL (default 1 hour). + |Messages are capped at a configurable maximum per channel (default 1000). + | + |The payload field accepts any valid JSON content. + | + |Set to_user_id to send a private message visible only to the sender and recipient. + |Leave to_user_id empty for a broadcast message visible to all channel readers. + | + |Authentication is Required. + | + |""".stripMargin, + postSignalMessageJsonV600, + signalMessagePublishedJsonV600, + List( + $AuthenticatedUserIsRequired, + InvalidJsonFormat, + UnknownError + ), + List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) + + lazy val publishSignalMessage: OBPEndpoint = { + case "signal" :: "channels" :: channelName :: "messages" :: Nil JsonPost json -> _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostSignalMessageJsonV600", 400, callContext) { + json.extract[PostSignalMessageJsonV600] + } + _ <- Helper.booleanToFuture(failMsg = "Invalid channel name. Use alphanumeric characters, dots, hyphens, underscores. Max 128 chars.", cc = callContext) { + RedisMessaging.validateChannelName(channelName) + } + channelMessageCount <- Future { + val consumerId: String = cc.consumer match { + case Full(c) => c.consumerId.get + case _ => "" + } + val messageId = randomUUID().toString + val sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + sdf.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + val timestamp = sdf.format(new java.util.Date()) + val messageEnvelope = SignalMessageJsonV600( + message_id = messageId, + channel_name = channelName, + sender_consumer_id = consumerId, + sender_user_id = u.userId, + to_user_id = postJson.to_user_id, + timestamp = timestamp, + message_type = postJson.message_type.getOrElse(""), + payload = postJson.payload + ) + val messageJsonString = net.liftweb.json.compactRender(net.liftweb.json.Extraction.decompose(messageEnvelope)) + val count = RedisMessaging.publishMessage(channelName, messageJsonString) + (messageId, timestamp, count) + } + } yield { + val (messageId, timestamp, count) = channelMessageCount + val response = SignalMessagePublishedJsonV600( + message_id = messageId, + channel_name = channelName, + timestamp = timestamp, + channel_message_count = count + ) + (response, HttpCode.`201`(callContext)) + } + } + + + staticResourceDocs += ResourceDoc( + getSignalMessages, + implementedInApiVersion, + nameOf(getSignalMessages), + "GET", + "/signal/channels/CHANNEL_NAME/messages", + "Get Signal Messages", + s"""Fetch messages from a signal channel with offset/limit pagination. + | + |Signal channels provide short-lived, Redis-backed messaging designed for AI agent discovery + |and coordination, but usable by any authenticated OBP consumer. + | + |Messages are returned oldest-first. + | + |Privacy filtering is applied server-side: you will only see broadcast messages (no to_user_id) + |and private messages addressed to you (to_user_id matches your user ID) or sent by you. + | + |Use the offset parameter to poll for new messages by tracking your position. + | + |Authentication is Required. + | + |""".stripMargin, + EmptyBody, + signalMessagesJsonV600, + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) + + lazy val getSignalMessages: OBPEndpoint = { + case "signal" :: "channels" :: channelName :: "messages" :: Nil JsonGet _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Helper.booleanToFuture(failMsg = "Invalid channel name.", cc = callContext) { + RedisMessaging.validateChannelName(channelName) + } + httpParams <- NewStyle.function.extractHttpParamsFromUrl(cc.url) + (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, callContext) + limit = obpQueryParams.collectFirst { case OBPLimit(value) => value }.getOrElse(50) + offset = obpQueryParams.collectFirst { case OBPOffset(value) => value }.getOrElse(0) + (rawMessages, totalCount) <- Future { + RedisMessaging.fetchMessages(channelName, offset, limit) + } + } yield { + val parsedMessages: List[SignalMessageJsonV600] = rawMessages.flatMap { msgStr => + scala.util.Try(net.liftweb.json.parse(msgStr).extract[SignalMessageJsonV600]).toOption + } + // Privacy filter: only show broadcasts (to_user_id is None) and messages to/from this user + val filteredMessages = parsedMessages.filter { msg => + msg.to_user_id.isEmpty || + msg.to_user_id.contains(u.userId) || + msg.sender_user_id == u.userId + } + val response = SignalMessagesJsonV600( + channel_name = channelName, + messages = filteredMessages, + total_count = totalCount, + has_more = (offset + limit) < totalCount + ) + (response, HttpCode.`200`(callContext)) + } + } + + + staticResourceDocs += ResourceDoc( + getSignalChannels, + implementedInApiVersion, + nameOf(getSignalChannels), + "GET", + "/signal/channels", + "List Signal Channels", + s"""Signal channels provide short-lived, Redis-backed messaging designed for AI agent discovery and coordination, but usable by any authenticated OBP consumer. + |Messages are ephemeral and will expire after the configured TTL (default 1 hour). + | + |This endpoint lists active signal channels. + |Only channels that contain at least one broadcast message (no to_user_id) are listed. + |Private-only channels are not shown. + | + |Authentication is Required. + | + |""".stripMargin, + EmptyBody, + signalChannelsJsonV600, + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) + + lazy val getSignalChannels: OBPEndpoint = { + case "signal" :: "channels" :: Nil JsonGet _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + channelNames <- Future { + RedisMessaging.listChannels() + } + channelsWithInfo <- Future.sequence( + channelNames.map { name => + Future { + RedisMessaging.channelInfo(name).map { case (count, ttl) => + // Check if channel has any broadcast messages + val (messages, _) = RedisMessaging.fetchMessages(name, 0, count.toInt) + val hasBroadcast = messages.exists { msgStr => + scala.util.Try { + val msg = net.liftweb.json.parse(msgStr).extract[SignalMessageJsonV600] + msg.to_user_id.isEmpty + }.getOrElse(false) + } + (name, count, ttl, hasBroadcast) + } + } + } + ) + } yield { + val channels = channelsWithInfo.flatten + .filter(_._4) // Only channels with broadcast messages + .map { case (name, count, ttl, _) => + SignalChannelInfoJsonV600( + channel_name = name, + message_count = count, + ttl_seconds = ttl + ) + } + (SignalChannelsJsonV600(channels), HttpCode.`200`(callContext)) + } + } + + + staticResourceDocs += ResourceDoc( + getSignalChannelInfo, + implementedInApiVersion, + nameOf(getSignalChannelInfo), + "GET", + "/signal/channels/CHANNEL_NAME/info", + "Get Signal Channel Info", + s"""Signal channels provide short-lived, Redis-backed messaging designed for AI agent discovery and coordination, but usable by any authenticated OBP consumer. + |Messages are ephemeral and will expire after the configured TTL (default 1 hour). + | + |This endpoint returns metadata about a signal channel including the current message count and remaining TTL in seconds. + | + |Authentication is Required. + | + |""".stripMargin, + EmptyBody, + signalChannelInfoJsonV600, + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) + + lazy val getSignalChannelInfo: OBPEndpoint = { + case "signal" :: "channels" :: channelName :: "info" :: Nil JsonGet _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Helper.booleanToFuture(failMsg = "Invalid channel name.", cc = callContext) { + RedisMessaging.validateChannelName(channelName) + } + info <- Future { + RedisMessaging.channelInfo(channelName) + } + (count, ttl) <- info match { + case Some((c, t)) => Future.successful((c, t)) + case None => Future.failed(new RuntimeException(s"Channel '$channelName' not found")) + } + } yield { + val response = SignalChannelInfoJsonV600( + channel_name = channelName, + message_count = count, + ttl_seconds = ttl + ) + (response, HttpCode.`200`(callContext)) + } + } + + + staticResourceDocs += ResourceDoc( + deleteSignalChannel, + implementedInApiVersion, + nameOf(deleteSignalChannel), + "DELETE", + "/signal/channels/CHANNEL_NAME", + "Delete Signal Channel", + s"""Signal channels provide short-lived, Redis-backed messaging designed for AI agent discovery and coordination, but usable by any authenticated OBP consumer. + |Messages are ephemeral and will expire after the configured TTL (default 1 hour). + | + |This endpoint deletes a signal channel and all its messages immediately. + | + |Authentication is Required. + | + |""".stripMargin, + EmptyBody, + signalChannelDeletedJsonV600, + List( + $AuthenticatedUserIsRequired, + UnknownError + ), + List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) + + staticResourceDocs += ResourceDoc( + getSignalStats, + implementedInApiVersion, + nameOf(getSignalStats), + "GET", + "/signal/channels/stats", + "Get Signal Channel Stats", + s"""Returns statistics for all signal channels, including private-only channels. + | + |Unlike the List Signal Channels endpoint, this does not filter out private-only channels. + |It provides a complete view of all active channels with message counts and TTL info. + | + |Authentication is Required. + | + |""".stripMargin, + EmptyBody, + signalStatsJsonV600, + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + UnknownError + ), + List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel), + Some(List(canGetSignalStats))) + + lazy val getSignalStats: OBPEndpoint = { + case "signal" :: "channels" :: "stats" :: Nil JsonGet _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + channelNames <- Future { + RedisMessaging.listChannels() + } + channelsWithInfo <- Future.sequence( + channelNames.map { name => + Future { + RedisMessaging.channelInfo(name).map { case (count, ttl) => + SignalChannelInfoJsonV600( + channel_name = name, + message_count = count, + ttl_seconds = ttl + ) + } + } + } + ) + } yield { + val channels = channelsWithInfo.flatten + val totalMessages = channels.map(_.message_count).sum + val response = SignalStatsJsonV600( + total_channels = channels.size, + total_messages = totalMessages, + channels = channels + ) + (response, HttpCode.`200`(callContext)) + } + } + + + lazy val deleteSignalChannel: OBPEndpoint = { + case "signal" :: "channels" :: channelName :: Nil JsonDelete _ => + cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- Helper.booleanToFuture(failMsg = "Invalid channel name.", cc = callContext) { + RedisMessaging.validateChannelName(channelName) + } + deleted <- Future { + RedisMessaging.deleteChannel(channelName) + } + } yield { + val response = SignalChannelDeletedJsonV600( + channel_name = channelName, + deleted = deleted + ) + (response, HttpCode.`200`(callContext)) + } + } + } } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index febdb37f48..fbfef126d8 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -252,8 +252,7 @@ case class CreateUserJsonV600( username: String, password: String, first_name: String, - last_name: String, - validating_application: Option[String] = None + last_name: String ) case class PostVerifyUserCredentialsJsonV600( @@ -894,6 +893,59 @@ case class ConnectorTracesJsonV600( case class ConfigPropJsonV600(name: String, value: String) +// Signal Channels case classes (Redis-backed ephemeral messaging channels) +case class PostSignalMessageJsonV600( + payload: net.liftweb.json.JsonAST.JValue, + message_type: Option[String] = None, + to_user_id: Option[String] = None +) + +case class SignalMessageJsonV600( + message_id: String, + channel_name: String, + sender_consumer_id: String, + sender_user_id: String, + to_user_id: Option[String], + timestamp: String, + message_type: String, + payload: net.liftweb.json.JsonAST.JValue +) + +case class SignalMessagesJsonV600( + channel_name: String, + messages: List[SignalMessageJsonV600], + total_count: Long, + has_more: Boolean +) + +case class SignalMessagePublishedJsonV600( + message_id: String, + channel_name: String, + timestamp: String, + channel_message_count: Long +) + +case class SignalChannelInfoJsonV600( + channel_name: String, + message_count: Long, + ttl_seconds: Long +) + +case class SignalChannelsJsonV600( + channels: List[SignalChannelInfoJsonV600] +) + +case class SignalStatsJsonV600( + total_channels: Int, + total_messages: Long, + channels: List[SignalChannelInfoJsonV600] +) + +case class SignalChannelDeletedJsonV600( + channel_name: String, + deleted: Boolean +) + object JSONFactory600 extends CustomJsonFormats with MdcLoggable { def createRedisCallCountersJson( diff --git a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala index 2e98b89a3b..4694aeffc4 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala @@ -663,26 +663,40 @@ import net.liftweb.util.Helpers._ * Overridden to use the hostname set in the props file */ override def sendValidationEmail(user: TheUserType) { - val resetLink = Constant.HostName+"/"+validateUserPath.mkString("/")+"/"+urlEncode(user.getUniqueId()) - val email: String = user.getEmail - val textContent = Some(s"Welcome! Please validate your account by clicking the following link: $resetLink") - val htmlContent = Some(s"Welcome! Please validate your account by clicking the following link:
") - val subjectContent = "Sign up confirmation" - val emailContent = EmailContent( - from = emailFrom, - to = List(user.getEmail), - bcc = bccEmail.toList, - subject = subjectContent, - textContent = textContent, - htmlContent = htmlContent - ) - sendHtmlEmail(emailContent) match { - case Full(messageId) => - logger.debug(s"Validation email sent successfully with Message-ID: $messageId") - S.notice("Validation email sent successfully. Please check your email.") - case Empty => - logger.error("Failed to send validation email") - S.error("Failed to send validation email. Please try again.") + APIUtil.getPropsValue("portal_external_url") match { + case Full(portalUrl) => + // Create a JWT token with the uniqueId as subject and configurable expiry + val expiryMinutes = APIUtil.getPropsAsIntValue("email_validation_token_expiry_minutes", 1440) + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .subject(user.getUniqueId()) + .expirationTime(new java.util.Date(System.currentTimeMillis() + expiryMinutes * 60L * 1000L)) + .issueTime(new java.util.Date()) + .build() + val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) + val validationLink = portalUrl+"/user-validation?token="+urlEncode(jwtToken) + val email: String = user.getEmail + val textContent = Some(s"Welcome! Please validate your account by clicking the following link: $validationLink") + val htmlContent = Some(s"Welcome! Please validate your account by clicking the following link:
") + val subjectContent = "Sign up confirmation" + val emailContent = EmailContent( + from = emailFrom, + to = List(user.getEmail), + bcc = bccEmail.toList, + subject = subjectContent, + textContent = textContent, + htmlContent = htmlContent + ) + sendHtmlEmail(emailContent) match { + case Full(messageId) => + logger.debug(s"Validation email sent successfully with Message-ID: $messageId") + S.notice("Validation email sent successfully. Please check your email.") + case Empty => + logger.error("Failed to send validation email") + S.error("Failed to send validation email. Please try again.") + } + case _ => + logger.error("portal_external_url is not set in props. Cannot send validation email.") + S.error("Validation email could not be sent. Please contact the administrator.") } } @@ -693,23 +707,40 @@ import net.liftweb.util.Helpers._ } } - override def validateUser(id: String): NodeSeq = findUserByUniqueId(id) match { - case Full(user) if !user.validated_? => - user.setValidated(true).resetUniqueId().save - grantDefaultEntitlementsToAuthUser(user) - logUserIn(user, () => { - S.notice(S.?("account.validated")) - APIUtil.getPropsValue("user_account_validated_redirect_url") match { - case Full(redirectUrl) => - logger.debug(s"user_account_validated_redirect_url = $redirectUrl") - S.redirectTo(redirectUrl) - case _ => - logger.debug(s"user_account_validated_redirect_url is NOT defined") - S.redirectTo(homePage) - } - }) + override def validateUser(id: String): NodeSeq = { + // Extract uniqueId from JWT token: verify signature and expiry + val uniqueIdBox: Box[String] = tryo { + val signedJWT = com.nimbusds.jwt.SignedJWT.parse(id) + val expiration = signedJWT.getJWTClaimsSet.getExpirationTime + if (expiration == null || expiration.before(new java.util.Date())) { + throw new Exception("Token has expired") + } + if (!CertificateUtil.verifywtWithHmacProtection(id)) { + throw new Exception("Invalid token signature") + } + signedJWT.getJWTClaimsSet.getSubject + } + + val userBox = uniqueIdBox.flatMap(findUserByUniqueId) - case _ => S.error(S.?("invalid.validation.link")); S.redirectTo(homePage) + userBox match { + case Full(user) if !user.validated_? => + user.setValidated(true).resetUniqueId().save + grantDefaultEntitlementsToAuthUser(user) + logUserIn(user, () => { + S.notice(S.?("account.validated")) + APIUtil.getPropsValue("user_account_validated_redirect_url") match { + case Full(redirectUrl) => + logger.debug(s"user_account_validated_redirect_url = $redirectUrl") + S.redirectTo(redirectUrl) + case _ => + logger.debug(s"user_account_validated_redirect_url is NOT defined") + S.redirectTo(homePage) + } + }) + + case _ => S.error(S.?("invalid.validation.link")); S.redirectTo(homePage) + } } override def actionsAfterSignup(theUser: TheUserType, func: () => Nothing): Nothing = { diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala new file mode 100644 index 0000000000..b3e6b36bde --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala @@ -0,0 +1,61 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanGetOidcClient +import code.api.util.ErrorMessages +import code.api.util.ErrorMessages.UserHasMissingRoles +import code.api.v6_0_0.APIMethods600.Implementations6_0_0 +import code.entitlement.Entitlement +import code.setup.DefaultUsers +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +class GetOidcClientTest extends V600ServerSetup with DefaultUsers { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint extends Tag(nameOf(Implementations6_0_0.getOidcClient)) + + feature(s"Get OIDC Client - GET /obp/v6.0.0/oidc/clients/CLIENT_ID - $VersionOfApi") { + + scenario("Anonymous access should fail with 401", ApiEndpoint, VersionOfApi) { + When("We make the request without authentication") + val request = (v6_0_0_Request / "oidc" / "clients" / "nonexistent_client_id").GET + val response = makeGetRequest(request) + + 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) + } + + 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 = (v6_0_0_Request / "oidc" / "clients" / "nonexistent_client_id").GET <@ (user1) + val response = makeGetRequest(request) + + Then("We should get a 403") + response.code should equal(403) + And("The error message should indicate missing role") + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanGetOidcClient) + } + + scenario("Authenticated user with CanGetOidcClient role but invalid client should fail with 404", ApiEndpoint, VersionOfApi) { + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetOidcClient.toString) + + When("We request a non-existent client") + val request = (v6_0_0_Request / "oidc" / "clients" / "nonexistent_client_id").GET <@ (user1) + val response = try { + makeGetRequest(request) + } finally { + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } + + Then("We should not get a 401 or 403 (role check passed)") + response.code should not equal(401) + response.code should not equal(403) + } + + } +} diff --git a/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala new file mode 100644 index 0000000000..a984877124 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala @@ -0,0 +1,63 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanGetAnyUser +import code.api.util.ErrorMessages +import code.api.util.ErrorMessages.UserHasMissingRoles +import code.api.v6_0_0.APIMethods600.Implementations6_0_0 +import code.entitlement.Entitlement +import code.setup.DefaultUsers +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +class GetUserByUserIdTest extends V600ServerSetup with DefaultUsers { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint extends Tag(nameOf(Implementations6_0_0.getUserByUserId)) + + feature(s"Get User by USER_ID - GET /obp/v6.0.0/users/user-id/USER_ID - $VersionOfApi") { + + scenario("Anonymous access should fail with 401", ApiEndpoint, VersionOfApi) { + When("We make the request without authentication") + val request = (v6_0_0_Request / "users" / "user-id" / resourceUser1.userId).GET + val response = makeGetRequest(request) + + 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) + } + + 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 = (v6_0_0_Request / "users" / "user-id" / resourceUser1.userId).GET <@ (user1) + val response = makeGetRequest(request) + + Then("We should get a 403") + response.code should equal(403) + And("The error message should indicate missing role") + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanGetAnyUser) + } + + scenario("Authenticated user with CanGetAnyUser role should succeed", ApiEndpoint, VersionOfApi) { + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetAnyUser.toString) + + When("We make the request with the required role") + val request = (v6_0_0_Request / "users" / "user-id" / resourceUser1.userId).GET <@ (user1) + val response = try { + makeGetRequest(request) + } finally { + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } + + Then("We should get a 200") + response.code should equal(200) + + And("The response should contain user details") + (response.body \ "user_id").extract[String] should equal(resourceUser1.userId) + } + + } +} diff --git a/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala index b3cea76206..696d1a11b6 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala @@ -52,14 +52,13 @@ import org.scalatest.Tag */ class PasswordResetTest extends V600ServerSetup { - override def beforeAll(): Unit = { - super.beforeAll() - setPropsValues("ResetPasswordUrlEnabled" -> "true") - } - override def beforeEach() = { wipeTestData() super.beforeEach() + setPropsValues( + "portal_external_url" -> "https://test-portal.example.com", + "mail.test.mode" -> "true" + ) AuthUser.bulkDelete_!!(By(AuthUser.username, postJson.username)) ResourceUser.bulkDelete_!!(By(ResourceUser.providerId, postJson.username)) } @@ -128,7 +127,9 @@ class PasswordResetTest extends V600ServerSetup { val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1) val response600 = makePostRequest(request600, write(postJson.copy(user_id = resourceUser.map(_.userId).getOrElse("")))) Then("We should get a 201") - response600.code should equal(201) + withClue(s"Response body: ${response600.body} ") { + response600.code should equal(201) + } response600.body.extractOpt[JSONFactory600.ResetPasswordUrlJsonV600].isDefined should equal(true) And("The response should contain a valid reset URL") val resetUrl = (response600.body \ "reset_password_url").extract[String] @@ -384,7 +385,9 @@ class PasswordResetTest extends V600ServerSetup { val resetUrlJson = JSONFactory600.PostResetPasswordUrlJsonV600(testUsername, testEmail, resourceUser.map(_.userId).getOrElse("")) val resetUrlResponse = makePostRequest(resetUrlRequest, write(resetUrlJson)) Then("We should get a 201 with a reset URL") - resetUrlResponse.code should equal(201) + withClue(s"Response body: ${resetUrlResponse.body} ") { + resetUrlResponse.code should equal(201) + } val resetUrl = (resetUrlResponse.body \ "reset_password_url").extract[String] resetUrl should include("/reset-password/") diff --git a/obp-api/src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala new file mode 100644 index 0000000000..2730606d07 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala @@ -0,0 +1,74 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanVerifyOidcClient +import code.api.util.ErrorMessages +import code.api.util.ErrorMessages.UserHasMissingRoles +import code.api.v6_0_0.APIMethods600.Implementations6_0_0 +import code.entitlement.Entitlement +import code.setup.DefaultUsers +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.json.Serialization.write +import org.scalatest.Tag + +class VerifyOidcClientTest extends V600ServerSetup with DefaultUsers { + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint extends Tag(nameOf(Implementations6_0_0.verifyOidcClient)) + + feature(s"Verify OIDC Client - POST /obp/v6.0.0/oidc/clients/verify - $VersionOfApi") { + + scenario("Anonymous access should fail with 401", ApiEndpoint, VersionOfApi) { + When("We make the request without authentication") + val postJson = Map( + "client_id" -> "nonexistent_client_id", + "client_secret" -> "some_secret" + ) + val request = (v6_0_0_Request / "oidc" / "clients" / "verify").POST + val response = makePostRequest(request, write(postJson)) + + 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) + } + + 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 postJson = Map( + "client_id" -> "nonexistent_client_id", + "client_secret" -> "some_secret" + ) + val request = (v6_0_0_Request / "oidc" / "clients" / "verify").POST <@ (user1) + val response = makePostRequest(request, write(postJson)) + + Then("We should get a 403") + response.code should equal(403) + And("The error message should indicate missing role") + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanVerifyOidcClient) + } + + scenario("Authenticated user with CanVerifyOidcClient role but invalid client should fail with 404", ApiEndpoint, VersionOfApi) { + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanVerifyOidcClient.toString) + + When("We verify a non-existent client") + val postJson = Map( + "client_id" -> "nonexistent_client_id", + "client_secret" -> "some_secret" + ) + val request = (v6_0_0_Request / "oidc" / "clients" / "verify").POST <@ (user1) + val response = try { + makePostRequest(request, write(postJson)) + } finally { + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } + + Then("We should not get a 401 or 403 (role check passed)") + response.code should not equal(401) + response.code should not equal(403) + } + + } +}