Skip to main content

I’m writing this article to discuss implementing a working WSO2 toolkit as part of your Open Banking(OB) solution. 

WSO2 provides a sample Open Banking toolkit here: https://github.com/DivyaPremanantha/open-banking-sample-toolkit, but this has limitations. For example Account and Transaction endpoints give you the same response every time even if the query parameters are changed. API responses are also hard coded. In this article, we are going to implement a fully functioning basic toolkit, without these drawbacks.  At the end you will be able to extend the capabilities of the toolkit to fit your requirements. 

To build a functional OB toolkit there are several problems to be solved. Let’s discuss them one by one.

These three endpoints from Account and Transaction API are used by third party provider Fintech applications to retrieve account details:

  • GET /open-banking/v3.1/aisp/accounts/{accountNo} –  returns account details
  • GET /open-banking/v3.1/aisp/accounts/{accountNo}/balance – returns account balance
  • GET /open-banking/v3.1/aisp/accounts/{accountNo}/transactions   – returns account transactions list

You can add more endpoints to the OB solution by customizing API Manager, API definition and toolkit. We have endpoints to retrieve account details including balance and transactions, but one important endpoint is missing. Once a user has authorized a third party application to access account details for a particular bank, there should be an endpoint to retrieve authorized accounts from that bank. Therefore we need to add a new endpoint as below:

  • GET /open-banking/v3.1/aisp/accounts – retrieve only authorised accounts

As mentioned, the above endpoints return a hard coded response for each request. As it needs to work with real data, our OB solution should use a core banking backend. 

However, we can’t directly expose the same core banking endpoints through our OB solution as their URL paths and response structure need to be modified for compliance reasons. In this case, we are going to use the above mentioned URL paths instead of core banking URLs and the same core banking response structure. To do that we need to map our URLs with the core banking URLs. 

In summary we will:

  • Add new endpoints to data retrieval APIs to retrieve an authorized bank account list
  • Setup a core banking backend in the local environment
  • Integrate a core banking backend with toolkit
  • Map exposed data retrieval APIs with the APIs from the core banking backend

Set-up a core banking backend in the local environment

As our core banking backend solution we are going to use Mifos X built on top of Apache Fineract. It has a clean and easily understandable user interface with a rich set of APIs.

As per the documentation, you will need to install mysql 5.6 or 5.7. Since WSO2 products use mysql 8, you may need to install both versions of mysql in different ports. According to your port number you should modify the configuration files for the correct port. 

  • If you’re going to use a port other than 3306 for mysql 8, you will need to change the database port for all WSO2 products. Path to configuration file – repository/conf/deployment.toml
  • If you’re going to use a port other than 3306 for mysql 5.7, you need to change the database port for tomcat. Path to configuration file – tomcat/conf/server.xml

After the Mifos X setup is complete, you can view the dashboard and add users, accounts and transactions from here https://localhost:8443.

When creating users in Mifos X, you need to create a user account in WSO2 Identity Server as well. To map the users between both services, put userId (ex: admin@wso2.com@carbon.super) of the Identity Server user, to the “external ID” field of the Mifos X client. Before adding a bank account to users you need to create currency and bank account products. 

  • Got to Admin->Organization->Currency Configuration, add new currency
  • Got to Admin->Products->Saving Products, create new saving product

Now you can add bank accounts to users from the “New Saving” button. Don’t forget to put a value in the external ID field when creating an account. 

Use this documentation to find all APIs provided by Mifos X https://mifos.readme.io/reference/overview-1

Below, we are going to consume APIs from Mifos X:

The headers below are required for all API requests:

  • fineract-platform-tenantid: default
  • Authorization: Basic <base64encode(mifos_username:mifos_password)>

Use this postman collection to test these APIs. If API requests are working and return correct data, you have successfully set up the core banking solution.

Integrate Core Banking Backend with Toolkit

