From b8585fe5eb16b4884d27107df24e6ddec97fd723 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 18 Feb 2026 16:47:48 +0100 Subject: [PATCH 1/7] Email vaildation New Portal only approach - and Glossary item in Glossarry.scala --- .../resources/props/sample.props.template | 12 +- .../props/test.default.props.template | 13 ++ .../SwaggerDefinitionsJSON.scala | 3 +- .../main/scala/code/api/util/Glossary.scala | 102 ++++++++++++++- .../scala/code/api/v6_0_0/APIMethods600.scala | 122 ++++++++++-------- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 3 +- .../code/model/dataAccess/AuthUser.scala | 103 +++++++++------ 7 files changed, 261 insertions(+), 97 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 97b266b36f..1c123dcfbb 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 @@ -813,7 +817,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 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..7bdba328af 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,19 @@ 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 + #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/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index c27548ee8a..773e26ebf0 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" ) 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..ea3d93d4d1 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -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. | 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..379421fe6b 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 @@ -3811,15 +3811,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 +3828,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 +3864,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 +4256,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 +4264,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 +4325,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:

$emailValidationLink

") + 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:

$emailValidationLink

") - 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 @@ -5439,6 +5441,10 @@ trait APIMethods600 { // Explicitly type the user to ensure proper method resolution val user: code.model.dataAccess.AuthUser = authUser + val portalUrl = APIUtil.getPropsValue("portal_external_url").openOrThrowException( + "portal_external_url is not set in props. It is required to construct the password reset link." + ) + // Generate new reset token user.uniqueId.set(java.util.UUID.randomUUID().toString.replace("-", "")) user.save @@ -5453,7 +5459,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 +5551,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 +5567,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 +5587,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 } @@ -8794,6 +8803,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)) } 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..0a6cdacc3d 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( 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:

$resetLink

") - 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:

$validationLink

