Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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("<OIDC_provider_token_endpoint>")

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
|
Expand All @@ -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
Expand All @@ -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
Expand All @@ -112,21 +157,21 @@ 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)
|
|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 <OIDC_provider_token_endpoint>
| POST $oidcTokenEndpoint
| Content-Type: application/x-www-form-urlencoded
|
| grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET
Expand All @@ -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.
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion obp-api/src/main/scala/code/api/util/APIUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,17 @@
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(
Expand All @@ -68,16 +63,9 @@
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")
Expand All @@ -86,40 +74,118 @@
(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")

Check failure on line 116 in obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "https://api.example.com" 8 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AaE60Gy8sE23t82x5gxJ&open=AaE60Gy8sE23t82x5gxJ&pullRequest=2944
}

scenario("consumerRegistrationUrl points to /consumers/register when portal URL is set") {
setPropsValues("public_obp_portal_url" -> "https://portal.example.com")

Check failure on line 120 in obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "https://portal.example.com" 3 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AaE8FPA9TmqGe52UF-eP&open=AaE8FPA9TmqGe52UF-eP&pullRequest=2944
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")

Check failure on line 128 in obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "GET https://api.example.com/obp/v7.0.0/my/banks" 3 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AaE8PxjpMFN1tR6Y9zkF&open=AaE8PxjpMFN1tR6Y9zkF&pullRequest=2944
}

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 <OIDC_provider_token_endpoint> when no OIDC host is set") {
OpenAPI31JSONFactory.oidcTokenEndpoint should equal("<OIDC_provider_token_endpoint>")
}

scenario("oidcTokenEndpoint resolves token endpoint from oauth2.obp_oidc.host") {
setPropsValues("oauth2.obp_oidc.host" -> "https://oidc.example.com")

Check failure on line 160 in obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "https://oidc.example.com" 3 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AaE60Gy8sE23t82x5gxK&open=AaE60Gy8sE23t82x5gxK&pullRequest=2944

Check failure on line 160 in obp-api/src/test/scala/code/api/ResourceDocs1_4_0/OpenAPI31FactoryTest.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "oauth2.obp_oidc.host" 4 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AaE60Gy8sE23t82x5gxL&open=AaE60Gy8sE23t82x5gxL&pullRequest=2944
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")
}
}
}
Loading