There are two places to integrate the core banking backend in OB toolkit.

  • Consent Authorization screen
    • After credentials are validated in the authorization flow, it will display all bank accounts belonging to the authenticated user. In the sample toolkit these account values are hard coded, however we now need to dynamically find the accounts list using core banking APIs and reflect them on the screen.
  • Map open banking accounts and transaction APIs with core banking APIs.

Integration of the Consent Authorization Screen

After authentication, we have the user id which we can use to retrieve accounts from Core Bank BE. The WSO2 IS authenticated user id is used to map Core Bank BE users with IS users, as well as in Mifos X, however we need to use the “Search Resources” API to retrieve all the user accounts.

Then we can use the “entityId” value in the response to retrieve accounts with the “Retrieve client accounts overview” API.

The code required to create an adapter class within com.wso2.openbanking.consent.extensions package, to call core banking APIs is transcribed below:

public class CoreBankAdapter {
    private final static String CORE_BE_BASE = "https://localhost:8443/fineract-provider/api/v1/";
    private final static String CORE_BE_BASIC_AUTH = "Basic bWlmb3M6cGFzc3dvcmQ=";
    private final static String AUTH = "Authorization";
    private final static String TENANT = "fineract-platform-tenantid";
    private final static String CORE_BE_TENANT = "default";
    private final ObjectMapper om;
 
    private static CoreBankAdapter instance;
 
 
    private CoreBankAdapter() {
        om = new ObjectMapper();
    }
 
    public static synchronized CoreBankAdapter getInstance() {
        if (instance == null) {
            instance = new CoreBankAdapter();
        }
        return instance;
    }
 
    public SharableAccounts getAccountsForUser(String email) {
        Entity entity = getUserByEmail(email);
 
 
        HttpGet request = new HttpGet(CORE_BE_BASE + "clients/" + entity.entityId + "/accounts");
        request.addHeader(AUTH, CORE_BE_BASIC_AUTH);
        request.addHeader(TENANT, CORE_BE_TENANT);
 
        try (CloseableHttpClient httpClient = createTrustAllHttpClientBuilder().build()) {
            HttpResponse response = httpClient.execute(request);
            String responseString = EntityUtils.toString(response.getEntity());
            return om.readValue(responseString, SharableAccounts.class);
        } catch (IOException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        } catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) {
            throw new RuntimeException(e);
        }
 
    }
 
    public Entity getUserByEmail(String email) {
        HttpGet request = new HttpGet(CORE_BE_BASE + "search?query=" + email + "&resource=clients&exactMatch=true");
        request.addHeader(AUTH, CORE_BE_BASIC_AUTH);
        request.addHeader(TENANT, CORE_BE_TENANT);
 
        try (CloseableHttpClient httpClient = createTrustAllHttpClientBuilder().build()) {
            HttpResponse response = httpClient.execute(request);
            String responseString = EntityUtils.toString(response.getEntity());
            System.out.println("Response status code: " + response.getStatusLine().getStatusCode());
            System.out.println("Response body: " + responseString);
            return om.readValue(responseString, new TypeReference<List>() {
            }).get(0);
        } catch (IOException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        } catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) {
            throw new RuntimeException(e);
        }
    }
 
    public static HttpClientBuilder createTrustAllHttpClientBuilder() throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
        SSLContextBuilder builder = new SSLContextBuilder();
        builder.loadTrustMaterial(null, (chain, authType) -> true);
        SSLConnectionSocketFactory sslsf = new
                SSLConnectionSocketFactory(builder.build(), NoopHostnameVerifier.INSTANCE);
        return HttpClients.custom().setSSLSocketFactory(sslsf);
    }
}

In the above code snippet, Entity and ShareableAccounts classes are generated ‘pojo’ classes from sample responses to “Search Resources” and “Retrieve client accounts overview” APIs using jsonschema2pojo

Now we have to call the getAccountsForUser() method after the user is authenticated and replace hard coded values with actual account details in the response. 