") + 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 = { From 8b2ab02e5942ab1234913626c48ce39ce4e5e2e0 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Wed, 18 Feb 2026 17:47:19 +0100 Subject: [PATCH 2/7] Authentication glossary items --- .../src/main/scala/code/api/util/Glossary.scala | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 ea3d93d4d1..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) @@ -5248,7 +5248,7 @@ object Glossary extends MdcLoggable { """) glossaryItems += GlossaryItem( - title = "Credential Checking Flow", + title = "Authentication: Credential Checking Flow", description = s""" |### Overview From 6a68fff7a359ece86baa668e94ef9c96d496e121 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 19 Feb 2026 19:15:24 +0100 Subject: [PATCH 3/7] FixTest PasswordResetTest --- .../main/scala/code/api/util/ErrorMessages.scala | 1 + .../main/scala/code/api/v6_0_0/APIMethods600.scala | 14 ++++---------- .../scala/code/api/v6_0_0/PasswordResetTest.scala | 5 ++++- 3 files changed, 9 insertions(+), 11 deletions(-) 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/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 379421fe6b..ea08a4a6fd 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 @@ -5437,14 +5437,14 @@ 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 - val portalUrl = APIUtil.getPropsValue("portal_external_url").openOrThrowException( - "portal_external_url is not set in props. It is required to construct the password reset link." - ) - // Generate new reset token user.uniqueId.set(java.util.UUID.randomUUID().toString.replace("-", "")) user.save @@ -5649,12 +5649,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, 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..8fbb9d7385 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 @@ -54,7 +54,10 @@ class PasswordResetTest extends V600ServerSetup { override def beforeAll(): Unit = { super.beforeAll() - setPropsValues("ResetPasswordUrlEnabled" -> "true") + setPropsValues( + "portal_external_url" -> "https://test-portal.example.com", + "mail.test.mode" -> "true" + ) } override def beforeEach() = { From 3f89d3f065a0b0902bfea31e2dffb3835f33a3b2 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Thu, 19 Feb 2026 22:48:42 +0100 Subject: [PATCH 4/7] isOidcOperator and clearer virtual entitlements (no SuperAdmin virtual role) --- .../resources/props/sample.props.template | 12 +++++-- .../props/test.default.props.template | 4 +++ .../main/scala/code/api/util/APIUtil.scala | 10 ++++++ .../scala/code/api/v2_0_0/APIMethods200.scala | 14 ++++---- .../code/api/v2_0_0/JSONFactory2.0.0.scala | 18 +++++++++-- .../scala/code/api/v2_1_0/APIMethods210.scala | 15 ++++----- .../scala/code/api/v3_0_0/APIMethods300.scala | 11 ++++++- .../scala/code/api/v4_0_0/APIMethods400.scala | 12 +++---- .../scala/code/api/v6_0_0/APIMethods600.scala | 32 +++++++++++-------- .../code/api/v6_0_0/PasswordResetTest.scala | 8 +++-- 10 files changed, 93 insertions(+), 43 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 1c123dcfbb..c84dcdcece 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -697,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. 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 7bdba328af..511bf99803 100644 --- a/obp-api/src/main/resources/props/test.default.props.template +++ b/obp-api/src/main/resources/props/test.default.props.template @@ -52,6 +52,10 @@ 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/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 925fcfdc14..0019687062 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,16 @@ 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 + } + def hasScope(bankId: String, consumerId: String, role: ApiRole): Boolean = { !Scope.scope.vend.getScope(bankId, consumerId, role.toString).isEmpty } diff --git a/obp-api/src/main/scala/code/api/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..c06cda949c 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 @@ -837,8 +837,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 = List("CanCreateEntitlementAtOneBank", "CanCreateEntitlementAtAnyBank", "CanGetAnyUser") + // Virtual roles granted by oidc_operator_user_ids prop + val oidcOperatorVirtualRoles = List("CanGetAnyUser", "CanVerifyUserCredentials", "CanVerifyOidcClient", "CanGetOidcClient") + + /** + * 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 ea08a4a6fd..66fe808de1 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 @@ -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,8 @@ trait APIMethods600 { implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- NewStyle.function.hasEntitlement("", u.userId, canGetAnyUser, callContext) + _ <- if(isSuperAdmin(u.userId) || isOidcOperator(u.userId)) Future.successful(Full(Unit)) + else NewStyle.function.hasEntitlement("", u.userId, canGetAnyUser, callContext) user <- Users.users.vend.getUserByUserIdFuture(userId) map { x => unboxFullOrFail(x, callContext, s"$UserNotFoundByUserId Current UserId($userId)") } @@ -8705,7 +8711,7 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- if(isSuperAdmin(u.userId)) Future.successful(Full(Unit)) + _ <- if(isOidcOperator(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] @@ -8777,7 +8783,7 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- if(isSuperAdmin(u.userId)) Future.successful(Full(Unit)) + _ <- if(isOidcOperator(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] @@ -8843,7 +8849,7 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- if(isSuperAdmin(u.userId)) Future.successful(Full(Unit)) + _ <- if(isOidcOperator(u.userId)) Future.successful(Full(Unit)) else NewStyle.function.hasEntitlement("", u.userId, canGetOidcClient, callContext) consumerBox <- Future { Consumers.consumers.vend.getConsumerByConsumerKey(clientId) 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 8fbb9d7385..02e4d80ecc 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 @@ -131,7 +131,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] @@ -387,7 +389,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/") From e345d1969993f0fe320504451d290e27d5d7c451 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 20 Feb 2026 01:17:25 +0100 Subject: [PATCH 5/7] Added a depreciation warning for APIUtil.hasEntitlement in favour of using handleAccessControlRegardingEntitlementsAndScopes - so isSuperAdmin and isOidcOperator is checked at handleAccessControlRegardingEntitlementsAndScopes thus at any endpoint which uses the role in the Resource Doc as opposed to hardcoded in individual endpoints. Added signal channels for agent to agent communication. --- .../resources/props/sample.props.template | 15 + .../main/scala/bootstrap/liftweb/Boot.scala | 65 +++- .../SwaggerDefinitionsJSON.scala | 47 +++ .../scala/code/api/cache/RedisMessaging.scala | 150 ++++++++ .../main/scala/code/api/util/APIUtil.scala | 17 + .../src/main/scala/code/api/util/ApiTag.scala | 4 + .../main/scala/code/api/util/NewStyle.scala | 19 +- .../code/api/v2_0_0/JSONFactory2.0.0.scala | 5 +- .../scala/code/api/v6_0_0/APIMethods600.scala | 322 +++++++++++++++++- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 47 +++ .../code/api/v6_0_0/GetOidcClientTest.scala | 61 ++++ .../code/api/v6_0_0/GetUserByUserIdTest.scala | 63 ++++ .../api/v6_0_0/VerifyOidcClientTest.scala | 74 ++++ 13 files changed, 870 insertions(+), 19 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/cache/RedisMessaging.scala create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/GetOidcClientTest.scala create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/GetUserByUserIdTest.scala create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/VerifyOidcClientTest.scala diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index c84dcdcece..bd079e8f96 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1614,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 ## ================================ @@ -1713,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/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 773e26ebf0..fd3ce2b8ce 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 @@ -6188,6 +6188,53 @@ 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 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 0019687062..3e9068d546 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2411,6 +2411,11 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ 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 } @@ -2429,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 => @@ -2450,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" @@ -2472,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, _)) @@ -2482,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) @@ -2519,6 +2534,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ else { userHasTheRoles } + } // end of virtual roles else } } @@ -2528,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, _)) 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..549e5aea9d 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,10 @@ 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 apiTagChannel = ResourceDocTag("Channel") + private[this] val tagNameSymbolMapTag: MutableMap[String, ResourceDocTag] = MutableMap() /** 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/JSONFactory2.0.0.scala b/obp-api/src/main/scala/code/api/v2_0_0/JSONFactory2.0.0.scala index c06cda949c..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} @@ -838,9 +839,9 @@ def createTransactionTypeJSON(transactionType : TransactionType) : TransactionTy ))) // Virtual roles granted by super_admin_user_ids prop - val superAdminVirtualRoles = List("CanCreateEntitlementAtOneBank", "CanCreateEntitlementAtAnyBank", "CanGetAnyUser") + val superAdminVirtualRoles = APIUtil.superAdminVirtualRoles // Virtual roles granted by oidc_operator_user_ids prop - val oidcOperatorVirtualRoles = List("CanGetAnyUser", "CanVerifyUserCredentials", "CanVerifyOidcClient", "CanGetOidcClient") + val oidcOperatorVirtualRoles = APIUtil.oidcOperatorVirtualRoles /** * Add virtual entitlements to an entitlement list. 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 66fe808de1..39a7f18d2a 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 @@ -1795,8 +1795,6 @@ trait APIMethods600 { implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- if(isSuperAdmin(u.userId) || isOidcOperator(u.userId)) Future.successful(Full(Unit)) - else NewStyle.function.hasEntitlement("", u.userId, canGetAnyUser, callContext) user <- Users.users.vend.getUserByUserIdFuture(userId) map { x => unboxFullOrFail(x, callContext, s"$UserNotFoundByUserId Current UserId($userId)") } @@ -8711,8 +8709,6 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- if(isOidcOperator(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] } @@ -8783,8 +8779,6 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- if(isOidcOperator(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] } @@ -8849,8 +8843,6 @@ trait APIMethods600 { cc => implicit val ec = EndpointContext(Some(cc)) for { (Full(u), callContext) <- authenticatedAccess(cc) - _ <- if(isOidcOperator(u.userId)) Future.successful(Full(Unit)) - else NewStyle.function.hasEntitlement("", u.userId, canGetOidcClient, callContext) consumerBox <- Future { Consumers.consumers.vend.getConsumerByConsumerKey(clientId) } @@ -10413,6 +10405,316 @@ 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, 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, 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, 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, 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, apiTagChannel)) + + 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 0a6cdacc3d..bddcebf55f 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 @@ -893,6 +893,53 @@ 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 SignalChannelDeletedJsonV600( + channel_name: String, + deleted: Boolean +) + object JSONFactory600 extends CustomJsonFormats with MdcLoggable { def createRedisCallCountersJson( 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/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) + } + + } +} From e553e810b582c29bfe735dd07aa476746ce8fa2f Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 20 Feb 2026 10:16:23 +0100 Subject: [PATCH 6/7] Get Signal Channel Stats --- .../SwaggerDefinitionsJSON.scala | 6 ++ .../main/scala/code/api/util/APIUtil.scala | 32 +++++---- .../main/scala/code/api/util/ApiRole.scala | 3 + .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../scala/code/api/v6_0_0/APIMethods600.scala | 70 +++++++++++++++++-- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 6 ++ 6 files changed, 99 insertions(+), 19 deletions(-) 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 fd3ce2b8ce..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 @@ -6230,6 +6230,12 @@ object SwaggerDefinitionsJSON { 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 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 3e9068d546..a3a7a10632 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3944,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 @@ -3979,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 549e5aea9d..2bcd2b91b3 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -160,6 +160,7 @@ object ApiTag { 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/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 39a7f18d2a..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 @@ -10438,7 +10438,7 @@ trait APIMethods600 { InvalidJsonFormat, UnknownError ), - List(apiTagAiAgent, apiTagSignal, apiTagChannel)) + List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) lazy val publishSignalMessage: OBPEndpoint = { case "signal" :: "channels" :: channelName :: "messages" :: Nil JsonPost json -> _ => @@ -10516,7 +10516,7 @@ trait APIMethods600 { $AuthenticatedUserIsRequired, UnknownError ), - List(apiTagAiAgent, apiTagSignal, apiTagChannel)) + List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) lazy val getSignalMessages: OBPEndpoint = { case "signal" :: "channels" :: channelName :: "messages" :: Nil JsonGet _ => @@ -10578,7 +10578,7 @@ trait APIMethods600 { $AuthenticatedUserIsRequired, UnknownError ), - List(apiTagAiAgent, apiTagSignal, apiTagChannel)) + List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) lazy val getSignalChannels: OBPEndpoint = { case "signal" :: "channels" :: Nil JsonGet _ => @@ -10642,7 +10642,7 @@ trait APIMethods600 { $AuthenticatedUserIsRequired, UnknownError ), - List(apiTagAiAgent, apiTagSignal, apiTagChannel)) + List(apiTagAiAgent, apiTagSignal, apiTagSignalling, apiTagChannel)) lazy val getSignalChannelInfo: OBPEndpoint = { case "signal" :: "channels" :: channelName :: "info" :: Nil JsonGet _ => @@ -10692,7 +10692,67 @@ trait APIMethods600 { $AuthenticatedUserIsRequired, UnknownError ), - List(apiTagAiAgent, apiTagSignal, apiTagChannel)) + 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 _ => 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 bddcebf55f..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 @@ -935,6 +935,12 @@ 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 From 9523cb6ef0eeed94b1e8e0cf5bccd1d97f85f0f0 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Fri, 20 Feb 2026 10:41:18 +0100 Subject: [PATCH 7/7] PasswordResetTest fix --- .../test/scala/code/api/v6_0_0/PasswordResetTest.scala | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 02e4d80ecc..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,17 +52,13 @@ import org.scalatest.Tag */ class PasswordResetTest extends V600ServerSetup { - override def beforeAll(): Unit = { - super.beforeAll() + override def beforeEach() = { + wipeTestData() + super.beforeEach() setPropsValues( "portal_external_url" -> "https://test-portal.example.com", "mail.test.mode" -> "true" ) - } - - override def beforeEach() = { - wipeTestData() - super.beforeEach() AuthUser.bulkDelete_!!(By(AuthUser.username, postJson.username)) ResourceUser.bulkDelete_!!(By(ResourceUser.providerId, postJson.username)) }