diff --git a/obp-api/src/main/resources/docs/introductory_system_documentation.md b/obp-api/src/main/resources/docs/introductory_system_documentation.md index 487a0ae010..c1e99af082 100644 --- a/obp-api/src/main/resources/docs/introductory_system_documentation.md +++ b/obp-api/src/main/resources/docs/introductory_system_documentation.md @@ -900,7 +900,7 @@ obp.base_url=https://api.example.com # Client credentials (from OBP consumer registration) oauth2.client_id=your-client-id -oauth2.redirect_uri=http://localhost:8087/callback +oauth2.redirect_uri=http://localhost:48123/callback oauth2.client_scope=ReadAccountsDetail ReadBalances ReadTransactionsDetail # mTLS (if required) @@ -917,14 +917,14 @@ mvn clean package # Run locally java -jar target/obp-hola-app-0.0.29-SNAPSHOT.jar -# Access at http://localhost:8087 +# Access at http://localhost:48123 ``` **Docker Deployment:** ```bash docker build -t obp-hola . -docker run -p 8087:8087 \ +docker run -p 48123:48123 \ -e OAUTH2_PUBLIC_URL=https://oauth2.example.com \ -e OBP_BASE_URL=https://api.example.com \ obp-hola @@ -2866,6 +2866,23 @@ super_admin_user_ids=uuid-1,uuid-2 # Then remove super_admin_user_ids from props ``` +**Bootstrap OIDC Operator Consumer:** + +OBP can bootstrap a Consumer (Application) for OBP-OIDC at startup. This allows OBP-OIDC to authenticate as an application (without a User) and manage consumers via the API, eliminating the need for direct database access. + +The bootstrap consumer is granted the following Scopes: `CanGetConsumers`, `CanCreateConsumer`, `CanVerifyOidcClient`, `CanGetOidcClient`. + +These endpoints use `authMode = UserOrApplication`, meaning they can be accessed either by a logged-in User with Entitlements, or by an Application using a Consumer Key with Scopes. + +```properties +# Bootstrap OIDC Operator Consumer +# Both values must be between 10 and 250 characters. +oidc_operator_consumer_key=your-consumer-key-here +oidc_operator_consumer_secret=your-consumer-secret-here +``` + +Note: If you use the Bootstrap OIDC Operator Consumer, you may not need the Bootstrap OIDC Operator User, depending on how OBP-OIDC implements its authentication. + **Checking User Entitlements:** ```bash diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 636ed4225a..699e92a6ec 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1151,7 +1151,7 @@ database_messages_scheduler_interval=3600 # consumer_validation_method_for_consent=CONSUMER_CERTIFICATE # # consents.max_time_to_live=3600 -# In case isn't defined default value is "false" +# In case isn't defined default value is "true" # consents.sca.enabled=true # --------------------------------------------------------- diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala index 6db773c921..9cee12f436 100644 --- a/obp-api/src/main/scala/code/api/OAuth2.scala +++ b/obp-api/src/main/scala/code/api/OAuth2.scala @@ -501,7 +501,11 @@ object OAuth2Login extends RestHelper with MdcLoggable { val sub = getClaim(name = "sub", jwtToken = jwtToken) val email = getClaim(name = "email", jwtToken = jwtToken) val name = getClaim(name = "name", jwtToken = jwtToken).orElse(description) - val consumerId = if(APIUtil.checkIfStringIsUUID(azp.getOrElse(""))) azp else Some(s"{$azp}_${APIUtil.generateUUID()}") + val consumerId = azp match { + case Some(value) if APIUtil.checkIfStringIsUUID(value) => azp + case Some(value) => Some(s"${value}_${APIUtil.generateUUID()}") + case None => Some(APIUtil.generateUUID()) + } Consumers.consumers.vend.getOrCreateConsumer( consumerId = consumerId, // Use azp as consumer id if it is uuid value key = Some(Helpers.randomString(40).toLowerCase), 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 44918b7ffb..cf0faf6245 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 @@ -6208,7 +6208,7 @@ object SwaggerDefinitionsJSON { ConfigPropJsonV600("public_obp_sandbox_populator_url", "http://localhost:5178"), ConfigPropJsonV600("public_obp_oidc_url", "http://localhost:9000"), ConfigPropJsonV600("public_keycloak_url", "http://localhost:7787"), - ConfigPropJsonV600("public_obp_hola_url", "http://localhost:8087"), + ConfigPropJsonV600("public_obp_hola_url", "http://localhost:48123"), ConfigPropJsonV600("public_obp_mcp_url", "http://localhost:9100"), ConfigPropJsonV600("public_obp_opey_url", "http://localhost:5000") ) 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 3bf6cac629..536a12d18e 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -4093,7 +4093,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ "public_obp_sandbox_populator_url" -> getPropsValue("public_obp_sandbox_populator_url").openOr("http://localhost:5178"), "public_obp_oidc_url" -> getPropsValue("public_obp_oidc_url").openOr("http://localhost:9000"), "public_keycloak_url" -> getPropsValue("public_keycloak_url").openOr("http://localhost:7787"), - "public_obp_hola_url" -> getPropsValue("public_obp_hola_url").openOr("http://localhost:8087"), + "public_obp_hola_url" -> getPropsValue("public_obp_hola_url").openOr("http://localhost:48123"), "public_obp_mcp_url" -> getPropsValue("public_obp_mcp_url").openOr("http://localhost:9100"), "public_obp_opey_url" -> getPropsValue("public_obp_opey_url").openOr("http://localhost:5000"), "public_rabbit_cats_adapter_url" -> getPropsValue("public_rabbit_cats_adapter_url").openOr("http://localhost:8089") diff --git a/obp-api/src/main/scala/code/api/util/migration/Migration.scala b/obp-api/src/main/scala/code/api/util/migration/Migration.scala index 84cef63c05..34527df59f 100644 --- a/obp-api/src/main/scala/code/api/util/migration/Migration.scala +++ b/obp-api/src/main/scala/code/api/util/migration/Migration.scala @@ -107,6 +107,8 @@ object Migration extends MdcLoggable { addUniqueIndexOnResourceUserUserId() addIndexOnMappedMetricUserId() alterRoleNameLength() + alterConsentRequestColumnConsumerIdLength() + alterMappedConsentColumnConsumerIdLength() } private def dummyScript(): Boolean = { @@ -553,6 +555,20 @@ object Migration extends MdcLoggable { MigrationOfRoleNameFieldLength.alterRoleNameLength(name) } } + + private def alterConsentRequestColumnConsumerIdLength(): Boolean = { + val name = nameOf(alterConsentRequestColumnConsumerIdLength) + runOnce(name) { + MigrationOfConsentRequestConsumerIdFieldLength.alterColumnConsumerIdLength(name) + } + } + + private def alterMappedConsentColumnConsumerIdLength(): Boolean = { + val name = nameOf(alterMappedConsentColumnConsumerIdLength) + runOnce(name) { + MigrationOfMappedConsent.alterColumnConsumerIdLength(name) + } + } } /** diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentRequestConsumerIdFieldLength.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentRequestConsumerIdFieldLength.scala new file mode 100644 index 0000000000..3e2453cd7d --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfConsentRequestConsumerIdFieldLength.scala @@ -0,0 +1,54 @@ +package code.api.util.migration + +import code.api.util.APIUtil +import code.api.util.migration.Migration.{DbFunction, saveLog} +import code.consent.ConsentRequest +import net.liftweb.common.Full +import net.liftweb.mapper.Schemifier + +object MigrationOfConsentRequestConsumerIdFieldLength { + + def alterColumnConsumerIdLength(name: String): Boolean = { + DbFunction.tableExists(ConsentRequest) match { + case true => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + var isSuccessful = false + + val executedSql = + DbFunction.maybeWrite(true, Schemifier.infoF _) { + APIUtil.getPropsValue("db.driver") match { + case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") => + () => + """ + |ALTER TABLE consentrequest ALTER COLUMN consumerid varchar(250); + |""".stripMargin + case _ => + () => + """ + |ALTER TABLE consentrequest ALTER COLUMN consumerid TYPE character varying(250); + |""".stripMargin + } + } + + val endDate = System.currentTimeMillis() + val comment: String = + s"""Executed SQL: + |$executedSql + |""".stripMargin + isSuccessful = true + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + + case false => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val isSuccessful = false + val endDate = System.currentTimeMillis() + val comment: String = + s"""${ConsentRequest._dbTableNameLC} table does not exist""".stripMargin + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + } + } +} diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMappedConsent.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMappedConsent.scala index 412b279320..ecb043b779 100644 --- a/obp-api/src/main/scala/code/api/util/migration/MigrationOfMappedConsent.scala +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfMappedConsent.scala @@ -102,6 +102,56 @@ object MigrationOfMappedConsent { isSuccessful } } + // The mConsumerId column was originally MappedUUID (varchar(36)), but Consumer.consumerId + // is MappedString(250) and can hold composite IDs like "{azp_value}_UUID" generated + // by OAuth2.getOrCreateConsumer when the azp claim is not a UUID. + // This migration widens mConsumerId to match the Consumer model. + def alterColumnConsumerIdLength(name: String): Boolean = { + DbFunction.tableExists(MappedConsent) match { + case true => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + var isSuccessful = false + + val executedSql = + DbFunction.maybeWrite(true, Schemifier.infoF _) { + APIUtil.getPropsValue("db.driver") match { + case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") => + () => + """ALTER TABLE mappedconsent ALTER COLUMN mconsumerid varchar(250); + |""".stripMargin + case Full(dbDriver) if dbDriver.contains("com.mysql.cj.jdbc.Driver") => // MySQL + () => + """ALTER TABLE mappedconsent MODIFY COLUMN mconsumerid varchar(250); + |""".stripMargin + case _ => + () => + """ALTER TABLE mappedconsent ALTER COLUMN mconsumerid TYPE character varying(250); + |""".stripMargin + } + } + + val endDate = System.currentTimeMillis() + val comment: String = + s"""Executed SQL: + |$executedSql + |""".stripMargin + isSuccessful = true + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + + case false => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val isSuccessful = false + val endDate = System.currentTimeMillis() + val comment: String = + s"""${MappedConsent._dbTableNameLC} table does not exist""".stripMargin + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + } + } + def alterColumnStatus(name: String): Boolean = { DbFunction.tableExists(MappedConsent) match { case true => diff --git a/obp-api/src/main/scala/code/consent/ConsentRequest.scala b/obp-api/src/main/scala/code/consent/ConsentRequest.scala index e307eeb078..a920d0c910 100644 --- a/obp-api/src/main/scala/code/consent/ConsentRequest.scala +++ b/obp-api/src/main/scala/code/consent/ConsentRequest.scala @@ -29,7 +29,7 @@ class ConsentRequest extends ConsentRequestTrait with LongKeyedMapper[ConsentReq //the following are the obp consent. object ConsentRequestId extends MappedUUID(this) object Payload extends MappedText(this) - object ConsumerId extends MappedUUID(this) { + object ConsumerId extends MappedString(this, 250) { override def defaultValue = null } diff --git a/obp-api/src/main/scala/code/consent/MappedConsent.scala b/obp-api/src/main/scala/code/consent/MappedConsent.scala index 1a53050745..6bb0e692c7 100644 --- a/obp-api/src/main/scala/code/consent/MappedConsent.scala +++ b/obp-api/src/main/scala/code/consent/MappedConsent.scala @@ -400,7 +400,7 @@ class MappedConsent extends ConsentTrait with LongKeyedMapper[MappedConsent] wit override def defaultValue = BCrypt.gensalt() } object mJsonWebToken extends MappedText(this) - object mConsumerId extends MappedUUID(this) { + object mConsumerId extends MappedString(this, 250) { override def defaultValue = null } object mConsentRequestId extends MappedUUID(this) { diff --git a/obp-api/src/main/scala/code/model/OAuth.scala b/obp-api/src/main/scala/code/model/OAuth.scala index d132c2fd44..ea00a31930 100644 --- a/obp-api/src/main/scala/code/model/OAuth.scala +++ b/obp-api/src/main/scala/code/model/OAuth.scala @@ -510,9 +510,17 @@ class Consumer extends LongKeyedMapper[Consumer] with CreatedUpdated{ def getSingleton = Consumer def primaryKeyField = id - //Note: we have two id here for Consumer. id is the primaryKeyField, we used it as the CONSUMER_ID in api level for a long time. - //But from `a4222f9824fcac039e7968f4abcd009fa3918d4a` 2017-07-07 we introduced the consumerId here. It is confused now - //For now consumerId is only used in Gateway Login, all other cases, we should use the id instead `consumerId`. + // Note: There are two IDs on Consumer. + // `id` is the Long primary key (MappedLongIndex). + // `consumerId` is the UUID-based string identifier exposed externally as consumer_id in the API. + // + // consumerId is 250 chars to accommodate: + // - Standard UUIDs (36 chars) — the default + // - Gateway Login external app_id values (variable length) + // - OAuth2 composite IDs in format "azp_UUID" created by OAuth2.getOrCreateConsumer (up to ~77 chars) + // + // WARNING: Do not increase this length. Other tables (e.g. MappedConsent.mConsumerId) store + // copies of this value. object id extends MappedLongIndex(this) object consumerId extends MappedString(this, 250) { // Introduced to cover gateway login functionality override def defaultValue = APIUtil.generateUUID()