DefaultConsentPersistStep class is used to hard code account names in the sample toolkit. You can find it in the SampleRetrievalStep class in com.wso2.openbanking.consent.extensions.authorize.impl package. We now have to modify this. To do it we have to replicate the DefaultConsentPersistStep class as a new class. Give it a name like “SampleConsentRetrievalStep”.

public class SampleConsentRetrievalStep  implements ConsentRetrievalStep {
    ...
    public SampleConsentRetrievalStep() {
    }
 
    public void execute(ConsentData consentData, JSONObject jsonObject) throws ConsentException {
        try {
            if (consentData.isRegulatory()) {
                ....
                if (consentId == null) {
                   ...
                }
                //Modification 1
                ConsentExtensionsDataHolder.getInstance().setConsentCoreService(new ConsentCoreServiceImpl());
                ConsentResource consentResource = ConsentExtensionsDataHolder.getInstance().getConsentCoreService().getConsent(consentId, false);
                if (!consentResource.getCurrentStatus().equals("awaitingAuthorisation")) {
                    ...
                } else {
                    AuthorizationResource authorizationResource = (AuthorizationResource)ConsentExtensionsDataHolder.getInstance().getConsentCoreService().searchAuthorizations(consentId).get(0);
                    if (!authorizationResource.getAuthorizationStatus().equals("created")) {
                        ...
                    } else {
                       ...
                        if (!(receiptJSON instanceof JSONObject)) {
                            ...
                        } else {
                            JSONObject receipt = (JSONObject)receiptJSON;
                            JSONArray permissions = (JSONArray)((JSONObject)receipt.get("Data")).get("Permissions");
                            JSONArray consentDataJSON = new JSONArray();
                            JSONObject jsonElementPermissions = new JSONObject();
                            jsonElementPermissions.appendField("title", "Permissions");
                            jsonElementPermissions.appendField("data", permissions);
                            consentDataJSON.add(jsonElementPermissions);
                            String expiry = ((JSONObject)receipt.get("Data")).getAsString("ExpirationDateTime");
                            JSONArray expiryArray = new JSONArray();
                            expiryArray.add(expiry);
                            JSONObject jsonElementExpiry = new JSONObject();
                            jsonElementExpiry.appendField("title", "Expiration Date Time");
                            jsonElementExpiry.appendField("data", expiryArray);
                            consentDataJSON.add(jsonElementExpiry);
                            jsonObject.appendField("consentData", consentDataJSON);
 
                            //Modification 2
                            CoreBankAdapter adapter = CoreBankAdapter.getInstance();
                            SharableAccounts accounts = adapter.getAccountsForUser(consentData.getUserId());
                            JSONArray accountsJSON = new JSONArray();
                            accounts.savingsAccounts.forEach(savingAccount -> {
                                JSONObject account = new JSONObject();
                                account.appendField("account_id", savingAccount.accountNo);
                                account.appendField("display_name", savingAccount.productName +" (" +savingAccount.accountNo+")");
                                accountsJSON.add(account);
                            });
                            jsonObject.appendField("accounts", accountsJSON);
                        }
                    }
                }
            }
        } catch (ParseException | ConsentManagementException var18) {
            ....
        }
    }
 
    private String extractRequestObject(String spQueryParams) {
        ...
    }
 
    private String validateRequestObjectAndExtractConsentId(String requestObject) {
       ...
    }
}

While writing this class you may have to replicate “ConsentExtensionsDataHolder” and “ConsentExtensionsComponent” class in com.wso2.openbanking.consent.extensions.internal package.

Now build the project using the ‘mvn install’ command and put ‘com.wso2.openbanking.identity-1.0.0.jar’ into ‘<IS_HOME>/repository/components/dropins/’ folder. Restart the IS and go through the authorization flow to check that the integration works.

Map Open Banking Accounts and Transaction APIs with Core Banking APIs

With the default behaviour of API Manager, we can’t reroute a request to a different path, we have to manually reroute using a mediator. You can use the SampleMediator class in com.wso2.openbanking.gateway.executor package to do that.

