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..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 @@ -60,12 +60,49 @@ 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("/")}/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")) + .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. | |### Step 1: Get your Consumer Key | - |Register your application at ${hostname}/consumer-registration to obtain a consumer_key. + |${consumerRegistrationInstruction(hostname, "consumer_key")} | |### Step 2: Obtain a token | @@ -85,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 @@ -102,7 +139,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 @@ -112,13 +157,13 @@ 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. ${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. |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) @@ -126,7 +171,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 @@ -136,8 +181,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. @@ -146,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/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..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 @@ -28,22 +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.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 { +class OpenAPI31FactoryTest extends ServerSetup { private def doc(operationId: String, typedBody: JValue): JSONFactory1_4_0.ResourceDocJson = JSONFactory1_4_0.ResourceDocJson( @@ -68,16 +63,9 @@ class OpenAPI31FactoryTest extends FlatSpec with Matchers { 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") @@ -86,40 +74,118 @@ class OpenAPI31FactoryTest extends FlatSpec with Matchers { (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") + scenario("survives a typed body that is absent") { + noException should be thrownBy + OpenAPI31JSONFactory.createOpenAPI31Json(List(doc("testOp", JNothing)), "v4.0.0", "http://localhost:8080") + } } - 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") + 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") + } + + 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")) + } + + 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/v7.0.0/my/banks") + } + + 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.") + } + + 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") + 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") { + 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.") + } + + 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("") + } + + 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") + } + + 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")) + } + + 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") + } } }