From 0c0e59373d145a28c3b0bf3de70ae3cff6ee8ec0 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 16:01:18 +0200 Subject: [PATCH 1/4] fix: dynamically resolve consumer registration url and fallback for getServerUrl --- .../ResourceDocs1_4_0/OpenAPI31JSONFactory.scala | 12 ++++++++++-- .../src/main/scala/code/api/util/APIUtil.scala | 7 ++++++- .../ResourceDocs1_4_0/OpenAPI31FactoryTest.scala | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala index 8425e34be4..b28bc63523 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala @@ -60,12 +60,20 @@ object OpenAPI31JSONFactory extends MdcLoggable { // If you need to change authentication documentation, change it here. // ===================================================================================== + def consumerRegistrationUrl(hostname: String): String = + APIUtil.getPropsValue("consumer_registration_url").filter(_.trim.nonEmpty) + .openOr( + APIUtil.getPortalUrl + .map(portal => s"${portal.stripSuffix("/")}/developers/consumer-creation") + .openOr(s"${hostname}/consumer-registration") + ) + def directLoginDescription(hostname: String): String = s"""Direct Login is a simple authentication process for trusted environments and testing. | |### Step 1: Get your Consumer Key | - |Register your application at ${hostname}/consumer-registration to obtain a consumer_key. + |Register your application at ${consumerRegistrationUrl(hostname)} to obtain a consumer_key. | |### Step 2: Obtain a token | @@ -112,7 +120,7 @@ object OpenAPI31JSONFactory extends MdcLoggable { | |Use this flow when your application needs to act on behalf of a user. | - |1. Register your application at ${hostname}/consumer-registration to obtain a client_id and client_secret. + |1. Register your application at ${consumerRegistrationUrl(hostname)} to obtain a client_id and client_secret. |2. Redirect the user to the authorization URL with your client_id and redirect_uri. |3. After the user authenticates and consents, they are redirected back with an authorization code. |4. Exchange the authorization code for an access token at the token endpoint. 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 3d6e2dfd64..69144000e0 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3803,7 +3803,11 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ * This value is used to construct some urls in Resource Docs * Its the root of the server as opposed to the root of the api */ - def getServerUrl: String = getPropsValue("documented_server_url").openOr(MissingPropsValueAtThisInstance + "documented_server_url") + def getServerUrl: String = + getPropsValue("documented_server_url").filter(_.trim.nonEmpty) + .openOr(getPropsValue("public_obp_api_url").filter(_.trim.nonEmpty) + .openOr(getPropsValue("hostname").filter(_.trim.nonEmpty) + .openOr(MissingPropsValueAtThisInstance + "documented_server_url"))) // All OBP REST end points start with /obp def getObpApiRoot: String = s"$getServerUrl/obp" @@ -3922,6 +3926,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def getPortalUrl: Box[String] = getPropsValue("public_obp_portal_url").filter(_.trim.nonEmpty) .or(getPropsValue("portal_external_url").filter(_.trim.nonEmpty)) + .or(getPropsValue("portal_hostname").filter(_.trim.nonEmpty)) // Returns config props matching the public_*_url convention. // Empty values are excluded (prop not configured). diff --git a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala index 5d2ab6d65a..48e67e2220 100644 --- a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala +++ b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala @@ -122,4 +122,20 @@ class OpenAPI31FactoryTest extends FlatSpec with Matchers { noException should be thrownBy OpenAPI31JSONFactory.createOpenAPI31Json(List(doc("testOp", JNothing)), "v4.0.0", "http://localhost:8080") } + + "consumerRegistrationUrl" should "fall back to hostname/consumer-registration when no portal URL is set" in { + OpenAPI31JSONFactory.consumerRegistrationUrl("https://api.example.com") should equal("https://api.example.com/consumer-registration") + } + + "oAuth2Description" should "use consumerRegistrationUrl for registration and hostname for API endpoints" in { + val desc = OpenAPI31JSONFactory.oAuth2Description("https://api.example.com") + desc should include("Register your application at https://api.example.com/consumer-registration to obtain a client_id and client_secret.") + desc should include("GET https://api.example.com/obp/v6.0.0/my/banks") + } + + "directLoginDescription" should "use consumerRegistrationUrl for registration and hostname for API endpoints" in { + val desc = OpenAPI31JSONFactory.directLoginDescription("https://api.example.com") + desc should include("Register your application at https://api.example.com/consumer-registration to obtain a consumer_key.") + desc should include("POST https://api.example.com/obp/v6.0.0/my/logins/direct") + } } From 34002e11a7c130aec7f3e9c0b8d97062249c1392 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 16:27:07 +0200 Subject: [PATCH 2/4] fix: dynamically resolve oidc token endpoint and discovery url in auth docs --- .../OpenAPI31JSONFactory.scala | 30 +++++++++++++- .../OpenAPI31FactoryTest.scala | 40 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala index b28bc63523..a5c21534fc 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala @@ -68,6 +68,22 @@ object OpenAPI31JSONFactory extends MdcLoggable { .openOr(s"${hostname}/consumer-registration") ) + def oidcTokenEndpoint: String = + APIUtil.getPropsValue("oauth2.obp_oidc.host") + .or(APIUtil.getPropsValue("public_obp_oidc_url")) + .filter(_.trim.nonEmpty) + .map(host => s"${host.stripSuffix("/")}/obp-oidc/token") + .openOr("") + + def oidcDiscoveryUrl: Option[String] = + APIUtil.getPropsValue("oauth2.obp_oidc.well_known").filter(_.trim.nonEmpty) + .or( + APIUtil.getPropsValue("oauth2.obp_oidc.host") + .or(APIUtil.getPropsValue("public_obp_oidc_url")) + .filter(_.trim.nonEmpty) + .map(host => s"${host.stripSuffix("/")}/obp-oidc/.well-known/openid-configuration") + ).toOption + def directLoginDescription(hostname: String): String = s"""Direct Login is a simple authentication process for trusted environments and testing. | @@ -110,7 +126,15 @@ object OpenAPI31JSONFactory extends MdcLoggable { |- Direct Login is intended for hackathons, testing, and trusted environments. |- For production use with third-party applications, use OAuth2 / OIDC instead.""".stripMargin - def oAuth2Description(hostname: String): String = + def oAuth2Description(hostname: String): String = { + val discoveryNote = oidcDiscoveryUrl match { + case Some(discoveryUrl) => + s"- OIDC discovery endpoint: $discoveryUrl (contains token_endpoint, authorization_endpoint, and jwks_uri).\n" + + s"- All configured discovery URIs can be queried via: GET ${hostname}/obp/v5.1.0/well-known" + case None => + s"- Configured discovery URIs can be queried via: GET ${hostname}/obp/v5.1.0/well-known" + } + s"""OAuth2 / OpenID Connect (OIDC) is the recommended authentication method for production use. | |OBP supports the OAuth2 Authorization Code flow for user-facing applications and the @@ -134,7 +158,7 @@ object OpenAPI31JSONFactory extends MdcLoggable { |Use this flow for endpoints with authMode ApplicationOnly or UserOrApplication. |The application authenticates itself using its client_id and client_secret without a user login. | - | POST + | POST $oidcTokenEndpoint | Content-Type: application/x-www-form-urlencoded | | grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET @@ -144,8 +168,10 @@ object OpenAPI31JSONFactory extends MdcLoggable { |### Notes | |- The authorization and token endpoint URLs depend on the OIDC provider configured for this OBP instance. + |$discoveryNote |- Token expiry and refresh behaviour depend on the OIDC provider configuration. |- OAuth2 / OIDC is the recommended method for production deployments.""".stripMargin + } def gatewayLoginDescription(hostname: String): String = s"""Gateway Login allows a gateway (e.g. API Gateway) to authenticate on behalf of a user. diff --git a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala index 48e67e2220..7e19e48f49 100644 --- a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala +++ b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala @@ -28,6 +28,7 @@ TESOBE (http://www.tesobe.com/) package code.api.ResourceDocs1_4_0 import code.api.v1_4_0.JSONFactory1_4_0 +import code.setup.PropsReset import org.json4s.JsonAST.{JNothing, JValue} import org.json4s.native.JsonMethods.parse import org.scalatest.{FlatSpec, Matchers} @@ -43,7 +44,7 @@ import org.scalatest.{FlatSpec, Matchers} * * These are unit tests over the factory, not the endpoint: no server, no resource-docs fetch. */ -class OpenAPI31FactoryTest extends FlatSpec with Matchers { +class OpenAPI31FactoryTest extends FlatSpec with Matchers with PropsReset { private def doc(operationId: String, typedBody: JValue): JSONFactory1_4_0.ResourceDocJson = JSONFactory1_4_0.ResourceDocJson( @@ -138,4 +139,41 @@ class OpenAPI31FactoryTest extends FlatSpec with Matchers { desc should include("Register your application at https://api.example.com/consumer-registration to obtain a consumer_key.") desc should include("POST https://api.example.com/obp/v6.0.0/my/logins/direct") } + + "oidcTokenEndpoint" should "fall back to when no OIDC host is set" in { + OpenAPI31JSONFactory.oidcTokenEndpoint should equal("") + } + + it should "resolve token endpoint from oauth2.obp_oidc.host" in { + setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com") + OpenAPI31JSONFactory.oidcTokenEndpoint should equal("https://oidc.example.com/obp-oidc/token") + } + + it should "strip trailing slashes when resolving token endpoint" in { + setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com/") + OpenAPI31JSONFactory.oidcTokenEndpoint should equal("https://oidc.example.com/obp-oidc/token") + } + + "oidcDiscoveryUrl" should "return None when no OIDC host or well-known prop is set" in { + OpenAPI31JSONFactory.oidcDiscoveryUrl should equal(None) + } + + it should "resolve discovery url from oauth2.obp_oidc.host" in { + setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com") + OpenAPI31JSONFactory.oidcDiscoveryUrl should equal(Some("https://oidc.example.com/obp-oidc/.well-known/openid-configuration")) + } + + it should "prefer explicit oauth2.obp_oidc.well_known prop when present" in { + setPropsValues("oauth2.obp_oidc.well_known" -> "https://custom.example.com/.well-known/openid-configuration") + OpenAPI31JSONFactory.oidcDiscoveryUrl should equal(Some("https://custom.example.com/.well-known/openid-configuration")) + } + + "oAuth2Description" should "render dynamically resolved token endpoint and discovery info when OIDC host is set" in { + setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com") + val desc = OpenAPI31JSONFactory.oAuth2Description("https://api.example.com") + desc should include("POST https://oidc.example.com/obp-oidc/token") + desc should include("OIDC discovery endpoint: https://oidc.example.com/obp-oidc/.well-known/openid-configuration") + desc should include("GET https://api.example.com/obp/v5.1.0/well-known") + } } + From ac8ffbf1d6c38f9f607d0a76ad347257a9a53664 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 22:21:41 +0200 Subject: [PATCH 3/4] fix: clarify app registration guidance and satisfy test isolation --- .../OpenAPI31JSONFactory.scala | 19 +- .../OpenAPI31FactoryTest.scala | 178 +++++++++--------- 2 files changed, 108 insertions(+), 89 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala index a5c21534fc..de84cb377e 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala @@ -64,10 +64,23 @@ object OpenAPI31JSONFactory extends MdcLoggable { APIUtil.getPropsValue("consumer_registration_url").filter(_.trim.nonEmpty) .openOr( APIUtil.getPortalUrl - .map(portal => s"${portal.stripSuffix("/")}/developers/consumer-creation") + .map(portal => s"${portal.stripSuffix("/")}/consumers/register") .openOr(s"${hostname}/consumer-registration") ) + def consumerCreationGuideUrl: Option[String] = + APIUtil.getPortalUrl + .map(portal => s"${portal.stripSuffix("/")}/developers/consumer-creation") + .toOption + + def consumerRegistrationInstruction(hostname: String, credentialDescription: String): String = + consumerCreationGuideUrl match { + case Some(guideUrl) => + s"Read the registration instructions at $guideUrl, log in and proceed to ${consumerRegistrationUrl(hostname)} to register your application and obtain a $credentialDescription." + case None => + s"Register your application at ${consumerRegistrationUrl(hostname)} to obtain a $credentialDescription." + } + def oidcTokenEndpoint: String = APIUtil.getPropsValue("oauth2.obp_oidc.host") .or(APIUtil.getPropsValue("public_obp_oidc_url")) @@ -89,7 +102,7 @@ object OpenAPI31JSONFactory extends MdcLoggable { | |### Step 1: Get your Consumer Key | - |Register your application at ${consumerRegistrationUrl(hostname)} to obtain a consumer_key. + |${consumerRegistrationInstruction(hostname, "consumer_key")} | |### Step 2: Obtain a token | @@ -144,7 +157,7 @@ object OpenAPI31JSONFactory extends MdcLoggable { | |Use this flow when your application needs to act on behalf of a user. | - |1. Register your application at ${consumerRegistrationUrl(hostname)} to obtain a client_id and client_secret. + |1. ${consumerRegistrationInstruction(hostname, "client_id and client_secret")} |2. Redirect the user to the authorization URL with your client_id and redirect_uri. |3. After the user authenticates and consents, they are redirected back with an authorization code. |4. Exchange the authorization code for an access token at the token endpoint. diff --git a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala index 7e19e48f49..f856b824a0 100644 --- a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala +++ b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala @@ -28,23 +28,17 @@ TESOBE (http://www.tesobe.com/) package code.api.ResourceDocs1_4_0 import code.api.v1_4_0.JSONFactory1_4_0 -import code.setup.PropsReset +import code.setup.ServerSetup import org.json4s.JsonAST.{JNothing, JValue} import org.json4s.native.JsonMethods.parse -import org.scalatest.{FlatSpec, Matchers} /** - * Covers OpenAPI31JSONFactory, which had no test of any kind. + * Covers OpenAPI31JSONFactory. * * It serves a public endpoint - GET /obp/vX/resource-docs/API_VERSION/openapi - and its input is - * the typed_success_response_body that ResourceDocJson carries. This branch changed that field for - * 62 (version, endpoint) pairs while correcting how collections are described, so the one consumer - * that reads those schemas structurally was being handed new shapes with nothing asserting what it - * did with them. It turned out to handle them correctly; that is now pinned rather than assumed. - * - * These are unit tests over the factory, not the endpoint: no server, no resource-docs fetch. + * the typed_success_response_body that ResourceDocJson carries. */ -class OpenAPI31FactoryTest extends FlatSpec with Matchers with PropsReset { +class OpenAPI31FactoryTest extends ServerSetup { private def doc(operationId: String, typedBody: JValue): JSONFactory1_4_0.ResourceDocJson = JSONFactory1_4_0.ResourceDocJson( @@ -69,16 +63,9 @@ class OpenAPI31FactoryTest extends FlatSpec with Matchers with PropsReset { created_by_bank_id = None ) - // DefaultFormats, not CustomJsonFormats: the latter reaches APIUtil, whose static initialiser - // wants props that a unit test has not set up, and the suite dies before its first assertion. - private implicit val formats: org.json4s.Formats = org.json4s.DefaultFormats - private def responseSchema(typedBody: JValue): JValue = { val openApi = OpenAPI31JSONFactory.createOpenAPI31Json(List(doc("testOp", typedBody)), "v4.0.0", "http://localhost:8080") val rendered = parse(org.json4s.native.Serialization.write(openApi)) - // The one and only path, found rather than spelled: createOpenAPI31Json runs the url through - // convertPathToOpenAPI, and hard-coding the result would test that transform rather than the - // schema conversion this suite is about. val paths = (rendered \ "paths") match { case org.json4s.JObject(fields) => fields case other => fail(s"paths was not an object: $other") @@ -87,93 +74,112 @@ class OpenAPI31FactoryTest extends FlatSpec with Matchers with PropsReset { (paths.head._2 \ "get" \ "responses" \ "200" \ "content" \ "application/json" \ "schema") } - "an object typed body" should "become an object schema carrying its properties" in { - val schema = responseSchema(parse("""{"type":"object","properties":{"bank_id":{"type":"string"}}}""")) + feature("OpenAPI 3.1 JSON factory schema conversion") { + scenario("an object typed body becomes an object schema carrying its properties") { + val schema = responseSchema(parse("""{"type":"object","properties":{"bank_id":{"type":"string"}}}""")) - (schema \ "type") should equal(org.json4s.JString("object")) - (schema \ "properties" \ "bank_id" \ "type") should equal(org.json4s.JString("string")) - } + (schema \ "type") should equal(org.json4s.JString("object")) + (schema \ "properties" \ "bank_id" \ "type") should equal(org.json4s.JString("string")) + } - // The shape this branch introduced for a bare-List response. It reaches the factory as a root - // array, which is the one case the object-shaped conversion path could have dropped. - "an array typed body" should "become an array schema that keeps its items" in { - val schema = responseSchema(parse( - """{"type":"array","items":{"type":"object","properties":{"tag_name":{"type":"string"}}}}""")) + scenario("an array typed body becomes an array schema that keeps its items") { + val schema = responseSchema(parse( + """{"type":"array","items":{"type":"object","properties":{"tag_name":{"type":"string"}}}}""")) - (schema \ "type") should equal(org.json4s.JString("array")) - (schema \ "items" \ "type") should equal(org.json4s.JString("object")) - (schema \ "items" \ "properties" \ "tag_name" \ "type") should equal(org.json4s.JString("string")) - } + (schema \ "type") should equal(org.json4s.JString("array")) + (schema \ "items" \ "type") should equal(org.json4s.JString("object")) + (schema \ "items" \ "properties" \ "tag_name" \ "type") should equal(org.json4s.JString("string")) + } - "a nested array field" should "keep its items through the conversion" in { - val schema = responseSchema(parse( - """{"type":"object","properties":{"views":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"}}}}}}""")) + scenario("a nested array field keeps its items through the conversion") { + val schema = responseSchema(parse( + """{"type":"object","properties":{"views":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"}}}}}}""")) - (schema \ "properties" \ "views" \ "type") should equal(org.json4s.JString("array")) - (schema \ "properties" \ "views" \ "items" \ "properties" \ "id" \ "type") should equal(org.json4s.JString("string")) - } + (schema \ "properties" \ "views" \ "type") should equal(org.json4s.JString("array")) + (schema \ "properties" \ "views" \ "items" \ "properties" \ "id" \ "type") should equal(org.json4s.JString("string")) + } - "the document" should "declare the OpenAPI version it claims to be" in { - val openApi = OpenAPI31JSONFactory.createOpenAPI31Json(List(doc("testOp", JNothing)), "v4.0.0", "http://localhost:8080") + scenario("the document declares the OpenAPI version it claims to be") { + val openApi = OpenAPI31JSONFactory.createOpenAPI31Json(List(doc("testOp", JNothing)), "v4.0.0", "http://localhost:8080") - openApi.openapi should equal("3.1.0") - } + openApi.openapi should equal("3.1.0") + } - it should "survive a typed body that is absent" in { - noException should be thrownBy - OpenAPI31JSONFactory.createOpenAPI31Json(List(doc("testOp", JNothing)), "v4.0.0", "http://localhost:8080") + scenario("survives a typed body that is absent") { + noException should be thrownBy + OpenAPI31JSONFactory.createOpenAPI31Json(List(doc("testOp", JNothing)), "v4.0.0", "http://localhost:8080") + } } - "consumerRegistrationUrl" should "fall back to hostname/consumer-registration when no portal URL is set" in { - OpenAPI31JSONFactory.consumerRegistrationUrl("https://api.example.com") should equal("https://api.example.com/consumer-registration") - } + feature("OpenAPI 3.1 auth descriptions and URLs") { + scenario("consumerRegistrationUrl falls back to hostname/consumer-registration when no portal URL is set") { + OpenAPI31JSONFactory.consumerRegistrationUrl("https://api.example.com") should equal("https://api.example.com/consumer-registration") + } - "oAuth2Description" should "use consumerRegistrationUrl for registration and hostname for API endpoints" in { - val desc = OpenAPI31JSONFactory.oAuth2Description("https://api.example.com") - desc should include("Register your application at https://api.example.com/consumer-registration to obtain a client_id and client_secret.") - desc should include("GET https://api.example.com/obp/v6.0.0/my/banks") - } + scenario("consumerRegistrationUrl points to /consumers/register when portal URL is set") { + setPropsValues("public_obp_portal_url" -> "https://portal.example.com") + OpenAPI31JSONFactory.consumerRegistrationUrl("https://api.example.com") should equal("https://portal.example.com/consumers/register") + OpenAPI31JSONFactory.consumerCreationGuideUrl should equal(Some("https://portal.example.com/developers/consumer-creation")) + } - "directLoginDescription" should "use consumerRegistrationUrl for registration and hostname for API endpoints" in { - val desc = OpenAPI31JSONFactory.directLoginDescription("https://api.example.com") - desc should include("Register your application at https://api.example.com/consumer-registration to obtain a consumer_key.") - desc should include("POST https://api.example.com/obp/v6.0.0/my/logins/direct") - } + scenario("oAuth2Description uses consumerRegistrationUrl for registration and hostname for API endpoints") { + val desc = OpenAPI31JSONFactory.oAuth2Description("https://api.example.com") + desc should include("Register your application at https://api.example.com/consumer-registration to obtain a client_id and client_secret.") + desc should include("GET https://api.example.com/obp/v6.0.0/my/banks") + } - "oidcTokenEndpoint" should "fall back to when no OIDC host is set" in { - OpenAPI31JSONFactory.oidcTokenEndpoint should equal("") - } + scenario("oAuth2Description guides users through portal guide and registration form when portal URL is set") { + setPropsValues("public_obp_portal_url" -> "https://portal.example.com") + val desc = OpenAPI31JSONFactory.oAuth2Description("https://api.example.com") + desc should include("Read the registration instructions at https://portal.example.com/developers/consumer-creation, log in and proceed to https://portal.example.com/consumers/register to register your application and obtain a client_id and client_secret.") + } - it should "resolve token endpoint from oauth2.obp_oidc.host" in { - setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com") - OpenAPI31JSONFactory.oidcTokenEndpoint should equal("https://oidc.example.com/obp-oidc/token") - } + scenario("directLoginDescription uses consumerRegistrationUrl for registration and hostname for API endpoints") { + val desc = OpenAPI31JSONFactory.directLoginDescription("https://api.example.com") + desc should include("Register your application at https://api.example.com/consumer-registration to obtain a consumer_key.") + desc should include("POST https://api.example.com/obp/v6.0.0/my/logins/direct") + } - it should "strip trailing slashes when resolving token endpoint" in { - setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com/") - OpenAPI31JSONFactory.oidcTokenEndpoint should equal("https://oidc.example.com/obp-oidc/token") - } + scenario("directLoginDescription guides users through portal guide and registration form when portal URL is set") { + setPropsValues("public_obp_portal_url" -> "https://portal.example.com") + val desc = OpenAPI31JSONFactory.directLoginDescription("https://api.example.com") + desc should include("Read the registration instructions at https://portal.example.com/developers/consumer-creation, log in and proceed to https://portal.example.com/consumers/register to register your application and obtain a consumer_key.") + } - "oidcDiscoveryUrl" should "return None when no OIDC host or well-known prop is set" in { - OpenAPI31JSONFactory.oidcDiscoveryUrl should equal(None) - } + scenario("oidcTokenEndpoint falls back to when no OIDC host is set") { + OpenAPI31JSONFactory.oidcTokenEndpoint should equal("") + } - it should "resolve discovery url from oauth2.obp_oidc.host" in { - setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com") - OpenAPI31JSONFactory.oidcDiscoveryUrl should equal(Some("https://oidc.example.com/obp-oidc/.well-known/openid-configuration")) - } + scenario("oidcTokenEndpoint resolves token endpoint from oauth2.obp_oidc.host") { + setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com") + OpenAPI31JSONFactory.oidcTokenEndpoint should equal("https://oidc.example.com/obp-oidc/token") + } - it should "prefer explicit oauth2.obp_oidc.well_known prop when present" in { - setPropsValues("oauth2.obp_oidc.well_known" -> "https://custom.example.com/.well-known/openid-configuration") - OpenAPI31JSONFactory.oidcDiscoveryUrl should equal(Some("https://custom.example.com/.well-known/openid-configuration")) - } + scenario("oidcTokenEndpoint strips trailing slashes when resolving token endpoint") { + setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com/") + OpenAPI31JSONFactory.oidcTokenEndpoint should equal("https://oidc.example.com/obp-oidc/token") + } + + scenario("oidcDiscoveryUrl returns None when no OIDC host or well-known prop is set") { + OpenAPI31JSONFactory.oidcDiscoveryUrl should equal(None) + } + + scenario("oidcDiscoveryUrl resolves discovery url from oauth2.obp_oidc.host") { + setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com") + OpenAPI31JSONFactory.oidcDiscoveryUrl should equal(Some("https://oidc.example.com/obp-oidc/.well-known/openid-configuration")) + } - "oAuth2Description" should "render dynamically resolved token endpoint and discovery info when OIDC host is set" in { - setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com") - val desc = OpenAPI31JSONFactory.oAuth2Description("https://api.example.com") - desc should include("POST https://oidc.example.com/obp-oidc/token") - desc should include("OIDC discovery endpoint: https://oidc.example.com/obp-oidc/.well-known/openid-configuration") - desc should include("GET https://api.example.com/obp/v5.1.0/well-known") + scenario("oidcDiscoveryUrl prefers explicit oauth2.obp_oidc.well_known prop when present") { + setPropsValues("oauth2.obp_oidc.well_known" -> "https://custom.example.com/.well-known/openid-configuration") + OpenAPI31JSONFactory.oidcDiscoveryUrl should equal(Some("https://custom.example.com/.well-known/openid-configuration")) + } + + scenario("oAuth2Description renders dynamically resolved token endpoint and discovery info when OIDC host is set") { + setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com") + val desc = OpenAPI31JSONFactory.oAuth2Description("https://api.example.com") + desc should include("POST https://oidc.example.com/obp-oidc/token") + desc should include("OIDC discovery endpoint: https://oidc.example.com/obp-oidc/.well-known/openid-configuration") + desc should include("GET https://api.example.com/obp/v5.1.0/well-known") + } } } - From a5158cad3d322965c299c50ce6beea3fdd17c6bd Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 14 Oct 2026 23:07:51 +0200 Subject: [PATCH 4/4] fix: update example my-banks endpoint version to v7.0.0 in auth docs --- .../code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala | 6 +++--- .../code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala | 8 +++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala index de84cb377e..76f5b2e117 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala @@ -122,7 +122,7 @@ object OpenAPI31JSONFactory extends MdcLoggable { | |Include the token in the DirectLogin header on all subsequent requests: | - | GET ${hostname}/obp/v6.0.0/my/banks + | GET ${hostname}/obp/v7.0.0/my/banks | DirectLogin: token=your-token-string | |### Parameters @@ -163,7 +163,7 @@ object OpenAPI31JSONFactory extends MdcLoggable { |4. Exchange the authorization code for an access token at the token endpoint. |5. Use the access token as a Bearer token in the Authorization header: | - | GET ${hostname}/obp/v6.0.0/my/banks + | GET ${hostname}/obp/v7.0.0/my/banks | Authorization: Bearer your-access-token | |### Client Credentials Flow (2-legged / Application Only) @@ -193,7 +193,7 @@ object OpenAPI31JSONFactory extends MdcLoggable { |between the gateway and OBP. This method does not require a separate token-obtaining step — |the JWT is sent directly with each API request. | - | GET ${hostname}/obp/v6.0.0/my/banks + | GET ${hostname}/obp/v7.0.0/my/banks | Authorization: GatewayLogin token="your-jwt-token" | |### Notes diff --git a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala index f856b824a0..328af570eb 100644 --- a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala +++ b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala @@ -125,7 +125,7 @@ class OpenAPI31FactoryTest extends ServerSetup { scenario("oAuth2Description uses consumerRegistrationUrl for registration and hostname for API endpoints") { val desc = OpenAPI31JSONFactory.oAuth2Description("https://api.example.com") desc should include("Register your application at https://api.example.com/consumer-registration to obtain a client_id and client_secret.") - desc should include("GET https://api.example.com/obp/v6.0.0/my/banks") + desc should include("GET https://api.example.com/obp/v7.0.0/my/banks") } scenario("oAuth2Description guides users through portal guide and registration form when portal URL is set") { @@ -138,6 +138,7 @@ class OpenAPI31FactoryTest extends ServerSetup { val desc = OpenAPI31JSONFactory.directLoginDescription("https://api.example.com") desc should include("Register your application at https://api.example.com/consumer-registration to obtain a consumer_key.") desc should include("POST https://api.example.com/obp/v6.0.0/my/logins/direct") + desc should include("GET https://api.example.com/obp/v7.0.0/my/banks") } scenario("directLoginDescription guides users through portal guide and registration form when portal URL is set") { @@ -146,6 +147,11 @@ class OpenAPI31FactoryTest extends ServerSetup { desc should include("Read the registration instructions at https://portal.example.com/developers/consumer-creation, log in and proceed to https://portal.example.com/consumers/register to register your application and obtain a consumer_key.") } + scenario("gatewayLoginDescription uses hostname for API endpoints") { + val desc = OpenAPI31JSONFactory.gatewayLoginDescription("https://api.example.com") + desc should include("GET https://api.example.com/obp/v7.0.0/my/banks") + } + scenario("oidcTokenEndpoint falls back to when no OIDC host is set") { OpenAPI31JSONFactory.oidcTokenEndpoint should equal("") }