Final mediator class should look like this:

public class SampleMediator extends AbstractMediator {
 
    private String accountRequestInformation;
 
    @Override
    public boolean mediate(MessageContext messageContext) {
        Axis2MessageContext mc = (Axis2MessageContext) messageContext;
        if (mc.getProperty("API_ELECTED_RESOURCE").equals("/accounts")) {
            try {
                mc.getAxis2MessageContext().setProperty("REST_URL_POSTFIX", "/savingsaccounts?sqlSearch=" + URLEncoder.encode("accountNo=" + String.join(" or accountNo=", retrieveAccountId(getAccountRequestInformation())), StandardCharsets.UTF_8));
            } catch (OpenBankingExecutorException e) {
                throw new RuntimeException(e);
            }
        } else if (mc.getProperty("API_ELECTED_RESOURCE").equals("/accounts/{AccountId}")) {
            mc.getAxis2MessageContext().setProperty("REST_URL_POSTFIX", "/savingsaccounts?sqlSearch=" + URLEncoder.encode("accountNo=" + mc.getProperty("uri.var.AccountId"), StandardCharsets.UTF_8));
        } else if (mc.getProperty("API_ELECTED_RESOURCE").equals("/accounts/{AccountId}/balances")) {
            mc.getAxis2MessageContext().setProperty("REST_URL_POSTFIX", "/savingsaccounts?sqlSearch=" + URLEncoder.encode("accountNo=" + mc.getProperty("uri.var.AccountId"), StandardCharsets.UTF_8));
        } else if (mc.getProperty("API_ELECTED_RESOURCE").equals("/accounts/{AccountId}/transactions")) {
            mc.getAxis2MessageContext().setProperty("REST_URL_POSTFIX", "/interoperation/accounts/" + mc.getProperty("uri.var.AccountId") + "/transactions?debit=true&credit=true");
        }
        return true;
    }
 
    public String getAccountRequestInformation() {
        return accountRequestInformation;
    }
 
    public void setAccountRequestInformation(String jwtHeader) {
        this.accountRequestInformation = jwtHeader;
    }
 
    private static String[] retrieveAccountId(String accountRequestInfo) throws OpenBankingExecutorException {
        String[] split_string = accountRequestInfo.split("\\.");
        String base64EncodedBody = split_string[1];
 
        Base64.Decoder base64 = Base64.getDecoder();
        String decodedString = new String(base64.decode(base64EncodedBody.getBytes()));
        JSONObject accountRequestInfoJson = new JSONObject(decodedString);
        if (accountRequestInfoJson.has("consentMappingResources")) {
            JSONArray consentMappingResources = (JSONArray) accountRequestInfoJson.get("consentMappingResources");
            String[] accountIdsArray = new String[consentMappingResources.length()];
            JSONObject consentMappingResource;
            for (int i = 0; i < consentMappingResources.length(); i++) {
                consentMappingResource = (JSONObject) consentMappingResources.get(i);
                accountIdsArray[i] = consentMappingResource.get("accountId").toString();
            }
            return accountIdsArray;
        } else {
            throw new OpenBankingExecutorException("Account Request Ids attribute is not available in jwt", "210001", "Account Request Ids is not available");
        }
    }
}

To class this mediator, modify the request message mediation in sequence file, like below:

<?xml version=“1.0” encoding=“UTF-8”?>
<sequence name=“accounts-dynamic-endpoint-insequence” trace=“disable” xmlns=“http://ws.apache.org/ns/synapse”>
    <property expression=“get-property(‘To’)” name=“endpointURI” scope=“default” type=“STRING”/>
    <property name=“To” scope=“default” type=“STRING” value=“/open-banking/v3.1/aisp/accounts”/>
     <class name=“com.wso2.openbanking.gateway.executor.SampleMediator”>
        <property name=“AccountRequestInformation” expression=“$trp:Account-Request-Information”/>
     </class>
 
    <property action=“remove” name=“Authorization” scope=“transport”/>
    <filter regex=“.*\/account-access-consents.*” source=“$ctx:endpointURI”>
        <then>
            <header name=“To” scope=“default” value=“https://localhost:9446/api/openbanking/consent/manage”/>
            <header expression=“get-property(‘api.ut.consumerKey’)” name=“x-wso2-client-id” scope=“transport”/>
            <header name=“Authorization” scope=“transport” value=“Basic YWRtaW5Ad3NvMi5jb206d3NvMjEyMw==”/>
        </then>
        <else>
            <header name=“To” scope=“default” value=“https://localhost:8443/fineract-provider/api/v1”/>
            <header name=“Authorization” scope=“transport” value=“Basic bWlmb3M6cGFzc3dvcmQ=”/>
            <header name=“fineract-platform-tenantid” scope=“transport” value=“default”/>
        </else>
    </filter>
</sequence>

You can change the Authorization header value as per your credentials.

Add new endpoint to data retrieval APIs to retrieve authorized bank accounts list

Go to AccountandTransactionAPI in API Manager. Open API Definition tab and add below yaml object to “paths” object.

/accounts:
  get:
    tags:
      - Accounts
    parameters:
      - name: x-fapi-auth-date
        in: header
        description: >-
          The time when the PSU last logged in with the TPP.
 
          All dates in the HTTP headers are represented as RFC 7231 Full Dates.
          An example is below:

          Sun, 10 Sep 2017 19:43:31 UTC
        required: false
        type: string
        pattern: >-
          ^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2}
          (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4}
          \d{2}:\d{2}:\d{2} (GMT|UTC)$
      - name: x-fapi-customer-ip-address
        in: header
        description: The PSU's IP address if the PSU is currently logged in with the TPP.
        required: false
        type: string
      - name: x-fapi-interaction-id
        in: header
        description: An RFC4122 UID used as a correlation id.
        required: false
        type: string
      - name: Authorization
        in: header
        description: 'An Authorisation Token as per https://tools.ietf.org/html/rfc6750'
        required: true
        type: string
    responses:
      '200':
        description: ok
    security:
      - PSUOAuth2Security:
          - accounts
      - default:
          - accounts
    x-auth-type: Application & Application User
    x-throttling-tier: Unlimited
    x-wso2-application-security:
      security-types:
        - oauth2
      optional: false

We need to modify the “DefaultConsentValidator” class as well. You can find a reference to that class inside the “SampleConsentValidator” class. We can replicate “DefaultConsentValidator” as a new class or we can just paste the method bodies to the “SampleConsentValidator” class. Going with option 2, the final code should look like this:

public class SampleConsentValidator implements ConsentValidator {
 
    private static final Log log = LogFactory.getLog(DefaultConsentValidator.class);
    private static final String ACCOUNTS = "/accounts";
    private static final String ACCOUNTS_REGEX = "/accounts/[^/?]*";
    private static final String TRANSACTIONS_REGEX = "/accounts/[^/?]*/transactions";
    private static final String BALANCES_REGEX = "/accounts/[^/?]*/balances";
    private static final String PERMISSION_MISMATCH_ERROR = "Permission mismatch. Consent does not contain necessary permissions";
    private static final String INVALID_URI_ERROR = "Path requested is invalid";
    private static final String CONSENT_EXPIRED_ERROR = "Provided consent is expired";
    private static final String CONSENT_STATE_ERROR = "Provided consent not in authorised state";
    private static final String ACCOUNT_NOT_AUTHORIZED_ERROR = "Account is not authorized for the consent";
    private static final String AUTHORISED_STATUS = "authorised";
 
    public SampleConsentValidator() {
    }
 
    public void validate(ConsentValidateData consentValidateData, ConsentValidationResult consentValidationResult) throws ConsentException {
        String uri = consentValidateData.getRequestPath();
 
        JSONObject receiptJSON;
        JSONArray permissions;
        try {
            receiptJSON = (JSONObject)(new JSONParser(-1)).parse(consentValidateData.getComprehensiveConsent().getReceipt());
            permissions = (JSONArray)((JSONObject)receiptJSON.get("Data")).get("Permissions");
        } catch (ParseException var7) {
            throw new ConsentException(ResponseStatus.INTERNAL_SERVER_ERROR, "Exception occurred while validating permissions");
        }
 
        if (!uri.matches(ACCOUNTS) && !uri.matches(ACCOUNTS_REGEX) && !uri.matches(TRANSACTIONS_REGEX) && !uri.matches(BALANCES_REGEX)) { 
            consentValidationResult.setErrorMessage(INVALID_URI_ERROR);
            consentValidationResult.setErrorCode("00013");
            consentValidationResult.setHttpCode(401);
        } else if (uri.matches(ACCOUNTS) && !permissions.contains("ReadAccountsDetail")
                || uri.matches(ACCOUNTS_REGEX) && !permissions.contains("ReadAccountsDetail")
                || uri.matches(TRANSACTIONS_REGEX) && !permissions.contains("ReadTransactionsDetail")
                || uri.matches(BALANCES_REGEX) && !permissions.contains("ReadBalances")) {
            consentValidationResult.setErrorMessage(PERMISSION_MISMATCH_ERROR);
            consentValidationResult.setErrorCode("00010");
            consentValidationResult.setHttpCode(401);
        } else if (this.isConsentExpired(((JSONObject)receiptJSON.get("Data")).getAsString("ExpirationDateTime"))) {
            consentValidationResult.setErrorMessage(CONSENT_EXPIRED_ERROR);
            consentValidationResult.setErrorCode("00011");
            consentValidationResult.setHttpCode(401);
        } else if (!AUTHORISED_STATUS.equals(consentValidateData.getComprehensiveConsent().getCurrentStatus())) {
            consentValidationResult.setErrorMessage(CONSENT_STATE_ERROR);
            consentValidationResult.setErrorCode("00012");
            consentValidationResult.setHttpCode(401);
        }  else if ((uri.matches(ACCOUNTS_REGEX) || uri.matches(BALANCES_REGEX))  //Validates account belongs to requested user
                && consentValidateData.getComprehensiveConsent().getConsentMappingResources().stream()
                .noneMatch(consentMappingResource -> consentMappingResource.getAccountID().equals(consentValidateData.getResourceParams().get("ResourcePath").split("/|\\?")[3]))) {
            consentValidationResult.setErrorMessage(ACCOUNT_NOT_AUTHORIZED_ERROR);
            consentValidationResult.setErrorCode("00013");
            consentValidationResult.setHttpCode(401);
        } else {
            consentValidationResult.setValid(true);
        }
    }
 
    private boolean isConsentExpired(String expDateVal) throws ConsentException {
        if (expDateVal != null && !expDateVal.isEmpty()) {
            try {
                OffsetDateTime expDate = OffsetDateTime.parse(expDateVal);
                return OffsetDateTime.now().isAfter(expDate);
            } catch (DateTimeParseException var3) {
                log.error("Error occurred while parsing the expiration date : " + expDateVal);
                throw new ConsentException(ResponseStatus.INTERNAL_SERVER_ERROR, "Error occurred while parsing the expiration date");
            }
        } else {
            return false;
        }
    }
}

The last else if condition of the validate() method validates whether the user has authorised the requested bank account. 

Now build the toolkit and put all the ‘com.wso2.openbanking.gateway-1.0.0.jar’ files into the dropins folder of API Manager and ‘com.wso2.openbanking.identity-1.0.0.jar’  into dropins folder of IS. Restart the API Manager and IS to reflect the changes.

Now you have a working WSO2 Open Banking Toolkit with basic features. I hope you have understood the request flow within the toolkit and from that understanding you should be able to undertake whatever customizations are required.

Divya Premanantha